From d691619b9862b3b17f9c53c488e7352411b0e78a Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 17 Mar 2026 11:37:27 +0100 Subject: [PATCH 001/350] Burn everything and start fresh --- flake.nix | 6 +- server/android/build.gradle.kts | 10 +- server/core/build.gradle.kts | 17 +- .../src/main/java/dev/slimevr/Keybinding.kt | 96 - .../java/dev/slimevr/NetworkProfileChecker.kt | 59 - .../java/dev/slimevr/NetworkProtocol.java | 8 - .../src/main/java/dev/slimevr/VRServer.kt | 485 +- .../java/dev/slimevr/autobone/AutoBone.kt | 698 -- .../dev/slimevr/autobone/AutoBoneHandler.kt | 408 - .../dev/slimevr/autobone/AutoBoneListener.kt | 22 - .../slimevr/autobone/AutoBoneProcessType.kt | 15 - .../java/dev/slimevr/autobone/AutoBoneStep.kt | 13 - .../dev/slimevr/autobone/BoneContribution.kt | 84 - .../dev/slimevr/autobone/PoseFrameIterator.kt | 90 - .../dev/slimevr/autobone/PoseFrameStep.kt | 70 - .../dev/slimevr/autobone/StatsCalculator.kt | 43 - .../autobone/errors/AutoBoneException.kt | 14 - .../autobone/errors/BodyProportionError.kt | 123 - .../autobone/errors/FootHeightOffsetError.kt | 50 - .../slimevr/autobone/errors/HeightError.kt | 16 - .../slimevr/autobone/errors/IAutoBoneError.kt | 9 - .../autobone/errors/OffsetSlideError.kt | 50 - .../slimevr/autobone/errors/PositionError.kt | 55 - .../autobone/errors/PositionOffsetError.kt | 55 - .../dev/slimevr/autobone/errors/SlideError.kt | 44 - .../errors/proportions/ProportionLimiter.kt | 80 - .../main/java/dev/slimevr/bridge/Bridge.kt | 58 - .../java/dev/slimevr/bridge/BridgeThread.java | 9 - .../java/dev/slimevr/config/AutoBoneConfig.kt | 28 - .../java/dev/slimevr/config/BridgeConfig.java | 33 - .../dev/slimevr/config/ConfigManager.java | 176 - .../config/CurrentVRConfigConverter.java | 357 - .../slimevr/config/DriftCompensationConfig.kt | 26 - .../java/dev/slimevr/config/FiltersConfig.kt | 20 - .../main/java/dev/slimevr/config/HIDConfig.kt | 7 - .../dev/slimevr/config/KeybindingsConfig.java | 88 - .../dev/slimevr/config/LegTweaksConfig.kt | 6 - .../main/java/dev/slimevr/config/OSCConfig.kt | 16 - .../dev/slimevr/config/OverlayConfig.java | 24 - .../java/dev/slimevr/config/ResetsConfig.kt | 78 - .../java/dev/slimevr/config/ServerConfig.kt | 66 - .../dev/slimevr/config/SkeletonConfig.java | 63 - .../dev/slimevr/config/StayAlignedConfig.kt | 44 - .../config/StayAlignedRelaxedPoseConfig.kt | 25 - .../dev/slimevr/config/TapDetectionConfig.kt | 29 - .../java/dev/slimevr/config/TrackerConfig.kt | 49 - .../slimevr/config/TrackingChecklistConfig.kt | 5 - .../main/java/dev/slimevr/config/VMCConfig.kt | 13 - .../main/java/dev/slimevr/config/VRCConfig.kt | 6 - .../java/dev/slimevr/config/VRCOSCConfig.kt | 24 - .../main/java/dev/slimevr/config/VRConfig.kt | 145 - .../serializers/BooleanMapDeserializer.java | 15 - .../BridgeConfigMapDeserializer.java | 18 - .../serializers/FloatMapDeserializer.java | 15 - .../config/serializers/MapDeserializer.java | 36 - .../serializers/QuaternionDeserializer.java | 26 - .../serializers/QuaternionSerializer.java | 23 - .../TrackerConfigMapDeserializer.java | 18 - .../slimevr/filtering/CircularArrayList.java | 117 - .../filtering/QuaternionMovingAverage.kt | 127 - .../dev/slimevr/filtering/TrackerFilters.kt | 34 - .../slimevr/firmware/FirmwareUpdateHandler.kt | 539 - .../firmware/FirmwareUpdateListener.kt | 5 - .../slimevr/firmware/FirmwareUpdateMethod.kt | 14 - .../slimevr/firmware/FirmwareUpdateStatus.kt | 29 - .../dev/slimevr/firmware/OTAUpdateTask.kt | 205 - .../slimevr/firmware/SerialFlashingHandler.kt | 5 - .../slimevr/firmware/SerialRebootHandler.kt | 66 - .../dev/slimevr/firmware/UpdateDeviceId.kt | 24 - .../dev/slimevr/firmware/UpdateStatusEvent.kt | 8 - .../slimevr/games/vrchat/VRCConfigHandler.kt | 217 - .../java/dev/slimevr/guards/ServerGuards.kt | 28 - .../src/main/java/dev/slimevr/math/Angle.kt | 67 - .../java/dev/slimevr/math/AngleAverage.kt | 36 - .../main/java/dev/slimevr/math/AngleErrors.kt | 14 - .../main/java/dev/slimevr/osc/OSCHandler.java | 29 - .../main/java/dev/slimevr/osc/OSCRouter.java | 179 - .../java/dev/slimevr/osc/UnityArmature.kt | 332 - .../main/java/dev/slimevr/osc/UnityBone.kt | 346 - .../main/java/dev/slimevr/osc/VMCHandler.kt | 511 - .../java/dev/slimevr/osc/VRCOSCHandler.kt | 560 - .../dev/slimevr/osc/VRCOSCQueryHandler.kt | 86 - .../main/java/dev/slimevr/osc/VRMReader.kt | 103 - .../java/dev/slimevr/poseframeformat/PfrIO.kt | 189 - .../java/dev/slimevr/poseframeformat/PfsIO.kt | 197 - .../dev/slimevr/poseframeformat/PfsPackets.kt | 40 - .../dev/slimevr/poseframeformat/PoseFrames.kt | 164 - .../slimevr/poseframeformat/PoseRecorder.kt | 162 - .../poseframeformat/player/PlayerTracker.kt | 72 - .../player/TrackerFramesPlayer.kt | 44 - .../trackerdata/TrackerFrame.kt | 117 - .../trackerdata/TrackerFrameData.kt | 23 - .../trackerdata/TrackerFrames.kt | 51 - .../dev/slimevr/posestreamer/BVHFileStream.kt | 213 - .../dev/slimevr/posestreamer/BVHRecorder.kt | 67 - .../dev/slimevr/posestreamer/BVHSettings.kt | 41 - .../slimevr/posestreamer/PoseDataStream.kt | 33 - .../slimevr/posestreamer/PoseFrameStreamer.kt | 35 - .../dev/slimevr/posestreamer/PoseStreamer.kt | 74 - .../posestreamer/ServerPoseStreamer.kt | 30 - .../slimevr/posestreamer/TickPoseStreamer.kt | 33 - .../dev/slimevr/protocol/ConnectionContext.kt | 10 - .../java/dev/slimevr/protocol/DataFeed.kt | 7 - .../dev/slimevr/protocol/GenericConnection.kt | 12 - .../java/dev/slimevr/protocol/ProtocolAPI.kt | 47 - .../dev/slimevr/protocol/ProtocolAPIServer.kt | 7 - .../dev/slimevr/protocol/ProtocolHandler.kt | 25 - .../protocol/datafeed/DataFeedBuilder.kt | 506 - .../protocol/datafeed/DataFeedHandler.kt | 177 - .../slimevr/protocol/pubsub/HashedTopicId.kt | 29 - .../slimevr/protocol/pubsub/PubSubHandler.kt | 170 - .../dev/slimevr/protocol/rpc/RPCBuilder.kt | 25 - .../dev/slimevr/protocol/rpc/RPCHandler.kt | 616 -- .../protocol/rpc/RPCUserHeightCalibration.kt | 65 - .../rpc/autobone/RPCAutoBoneHandler.kt | 181 - .../rpc/firmware/RPCFirmwareUpdateHandler.kt | 133 - .../rpc/games/vrchat/RPCVRCBuilder.kt | 84 - .../rpc/games/vrchat/RPCVRChatHandler.kt | 81 - .../protocol/rpc/reset/RPCResetHandler.kt | 139 - .../rpc/serial/RPCProvisioningHandler.kt | 68 - .../protocol/rpc/serial/RPCSerialHandler.kt | 263 - .../rpc/settings/RPCSettingsBuilder.kt | 458 - .../rpc/settings/RPCSettingsHandler.kt | 389 - .../protocol/rpc/setup/RPCHandshakeHandler.kt | 86 - .../protocol/rpc/setup/RPCTapSetupHandler.kt | 45 - .../dev/slimevr/protocol/rpc/setup/RPCUtil.kt | 19 - .../protocol/rpc/status/RPCStatusHandler.kt | 76 - .../RPCTrackingChecklistHandler.kt | 73 - .../rpc/trackingpause/RPCTrackingPause.kt | 53 - .../java/dev/slimevr/reset/ResetHandler.kt | 24 - .../java/dev/slimevr/reset/ResetListener.kt | 7 - .../main/java/dev/slimevr/reset/ResetTimer.kt | 40 - .../slimevr/serial/ProvisioningHandler.java | 250 - .../slimevr/serial/ProvisioningListener.java | 6 - .../dev/slimevr/serial/ProvisioningStatus.kt | 17 - .../java/dev/slimevr/serial/SerialHandler.kt | 83 - .../java/dev/slimevr/serial/SerialListener.kt | 33 - .../dev/slimevr/setup/HandshakeHandler.kt | 23 - .../java/dev/slimevr/setup/TapSetupHandler.kt | 24 - .../java/dev/slimevr/status/StatusSystem.kt | 39 - .../dev/slimevr/tracking/processor/Bone.kt | 165 - .../slimevr/tracking/processor/BoneType.java | 87 - .../slimevr/tracking/processor/Constraint.kt | 190 - .../tracking/processor/HumanPoseManager.kt | 691 -- .../tracking/processor/TransformNode.kt | 73 - .../processor/config/SkeletonConfigManager.kt | 556 - .../config/SkeletonConfigOffsets.java | 172 - .../config/SkeletonConfigToggles.java | 54 - .../config/SkeletonConfigValues.java | 45 - .../processor/skeleton/HumanSkeleton.kt | 1791 ---- .../tracking/processor/skeleton/IKChain.kt | 159 - .../processor/skeleton/IKConstraint.kt | 21 - .../tracking/processor/skeleton/IKSolver.kt | 245 - .../tracking/processor/skeleton/LegTweaks.kt | 1060 -- .../processor/skeleton/LegTweaksBuffer.kt | 728 -- .../tracking/processor/skeleton/Localizer.kt | 417 - .../processor/skeleton/TapDetection.kt | 163 - .../processor/skeleton/TapDetectionManager.kt | 117 - .../skeleton/UserHeightCalibration.kt | 317 - .../processor/stayaligned/StayAligned.kt | 60 - .../stayaligned/StayAlignedDefaults.kt | 72 - .../stayaligned/adjust/AdjustTrackerYaw.kt | 185 - .../stayaligned/adjust/CenterErrorVisitor.kt | 92 - .../processor/stayaligned/adjust/CenterYaw.kt | 67 - .../stayaligned/adjust/LockedErrorVisitor.kt | 143 - .../adjust/NeighborErrorVisitor.kt | 164 - .../stayaligned/adjust/TrackerYaw.kt | 56 - .../processor/stayaligned/poses/PlayerPose.kt | 89 - .../stayaligned/poses/RelaxedPose.kt | 109 - .../stayaligned/poses/TrackerPose.kt | 61 - .../stayaligned/trackers/RestDetector.kt | 95 - .../processor/stayaligned/trackers/Side.kt | 6 - .../trackers/StayAlignedTrackerState.kt | 43 - .../stayaligned/trackers/TrackerSkeleton.kt | 415 - .../stayaligned/trackers/YawErrors.kt | 12 - .../dev/slimevr/tracking/trackers/Device.kt | 52 - .../tracking/trackers/DeviceManager.kt | 19 - .../dev/slimevr/tracking/trackers/Tracker.kt | 453 - .../trackers/TrackerFilteringHandler.kt | 69 - .../tracking/trackers/TrackerFlexHandler.kt | 176 - .../tracking/trackers/TrackerPosition.kt | 236 - .../tracking/trackers/TrackerResetsHandler.kt | 599 -- .../slimevr/tracking/trackers/TrackerRole.kt | 42 - .../tracking/trackers/TrackerStatus.kt | 20 - .../trackers/TrackerStatusListener.kt | 6 - .../slimevr/tracking/trackers/TrackerUtils.kt | 119 - .../tracking/trackers/hid/HIDCommon.kt | 383 - .../tracking/trackers/hid/HIDDevice.kt | 13 - .../tracking/trackers/udp/FeatureFlags.kt | 80 - .../trackers/udp/FirmwareConstants.kt | 179 - .../tracking/trackers/udp/SensorTap.kt | 11 - .../trackers/udp/TrackersUDPServer.kt | 681 -- .../tracking/trackers/udp/UDPDevice.kt | 85 - .../tracking/trackers/udp/UDPPacket.kt | 454 - .../trackers/udp/UDPProtocolParser.kt | 169 - .../TrackingChecklistManager.kt | 353 - .../trackingpause/TrackingPauseHandler.kt | 19 - .../trackingpause/TrackingPauseListener.kt | 5 - .../dev/slimevr/util/InterpolationHandler.kt | 102 - .../main/java/dev/slimevr/util/TickReducer.kt | 59 - .../dev/slimevr/util/ann/VRServerThread.java | 10 - .../slimevr/websocketapi/WebSocketVRBridge.kt | 239 - .../slimevr/websocketapi/WebsocketAPI.java | 111 - .../websocketapi/WebsocketConnection.java | 44 - .../src/main/java/io/eiren/math/FloatMath.kt | 435 - .../java/io/eiren/util/BufferedTimer.java | 127 - .../java/io/eiren/util/OperatingSystem.kt | 69 - .../main/java/io/eiren/util/StringUtils.java | 35 - .../src/main/java/io/eiren/util/Util.java | 176 - .../java/io/eiren/util/ann/AWTThread.java | 10 - .../java/io/eiren/util/ann/DebugSwitch.java | 9 - .../java/io/eiren/util/ann/NativeUnsafe.java | 19 - .../java/io/eiren/util/ann/Synchronize.java | 30 - .../java/io/eiren/util/ann/ThreadSafe.java | 27 - .../io/eiren/util/ann/ThreadSafeSingle.java | 17 - .../java/io/eiren/util/ann/ThreadSecure.java | 22 - .../java/io/eiren/util/ann/Transient.java | 39 - .../io/eiren/util/collections/FastList.java | 546 - .../collections/RemoveAtSwapFastList.java | 41 - .../util/collections/RemoveAtSwapList.java | 11 - .../util/collections/ResettableIterator.java | 17 - .../eiren/util/collections/SkipIterator.java | 16 - .../io/eiren/util/logging/DefaultGLog.java | 137 - .../eiren/util/logging/FileLogFormatter.java | 58 - .../io/eiren/util/logging/FileLogHandler.kt | 308 - .../java/io/eiren/util/logging/IGLog.java | 48 - .../io/eiren/util/logging/LogManager.java | 128 - .../util/logging/LoggerOutputStream.java | 52 - .../io/eiren/util/logging/LoggerRecorder.java | 23 - .../logging/PreciseConsoleLogFormatter.java | 32 - .../logging/ShortConsoleLogFormatter.java | 58 - .../java/dev/slimevr/unit/LegTweaksTests.kt | 72 - .../dev/slimevr/unit/MountingResetTests.kt | 163 - .../slimevr/unit/ReferenceAdjustmentsTests.kt | 337 - .../dev/slimevr/unit/SkeletonResetTests.kt | 122 - .../java/dev/slimevr/unit/TestTrackerSet.kt | 69 - .../java/dev/slimevr/unit/TrackerTestUtils.kt | 87 - .../dev/slimevr/unit/TrackingPauseTests.kt | 62 - .../dev/slimevr/unit/TwinExtendedBackTests.kt | 55 - server/desktop/build.gradle.kts | 9 +- server/desktop/protobuf_update.bat | 1 - .../desktop/DesktopNetworkProfileChecker.kt | 285 - .../src/main/java/dev/slimevr/desktop/Main.kt | 255 +- .../firmware/DesktopSerialFlashingHandler.kt | 88 - .../games/vrchat/DesktopVRCConfigHandler.kt | 87 - .../slimevr/desktop/games/vrchat/RegEdit.kt | 159 - .../desktop/platform/ProtobufBridge.kt | 300 - .../desktop/platform/ProtobufMessages.java | 9167 ----------------- .../slimevr/desktop/platform/SteamVRBridge.kt | 425 - .../desktop/platform/linux/SocketUtils.java | 20 - .../platform/linux/UnixSocketBridge.java | 263 - .../platform/linux/UnixSocketConnection.java | 114 - .../platform/linux/UnixSocketRpcBridge.java | 151 - .../desktop/platform/windows/PipeState.java | 7 - .../windows/WindowsNamedPipeBridge.java | 333 - .../desktop/platform/windows/WindowsPipe.java | 24 - .../desktop/serial/DesktopSerialHandler.kt | 268 - .../trackers/hid/DesktopHIDManager.kt | 289 - 258 files changed, 16 insertions(+), 43016 deletions(-) delete mode 100644 server/core/src/main/java/dev/slimevr/Keybinding.kt delete mode 100644 server/core/src/main/java/dev/slimevr/NetworkProfileChecker.kt delete mode 100644 server/core/src/main/java/dev/slimevr/NetworkProtocol.java delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/AutoBone.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/AutoBoneHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/AutoBoneListener.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/AutoBoneProcessType.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/AutoBoneStep.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/BoneContribution.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/PoseFrameIterator.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/PoseFrameStep.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/StatsCalculator.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/errors/AutoBoneException.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/errors/BodyProportionError.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/errors/FootHeightOffsetError.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/errors/HeightError.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/errors/IAutoBoneError.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/errors/OffsetSlideError.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/errors/PositionError.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/errors/PositionOffsetError.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/errors/SlideError.kt delete mode 100644 server/core/src/main/java/dev/slimevr/autobone/errors/proportions/ProportionLimiter.kt delete mode 100644 server/core/src/main/java/dev/slimevr/bridge/Bridge.kt delete mode 100644 server/core/src/main/java/dev/slimevr/bridge/BridgeThread.java delete mode 100644 server/core/src/main/java/dev/slimevr/config/AutoBoneConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/BridgeConfig.java delete mode 100644 server/core/src/main/java/dev/slimevr/config/ConfigManager.java delete mode 100644 server/core/src/main/java/dev/slimevr/config/CurrentVRConfigConverter.java delete mode 100644 server/core/src/main/java/dev/slimevr/config/DriftCompensationConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/FiltersConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/HIDConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/KeybindingsConfig.java delete mode 100644 server/core/src/main/java/dev/slimevr/config/LegTweaksConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/OSCConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/OverlayConfig.java delete mode 100644 server/core/src/main/java/dev/slimevr/config/ResetsConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/ServerConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/SkeletonConfig.java delete mode 100644 server/core/src/main/java/dev/slimevr/config/StayAlignedConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/StayAlignedRelaxedPoseConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/TapDetectionConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/TrackerConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/TrackingChecklistConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/VMCConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/VRCConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/VRCOSCConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/VRConfig.kt delete mode 100644 server/core/src/main/java/dev/slimevr/config/serializers/BooleanMapDeserializer.java delete mode 100644 server/core/src/main/java/dev/slimevr/config/serializers/BridgeConfigMapDeserializer.java delete mode 100644 server/core/src/main/java/dev/slimevr/config/serializers/FloatMapDeserializer.java delete mode 100644 server/core/src/main/java/dev/slimevr/config/serializers/MapDeserializer.java delete mode 100644 server/core/src/main/java/dev/slimevr/config/serializers/QuaternionDeserializer.java delete mode 100644 server/core/src/main/java/dev/slimevr/config/serializers/QuaternionSerializer.java delete mode 100644 server/core/src/main/java/dev/slimevr/config/serializers/TrackerConfigMapDeserializer.java delete mode 100644 server/core/src/main/java/dev/slimevr/filtering/CircularArrayList.java delete mode 100644 server/core/src/main/java/dev/slimevr/filtering/QuaternionMovingAverage.kt delete mode 100644 server/core/src/main/java/dev/slimevr/filtering/TrackerFilters.kt delete mode 100644 server/core/src/main/java/dev/slimevr/firmware/FirmwareUpdateHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/firmware/FirmwareUpdateListener.kt delete mode 100644 server/core/src/main/java/dev/slimevr/firmware/FirmwareUpdateMethod.kt delete mode 100644 server/core/src/main/java/dev/slimevr/firmware/FirmwareUpdateStatus.kt delete mode 100644 server/core/src/main/java/dev/slimevr/firmware/OTAUpdateTask.kt delete mode 100644 server/core/src/main/java/dev/slimevr/firmware/SerialFlashingHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/firmware/SerialRebootHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/firmware/UpdateDeviceId.kt delete mode 100644 server/core/src/main/java/dev/slimevr/firmware/UpdateStatusEvent.kt delete mode 100644 server/core/src/main/java/dev/slimevr/games/vrchat/VRCConfigHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/guards/ServerGuards.kt delete mode 100644 server/core/src/main/java/dev/slimevr/math/Angle.kt delete mode 100644 server/core/src/main/java/dev/slimevr/math/AngleAverage.kt delete mode 100644 server/core/src/main/java/dev/slimevr/math/AngleErrors.kt delete mode 100644 server/core/src/main/java/dev/slimevr/osc/OSCHandler.java delete mode 100644 server/core/src/main/java/dev/slimevr/osc/OSCRouter.java delete mode 100644 server/core/src/main/java/dev/slimevr/osc/UnityArmature.kt delete mode 100644 server/core/src/main/java/dev/slimevr/osc/UnityBone.kt delete mode 100644 server/core/src/main/java/dev/slimevr/osc/VMCHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/osc/VRCOSCHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/osc/VRCOSCQueryHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/osc/VRMReader.kt delete mode 100644 server/core/src/main/java/dev/slimevr/poseframeformat/PfrIO.kt delete mode 100644 server/core/src/main/java/dev/slimevr/poseframeformat/PfsIO.kt delete mode 100644 server/core/src/main/java/dev/slimevr/poseframeformat/PfsPackets.kt delete mode 100644 server/core/src/main/java/dev/slimevr/poseframeformat/PoseFrames.kt delete mode 100644 server/core/src/main/java/dev/slimevr/poseframeformat/PoseRecorder.kt delete mode 100644 server/core/src/main/java/dev/slimevr/poseframeformat/player/PlayerTracker.kt delete mode 100644 server/core/src/main/java/dev/slimevr/poseframeformat/player/TrackerFramesPlayer.kt delete mode 100644 server/core/src/main/java/dev/slimevr/poseframeformat/trackerdata/TrackerFrame.kt delete mode 100644 server/core/src/main/java/dev/slimevr/poseframeformat/trackerdata/TrackerFrameData.kt delete mode 100644 server/core/src/main/java/dev/slimevr/poseframeformat/trackerdata/TrackerFrames.kt delete mode 100644 server/core/src/main/java/dev/slimevr/posestreamer/BVHFileStream.kt delete mode 100644 server/core/src/main/java/dev/slimevr/posestreamer/BVHRecorder.kt delete mode 100644 server/core/src/main/java/dev/slimevr/posestreamer/BVHSettings.kt delete mode 100644 server/core/src/main/java/dev/slimevr/posestreamer/PoseDataStream.kt delete mode 100644 server/core/src/main/java/dev/slimevr/posestreamer/PoseFrameStreamer.kt delete mode 100644 server/core/src/main/java/dev/slimevr/posestreamer/PoseStreamer.kt delete mode 100644 server/core/src/main/java/dev/slimevr/posestreamer/ServerPoseStreamer.kt delete mode 100644 server/core/src/main/java/dev/slimevr/posestreamer/TickPoseStreamer.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/ConnectionContext.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/DataFeed.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/GenericConnection.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/ProtocolAPI.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/ProtocolAPIServer.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/ProtocolHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/datafeed/DataFeedBuilder.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/datafeed/DataFeedHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/pubsub/HashedTopicId.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/pubsub/PubSubHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/RPCBuilder.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/RPCUserHeightCalibration.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/autobone/RPCAutoBoneHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/firmware/RPCFirmwareUpdateHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/games/vrchat/RPCVRCBuilder.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/games/vrchat/RPCVRChatHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/reset/RPCResetHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/serial/RPCProvisioningHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/serial/RPCSerialHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/settings/RPCSettingsBuilder.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/settings/RPCSettingsHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/setup/RPCHandshakeHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/setup/RPCTapSetupHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/setup/RPCUtil.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/status/RPCStatusHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/trackingchecklist/RPCTrackingChecklistHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/protocol/rpc/trackingpause/RPCTrackingPause.kt delete mode 100644 server/core/src/main/java/dev/slimevr/reset/ResetHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/reset/ResetListener.kt delete mode 100644 server/core/src/main/java/dev/slimevr/reset/ResetTimer.kt delete mode 100644 server/core/src/main/java/dev/slimevr/serial/ProvisioningHandler.java delete mode 100644 server/core/src/main/java/dev/slimevr/serial/ProvisioningListener.java delete mode 100644 server/core/src/main/java/dev/slimevr/serial/ProvisioningStatus.kt delete mode 100644 server/core/src/main/java/dev/slimevr/serial/SerialHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/serial/SerialListener.kt delete mode 100644 server/core/src/main/java/dev/slimevr/setup/HandshakeHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/setup/TapSetupHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/status/StatusSystem.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/Bone.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/BoneType.java delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/Constraint.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/HumanPoseManager.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/TransformNode.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigManager.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigOffsets.java delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigToggles.java delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigValues.java delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/HumanSkeleton.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/IKChain.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/IKConstraint.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/IKSolver.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/LegTweaks.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/LegTweaksBuffer.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/Localizer.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/TapDetection.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/TapDetectionManager.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/UserHeightCalibration.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/StayAligned.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/StayAlignedDefaults.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/AdjustTrackerYaw.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/CenterErrorVisitor.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/CenterYaw.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/LockedErrorVisitor.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/NeighborErrorVisitor.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/TrackerYaw.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/poses/PlayerPose.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/poses/RelaxedPose.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/poses/TrackerPose.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/RestDetector.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/Side.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/StayAlignedTrackerState.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/TrackerSkeleton.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/YawErrors.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/Device.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/DeviceManager.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/Tracker.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerFilteringHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerFlexHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerPosition.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerResetsHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerRole.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerStatus.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerStatusListener.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerUtils.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/hid/HIDCommon.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/hid/HIDDevice.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/udp/FeatureFlags.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/udp/FirmwareConstants.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/udp/SensorTap.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/udp/TrackersUDPServer.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/udp/UDPDevice.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/udp/UDPPacket.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracking/trackers/udp/UDPProtocolParser.kt delete mode 100644 server/core/src/main/java/dev/slimevr/trackingchecklist/TrackingChecklistManager.kt delete mode 100644 server/core/src/main/java/dev/slimevr/trackingpause/TrackingPauseHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/trackingpause/TrackingPauseListener.kt delete mode 100644 server/core/src/main/java/dev/slimevr/util/InterpolationHandler.kt delete mode 100644 server/core/src/main/java/dev/slimevr/util/TickReducer.kt delete mode 100644 server/core/src/main/java/dev/slimevr/util/ann/VRServerThread.java delete mode 100644 server/core/src/main/java/dev/slimevr/websocketapi/WebSocketVRBridge.kt delete mode 100644 server/core/src/main/java/dev/slimevr/websocketapi/WebsocketAPI.java delete mode 100644 server/core/src/main/java/dev/slimevr/websocketapi/WebsocketConnection.java delete mode 100644 server/core/src/main/java/io/eiren/math/FloatMath.kt delete mode 100644 server/core/src/main/java/io/eiren/util/BufferedTimer.java delete mode 100644 server/core/src/main/java/io/eiren/util/OperatingSystem.kt delete mode 100644 server/core/src/main/java/io/eiren/util/StringUtils.java delete mode 100644 server/core/src/main/java/io/eiren/util/Util.java delete mode 100644 server/core/src/main/java/io/eiren/util/ann/AWTThread.java delete mode 100644 server/core/src/main/java/io/eiren/util/ann/DebugSwitch.java delete mode 100644 server/core/src/main/java/io/eiren/util/ann/NativeUnsafe.java delete mode 100644 server/core/src/main/java/io/eiren/util/ann/Synchronize.java delete mode 100644 server/core/src/main/java/io/eiren/util/ann/ThreadSafe.java delete mode 100644 server/core/src/main/java/io/eiren/util/ann/ThreadSafeSingle.java delete mode 100644 server/core/src/main/java/io/eiren/util/ann/ThreadSecure.java delete mode 100644 server/core/src/main/java/io/eiren/util/ann/Transient.java delete mode 100644 server/core/src/main/java/io/eiren/util/collections/FastList.java delete mode 100644 server/core/src/main/java/io/eiren/util/collections/RemoveAtSwapFastList.java delete mode 100644 server/core/src/main/java/io/eiren/util/collections/RemoveAtSwapList.java delete mode 100644 server/core/src/main/java/io/eiren/util/collections/ResettableIterator.java delete mode 100644 server/core/src/main/java/io/eiren/util/collections/SkipIterator.java delete mode 100644 server/core/src/main/java/io/eiren/util/logging/DefaultGLog.java delete mode 100644 server/core/src/main/java/io/eiren/util/logging/FileLogFormatter.java delete mode 100644 server/core/src/main/java/io/eiren/util/logging/FileLogHandler.kt delete mode 100644 server/core/src/main/java/io/eiren/util/logging/IGLog.java delete mode 100644 server/core/src/main/java/io/eiren/util/logging/LogManager.java delete mode 100644 server/core/src/main/java/io/eiren/util/logging/LoggerOutputStream.java delete mode 100644 server/core/src/main/java/io/eiren/util/logging/LoggerRecorder.java delete mode 100644 server/core/src/main/java/io/eiren/util/logging/PreciseConsoleLogFormatter.java delete mode 100644 server/core/src/main/java/io/eiren/util/logging/ShortConsoleLogFormatter.java delete mode 100644 server/core/src/test/java/dev/slimevr/unit/LegTweaksTests.kt delete mode 100644 server/core/src/test/java/dev/slimevr/unit/MountingResetTests.kt delete mode 100644 server/core/src/test/java/dev/slimevr/unit/ReferenceAdjustmentsTests.kt delete mode 100644 server/core/src/test/java/dev/slimevr/unit/SkeletonResetTests.kt delete mode 100644 server/core/src/test/java/dev/slimevr/unit/TestTrackerSet.kt delete mode 100644 server/core/src/test/java/dev/slimevr/unit/TrackerTestUtils.kt delete mode 100644 server/core/src/test/java/dev/slimevr/unit/TrackingPauseTests.kt delete mode 100644 server/core/src/test/java/dev/slimevr/unit/TwinExtendedBackTests.kt delete mode 100644 server/desktop/protobuf_update.bat delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/DesktopNetworkProfileChecker.kt delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/firmware/DesktopSerialFlashingHandler.kt delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/games/vrchat/DesktopVRCConfigHandler.kt delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/games/vrchat/RegEdit.kt delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/platform/ProtobufBridge.kt delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/platform/ProtobufMessages.java delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/platform/SteamVRBridge.kt delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/SocketUtils.java delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketBridge.java delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketConnection.java delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketRpcBridge.java delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/platform/windows/PipeState.java delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/platform/windows/WindowsNamedPipeBridge.java delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/platform/windows/WindowsPipe.java delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/serial/DesktopSerialHandler.kt delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/tracking/trackers/hid/DesktopHIDManager.kt diff --git a/flake.nix b/flake.nix index 272217281c..96e33f387f 100644 --- a/flake.nix +++ b/flake.nix @@ -13,7 +13,7 @@ perSystem = { pkgs, ... }: let runtimeLibs = pkgs: (with pkgs; [ - jdk17 + jdk22 alsa-lib at-spi2-atk at-spi2-core cairo cups dbus expat gdk-pixbuf glib gtk3 libdrm libgbm libglvnd libnotify @@ -33,8 +33,8 @@ name = "slimevr-env"; targetPkgs = runtimeLibs; profile = '' - export JAVA_HOME=${pkgs.jdk17} - export PATH="${pkgs.jdk17}/bin:$PATH" + export JAVA_HOME=${pkgs.jdk22} + export PATH="${pkgs.jdk22}/bin:$PATH" # Tell electron-builder to use system tools instead of downloading them export USE_SYSTEM_FPM=true diff --git a/server/android/build.gradle.kts b/server/android/build.gradle.kts index e48cd0c3fb..694ff83927 100644 --- a/server/android/build.gradle.kts +++ b/server/android/build.gradle.kts @@ -22,12 +22,12 @@ plugins { kotlin { jvmToolchain { - languageVersion.set(JavaLanguageVersion.of(17)) + languageVersion.set(JavaLanguageVersion.of(22)) } } java { toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) + languageVersion.set(JavaLanguageVersion.of(22)) } } @@ -83,7 +83,7 @@ val deleteTempKeyStore = tasks.register("deleteTempKeyStore") { tasks.withType { compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + jvmTarget.set(JvmTarget.JVM_22) freeCompilerArgs.set(listOf("-Xvalue-classes")) } } @@ -217,7 +217,7 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_22 + targetCompatibility = JavaVersion.VERSION_22 } } diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index 0f34ebb915..a98a9d9e2c 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -14,20 +14,19 @@ plugins { `java-library` } -// FIXME: Please replace these to Java 11 as that's what they actually are kotlin { jvmToolchain { - languageVersion.set(JavaLanguageVersion.of(17)) + languageVersion.set(JavaLanguageVersion.of(22)) } } java { toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) + languageVersion.set(JavaLanguageVersion.of(22)) } } tasks.withType { compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + jvmTarget.set(JvmTarget.JVM_22) freeCompilerArgs.set(listOf("-Xvalue-classes")) } } @@ -60,17 +59,7 @@ allprojects { dependencies { implementation(project(":solarxr-protocol")) - // This dependency is used internally, - // and not exposed to consumers on their own compile classpath. implementation("com.google.flatbuffers:flatbuffers-java:22.10.26") - implementation("commons-cli:commons-cli:1.11.0") - implementation("com.fasterxml.jackson.core:jackson-databind:2.21.0") - implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.0") - - implementation("com.github.jonpeterson:jackson-module-model-versioning:1.2.2") - implementation("org.apache.commons:commons-math3:3.6.1") - implementation("org.apache.commons:commons-lang3:3.20.0") - implementation("org.apache.commons:commons-collections4:4.5.0") implementation("com.illposed.osc:javaosc-core:0.9") implementation("org.java-websocket:Java-WebSocket:1.+") diff --git a/server/core/src/main/java/dev/slimevr/Keybinding.kt b/server/core/src/main/java/dev/slimevr/Keybinding.kt deleted file mode 100644 index 9f20f768a8..0000000000 --- a/server/core/src/main/java/dev/slimevr/Keybinding.kt +++ /dev/null @@ -1,96 +0,0 @@ -package dev.slimevr - -import com.melloware.jintellitype.HotkeyListener -import com.melloware.jintellitype.JIntellitype -import dev.slimevr.config.KeybindingsConfig -import dev.slimevr.tracking.trackers.TrackerUtils -import io.eiren.util.OperatingSystem -import io.eiren.util.OperatingSystem.Companion.currentPlatform -import io.eiren.util.ann.AWTThread -import io.eiren.util.logging.LogManager - -class Keybinding @AWTThread constructor(val server: VRServer) : HotkeyListener { - val config: KeybindingsConfig = server.configManager.vrConfig.keybindings - - init { - if (currentPlatform != OperatingSystem.WINDOWS) { - LogManager - .info( - "[Keybinding] Currently only supported on Windows. Keybindings will be disabled.", - ) - } else { - try { - if (JIntellitype.getInstance() != null) { - JIntellitype.getInstance().addHotKeyListener(this) - - val fullResetBinding = config.fullResetBinding - JIntellitype.getInstance() - .registerHotKey(FULL_RESET, fullResetBinding) - LogManager.info("[Keybinding] Bound full reset to $fullResetBinding") - - val yawResetBinding = config.yawResetBinding - JIntellitype.getInstance() - .registerHotKey(YAW_RESET, yawResetBinding) - LogManager.info("[Keybinding] Bound yaw reset to $yawResetBinding") - - val mountingResetBinding = config.mountingResetBinding - JIntellitype.getInstance() - .registerHotKey(MOUNTING_RESET, mountingResetBinding) - LogManager.info("[Keybinding] Bound reset mounting to $mountingResetBinding") - - val feetMountingResetBinding = config.feetMountingResetBinding - JIntellitype.getInstance() - .registerHotKey(FEET_MOUNTING_RESET, feetMountingResetBinding) - LogManager.info("[Keybinding] Bound feet reset mounting to $feetMountingResetBinding") - - val pauseTrackingBinding = config.pauseTrackingBinding - JIntellitype.getInstance() - .registerHotKey(PAUSE_TRACKING, pauseTrackingBinding) - LogManager.info("[Keybinding] Bound pause tracking to $pauseTrackingBinding") - } - } catch (e: Throwable) { - LogManager - .warning( - "[Keybinding] JIntellitype initialization failed. Keybindings will be disabled. Try restarting your computer.", - ) - } - } - } - - @AWTThread - override fun onHotKey(identifier: Int) { - when (identifier) { - FULL_RESET -> server.scheduleResetTrackersFull(RESET_SOURCE_NAME, config.fullResetDelay) - - YAW_RESET -> server.scheduleResetTrackersYaw(RESET_SOURCE_NAME, config.yawResetDelay) - - MOUNTING_RESET -> server.scheduleResetTrackersMounting( - RESET_SOURCE_NAME, - config.mountingResetDelay, - ) - - FEET_MOUNTING_RESET -> server.scheduleResetTrackersMounting( - RESET_SOURCE_NAME, - config.feetMountingResetDelay, - TrackerUtils.feetsBodyParts, - ) - - PAUSE_TRACKING -> - server - .scheduleTogglePauseTracking( - RESET_SOURCE_NAME, - config.pauseTrackingDelay, - ) - } - } - - companion object { - private const val RESET_SOURCE_NAME = "Keybinding" - - private const val FULL_RESET = 1 - private const val YAW_RESET = 2 - private const val MOUNTING_RESET = 3 - private const val FEET_MOUNTING_RESET = 4 - private const val PAUSE_TRACKING = 5 - } -} diff --git a/server/core/src/main/java/dev/slimevr/NetworkProfileChecker.kt b/server/core/src/main/java/dev/slimevr/NetworkProfileChecker.kt deleted file mode 100644 index 4c6e77b0ab..0000000000 --- a/server/core/src/main/java/dev/slimevr/NetworkProfileChecker.kt +++ /dev/null @@ -1,59 +0,0 @@ -package dev.slimevr - -data class NetworkInfo( - val name: String?, - val description: String?, - val category: NetworkCategory?, - val connectivity: Set?, - val connected: Boolean?, -) - -/** - * @see NLM_NETWORK_CATEGORY enumeration (netlistmgr.h) - */ -enum class NetworkCategory(val value: Int) { - PUBLIC(0), - PRIVATE(1), - DOMAIN_AUTHENTICATED(2), - ; - - companion object { - fun fromInt(value: Int) = values().find { it.value == value } - } -} - -/** - * @see NLM_CONNECTIVITY enumeration (netlistmgr.h) - */ -enum class ConnectivityFlags(val value: Int) { - DISCONNECTED(0), - IPV4_NOTRAFFIC(0x1), - IPV6_NOTRAFFIC(0x2), - IPV4_SUBNET(0x10), - IPV4_LOCALNETWORK(0x20), - IPV4_INTERNET(0x40), - IPV6_SUBNET(0x100), - IPV6_LOCALNETWORK(0x200), - IPV6_INTERNET(0x400), - ; - - companion object { - fun fromInt(value: Int): Set = if (value == 0) { - setOf(DISCONNECTED) - } else { - values().filter { it != DISCONNECTED && (value and it.value) != 0 }.toSet() - } - } -} - -abstract class NetworkProfileChecker { - abstract val isSupported: Boolean - abstract val publicNetworks: List -} - -class NetworkProfileCheckerStub : NetworkProfileChecker() { - override val isSupported: Boolean - get() = false - override val publicNetworks: List - get() = listOf() -} diff --git a/server/core/src/main/java/dev/slimevr/NetworkProtocol.java b/server/core/src/main/java/dev/slimevr/NetworkProtocol.java deleted file mode 100644 index 07366b1c81..0000000000 --- a/server/core/src/main/java/dev/slimevr/NetworkProtocol.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.slimevr; - -public enum NetworkProtocol { - OWO_LEGACY, - SLIMEVR_RAW, - SLIMEVR_FLATBUFFER, - SLIMEVR_WEBSOCKET -} diff --git a/server/core/src/main/java/dev/slimevr/VRServer.kt b/server/core/src/main/java/dev/slimevr/VRServer.kt index 4db758d06d..b7fd794e3d 100644 --- a/server/core/src/main/java/dev/slimevr/VRServer.kt +++ b/server/core/src/main/java/dev/slimevr/VRServer.kt @@ -1,487 +1,4 @@ package dev.slimevr -import com.jme3.system.NanoTimer -import dev.slimevr.autobone.AutoBoneHandler -import dev.slimevr.bridge.Bridge -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.guards.ServerGuards -import dev.slimevr.osc.OSCHandler -import dev.slimevr.osc.OSCRouter -import dev.slimevr.osc.VMCHandler -import dev.slimevr.osc.VRCOSCHandler -import dev.slimevr.posestreamer.BVHRecorder -import dev.slimevr.protocol.ProtocolAPI -import dev.slimevr.protocol.rpc.settings.RPCSettingsHandler -import dev.slimevr.reset.ResetHandler -import dev.slimevr.reset.ResetTimerManager -import dev.slimevr.reset.resetTimer -import dev.slimevr.serial.ProvisioningHandler -import dev.slimevr.serial.SerialHandler -import dev.slimevr.serial.SerialHandlerStub -import dev.slimevr.setup.HandshakeHandler -import dev.slimevr.setup.TapSetupHandler -import dev.slimevr.status.StatusSystem -import dev.slimevr.tracking.processor.HumanPoseManager -import dev.slimevr.tracking.processor.skeleton.HumanSkeleton -import dev.slimevr.tracking.trackers.* -import dev.slimevr.tracking.trackers.udp.TrackersUDPServer -import dev.slimevr.trackingchecklist.TrackingChecklistManager -import dev.slimevr.util.ann.VRServerThread -import dev.slimevr.websocketapi.WebSocketVRBridge -import io.eiren.util.ann.ThreadSafe -import io.eiren.util.ann.ThreadSecure -import io.eiren.util.collections.FastList -import io.eiren.util.logging.LogManager -import solarxr_protocol.datatypes.TrackerIdT -import solarxr_protocol.rpc.ResetType -import java.util.* -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.atomic.AtomicInteger -import java.util.function.Consumer -import kotlin.collections.ArrayList -import kotlin.concurrent.schedule - -typealias BridgeProvider = ( - server: VRServer, - computedTrackers: List, -) -> Sequence - -const val SLIMEVR_IDENTIFIER = "dev.slimevr.SlimeVR" - -class VRServer @JvmOverloads constructor( - bridgeProvider: BridgeProvider = { _, _ -> sequence {} }, - serialHandlerProvider: (VRServer) -> SerialHandler = { _ -> SerialHandlerStub() }, - flashingHandlerProvider: (VRServer) -> SerialFlashingHandler? = { _ -> null }, - vrcConfigHandlerProvider: (VRServer) -> VRCConfigHandler = { _ -> VRCConfigHandlerStub() }, - networkProfileProvider: (VRServer) -> NetworkProfileChecker = { _ -> NetworkProfileCheckerStub() }, - acquireMulticastLock: () -> Any? = { null }, - @JvmField val configManager: ConfigManager, -) : Thread("VRServer") { - - @JvmField - val humanPoseManager: HumanPoseManager - private val trackers: MutableList = FastList() - val trackersServer: TrackersUDPServer - private val bridges: MutableList = FastList() - private val tasks: Queue = LinkedBlockingQueue() - private val newTrackersConsumers: MutableList> = FastList() - private val trackerStatusListeners: MutableList = FastList() - private val onTick: MutableList = FastList() - private val lock = acquireMulticastLock() - val oSCRouter: OSCRouter - - @JvmField - val vrcOSCHandler: VRCOSCHandler - val vMCHandler: VMCHandler - - @JvmField - val deviceManager: DeviceManager - - @JvmField - val bvhRecorder: BVHRecorder - - @JvmField - val serialHandler: SerialHandler - - var serialFlashingHandler: SerialFlashingHandler? - - val firmwareUpdateHandler: FirmwareUpdateHandler - - val vrcConfigManager: VRChatConfigManager - - @JvmField - val autoBoneHandler: AutoBoneHandler - - @JvmField - val tapSetupHandler: TapSetupHandler - - @JvmField - val protocolAPI: ProtocolAPI - private val timer = Timer() - private val resetTimerManager = ResetTimerManager() - val fpsTimer = NanoTimer() - - @JvmField - val provisioningHandler: ProvisioningHandler - - @JvmField - val resetHandler: ResetHandler - - @JvmField - val statusSystem = StatusSystem() - - @JvmField - val handshakeHandler = HandshakeHandler() - - val trackingChecklistManager: TrackingChecklistManager - - val networkProfileChecker: NetworkProfileChecker - - val serverGuards = ServerGuards() - - init { - // UwU - deviceManager = DeviceManager(this) - serialHandler = serialHandlerProvider(this) - serialFlashingHandler = flashingHandlerProvider(this) - provisioningHandler = ProvisioningHandler(this) - resetHandler = ResetHandler() - tapSetupHandler = TapSetupHandler() - humanPoseManager = HumanPoseManager(this) - // AutoBone requires HumanPoseManager first - autoBoneHandler = AutoBoneHandler(this) - firmwareUpdateHandler = FirmwareUpdateHandler(this) - vrcConfigManager = VRChatConfigManager(this, vrcConfigHandlerProvider(this)) - networkProfileChecker = networkProfileProvider(this) - trackingChecklistManager = TrackingChecklistManager(this) - protocolAPI = ProtocolAPI(this) - val computedTrackers = humanPoseManager.computedTrackers - - // Start server for SlimeVR trackers - val trackerPort = configManager.vrConfig.server.trackerPort - LogManager.info("Starting the tracker server on port $trackerPort...") - trackersServer = TrackersUDPServer( - trackerPort, - "Sensors UDP server", - ) { tracker: Tracker -> registerTracker(tracker) } - - // Start bridges and WebSocket server - for (bridge in bridgeProvider(this, computedTrackers) + sequenceOf(WebSocketVRBridge(computedTrackers, this))) { - tasks.add(Runnable { bridge.startBridge() }) - bridges.add(bridge) - } - - // Initialize OSC handlers - vrcOSCHandler = VRCOSCHandler( - this, - configManager.vrConfig.vrcOSC, - computedTrackers, - ) - vMCHandler = VMCHandler( - this, - humanPoseManager, - configManager.vrConfig.vmc, - ) - - // Initialize OSC router - val oscHandlers = FastList() - oscHandlers.add(vrcOSCHandler) - oscHandlers.add(vMCHandler) - oSCRouter = OSCRouter(configManager.vrConfig.oscRouter, oscHandlers) - bvhRecorder = BVHRecorder(this) - for (tracker in computedTrackers) { - registerTracker(tracker) - } - - instance = this - } - - fun hasBridge(bridgeClass: Class): Boolean { - for (bridge in bridges) { - if (bridgeClass.isAssignableFrom(bridge.javaClass)) { - return true - } - } - return false - } - - // FIXME: Code using this function normally uses this to get the SteamVR driver but - // that's because we first save the SteamVR driver bridge and then the feeder in the array. - // Not really a great thing to have. - @ThreadSafe - fun getVRBridge(bridgeClass: Class): E? { - for (bridge in bridges) { - if (bridgeClass.isAssignableFrom(bridge.javaClass)) { - return bridgeClass.cast(bridge) - } - } - return null - } - - fun addOnTick(runnable: Runnable) { - onTick.add(runnable) - } - - @ThreadSafe - fun addNewTrackerConsumer(consumer: Consumer) { - queueTask { - newTrackersConsumers.add(consumer) - for (tracker in trackers) { - consumer.accept(tracker) - } - } - } - - @ThreadSafe - fun trackerUpdated(tracker: Tracker?) { - queueTask { - humanPoseManager.trackerUpdated(tracker) - updateSkeletonModel() - refreshTrackersDriftCompensationEnabled() - configManager.vrConfig.writeTrackerConfig(tracker) - configManager.saveConfig() - } - } - - @ThreadSafe - fun addSkeletonUpdatedCallback(consumer: Consumer) { - queueTask { humanPoseManager.addSkeletonUpdatedCallback(consumer) } - } - - @VRServerThread - override fun run() { - trackersServer.start() - while (true) { - // final long start = System.currentTimeMillis(); - fpsTimer.update() - do { - val task = tasks.poll() ?: break - task.run() - } while (true) - for (task in onTick) { - task.run() - } - for (bridge in bridges) { - bridge.dataRead() - } - for (tracker in trackers) { - tracker.tick(fpsTimer.timePerFrame) - } - humanPoseManager.update() - for (bridge in bridges) { - bridge.dataWrite() - } - vrcOSCHandler.update() - vMCHandler.update() - // final long time = System.currentTimeMillis() - start; - try { - sleep(1) // 1000Hz - } catch (error: InterruptedException) { - LogManager.info("VRServer thread interrupted") - break - } - } - } - - @ThreadSafe - fun queueTask(r: Runnable) { - tasks.add(r) - } - - @VRServerThread - private fun trackerAdded(tracker: Tracker) { - humanPoseManager.trackerAdded(tracker) - updateSkeletonModel() - if (tracker.isComputed) { - vMCHandler.addComputedTracker(tracker) - } - refreshTrackersDriftCompensationEnabled() - } - - @ThreadSecure - fun registerTracker(tracker: Tracker) { - configManager.vrConfig.readTrackerConfig(tracker) - queueTask { - trackers.add(tracker) - trackerAdded(tracker) - for (tc in newTrackersConsumers) { - tc.accept(tracker) - } - } - } - - @ThreadSafe - fun updateSkeletonModel() { - queueTask { - humanPoseManager.updateSkeletonModelFromServer() - vrcOSCHandler.setHeadTracker(TrackerUtils.getTrackerForSkeleton(trackers, TrackerPosition.HEAD)) - if (this.getVRBridge(ISteamVRBridge::class.java)?.updateShareSettingsAutomatically() == true) { - RPCSettingsHandler.sendSteamVRUpdatedSettings(protocolAPI, protocolAPI.rpcHandler) - } - } - } - - fun resetTrackersFull(resetSourceName: String?, bodyParts: List = ArrayList()) { - queueTask { humanPoseManager.resetTrackersFull(resetSourceName, bodyParts) } - } - - fun resetTrackersYaw(resetSourceName: String?, bodyParts: List = TrackerUtils.allBodyPartsButFingers) { - queueTask { humanPoseManager.resetTrackersYaw(resetSourceName, bodyParts) } - } - - fun resetTrackersMounting(resetSourceName: String?, bodyParts: List? = null) { - queueTask { humanPoseManager.resetTrackersMounting(resetSourceName, bodyParts) } - } - - fun clearTrackersMounting(resetSourceName: String?) { - queueTask { humanPoseManager.clearTrackersMounting(resetSourceName) } - } - - fun getPauseTracking(): Boolean = humanPoseManager.getPauseTracking() - - fun setPauseTracking(pauseTracking: Boolean, sourceName: String?) { - queueTask { - humanPoseManager.setPauseTracking(pauseTracking, sourceName) - // Toggle trackers as they don't toggle when tracking is paused - if (this.getVRBridge(ISteamVRBridge::class.java)?.updateShareSettingsAutomatically() == true) { - RPCSettingsHandler.sendSteamVRUpdatedSettings(protocolAPI, protocolAPI.rpcHandler) - } - } - } - - fun togglePauseTracking(sourceName: String?) { - queueTask { - humanPoseManager.togglePauseTracking(sourceName) - // Toggle trackers as they don't toggle when tracking is paused - if (this.getVRBridge(ISteamVRBridge::class.java)?.updateShareSettingsAutomatically() == true) { - RPCSettingsHandler.sendSteamVRUpdatedSettings(protocolAPI, protocolAPI.rpcHandler) - } - } - } - - fun scheduleResetTrackersFull(resetSourceName: String?, delay: Long, bodyParts: List = ArrayList()) { - resetTimer( - resetTimerManager, - delay, - onTick = { progress -> - resetHandler.sendStarted(ResetType.Full, bodyParts, progress, delay.toInt()) - }, - onComplete = { - queueTask { - humanPoseManager.resetTrackersFull(resetSourceName, bodyParts) - resetHandler.sendFinished(ResetType.Full, bodyParts, delay.toInt()) - } - }, - ) - } - - fun scheduleResetTrackersYaw(resetSourceName: String?, delay: Long, bodyParts: List = TrackerUtils.allBodyPartsButFingers) { - resetTimer( - resetTimerManager, - delay, - onTick = { progress -> - resetHandler.sendStarted(ResetType.Yaw, bodyParts, progress, delay.toInt()) - }, - onComplete = { - queueTask { - humanPoseManager.resetTrackersYaw(resetSourceName, bodyParts) - resetHandler.sendFinished(ResetType.Yaw, bodyParts, delay.toInt()) - } - }, - ) - } - - fun scheduleResetTrackersMounting(resetSourceName: String?, delay: Long, bodyParts: List? = null) { - resetTimer( - resetTimerManager, - delay, - onTick = { progress -> - resetHandler.sendStarted(ResetType.Mounting, bodyParts, progress, delay.toInt()) - }, - onComplete = { - queueTask { - humanPoseManager.resetTrackersMounting(resetSourceName, bodyParts) - resetHandler.sendFinished(ResetType.Mounting, bodyParts, delay.toInt()) - } - }, - ) - } - - fun scheduleSetPauseTracking(pauseTracking: Boolean, sourceName: String?, delay: Long) { - timer.schedule(delay) { - queueTask { humanPoseManager.setPauseTracking(pauseTracking, sourceName) } - } - } - - fun scheduleTogglePauseTracking(sourceName: String?, delay: Long) { - timer.schedule(delay) { - queueTask { humanPoseManager.togglePauseTracking(sourceName) } - } - } - - fun setLegTweaksEnabled(value: Boolean) { - queueTask { humanPoseManager.setLegTweaksEnabled(value) } - } - - fun setSkatingReductionEnabled(value: Boolean) { - queueTask { humanPoseManager.setSkatingCorrectionEnabled(value) } - } - - fun setFloorClipEnabled(value: Boolean) { - queueTask { humanPoseManager.setFloorClipEnabled(value) } - } - - val trackersCount: Int - get() = trackers.size - val allTrackers: List - get() = FastList(trackers) - - fun getTrackerById(id: TrackerIdT): Tracker? { - for (tracker in trackers) { - if (tracker.trackerNum != id.trackerNum) { - continue - } - - // Handle synthetic devices - if (id.deviceId == null && tracker.device == null) { - return tracker - } - if (tracker.device != null && id.deviceId != null && id.deviceId.id == tracker.device.id) { - // This is a physical tracker, and both device id and the - // tracker num match - return tracker - } - } - return null - } - - fun clearTrackersDriftCompensation() { - for (t in allTrackers) { - if (t.isImu()) { - t.resetsHandler.clearDriftCompensation() - } - } - } - - fun refreshTrackersDriftCompensationEnabled() { - for (t in allTrackers) { - if (t.isImu()) { - t.resetsHandler.refreshDriftCompensationEnabled() - } - } - } - - fun trackerStatusChanged(tracker: Tracker, oldStatus: TrackerStatus, newStatus: TrackerStatus) { - trackerStatusListeners.forEach { it.onTrackerStatusChanged(tracker, oldStatus, newStatus) } - } - - fun addTrackerStatusListener(listener: TrackerStatusListener) { - trackerStatusListeners.add(listener) - } - - fun removeTrackerStatusListener(listener: TrackerStatusListener) { - trackerStatusListeners.removeIf { listener == it } - } - - companion object { - private val nextLocalTrackerId = AtomicInteger() - lateinit var instance: VRServer - private set - - val instanceInitialized: Boolean - get() = ::instance.isInitialized - - @JvmStatic - fun getNextLocalTrackerId(): Int = nextLocalTrackerId.incrementAndGet() - - @JvmStatic - val currentLocalTrackerId: Int - get() = nextLocalTrackerId.get() - } +class VRServer { } diff --git a/server/core/src/main/java/dev/slimevr/autobone/AutoBone.kt b/server/core/src/main/java/dev/slimevr/autobone/AutoBone.kt deleted file mode 100644 index 179602d615..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/AutoBone.kt +++ /dev/null @@ -1,698 +0,0 @@ -package dev.slimevr.autobone - -import dev.slimevr.SLIMEVR_IDENTIFIER -import dev.slimevr.VRServer -import dev.slimevr.autobone.errors.* -import dev.slimevr.config.AutoBoneConfig -import dev.slimevr.config.SkeletonConfig -import dev.slimevr.poseframeformat.PfrIO -import dev.slimevr.poseframeformat.PfsIO -import dev.slimevr.poseframeformat.PoseFrames -import dev.slimevr.tracking.processor.HumanPoseManager -import dev.slimevr.tracking.processor.config.SkeletonConfigManager -import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets -import dev.slimevr.tracking.trackers.TrackerRole -import io.eiren.util.OperatingSystem -import io.eiren.util.StringUtils -import io.eiren.util.collections.FastList -import io.eiren.util.logging.LogManager -import io.github.axisangles.ktmath.Vector3 -import org.apache.commons.lang3.tuple.Pair -import java.io.File -import java.util.* -import java.util.function.Consumer -import java.util.function.Function -import kotlin.math.* - -class AutoBone(private val server: VRServer) { - // This is filled by loadConfigValues() - val offsets = EnumMap( - SkeletonConfigOffsets::class.java, - ) - val adjustOffsets = FastList( - arrayOf( - SkeletonConfigOffsets.HEAD, - SkeletonConfigOffsets.NECK, - SkeletonConfigOffsets.UPPER_CHEST, - SkeletonConfigOffsets.CHEST, - SkeletonConfigOffsets.WAIST, - SkeletonConfigOffsets.HIP, - // HIPS_WIDTH now works when using body proportion error! It's not the - // best still, but it is somewhat functional - SkeletonConfigOffsets.HIPS_WIDTH, - SkeletonConfigOffsets.UPPER_LEG, - SkeletonConfigOffsets.LOWER_LEG, - ), - ) - - var estimatedHeight: Float = 1f - - // The total height of the normalized adjusted offsets - var adjustedHeightNormalized: Float = 1f - - // #region Error functions - var slideError = SlideError() - var offsetSlideError = OffsetSlideError() - var footHeightOffsetError = FootHeightOffsetError() - var bodyProportionError = BodyProportionError() - var heightError = HeightError() - var positionError = PositionError() - var positionOffsetError = PositionOffsetError() - // #endregion - - val globalConfig: AutoBoneConfig = server.configManager.vrConfig.autoBone - val globalSkeletonConfig: SkeletonConfig = server.configManager.vrConfig.skeleton - - init { - loadConfigValues() - } - - private fun loadConfigValues() { - // Remove all previous values - offsets.clear() - - // Get current or default skeleton configs - val skeleton = server.humanPoseManager - // Still compensate for a null skeleton, as it may not be initialized yet - val getOffset: Function = - if (skeleton != null) { - Function { key: SkeletonConfigOffsets -> skeleton.getOffset(key) } - } else { - val defaultConfig = SkeletonConfigManager(false) - Function { config: SkeletonConfigOffsets -> - defaultConfig.getOffset(config) - } - } - for (bone in adjustOffsets) { - val offset = getOffset.apply(bone) - if (offset > 0f) { - offsets[bone] = offset - } - } - } - - fun applyConfig( - humanPoseManager: HumanPoseManager, - offsets: Map = this.offsets, - ) { - for ((offset, value) in offsets) { - humanPoseManager.setOffset(offset, value) - } - } - - @JvmOverloads - fun applyAndSaveConfig(humanPoseManager: HumanPoseManager? = this.server.humanPoseManager): Boolean { - if (humanPoseManager == null) return false - applyConfig(humanPoseManager) - humanPoseManager.saveConfig() - server.configManager.saveConfig() - LogManager.info("[AutoBone] Configured skeleton bone lengths") - return true - } - - fun calcTargetHmdHeight( - frames: PoseFrames, - config: AutoBoneConfig = globalConfig, - ): Float { - val targetHeight: Float - // Get the current skeleton from the server - val humanPoseManager = server.humanPoseManager - // Still compensate for a null skeleton, as it may not be initialized yet - @Suppress("SENSELESS_COMPARISON") - if (config.useSkeletonHeight && humanPoseManager != null) { - // If there is a skeleton available, calculate the target height - // from its configs - targetHeight = humanPoseManager.userHeightFromConfig - LogManager - .warning( - "[AutoBone] Target height loaded from skeleton (Make sure you reset before running!): $targetHeight", - ) - } else { - // Otherwise if there is no skeleton available, attempt to get the - // max HMD height from the recording - val hmdHeight = frames.maxHmdHeight - if (hmdHeight <= MIN_HEIGHT) { - LogManager - .warning( - "[AutoBone] Max headset height detected (Value seems too low, did you not stand up straight while measuring?): $hmdHeight", - ) - } else { - LogManager.info("[AutoBone] Max headset height detected: $hmdHeight") - } - - // Estimate target height from HMD height - targetHeight = hmdHeight - } - return targetHeight - } - - private fun updateRecordingScale(step: PoseFrameStep, scale: Float) { - step.framePlayer1.setScales(scale) - step.framePlayer2.setScales(scale) - step.skeleton1.update() - step.skeleton2.update() - } - - fun filterFrames(frames: PoseFrames, step: PoseFrameStep) { - // Calculate the initial frame errors and recording stats - val frameErrors = FloatArray(frames.maxFrameCount) - val frameStats = StatsCalculator() - val recordingStats = StatsCalculator() - for (i in 0 until frames.maxFrameCount) { - frameStats.reset() - for (j in 0 until frames.maxFrameCount) { - if (i == j) continue - - step.setCursors( - i, - j, - updatePlayerCursors = true, - ) - - frameStats.addValue(getErrorDeriv(step)) - } - frameErrors[i] = frameStats.mean - recordingStats.addValue(frameStats.mean) - // LogManager.info("[AutoBone] Frame: ${i + 1}, Mean error: ${frameStats.mean} (SD ${frameStats.standardDeviation})") - } - LogManager.info("[AutoBone] Full recording mean error: ${frameStats.mean} (SD ${frameStats.standardDeviation})") - - // Remove outlier frames - val sdMult = 1.4f - val mean = recordingStats.mean - val sd = recordingStats.standardDeviation * sdMult - for (i in frameErrors.size - 1 downTo 0) { - val err = frameErrors[i] - if (err < mean - sd || err > mean + sd) { - for (frameHolder in frames.frameHolders) { - frameHolder.frames.removeAt(i) - } - } - } - step.maxFrameCount = frames.maxFrameCount - - // Calculate and print the resulting recording stats - recordingStats.reset() - for (i in 0 until frames.maxFrameCount) { - frameStats.reset() - for (j in 0 until frames.maxFrameCount) { - if (i == j) continue - - step.setCursors( - i, - j, - updatePlayerCursors = true, - ) - - frameStats.addValue(getErrorDeriv(step)) - } - recordingStats.addValue(frameStats.mean) - } - LogManager.info("[AutoBone] Full recording after mean error: ${frameStats.mean} (SD ${frameStats.standardDeviation})") - } - - @Throws(AutoBoneException::class) - fun processFrames( - frames: PoseFrames, - config: AutoBoneConfig = globalConfig, - skeletonConfig: SkeletonConfig = globalSkeletonConfig, - epochCallback: Consumer? = null, - ): AutoBoneResults { - check(frames.frameHolders.isNotEmpty()) { "Recording has no trackers." } - check(frames.maxFrameCount > 0) { "Recording has no frames." } - - // Load current values for adjustable configs - loadConfigValues() - - // Set the target heights either from config or calculate them - val targetHmdHeight = if (skeletonConfig.userHeight > MIN_HEIGHT) { - skeletonConfig.userHeight - } else { - calcTargetHmdHeight(frames, config) - } - check(targetHmdHeight > MIN_HEIGHT) { "Configured height ($targetHmdHeight) is too small (<= $MIN_HEIGHT)." } - - // Set up the current state, making all required players and setting up the - // skeletons appropriately - val step = PoseFrameStep( - config = config, - serverConfig = server.configManager, - frames = frames, - preEpoch = { step -> - // Set the current adjust rate based on the current epoch - step.data.adjustRate = decayFunc(step.config.initialAdjustRate, step.config.adjustRateDecay, step.epoch) - }, - onStep = this::step, - postEpoch = { step -> epoch(step, epochCallback) }, - randomSeed = config.randSeed, - data = AutoBoneStep( - targetHmdHeight = targetHmdHeight, - adjustRate = 1f, - ), - ) - - // Normalize the skeletons and get the normalized height for adjusted offsets - scaleSkeleton(step.skeleton1) - scaleSkeleton(step.skeleton2) - adjustedHeightNormalized = sumAdjustedHeightOffsets(step.skeleton1) - - // Normalize offsets based on the initial normalized skeleton - scaleOffsets() - - // Apply the initial normalized config values - applyConfig(step.skeleton1) - applyConfig(step.skeleton2) - - // Initialize normalization to the set target height (also updates skeleton) - estimatedHeight = targetHmdHeight - updateRecordingScale(step, 1f / targetHmdHeight) - - if (config.useFrameFiltering) { - filterFrames(frames, step) - } - - // Iterate frames now that it's set up - PoseFrameIterator.iterateFrames(step) - - // Scale the normalized offsets to the estimated height for the final result - for (entry in offsets.entries) { - entry.setValue(entry.value * estimatedHeight) - } - - LogManager - .info( - "[AutoBone] Target height: ${step.data.targetHmdHeight}, Final height: $estimatedHeight", - ) - if (step.data.errorStats.mean > config.maxFinalError) { - throw AutoBoneException("The final epoch error value (${step.data.errorStats.mean}) has exceeded the maximum allowed value (${config.maxFinalError}).") - } - - return AutoBoneResults( - estimatedHeight, - step.data.targetHmdHeight, - offsets, - ) - } - - private fun epoch( - step: PoseFrameStep, - epochCallback: Consumer? = null, - ) { - val config = step.config - val epoch = step.epoch - - // Calculate average error over the epoch - if (epoch <= 0 || epoch >= config.numEpochs - 1 || (epoch + 1) % config.printEveryNumEpochs == 0) { - LogManager - .info( - "[AutoBone] Epoch: ${epoch + 1}, Mean error: ${step.data.errorStats.mean} (SD ${step.data.errorStats.standardDeviation}), Adjust rate: ${step.data.adjustRate}", - ) - LogManager - .info( - "[AutoBone] Target height: ${step.data.targetHmdHeight}, Estimated height: $estimatedHeight", - ) - } - - if (epochCallback != null) { - // Scale the normalized offsets to the estimated height for the callback - val scaledOffsets = EnumMap(offsets) - for (entry in scaledOffsets.entries) { - entry.setValue(entry.value * estimatedHeight) - } - epochCallback.accept(Epoch(epoch + 1, config.numEpochs, step.data.errorStats, scaledOffsets)) - } - } - - private fun step(step: PoseFrameStep) { - // Pull frequently used variables out of trainingStep to reduce call length - val skeleton1 = step.skeleton1 - val skeleton2 = step.skeleton2 - - // Scaling each step used to mean enforcing the target height, so keep that - // behaviour to retain predictability - if (!step.config.scaleEachStep) { - // Try to estimate a new height by calculating the height with the lowest - // error between adding or subtracting from the height - val maxHeight = step.data.targetHmdHeight + 0.2f - val minHeight = step.data.targetHmdHeight - 0.2f - - step.data.hmdHeight = estimatedHeight - val heightErrorDeriv = getErrorDeriv(step) - val heightAdjust = errorFunc(heightErrorDeriv) * step.data.adjustRate - - val negHeight = (estimatedHeight - heightAdjust).coerceIn(minHeight, maxHeight) - updateRecordingScale(step, 1f / negHeight) - step.data.hmdHeight = negHeight - val negHeightErrorDeriv = getErrorDeriv(step) - - val posHeight = (estimatedHeight + heightAdjust).coerceIn(minHeight, maxHeight) - updateRecordingScale(step, 1f / posHeight) - step.data.hmdHeight = posHeight - val posHeightErrorDeriv = getErrorDeriv(step) - - if (negHeightErrorDeriv < heightErrorDeriv && negHeightErrorDeriv < posHeightErrorDeriv) { - estimatedHeight = negHeight - // Apply the negative height scale - updateRecordingScale(step, 1f / negHeight) - } else if (posHeightErrorDeriv < heightErrorDeriv) { - estimatedHeight = posHeight - // The last estimated height set was the positive adjustment, so no need to apply it again - } else { - // Reset to the initial scale - updateRecordingScale(step, 1f / estimatedHeight) - } - } - - // Update the heights used for error calculations - step.data.hmdHeight = estimatedHeight - - val errorDeriv = getErrorDeriv(step) - val error = errorFunc(errorDeriv) - - // In case of fire - if (java.lang.Float.isNaN(error) || java.lang.Float.isInfinite(error)) { - // Extinguish - LogManager - .warning( - "[AutoBone] Error value is invalid, resetting variables to recover", - ) - // Reset adjustable config values - loadConfigValues() - - // Reset error sum values - step.data.errorStats.reset() - - // Continue on new data - return - } - - // Store the error count for logging purposes - step.data.errorStats.addValue(errorDeriv) - val adjustVal = error * step.data.adjustRate - - // If there is no adjustment whatsoever, skip this - if (adjustVal == 0f) { - return - } - - val slideL = skeleton2.getComputedTracker(TrackerRole.LEFT_FOOT).position - - skeleton1.getComputedTracker(TrackerRole.LEFT_FOOT).position - val slideLLen = slideL.len() - val slideLUnit: Vector3? = if (slideLLen > MIN_SLIDE_DIST) slideL / slideLLen else null - - val slideR = skeleton2.getComputedTracker(TrackerRole.RIGHT_FOOT).position - - skeleton1.getComputedTracker(TrackerRole.RIGHT_FOOT).position - val slideRLen = slideR.len() - val slideRUnit: Vector3? = if (slideRLen > MIN_SLIDE_DIST) slideR / slideRLen else null - - val intermediateOffsets = EnumMap(offsets) - for (entry in intermediateOffsets.entries) { - // Skip adjustment if the epoch is before starting (for logging only) or - // if there are no BoneTypes for this value - if (step.epoch < 0 || entry.key.affectedOffsets.isEmpty()) { - break - } - val originalLength = entry.value - - // Calculate the total effect of the bone based on change in rotation - val slideDot = BoneContribution.getSlideDot( - skeleton1, - skeleton2, - entry.key, - slideLUnit, - slideRUnit, - ) - val dotLength = originalLength * slideDot - - // Scale by the total effect of the bone - val curAdjustVal = adjustVal * -dotLength - if (curAdjustVal == 0f) { - continue - } - - val newLength = originalLength + curAdjustVal - // No small or negative numbers!!! Bad algorithm! - if (newLength < 0.01f) { - continue - } - - // Apply new offset length - skeleton1.setOffset(entry.key, newLength) - skeleton2.setOffset(entry.key, newLength) - scaleSkeleton(skeleton1, onlyAdjustedHeight = true) - scaleSkeleton(skeleton2, onlyAdjustedHeight = true) - - // Update the skeleton poses for the new offset length - skeleton1.update() - skeleton2.update() - - val newErrorDeriv = getErrorDeriv(step) - if (newErrorDeriv < errorDeriv) { - // Apply the adjusted length to the current adjusted offsets - entry.setValue(newLength) - } - - // Reset the skeleton values to minimize bias in other variables, it's applied later - applyConfig(skeleton1) - applyConfig(skeleton2) - } - - // Update the offsets from the adjusted ones - offsets.putAll(intermediateOffsets) - - // Normalize the scale, it will be upscaled to the target height later - // We only need to scale height offsets, as other offsets are not affected by height - scaleOffsets(onlyHeightOffsets = true) - - // Apply the normalized offsets to the skeleton - applyConfig(skeleton1) - applyConfig(skeleton2) - } - - /** - * Sums only the adjusted height offsets of the provided HumanPoseManager - */ - private fun sumAdjustedHeightOffsets(humanPoseManager: HumanPoseManager): Float { - var sum = 0f - SkeletonConfigManager.HEIGHT_OFFSETS.forEach { - if (!adjustOffsets.contains(it)) return@forEach - sum += humanPoseManager.getOffset(it) - } - return sum - } - - /** - * Sums only the height offsets of the provided offset map - */ - private fun sumHeightOffsets(offsets: EnumMap = this.offsets): Float { - var sum = 0f - SkeletonConfigManager.HEIGHT_OFFSETS.forEach { - sum += offsets[it] ?: return@forEach - } - return sum - } - - private fun scaleSkeleton(humanPoseManager: HumanPoseManager, targetHeight: Float = 1f, onlyAdjustedHeight: Boolean = false) { - // Get the scale to apply for the appropriate offsets - val scale = if (onlyAdjustedHeight) { - // Only adjusted height offsets - val adjHeight = sumAdjustedHeightOffsets(humanPoseManager) - // Remove the constant from the target, leaving only the target for adjusted height offsets - val adjTarget = targetHeight - (humanPoseManager.userHeightFromConfig - adjHeight) - // Return only the scale for adjusted offsets - adjTarget / adjHeight - } else { - targetHeight / humanPoseManager.userHeightFromConfig - } - - val offsets = if (onlyAdjustedHeight) SkeletonConfigManager.HEIGHT_OFFSETS else SkeletonConfigOffsets.values - for (offset in offsets) { - if (onlyAdjustedHeight && !adjustOffsets.contains(offset)) continue - humanPoseManager.setOffset(offset, humanPoseManager.getOffset(offset) * scale) - } - } - - private fun scaleOffsets(offsets: EnumMap = this.offsets, targetHeight: Float = adjustedHeightNormalized, onlyHeightOffsets: Boolean = false) { - // Get the scale to apply for the appropriate offsets - val scale = targetHeight / sumHeightOffsets(offsets) - - for (entry in offsets.entries) { - if (onlyHeightOffsets && !SkeletonConfigManager.HEIGHT_OFFSETS.contains(entry.key)) continue - entry.setValue(entry.value * scale) - } - } - - @Throws(AutoBoneException::class) - private fun getErrorDeriv(step: PoseFrameStep): Float { - val config = step.config - var sumError = 0f - if (config.slideErrorFactor > 0f) { - sumError += slideError.getStepError(step) * config.slideErrorFactor - } - if (config.offsetSlideErrorFactor > 0f) { - sumError += ( - offsetSlideError.getStepError(step) * - config.offsetSlideErrorFactor - ) - } - if (config.footHeightOffsetErrorFactor > 0f) { - sumError += ( - footHeightOffsetError.getStepError(step) * - config.footHeightOffsetErrorFactor - ) - } - if (config.bodyProportionErrorFactor > 0f) { - sumError += ( - bodyProportionError.getStepError(step) * - config.bodyProportionErrorFactor - ) - } - if (config.heightErrorFactor > 0f) { - sumError += heightError.getStepError(step) * config.heightErrorFactor - } - if (config.positionErrorFactor > 0f) { - sumError += ( - positionError.getStepError(step) * - config.positionErrorFactor - ) - } - if (config.positionOffsetErrorFactor > 0f) { - sumError += ( - positionOffsetError.getStepError(step) * - config.positionOffsetErrorFactor - ) - } - return sumError - } - - val lengthsString: String - get() { - val configInfo = StringBuilder() - offsets.forEach { (key, value) -> - if (configInfo.isNotEmpty()) { - configInfo.append(", ") - } - configInfo - .append(key.configKey) - .append(": ") - .append(StringUtils.prettyNumber(value * 100f, 2)) - } - return configInfo.toString() - } - - fun saveRecording(frames: PoseFrames, recordingFile: File) { - if (saveDir.isDirectory || saveDir.mkdirs()) { - LogManager - .info("[AutoBone] Exporting frames to \"${recordingFile.path}\"...") - if (PfsIO.tryWriteToFile(recordingFile, frames)) { - LogManager - .info( - "[AutoBone] Done exporting! Recording can be found at \"${recordingFile.path}\".", - ) - } else { - LogManager - .severe( - "[AutoBone] Failed to export the recording to \"${recordingFile.path}\".", - ) - } - } else { - LogManager - .severe( - "[AutoBone] Failed to create the recording directory \"${saveDir.path}\".", - ) - } - } - - fun saveRecording(frames: PoseFrames, recordingFileName: String) { - saveRecording(frames, File(saveDir, recordingFileName)) - } - - fun saveRecording(frames: PoseFrames) { - var recordingFile: File - var recordingIndex = 1 - do { - recordingFile = File(saveDir, "ABRecording${recordingIndex++}.pfs") - } while (recordingFile.exists()) - saveRecording(frames, recordingFile) - } - - fun loadRecordings(): FastList> { - val recordings = FastList>() - - loadDir.listFiles()?.forEach { file -> - if (!file.isFile) return@forEach - - val frames = if (file.name.endsWith(".pfs", ignoreCase = true)) { - LogManager.info("[AutoBone] Loading PFS recording from \"${file.path}\"...") - PfsIO.tryReadFromFile(file) - } else if (file.name.endsWith(".pfr", ignoreCase = true)) { - LogManager.info("[AutoBone] Loading PFR recording from \"${file.path}\"...") - PfrIO.tryReadFromFile(file) - } else { - return@forEach - } - - if (frames == null) { - LogManager.severe("[AutoBone] Failed to load recording from \"${file.path}\".") - } else { - recordings.add(Pair.of(file.name, frames)) - LogManager.info("[AutoBone] Loaded recording from \"${file.path}\".") - } - } - - return recordings - } - - inner class Epoch( - val epoch: Int, - val totalEpochs: Int, - val epochError: StatsCalculator, - val configValues: EnumMap, - ) { - override fun toString(): String = "Epoch: $epoch, Epoch error: $epochError" - } - - inner class AutoBoneResults( - val finalHeight: Float, - val targetHeight: Float, - val configValues: EnumMap, - ) { - val heightDifference: Float - get() = abs(targetHeight - finalHeight) - } - - companion object { - const val MIN_HEIGHT = 0.4f - const val MIN_SLIDE_DIST = 0.002f - const val AUTOBONE_FOLDER = "AutoBone Recordings" - const val LOADAUTOBONE_FOLDER = "Load AutoBone Recordings" - - // FIXME: Won't work on iOS and Android, maybe fix resolveConfigDirectory more than this - val saveDir = File( - OperatingSystem.resolveConfigDirectory(SLIMEVR_IDENTIFIER)?.resolve( - AUTOBONE_FOLDER, - )?.toString() ?: AUTOBONE_FOLDER, - ) - val loadDir = File( - OperatingSystem.resolveConfigDirectory(SLIMEVR_IDENTIFIER)?.resolve( - LOADAUTOBONE_FOLDER, - )?.toString() ?: LOADAUTOBONE_FOLDER, - ) - - // Mean square error function - private fun errorFunc(errorDeriv: Float): Float = 0.5f * (errorDeriv * errorDeriv) - - private fun decayFunc(initialAdjustRate: Float, adjustRateDecay: Float, epoch: Int): Float = if (epoch >= 0) initialAdjustRate / (1 + (adjustRateDecay * epoch)) else 0.0f - - val SYMM_CONFIGS = arrayOf( - SkeletonConfigOffsets.HIPS_WIDTH, - SkeletonConfigOffsets.SHOULDERS_WIDTH, - SkeletonConfigOffsets.SHOULDERS_DISTANCE, - SkeletonConfigOffsets.UPPER_ARM, - SkeletonConfigOffsets.LOWER_ARM, - SkeletonConfigOffsets.UPPER_LEG, - SkeletonConfigOffsets.LOWER_LEG, - SkeletonConfigOffsets.FOOT_LENGTH, - ) - } -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/AutoBoneHandler.kt b/server/core/src/main/java/dev/slimevr/autobone/AutoBoneHandler.kt deleted file mode 100644 index 2795239370..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/AutoBoneHandler.kt +++ /dev/null @@ -1,408 +0,0 @@ -package dev.slimevr.autobone - -import dev.slimevr.VRServer -import dev.slimevr.autobone.AutoBone.AutoBoneResults -import dev.slimevr.autobone.AutoBone.Companion.loadDir -import dev.slimevr.autobone.errors.AutoBoneException -import dev.slimevr.poseframeformat.PoseFrames -import dev.slimevr.poseframeformat.PoseRecorder -import dev.slimevr.poseframeformat.PoseRecorder.RecordingProgress -import dev.slimevr.poseframeformat.trackerdata.TrackerFrameData -import dev.slimevr.poseframeformat.trackerdata.TrackerFrames -import dev.slimevr.tracking.processor.config.SkeletonConfigManager -import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets -import io.eiren.util.StringUtils -import io.eiren.util.collections.FastList -import io.eiren.util.logging.LogManager -import org.apache.commons.lang3.tuple.Pair -import java.util.* -import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.thread -import kotlin.concurrent.withLock - -class AutoBoneHandler(private val server: VRServer) { - private val poseRecorder: PoseRecorder = PoseRecorder(server) - private val autoBone: AutoBone = AutoBone(server) - - private val recordingLock = ReentrantLock() - private var recordingThread: Thread? = null - private val saveRecordingLock = ReentrantLock() - private var saveRecordingThread: Thread? = null - private val autoBoneLock = ReentrantLock() - private var autoBoneThread: Thread? = null - - private val listeners = CopyOnWriteArrayList() - - fun addListener(listener: AutoBoneListener) { - listeners.add(listener) - } - - fun removeListener(listener: AutoBoneListener) { - listeners.removeIf { listener == it } - } - - private fun announceProcessStatus( - processType: AutoBoneProcessType, - message: String? = null, - current: Long = -1L, - total: Long = -1L, - eta: Float = -1f, - completed: Boolean = false, - success: Boolean = true, - ) { - listeners.forEach { - it.onAutoBoneProcessStatus( - processType, - message, - current, - total, - eta, - completed, - success, - ) - } - } - - @Throws(AutoBoneException::class) - private fun processFrames(frames: PoseFrames): AutoBoneResults = autoBone - .processFrames(frames) { epoch -> - listeners.forEach { listener -> listener.onAutoBoneEpoch(epoch) } - } - - fun startProcessByType(processType: AutoBoneProcessType?): Boolean { - when (processType) { - AutoBoneProcessType.RECORD -> startRecording() - - AutoBoneProcessType.SAVE -> saveRecording() - - AutoBoneProcessType.PROCESS -> processRecording() - - else -> { - return false - } - } - return true - } - - fun startRecording() { - recordingLock.withLock { - // Prevent running multiple times - if (recordingThread != null) { - return - } - recordingThread = thread(start = true) { startRecordingThread() } - } - } - - private fun startRecordingThread() { - try { - if (poseRecorder.isReadyToRecord) { - announceProcessStatus(AutoBoneProcessType.RECORD, "Recording...") - - // ex. 1000 samples at 20 ms per sample is 20 seconds - val sampleCount = autoBone.globalConfig.sampleCount - val sampleRate = autoBone.globalConfig.sampleRateMs / 1000f - // Calculate total time in seconds - val totalTime: Float = sampleCount * sampleRate - - val framesFuture = poseRecorder - .startFrameRecording( - sampleCount, - sampleRate, - ) { progress: RecordingProgress -> - announceProcessStatus( - AutoBoneProcessType.RECORD, - current = progress.frame.toLong(), - total = progress.totalFrames.toLong(), - eta = totalTime - (progress.frame * totalTime / progress.totalFrames), - ) - } - val frames = framesFuture.get() - LogManager.info("[AutoBone] Done recording!") - - // Save a recurring recording for users to send as debug info - announceProcessStatus(AutoBoneProcessType.RECORD, "Saving recording...") - autoBone.saveRecording(frames, "LastABRecording.pfs") - if (autoBone.globalConfig.saveRecordings) { - announceProcessStatus( - AutoBoneProcessType.RECORD, - "Saving recording (from config option)...", - ) - autoBone.saveRecording(frames) - } - listeners.forEach { listener: AutoBoneListener -> listener.onAutoBoneRecordingEnd(frames) } - announceProcessStatus( - AutoBoneProcessType.RECORD, - "Done recording!", - completed = true, - success = true, - ) - } else { - announceProcessStatus( - AutoBoneProcessType.RECORD, - "The server is not ready to record", - completed = true, - success = false, - ) - LogManager.severe("[AutoBone] Unable to record...") - return - } - } catch (e: Exception) { - announceProcessStatus( - AutoBoneProcessType.RECORD, - "Recording failed: ${e.message}", - completed = true, - success = false, - ) - LogManager.severe("[AutoBone] Failed recording!", e) - } finally { - recordingThread = null - } - } - - fun stopRecording() { - if (poseRecorder.isRecording) { - poseRecorder.stopFrameRecording() - } - } - - fun cancelRecording() { - if (poseRecorder.isRecording) { - poseRecorder.cancelFrameRecording() - } - } - - fun saveRecording() { - saveRecordingLock.withLock { - // Prevent running multiple times - if (saveRecordingThread != null) { - return - } - saveRecordingThread = thread(start = true) { saveRecordingThread() } - } - } - - private fun saveRecordingThread() { - try { - val framesFuture = poseRecorder.framesAsync - if (framesFuture != null) { - announceProcessStatus(AutoBoneProcessType.SAVE, "Waiting for recording...") - val frames = framesFuture.get() - check(frames.frameHolders.isNotEmpty()) { "Recording has no trackers." } - check(frames.maxFrameCount > 0) { "Recording has no frames." } - announceProcessStatus(AutoBoneProcessType.SAVE, "Saving recording...") - autoBone.saveRecording(frames) - announceProcessStatus( - AutoBoneProcessType.SAVE, - "Recording saved!", - completed = true, - success = true, - ) - } else { - announceProcessStatus( - AutoBoneProcessType.SAVE, - "No recording found", - completed = true, - success = false, - ) - LogManager.severe("[AutoBone] Unable to save, no recording was done...") - return - } - } catch (e: Exception) { - announceProcessStatus( - AutoBoneProcessType.SAVE, - "Failed to save recording: ${e.message}", - completed = true, - success = false, - ) - LogManager.severe("[AutoBone] Failed to save recording!", e) - } finally { - saveRecordingThread = null - } - } - - fun processRecording() { - autoBoneLock.withLock { - // Prevent running multiple times - if (autoBoneThread != null) { - return - } - autoBoneThread = thread(start = true) { processRecordingThread() } - } - } - - private fun processRecordingThread() { - try { - announceProcessStatus(AutoBoneProcessType.PROCESS, "Loading recordings...") - val frameRecordings = autoBone.loadRecordings() - if (!frameRecordings.isEmpty()) { - LogManager.info("[AutoBone] Done loading frames!") - } else { - val framesFuture = poseRecorder.framesAsync - if (framesFuture != null) { - announceProcessStatus(AutoBoneProcessType.PROCESS, "Waiting for recording...") - val frames = framesFuture.get() - frameRecordings.add(Pair.of("", frames)) - } else { - announceProcessStatus( - AutoBoneProcessType.PROCESS, - "No recordings found...", - completed = true, - success = false, - ) - LogManager - .severe( - "[AutoBone] No recordings found in \"${loadDir.path}\" and no recording was done...", - ) - return - } - } - announceProcessStatus(AutoBoneProcessType.PROCESS, "Processing recording(s)...") - LogManager.info("[AutoBone] Processing frames...") - val errorStats = StatsCalculator() - val offsetStats = EnumMap( - SkeletonConfigOffsets::class.java, - ) - val skeletonConfigManagerBuffer = SkeletonConfigManager(false) - for ((key, value) in frameRecordings) { - LogManager.info("[AutoBone] Processing frames from \"$key\"...") - // Output tracker info for the recording - printTrackerInfo(value.frameHolders) - - // Actually process the recording - val autoBoneResults = processFrames(value) - LogManager.info("[AutoBone] Done processing!") - - // #region Stats/Values - // Accumulate height error - errorStats.addValue(autoBoneResults.heightDifference) - - // Accumulate length values - for (offset in autoBoneResults.configValues) { - val statCalc = offsetStats.getOrPut(offset.key) { - StatsCalculator() - } - // Multiply by 100 to get cm - statCalc.addValue(offset.value * 100f) - } - - // Calculate and output skeleton ratios - skeletonConfigManagerBuffer.setOffsets(autoBoneResults.configValues) - printSkeletonRatios(skeletonConfigManagerBuffer) - - LogManager.info("[AutoBone] Length values: ${autoBone.lengthsString}") - } - // Length value stats - val averageLengthVals = StringBuilder() - offsetStats.forEach { (key, value) -> - if (averageLengthVals.isNotEmpty()) { - averageLengthVals.append(", ") - } - averageLengthVals - .append(key.configKey) - .append(": ") - .append(StringUtils.prettyNumber(value.mean, 2)) - .append(" (SD ") - .append(StringUtils.prettyNumber(value.standardDeviation, 2)) - .append(")") - } - LogManager.info("[AutoBone] Average length values: $averageLengthVals") - - // Height error stats - LogManager - .info( - "[AutoBone] Average height error: ${ - StringUtils.prettyNumber(errorStats.mean, 6) - } (SD ${StringUtils.prettyNumber(errorStats.standardDeviation, 6)})", - ) - // #endregion - listeners.forEach { listener: AutoBoneListener -> listener.onAutoBoneEnd(autoBone.offsets) } - announceProcessStatus( - AutoBoneProcessType.PROCESS, - "Done processing!", - completed = true, - success = true, - ) - } catch (e: Exception) { - announceProcessStatus( - AutoBoneProcessType.PROCESS, - "Processing failed: ${e.message}", - completed = true, - success = false, - ) - LogManager.severe("[AutoBone] Failed adjustment!", e) - } finally { - autoBoneThread = null - } - } - - private fun printTrackerInfo(trackers: FastList) { - val trackerInfo = StringBuilder() - for (tracker in trackers) { - val frame = tracker?.tryGetFrame(0) ?: continue - - // Add a comma if this is not the first item listed - if (trackerInfo.isNotEmpty()) { - trackerInfo.append(", ") - } - - trackerInfo.append(frame.tryGetTrackerPosition()?.designation ?: "unassigned") - - // Represent the data flags - val trackerFlags = StringBuilder() - if (frame.hasData(TrackerFrameData.ROTATION)) { - trackerFlags.append("R") - } - if (frame.hasData(TrackerFrameData.POSITION)) { - trackerFlags.append("P") - } - if (frame.hasData(TrackerFrameData.ACCELERATION)) { - trackerFlags.append("A") - } - if (frame.hasData(TrackerFrameData.RAW_ROTATION)) { - trackerFlags.append("r") - } - - // If there are data flags, print them in brackets after the designation - if (trackerFlags.isNotEmpty()) { - trackerInfo.append(" (").append(trackerFlags).append(")") - } - } - LogManager.info("[AutoBone] (${trackers.size} trackers) [$trackerInfo]") - } - - private fun printSkeletonRatios(skeleton: SkeletonConfigManager) { - val neckLength = skeleton.getOffset(SkeletonConfigOffsets.NECK) - val upperChestLength = skeleton.getOffset(SkeletonConfigOffsets.UPPER_CHEST) - val chestLength = skeleton.getOffset(SkeletonConfigOffsets.CHEST) - val waistLength = skeleton.getOffset(SkeletonConfigOffsets.WAIST) - val hipLength = skeleton.getOffset(SkeletonConfigOffsets.HIP) - val torsoLength = upperChestLength + chestLength + waistLength + hipLength - val hipWidth = skeleton.getOffset(SkeletonConfigOffsets.HIPS_WIDTH) - val legLength = skeleton.getOffset(SkeletonConfigOffsets.UPPER_LEG) + - skeleton.getOffset(SkeletonConfigOffsets.LOWER_LEG) - val lowerLegLength = skeleton.getOffset(SkeletonConfigOffsets.LOWER_LEG) - - val neckTorso = neckLength / torsoLength - val chestTorso = (upperChestLength + chestLength) / torsoLength - val torsoWaist = hipWidth / torsoLength - val legTorso = legLength / torsoLength - val legBody = legLength / (torsoLength + neckLength) - val kneeLeg = lowerLegLength / legLength - - LogManager.info( - "[AutoBone] Ratios: [{Neck-Torso: ${ - StringUtils.prettyNumber(neckTorso)}}, {Chest-Torso: ${ - StringUtils.prettyNumber(chestTorso)}}, {Torso-Waist: ${ - StringUtils.prettyNumber(torsoWaist)}}, {Leg-Torso: ${ - StringUtils.prettyNumber(legTorso)}}, {Leg-Body: ${ - StringUtils.prettyNumber(legBody)}}, {Knee-Leg: ${ - StringUtils.prettyNumber(kneeLeg)}}]", - ) - } - - fun applyValues() { - autoBone.applyAndSaveConfig() - } -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/AutoBoneListener.kt b/server/core/src/main/java/dev/slimevr/autobone/AutoBoneListener.kt deleted file mode 100644 index 5101167db1..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/AutoBoneListener.kt +++ /dev/null @@ -1,22 +0,0 @@ -package dev.slimevr.autobone - -import dev.slimevr.autobone.AutoBone.Epoch -import dev.slimevr.poseframeformat.PoseFrames -import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets -import java.util.* - -interface AutoBoneListener { - fun onAutoBoneProcessStatus( - processType: AutoBoneProcessType, - message: String?, - current: Long, - total: Long, - eta: Float, - completed: Boolean, - success: Boolean, - ) - - fun onAutoBoneRecordingEnd(recording: PoseFrames) - fun onAutoBoneEpoch(epoch: Epoch) - fun onAutoBoneEnd(configValues: EnumMap) -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/AutoBoneProcessType.kt b/server/core/src/main/java/dev/slimevr/autobone/AutoBoneProcessType.kt deleted file mode 100644 index 17759cb71a..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/AutoBoneProcessType.kt +++ /dev/null @@ -1,15 +0,0 @@ -package dev.slimevr.autobone - -enum class AutoBoneProcessType(val id: Int) { - NONE(0), - RECORD(1), - SAVE(2), - PROCESS(3), - ; - - companion object { - fun getById(id: Int): AutoBoneProcessType? = byId[id] - } -} - -private val byId = AutoBoneProcessType.values().associateBy { it.id } diff --git a/server/core/src/main/java/dev/slimevr/autobone/AutoBoneStep.kt b/server/core/src/main/java/dev/slimevr/autobone/AutoBoneStep.kt deleted file mode 100644 index b0fe0df77c..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/AutoBoneStep.kt +++ /dev/null @@ -1,13 +0,0 @@ -package dev.slimevr.autobone - -class AutoBoneStep( - var hmdHeight: Float = 1f, - val targetHmdHeight: Float = 1f, - var adjustRate: Float = 0f, -) { - - val errorStats = StatsCalculator() - - val heightOffset: Float - get() = targetHmdHeight - hmdHeight -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/BoneContribution.kt b/server/core/src/main/java/dev/slimevr/autobone/BoneContribution.kt deleted file mode 100644 index 5a8ce46d38..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/BoneContribution.kt +++ /dev/null @@ -1,84 +0,0 @@ -package dev.slimevr.autobone - -import dev.slimevr.autobone.AutoBone.Companion.MIN_SLIDE_DIST -import dev.slimevr.autobone.AutoBone.Companion.SYMM_CONFIGS -import dev.slimevr.tracking.processor.BoneType -import dev.slimevr.tracking.processor.HumanPoseManager -import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets -import io.github.axisangles.ktmath.Vector3 - -object BoneContribution { - /** - * Computes the local tail position of the bone after rotation. - */ - fun getBoneLocalTail( - skeleton: HumanPoseManager, - boneType: BoneType, - ): Vector3 { - val bone = skeleton.getBone(boneType) - return bone.getTailPosition() - bone.getPosition() - } - - /** - * Computes the direction of the bone tail's movement between skeletons 1 and 2. - */ - fun getBoneLocalTailDir( - skeleton1: HumanPoseManager, - skeleton2: HumanPoseManager, - boneType: BoneType, - ): Vector3? { - val boneOff = getBoneLocalTail(skeleton2, boneType) - getBoneLocalTail(skeleton1, boneType) - val boneOffLen = boneOff.len() - // If the offset is approx 0, just return null so it can be easily ignored - return if (boneOffLen > MIN_SLIDE_DIST) boneOff / boneOffLen else null - } - - /** - * Predicts how much the provided config should be affecting the slide offsets - * of the left and right ankles. - */ - fun getSlideDot( - skeleton1: HumanPoseManager, - skeleton2: HumanPoseManager, - config: SkeletonConfigOffsets, - slideL: Vector3?, - slideR: Vector3?, - ): Float { - var slideDot = 0f - // Used for right offset if not a symmetric bone - var boneOffL: Vector3? = null - - // Treat null as 0 - if (slideL != null) { - boneOffL = getBoneLocalTailDir(skeleton1, skeleton2, config.affectedOffsets[0]) - - // Treat null as 0 - if (boneOffL != null) { - slideDot += slideL.dot(boneOffL) - } - } - - // Treat null as 0 - if (slideR != null) { - // IMPORTANT: This assumption for acquiring BoneType only works if - // SkeletonConfigOffsets is set up to only affect one BoneType, make sure no - // changes to SkeletonConfigOffsets goes against this assumption, please! - val boneOffR = if (SYMM_CONFIGS.contains(config)) { - getBoneLocalTailDir(skeleton1, skeleton2, config.affectedOffsets[1]) - } else if (slideL != null) { - // Use cached offset if slideL was used - boneOffL - } else { - // Compute offset if missing because of slideL - getBoneLocalTailDir(skeleton1, skeleton2, config.affectedOffsets[0]) - } - - // Treat null as 0 - if (boneOffR != null) { - slideDot += slideR.dot(boneOffR) - } - } - - return slideDot / 2f - } -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/PoseFrameIterator.kt b/server/core/src/main/java/dev/slimevr/autobone/PoseFrameIterator.kt deleted file mode 100644 index a5f8e0af09..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/PoseFrameIterator.kt +++ /dev/null @@ -1,90 +0,0 @@ -package dev.slimevr.autobone - -import kotlin.random.Random - -object PoseFrameIterator { - fun iterateFrames( - step: PoseFrameStep, - ) { - check(step.frames.frameHolders.isNotEmpty()) { "Recording has no trackers." } - check(step.maxFrameCount > 0) { "Recording has no frames." } - - // Epoch loop, each epoch is one full iteration over the full dataset - for (epoch in (if (step.config.calcInitError) -1 else 0) until step.config.numEpochs) { - // Set the current epoch to process - step.epoch = epoch - // Process the epoch - epoch(step) - } - } - - private fun randomIndices(count: Int, random: Random): IntArray { - val randIndices = IntArray(count) - - var zeroPos = -1 - for (i in 0 until count) { - var index = random.nextInt(count) - if (i > 0) { - while (index == zeroPos || randIndices[index] > 0) { - index = random.nextInt(count) - } - } else { - zeroPos = index - } - randIndices[index] = i - } - - return randIndices - } - - private fun epoch(step: PoseFrameStep) { - val config = step.config - val frameCount = step.maxFrameCount - - // Perform any setup that needs to be done before the current epoch - step.preEpoch?.accept(step) - - val randIndices = if (config.randomizeFrameOrder) { - randomIndices(step.maxFrameCount, step.random) - } else { - null - } - - // Iterate over the frames using a cursor and an offset for comparing - // frames a certain number of frames apart - var cursorOffset = config.minDataDistance - while (cursorOffset <= config.maxDataDistance && - cursorOffset < frameCount - ) { - var frameCursor = 0 - while (frameCursor < frameCount - cursorOffset) { - val frameCursor2 = frameCursor + cursorOffset - - // Then set the frame cursors and apply them to both skeletons - if (config.randomizeFrameOrder && randIndices != null) { - step - .setCursors( - randIndices[frameCursor], - randIndices[frameCursor2], - updatePlayerCursors = true, - ) - } else { - step.setCursors( - frameCursor, - frameCursor2, - updatePlayerCursors = true, - ) - } - - // Process the iteration - step.onStep.accept(step) - - // Move on to the next iteration - frameCursor += config.cursorIncrement - } - cursorOffset++ - } - - step.postEpoch?.accept(step) - } -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/PoseFrameStep.kt b/server/core/src/main/java/dev/slimevr/autobone/PoseFrameStep.kt deleted file mode 100644 index 0140c607a1..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/PoseFrameStep.kt +++ /dev/null @@ -1,70 +0,0 @@ -package dev.slimevr.autobone - -import dev.slimevr.config.AutoBoneConfig -import dev.slimevr.config.ConfigManager -import dev.slimevr.poseframeformat.PoseFrames -import dev.slimevr.poseframeformat.player.TrackerFramesPlayer -import dev.slimevr.tracking.processor.HumanPoseManager -import java.util.function.Consumer -import kotlin.random.Random - -class PoseFrameStep( - val config: AutoBoneConfig, - /** The config to initialize skeletons. */ - serverConfig: ConfigManager? = null, - val frames: PoseFrames, - /** The consumer run before each epoch. */ - val preEpoch: Consumer>? = null, - /** The consumer run for each step. */ - val onStep: Consumer>, - /** The consumer run after each epoch. */ - val postEpoch: Consumer>? = null, - /** The current epoch. */ - var epoch: Int = 0, - /** The current frame cursor position in [frames] for skeleton1. */ - var cursor1: Int = 0, - /** The current frame cursor position in [frames] for skeleton2. */ - var cursor2: Int = 0, - randomSeed: Long = 0, - val data: T, -) { - var maxFrameCount = frames.maxFrameCount - - val framePlayer1 = TrackerFramesPlayer(frames) - val framePlayer2 = TrackerFramesPlayer(frames) - - val trackers1 = framePlayer1.trackers.toList() - val trackers2 = framePlayer2.trackers.toList() - - val skeleton1 = HumanPoseManager(trackers1) - val skeleton2 = HumanPoseManager(trackers2) - - val random = Random(randomSeed) - - init { - // Load server configs into the skeleton - if (serverConfig != null) { - skeleton1.loadFromConfig(serverConfig) - skeleton2.loadFromConfig(serverConfig) - } - // Disable leg tweaks and IK solver, these will mess with the resulting positions - skeleton1.setLegTweaksEnabled(false) - skeleton2.setLegTweaksEnabled(false) - } - - fun setCursors(cursor1: Int, cursor2: Int, updatePlayerCursors: Boolean) { - this.cursor1 = cursor1 - this.cursor2 = cursor2 - - if (updatePlayerCursors) { - updatePlayerCursors() - } - } - - fun updatePlayerCursors() { - framePlayer1.setCursors(cursor1) - framePlayer2.setCursors(cursor2) - skeleton1.update() - skeleton2.update() - } -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/StatsCalculator.kt b/server/core/src/main/java/dev/slimevr/autobone/StatsCalculator.kt deleted file mode 100644 index 3fb53c18e7..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/StatsCalculator.kt +++ /dev/null @@ -1,43 +0,0 @@ -package dev.slimevr.autobone - -import kotlin.math.* - -/** - * This is a stat calculator based on Welford's online algorithm - * https://en.wikipedia.org/wiki/Algorithms_for_calculating_variance#Welford%27s_online_algorithm - */ -class StatsCalculator { - private var count = 0 - var mean = 0f - private set - private var m2 = 0f - - fun reset() { - count = 0 - mean = 0f - m2 = 0f - } - - fun addValue(newValue: Float) { - count += 1 - val delta = newValue - mean - mean += delta / count - val delta2 = newValue - mean - m2 += delta * delta2 - } - - val variance: Float - get() = if (count < 1) { - Float.NaN - } else { - m2 / count - } - val sampleVariance: Float - get() = if (count < 2) { - Float.NaN - } else { - m2 / (count - 1) - } - val standardDeviation: Float - get() = sqrt(variance) -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/errors/AutoBoneException.kt b/server/core/src/main/java/dev/slimevr/autobone/errors/AutoBoneException.kt deleted file mode 100644 index 39646e552f..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/errors/AutoBoneException.kt +++ /dev/null @@ -1,14 +0,0 @@ -package dev.slimevr.autobone.errors - -class AutoBoneException : Exception { - constructor() - constructor(message: String?) : super(message) - constructor(cause: Throwable?) : super(cause) - constructor(message: String?, cause: Throwable?) : super(message, cause) - constructor( - message: String?, - cause: Throwable?, - enableSuppression: Boolean, - writableStackTrace: Boolean, - ) : super(message, cause, enableSuppression, writableStackTrace) -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/errors/BodyProportionError.kt b/server/core/src/main/java/dev/slimevr/autobone/errors/BodyProportionError.kt deleted file mode 100644 index c0fa7f2b80..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/errors/BodyProportionError.kt +++ /dev/null @@ -1,123 +0,0 @@ -package dev.slimevr.autobone.errors - -import dev.slimevr.autobone.AutoBoneStep -import dev.slimevr.autobone.PoseFrameStep -import dev.slimevr.autobone.errors.proportions.ProportionLimiter -import dev.slimevr.tracking.processor.HumanPoseManager -import dev.slimevr.tracking.processor.config.SkeletonConfigManager -import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets -import kotlin.math.* - -// The distance from average human proportions -class BodyProportionError : IAutoBoneError { - @Throws(AutoBoneException::class) - override fun getStepError(step: PoseFrameStep): Float = getBodyProportionError( - step.skeleton1, - // Skeletons are now normalized to reduce bias, so height is always 1 - 1f, - ) - - fun getBodyProportionError(humanPoseManager: HumanPoseManager, fullHeight: Float): Float { - var sum = 0f - for (limiter in proportionLimits) { - sum += abs(limiter.getProportionError(humanPoseManager, fullHeight)) - } - return sum - } - - companion object { - // The headset height is not the full height! This value compensates for the - // offset from the headset height to the user full height - // From Drillis and Contini (1966) - @JvmField - var eyeHeightToHeightRatio = 0.936f - - val defaultHeight = SkeletonConfigManager.HEIGHT_OFFSETS.sumOf { - it.defaultValue.toDouble() - }.toFloat() - - private fun makeLimiter( - offset: SkeletonConfigOffsets, - range: Float, - scaleByHeight: Boolean = true, - ) = ProportionLimiter( - if (scaleByHeight) { - offset.defaultValue / defaultHeight - } else { - offset.defaultValue - }, - offset, - range, - scaleByHeight, - ) - - // "Expected" are values from Drillis and Contini (1966) - // Default are values from experimentation by the SlimeVR community - - /** - * Proportions are based off the headset height (or eye height), not the total height of the user. - * To use the total height of the user, multiply it by [eyeHeightToHeightRatio] and use that in the limiters. - */ - val proportionLimits = arrayOf( - makeLimiter( - SkeletonConfigOffsets.HEAD, - 0.01f, - scaleByHeight = false, - ), - // Expected: 0.052 - makeLimiter( - SkeletonConfigOffsets.NECK, - 0.002f, - ), - makeLimiter( - SkeletonConfigOffsets.SHOULDERS_WIDTH, - 0.04f, - scaleByHeight = false, - ), - makeLimiter( - SkeletonConfigOffsets.UPPER_ARM, - 0.02f, - ), - makeLimiter( - SkeletonConfigOffsets.LOWER_ARM, - 0.02f, - ), - makeLimiter( - SkeletonConfigOffsets.UPPER_CHEST, - 0.01f, - ), - makeLimiter( - SkeletonConfigOffsets.CHEST, - 0.01f, - ), - makeLimiter( - SkeletonConfigOffsets.WAIST, - 0.05f, - ), - makeLimiter( - SkeletonConfigOffsets.HIP, - 0.01f, - ), - // Expected: 0.191 - makeLimiter( - SkeletonConfigOffsets.HIPS_WIDTH, - 0.04f, - scaleByHeight = false, - ), - // Expected: 0.245 - makeLimiter( - SkeletonConfigOffsets.UPPER_LEG, - 0.02f, - ), - // Expected: 0.246 (0.285 including below ankle, could use a separate - // offset?) - makeLimiter( - SkeletonConfigOffsets.LOWER_LEG, - 0.02f, - ), - ) - - @JvmStatic - val proportionLimitMap = proportionLimits.associateBy { it.skeletonConfigOffset } - } -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/errors/FootHeightOffsetError.kt b/server/core/src/main/java/dev/slimevr/autobone/errors/FootHeightOffsetError.kt deleted file mode 100644 index 0121263755..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/errors/FootHeightOffsetError.kt +++ /dev/null @@ -1,50 +0,0 @@ -package dev.slimevr.autobone.errors - -import dev.slimevr.autobone.AutoBoneStep -import dev.slimevr.autobone.PoseFrameStep -import dev.slimevr.tracking.processor.BoneType -import dev.slimevr.tracking.processor.skeleton.HumanSkeleton -import io.github.axisangles.ktmath.Vector3 -import kotlin.math.* - -// The offset between the height both feet at one instant and over time -class FootHeightOffsetError : IAutoBoneError { - @Throws(AutoBoneException::class) - override fun getStepError(step: PoseFrameStep): Float = getSlideError( - step.skeleton1.skeleton, - step.skeleton2.skeleton, - ) - - companion object { - fun getSlideError(skeleton1: HumanSkeleton, skeleton2: HumanSkeleton): Float = getFootHeightError( - skeleton1.getBone(BoneType.LEFT_LOWER_LEG).getTailPosition(), - skeleton1.getBone(BoneType.RIGHT_LOWER_LEG).getTailPosition(), - skeleton2.getBone(BoneType.LEFT_LOWER_LEG).getTailPosition(), - skeleton2.getBone(BoneType.RIGHT_LOWER_LEG).getTailPosition(), - ) - - fun getFootHeightError( - leftFoot1: Vector3, - rightFoot1: Vector3, - leftFoot2: Vector3, - rightFoot2: Vector3, - ): Float { - val lFoot1Y = leftFoot1.y - val rFoot1Y = rightFoot1.y - val lFoot2Y = leftFoot2.y - val rFoot2Y = rightFoot2.y - - // Compute all combinations of heights - val dist1 = abs(lFoot1Y - rFoot1Y) - val dist2 = abs(lFoot1Y - lFoot2Y) - val dist3 = abs(lFoot1Y - rFoot2Y) - val dist4 = abs(rFoot1Y - lFoot2Y) - val dist5 = abs(rFoot1Y - rFoot2Y) - val dist6 = abs(lFoot2Y - rFoot2Y) - - // Divide by 12 (6 values * 2 to halve) to halve and average, it's - // halved because you want to approach a midpoint, not the other point - return (dist1 + dist2 + dist3 + dist4 + dist5 + dist6) / 12f - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/errors/HeightError.kt b/server/core/src/main/java/dev/slimevr/autobone/errors/HeightError.kt deleted file mode 100644 index 1303d20dbf..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/errors/HeightError.kt +++ /dev/null @@ -1,16 +0,0 @@ -package dev.slimevr.autobone.errors - -import dev.slimevr.autobone.AutoBoneStep -import dev.slimevr.autobone.PoseFrameStep -import kotlin.math.* - -// The difference from the current height to the target height -class HeightError : IAutoBoneError { - @Throws(AutoBoneException::class) - override fun getStepError(step: PoseFrameStep): Float = getHeightError( - step.data.hmdHeight, - step.data.targetHmdHeight, - ) - - fun getHeightError(currentHeight: Float, targetHeight: Float): Float = abs(targetHeight - currentHeight) -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/errors/IAutoBoneError.kt b/server/core/src/main/java/dev/slimevr/autobone/errors/IAutoBoneError.kt deleted file mode 100644 index e4667877e9..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/errors/IAutoBoneError.kt +++ /dev/null @@ -1,9 +0,0 @@ -package dev.slimevr.autobone.errors - -import dev.slimevr.autobone.AutoBoneStep -import dev.slimevr.autobone.PoseFrameStep - -interface IAutoBoneError { - @Throws(AutoBoneException::class) - fun getStepError(step: PoseFrameStep): Float -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/errors/OffsetSlideError.kt b/server/core/src/main/java/dev/slimevr/autobone/errors/OffsetSlideError.kt deleted file mode 100644 index f3b5233eb4..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/errors/OffsetSlideError.kt +++ /dev/null @@ -1,50 +0,0 @@ -package dev.slimevr.autobone.errors - -import dev.slimevr.autobone.AutoBoneStep -import dev.slimevr.autobone.PoseFrameStep -import dev.slimevr.tracking.processor.BoneType -import dev.slimevr.tracking.processor.skeleton.HumanSkeleton -import io.github.axisangles.ktmath.Vector3 -import kotlin.math.* - -// The change in distance between both of the ankles over time -class OffsetSlideError : IAutoBoneError { - @Throws(AutoBoneException::class) - override fun getStepError(step: PoseFrameStep): Float = getSlideError( - step.skeleton1.skeleton, - step.skeleton2.skeleton, - ) - - companion object { - fun getSlideError(skeleton1: HumanSkeleton, skeleton2: HumanSkeleton): Float = getSlideError( - skeleton1.getBone(BoneType.LEFT_LOWER_LEG).getTailPosition(), - skeleton1.getBone(BoneType.RIGHT_LOWER_LEG).getTailPosition(), - skeleton2.getBone(BoneType.LEFT_LOWER_LEG).getTailPosition(), - skeleton2.getBone(BoneType.RIGHT_LOWER_LEG).getTailPosition(), - ) - - fun getSlideError( - leftFoot1: Vector3, - rightFoot1: Vector3, - leftFoot2: Vector3, - rightFoot2: Vector3, - ): Float { - val slideDist1 = (rightFoot1 - leftFoot1).len() - val slideDist2 = (rightFoot2 - leftFoot2).len() - val slideDist3 = (rightFoot2 - leftFoot1).len() - val slideDist4 = (rightFoot1 - leftFoot2).len() - - // Compute all combinations of distances - val dist1 = abs(slideDist1 - slideDist2) - val dist2 = abs(slideDist1 - slideDist3) - val dist3 = abs(slideDist1 - slideDist4) - val dist4 = abs(slideDist2 - slideDist3) - val dist5 = abs(slideDist2 - slideDist4) - val dist6 = abs(slideDist3 - slideDist4) - - // Divide by 12 (6 values * 2 to halve) to halve and average, it's - // halved because you want to approach a midpoint, not the other point - return (dist1 + dist2 + dist3 + dist4 + dist5 + dist6) / 12f - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/errors/PositionError.kt b/server/core/src/main/java/dev/slimevr/autobone/errors/PositionError.kt deleted file mode 100644 index d5cedc4e9f..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/errors/PositionError.kt +++ /dev/null @@ -1,55 +0,0 @@ -package dev.slimevr.autobone.errors - -import dev.slimevr.autobone.AutoBoneStep -import dev.slimevr.autobone.PoseFrameStep -import dev.slimevr.poseframeformat.trackerdata.TrackerFrames -import dev.slimevr.tracking.processor.skeleton.HumanSkeleton - -// The distance of any points to the corresponding absolute position -class PositionError : IAutoBoneError { - @Throws(AutoBoneException::class) - override fun getStepError(step: PoseFrameStep): Float { - val trackers = step.frames.frameHolders - return ( - ( - getPositionError( - trackers, - step.cursor1, - step.skeleton1.skeleton, - ) + - getPositionError( - trackers, - step.cursor2, - step.skeleton2.skeleton, - ) - ) / - 2f - ) - } - - companion object { - fun getPositionError( - trackers: List, - cursor: Int, - skeleton: HumanSkeleton, - ): Float { - var offset = 0f - var offsetCount = 0 - for (tracker in trackers) { - val trackerFrame = tracker.tryGetFrame(cursor) ?: continue - val position = trackerFrame.tryGetPosition() ?: continue - val trackerRole = trackerFrame.tryGetTrackerPosition()?.trackerRole ?: continue - - try { - val computedTracker = skeleton.getComputedTracker(trackerRole) - - offset += (position - computedTracker.position).len() - offsetCount++ - } catch (_: Exception) { - // Ignore unsupported positions - } - } - return if (offsetCount > 0) offset / offsetCount else 0f - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/errors/PositionOffsetError.kt b/server/core/src/main/java/dev/slimevr/autobone/errors/PositionOffsetError.kt deleted file mode 100644 index e5cb83b7bb..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/errors/PositionOffsetError.kt +++ /dev/null @@ -1,55 +0,0 @@ -package dev.slimevr.autobone.errors - -import dev.slimevr.autobone.AutoBoneStep -import dev.slimevr.autobone.PoseFrameStep -import dev.slimevr.poseframeformat.trackerdata.TrackerFrames -import dev.slimevr.tracking.processor.skeleton.HumanSkeleton -import kotlin.math.* - -// The difference between offset of absolute position and the corresponding point over time -class PositionOffsetError : IAutoBoneError { - @Throws(AutoBoneException::class) - override fun getStepError(step: PoseFrameStep): Float { - val trackers = step.frames.frameHolders - return getPositionOffsetError( - trackers, - step.cursor1, - step.cursor2, - step.skeleton1.skeleton, - step.skeleton2.skeleton, - ) - } - - fun getPositionOffsetError( - trackers: List, - cursor1: Int, - cursor2: Int, - skeleton1: HumanSkeleton, - skeleton2: HumanSkeleton, - ): Float { - var offset = 0f - var offsetCount = 0 - for (tracker in trackers) { - val trackerFrame1 = tracker.tryGetFrame(cursor1) ?: continue - val position1 = trackerFrame1.tryGetPosition() ?: continue - val trackerRole1 = trackerFrame1.tryGetTrackerPosition()?.trackerRole ?: continue - - val trackerFrame2 = tracker.tryGetFrame(cursor2) ?: continue - val position2 = trackerFrame2.tryGetPosition() ?: continue - val trackerRole2 = trackerFrame2.tryGetTrackerPosition()?.trackerRole ?: continue - - try { - val computedTracker1 = skeleton1.getComputedTracker(trackerRole1) - val computedTracker2 = skeleton2.getComputedTracker(trackerRole2) - - val dist1 = (position1 - computedTracker1.position).len() - val dist2 = (position2 - computedTracker2.position).len() - offset += abs(dist2 - dist1) - offsetCount++ - } catch (_: Exception) { - // Ignore unsupported positions - } - } - return if (offsetCount > 0) offset / offsetCount else 0f - } -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/errors/SlideError.kt b/server/core/src/main/java/dev/slimevr/autobone/errors/SlideError.kt deleted file mode 100644 index df245736cf..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/errors/SlideError.kt +++ /dev/null @@ -1,44 +0,0 @@ -package dev.slimevr.autobone.errors - -import dev.slimevr.autobone.AutoBoneStep -import dev.slimevr.autobone.PoseFrameStep -import dev.slimevr.tracking.processor.Bone -import dev.slimevr.tracking.processor.BoneType -import dev.slimevr.tracking.processor.skeleton.HumanSkeleton - -// The change in position of the ankle over time -class SlideError : IAutoBoneError { - @Throws(AutoBoneException::class) - override fun getStepError(step: PoseFrameStep): Float = getSlideError( - step.skeleton1.skeleton, - step.skeleton2.skeleton, - ) - - companion object { - fun getSlideError(skeleton1: HumanSkeleton, skeleton2: HumanSkeleton): Float { - // Calculate and average between both feet - return ( - getSlideError(skeleton1, skeleton2, BoneType.LEFT_LOWER_LEG) + - getSlideError(skeleton1, skeleton2, BoneType.RIGHT_LOWER_LEG) - ) / - 2f - } - - fun getSlideError( - skeleton1: HumanSkeleton, - skeleton2: HumanSkeleton, - bone: BoneType, - ): Float { - // Calculate and average between both feet - return getSlideError( - skeleton1.getBone(bone), - skeleton2.getBone(bone), - ) - } - - fun getSlideError(bone1: Bone, bone2: Bone): Float { - // Return the midpoint distance - return (bone2.getTailPosition() - bone1.getTailPosition()).len() / 2f - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/autobone/errors/proportions/ProportionLimiter.kt b/server/core/src/main/java/dev/slimevr/autobone/errors/proportions/ProportionLimiter.kt deleted file mode 100644 index 711a6f44b2..0000000000 --- a/server/core/src/main/java/dev/slimevr/autobone/errors/proportions/ProportionLimiter.kt +++ /dev/null @@ -1,80 +0,0 @@ -package dev.slimevr.autobone.errors.proportions - -import dev.slimevr.tracking.processor.HumanPoseManager -import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets -import kotlin.math.* - -class ProportionLimiter { - val targetRatio: Float - val skeletonConfigOffset: SkeletonConfigOffsets - val scaleByHeight: Boolean - - val positiveRange: Float - val negativeRange: Float - - /** - * @param targetRatio The bone to height ratio to target - * @param skeletonConfigOffset The SkeletonConfigOffset to use for the length - * @param range The range from the target ratio to accept (ex. 0.1) - * @param scaleByHeight True if the bone length will be scaled by the height - */ - constructor( - targetRatio: Float, - skeletonConfigOffset: SkeletonConfigOffsets, - range: Float, - scaleByHeight: Boolean = true, - ) { - this.targetRatio = targetRatio - this.skeletonConfigOffset = skeletonConfigOffset - this.scaleByHeight = scaleByHeight - - // Handle if someone puts in a negative value - val absRange = abs(range) - positiveRange = absRange - negativeRange = -absRange - } - - /** - * @param targetRatio The bone to height ratio to target - * @param skeletonConfigOffset The SkeletonConfigOffset to use for the length - * @param positiveRange The positive range from the target ratio to accept - * (ex. 0.1) - * @param negativeRange The negative range from the target ratio to accept - * (ex. -0.1) - * @param scaleByHeight True if the bone length will be scaled by the height - */ - constructor( - targetRatio: Float, - skeletonConfigOffset: SkeletonConfigOffsets, - positiveRange: Float, - negativeRange: Float, - scaleByHeight: Boolean = true, - ) { - // If the positive range is less than the negative range, something is wrong - require(positiveRange >= negativeRange) { "positiveRange must not be less than negativeRange" } - - this.targetRatio = targetRatio - this.skeletonConfigOffset = skeletonConfigOffset - this.scaleByHeight = scaleByHeight - - this.positiveRange = positiveRange - this.negativeRange = negativeRange - } - - fun getProportionError(humanPoseManager: HumanPoseManager, height: Float): Float { - val boneLength = humanPoseManager.getOffset(skeletonConfigOffset) - val ratioOffset = if (scaleByHeight) { - targetRatio - boneLength / height - } else { - targetRatio - boneLength - } - - // If the range is exceeded, return the offset from the range limit - if (ratioOffset > positiveRange) { - return ratioOffset - positiveRange - } else if (ratioOffset < negativeRange) { - return ratioOffset - negativeRange - } - return 0f - } -} diff --git a/server/core/src/main/java/dev/slimevr/bridge/Bridge.kt b/server/core/src/main/java/dev/slimevr/bridge/Bridge.kt deleted file mode 100644 index 5750b53b3b..0000000000 --- a/server/core/src/main/java/dev/slimevr/bridge/Bridge.kt +++ /dev/null @@ -1,58 +0,0 @@ -package dev.slimevr.bridge - -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerRole -import dev.slimevr.util.ann.VRServerThread - -/** - * Bridge handles sending and receiving tracker data between SlimeVR and other - * systems like VR APIs (SteamVR, OpenXR, etc), apps and protocols (VMC, - * WebSocket, TIP). It can create and manage tracker received from the **remote - * side** or send shared **local trackers** to the other side. - */ -interface Bridge { - @VRServerThread - fun dataRead() - - @VRServerThread - fun dataWrite() - - /** - * Adds shared tracker to the bridge. Bridge should notify the other side of - * this tracker, if it's the type of tracker this bridge serves, and start - * sending data each update - * - * @param tracker - */ - @VRServerThread - fun addSharedTracker(tracker: Tracker?) - - /** - * Removes tracker from a bridge. If the other side supports tracker - * removal, bridge should notify it and stop sending new data. If it doesn't - * support tracker removal, the bridge can either stop sending new data, or - * keep sending it if it's available. - * - * @param tracker - */ - @VRServerThread - fun removeSharedTracker(tracker: Tracker?) - - @VRServerThread - fun startBridge() - - fun isConnected(): Boolean -} - -interface ISteamVRBridge : Bridge { - fun getShareSetting(role: TrackerRole): Boolean - - fun changeShareSettings(role: TrackerRole?, share: Boolean) - - fun updateShareSettingsAutomatically(): Boolean - - fun getAutomaticSharedTrackers(): Boolean - fun setAutomaticSharedTrackers(value: Boolean) - - fun getBridgeConfigKey(): String -} diff --git a/server/core/src/main/java/dev/slimevr/bridge/BridgeThread.java b/server/core/src/main/java/dev/slimevr/bridge/BridgeThread.java deleted file mode 100644 index 52f7224241..0000000000 --- a/server/core/src/main/java/dev/slimevr/bridge/BridgeThread.java +++ /dev/null @@ -1,9 +0,0 @@ -package dev.slimevr.bridge; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - - -@Retention(value = RetentionPolicy.SOURCE) -public @interface BridgeThread { -} diff --git a/server/core/src/main/java/dev/slimevr/config/AutoBoneConfig.kt b/server/core/src/main/java/dev/slimevr/config/AutoBoneConfig.kt deleted file mode 100644 index 3d3c62f22a..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/AutoBoneConfig.kt +++ /dev/null @@ -1,28 +0,0 @@ -package dev.slimevr.config - -class AutoBoneConfig { - var cursorIncrement = 2 - var minDataDistance = 1 - var maxDataDistance = 1 - var numEpochs = 50 - var printEveryNumEpochs = 25 - var initialAdjustRate = 10.0f - var adjustRateDecay = 1.0f - var slideErrorFactor = 1.0f - var offsetSlideErrorFactor = 0.0f - var footHeightOffsetErrorFactor = 0.0f - var bodyProportionErrorFactor = 0.05f - var heightErrorFactor = 0.0f - var positionErrorFactor = 0.0f - var positionOffsetErrorFactor = 0.0f - var calcInitError = false - var randomizeFrameOrder = true - var scaleEachStep = true - var sampleCount = 1500 - var sampleRateMs = 20L - var saveRecordings = false - var useSkeletonHeight = false - var randSeed = 4L - var useFrameFiltering = false - var maxFinalError = 0.03f -} diff --git a/server/core/src/main/java/dev/slimevr/config/BridgeConfig.java b/server/core/src/main/java/dev/slimevr/config/BridgeConfig.java deleted file mode 100644 index 065ffca468..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/BridgeConfig.java +++ /dev/null @@ -1,33 +0,0 @@ -package dev.slimevr.config; - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import com.fasterxml.jackson.databind.ser.std.StdKeySerializers; -import dev.slimevr.config.serializers.BooleanMapDeserializer; -import dev.slimevr.tracking.trackers.TrackerRole; -import java.util.HashMap; -import java.util.Map; - - -public class BridgeConfig { - - @JsonDeserialize(using = BooleanMapDeserializer.class) - @JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer.class) - public Map trackers = new HashMap<>(); - public boolean automaticSharedTrackersToggling = true; - - public BridgeConfig() { - } - - public boolean getBridgeTrackerRole(TrackerRole role, boolean def) { - return trackers.getOrDefault(role.name().toLowerCase(), def); - } - - public void setBridgeTrackerRole(TrackerRole role, boolean val) { - this.trackers.put(role.name().toLowerCase(), val); - } - - public Map getTrackers() { - return trackers; - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/ConfigManager.java b/server/core/src/main/java/dev/slimevr/config/ConfigManager.java deleted file mode 100644 index 51bfcd7b04..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/ConfigManager.java +++ /dev/null @@ -1,176 +0,0 @@ -package dev.slimevr.config; - -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.module.SimpleModule; -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; -import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator; -import com.github.jonpeterson.jackson.module.versioning.VersioningModule; -import dev.slimevr.config.serializers.QuaternionDeserializer; -import dev.slimevr.config.serializers.QuaternionSerializer; -import io.eiren.util.ann.ThreadSafe; -import io.eiren.util.logging.LogManager; -import io.github.axisangles.ktmath.ObjectQuaternion; - -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.nio.file.*; -import java.util.Comparator; -import java.util.stream.Collectors; -import java.util.stream.Stream; - - -public class ConfigManager { - - private final String configPath; - - private final ObjectMapper om; - - private VRConfig vrConfig; - - - public ConfigManager(String configPath) { - this.configPath = configPath; - om = new ObjectMapper(new YAMLFactory().disable(YAMLGenerator.Feature.SPLIT_LINES)); - om.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - om.registerModule(new VersioningModule()); - SimpleModule quaternionModule = new SimpleModule(); - quaternionModule.addSerializer(ObjectQuaternion.class, new QuaternionSerializer()); - quaternionModule.addDeserializer(ObjectQuaternion.class, new QuaternionDeserializer()); - om.registerModule(quaternionModule); - } - - public void loadConfig() { - try { - this.vrConfig = om - .readValue(new FileInputStream(configPath), VRConfig.class); - } catch (FileNotFoundException e) { - // Config file didn't exist, is not an error - } catch (IOException e) { - // Log the exception - LogManager.severe("Config failed to load: " + e); - // Make a backup of the erroneous config - backupConfig(); - } - - if (this.vrConfig == null) { - this.vrConfig = new VRConfig(); - } - } - - static public void atomicMove(Path from, Path to) throws IOException { - try { - // Atomic move to overwrite - Files.move(from, to, StandardCopyOption.ATOMIC_MOVE); - } catch (AtomicMoveNotSupportedException | FileAlreadyExistsException e) { - // Atomic move not supported or does not replace, try just replacing - Files.move(from, to, StandardCopyOption.REPLACE_EXISTING); - } - } - - public void backupConfig() { - Path cfgFile = Paths.get(configPath); - Path tmpBakCfgFile = Paths.get(configPath + ".bak.tmp"); - Path bakCfgFile = Paths.get(configPath + ".bak"); - - try { - Files - .copy( - cfgFile, - tmpBakCfgFile, - StandardCopyOption.REPLACE_EXISTING, - StandardCopyOption.COPY_ATTRIBUTES - ); - LogManager.info("Made a backup copy of config to \"" + tmpBakCfgFile + "\""); - } catch (IOException e) { - LogManager - .severe( - "Unable to make backup copy of config from \"" - + cfgFile - + "\" to \"" - + tmpBakCfgFile - + "\"", - e - ); - return; // Abort write - } - - try { - atomicMove(tmpBakCfgFile, bakCfgFile); - } catch (IOException e) { - LogManager - .severe( - "Unable to move backup config from \"" - + tmpBakCfgFile - + "\" to \"" - + bakCfgFile - + "\"", - e - ); - } - } - - @ThreadSafe - public synchronized void saveConfig() { - Path tmpCfgFile = Paths.get(configPath + ".tmp"); - Path cfgFile = Paths.get(configPath); - - // Serialize config - try { - // delete accidental folder caused by PR - // https://github.com/SlimeVR/SlimeVR-Server/pull/1176 - var cfgFileMaybeFolder = cfgFile.toFile(); - if (cfgFileMaybeFolder.isDirectory()) { - try (Stream pathStream = Files.walk(cfgFile)) { - // Can't use .toList() on Android - var list = pathStream - .sorted(Comparator.reverseOrder()) - .collect(Collectors.toList()); - for (var path : list) { - Files.delete(path); - } - } catch (IOException e) { - LogManager - .severe( - "Unable to delete folder that has same name as the config file on path \"" - + cfgFile - + "\"" - ); - return; - } - - } - var cfgFolder = cfgFile.toAbsolutePath().getParent().toFile(); - if (!cfgFolder.exists() && !cfgFolder.mkdirs()) { - LogManager - .severe("Unable to create folders for config on path \"" + cfgFile + "\""); - return; - } - om.writeValue(tmpCfgFile.toFile(), this.vrConfig); - } catch (IOException e) { - LogManager.severe("Unable to write serialized config to \"" + tmpCfgFile + "\"", e); - return; // Abort write - } - - // Overwrite old config - try { - atomicMove(tmpCfgFile, cfgFile); - } catch (IOException e) { - LogManager - .severe( - "Unable to move new config from \"" + tmpCfgFile + "\" to \"" + cfgFile + "\"", - e - ); - } - } - - public void resetConfig() { - this.vrConfig = new VRConfig(); - saveConfig(); - } - - public VRConfig getVrConfig() { - return vrConfig; - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/CurrentVRConfigConverter.java b/server/core/src/main/java/dev/slimevr/config/CurrentVRConfigConverter.java deleted file mode 100644 index 77a1a777fb..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/CurrentVRConfigConverter.java +++ /dev/null @@ -1,357 +0,0 @@ -package dev.slimevr.config; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.*; -import com.github.jonpeterson.jackson.module.versioning.VersionedModelConverter; -import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets; -import dev.slimevr.tracking.trackers.TrackerPosition; -import io.eiren.util.logging.LogManager; - -import java.util.Map; -import java.util.regex.Pattern; - - -public class CurrentVRConfigConverter implements VersionedModelConverter { - - @Override - public ObjectNode convert( - ObjectNode modelData, - String modelVersion, - String targetModelVersion, - JsonNodeFactory nodeFactory - ) { - try { - int version = Integer.parseInt(modelVersion); - - // Configs with old versions need a migration to the latest config - if (version < 2) { - // Move zoom to the window config - ObjectNode windowNode = (ObjectNode) modelData.get("window"); - DoubleNode zoomNode = (DoubleNode) modelData.get("zoom"); - if (windowNode != null && zoomNode != null) { - windowNode.set("zoom", zoomNode); - modelData.remove("zoom"); - } - - // Change trackers list to map - ArrayNode oldTrackersNode = modelData.withArray("trackers"); - if (oldTrackersNode != null) { - var trackersIter = oldTrackersNode.iterator(); - ObjectNode trackersNode = nodeFactory.objectNode(); - while (trackersIter.hasNext()) { - JsonNode node = trackersIter.next(); - JsonNode resultNode = TrackerConfig.toV2(node, nodeFactory); - trackersNode.set(node.get("name").asText(), resultNode); - } - modelData.set("trackers", trackersNode); - } - - // Rename bridge to bridges - ObjectNode bridgeNode = (ObjectNode) modelData.get("bridge"); - if (bridgeNode != null) { - modelData.set("bridges", bridgeNode); - modelData.remove("bridge"); - } - - // Move body to skeleton (and merge it to current skeleton) - ObjectNode bodyNode = (ObjectNode) modelData.get("body"); - if (bodyNode != null) { - var bodyIter = bodyNode.fields(); - ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton"); - if (skeletonNode == null) { - skeletonNode = nodeFactory.objectNode(); - } - - ObjectNode offsetsNode = nodeFactory.objectNode(); - while (bodyIter.hasNext()) { - Map.Entry node = bodyIter.next(); - // Filter only number values because other types would - // be stuff that didn't get migrated correctly before - if (node.getValue().isNumber()) { - offsetsNode.set(node.getKey(), node.getValue()); - } - } - - // Fix calibration wolf typos - offsetsNode.set("shouldersWidth", bodyNode.get("shoulersWidth")); - offsetsNode.set("shouldersDistance", bodyNode.get("shoulersDistance")); - offsetsNode.remove("shoulersWidth"); - offsetsNode.remove("shoulersDistance"); - skeletonNode.set("offsets", offsetsNode); - modelData.set("skeleton", skeletonNode); - modelData.remove("body"); - } - } - if (version < 3) { - // Check for out-of-bound filtering amount - ObjectNode filtersNode = (ObjectNode) modelData.get("filters"); - if (filtersNode != null && filtersNode.get("amount").floatValue() > 2f) { - filtersNode.set("amount", new FloatNode(0.2f)); - } - } - if (version < 4) { - // Change mountingRotation to mountingOrientation - ObjectNode oldTrackersNode = (ObjectNode) modelData.get("trackers"); - if (oldTrackersNode != null) { - var trackersIter = oldTrackersNode.iterator(); - var fieldNamesIter = oldTrackersNode.fieldNames(); - ObjectNode trackersNode = nodeFactory.objectNode(); - String fieldName; - while (trackersIter.hasNext()) { - ObjectNode node = (ObjectNode) trackersIter.next(); - fieldName = fieldNamesIter.next(); - node.set("mountingOrientation", node.get("mountingRotation")); - node.remove("mountingRotation"); - trackersNode.set(fieldName, node); - } - modelData.set("trackers", trackersNode); - } - } - if (version < 5) { - // Migrate old skeleton offsets to new ones - ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton"); - if (skeletonNode != null) { - ObjectNode offsetsNode = (ObjectNode) skeletonNode.get("offsets"); - if (offsetsNode != null) { - // torsoLength, chestDistance and waistDistance become - // chestLength, waistLength and hipLength. - float torsoLength = SkeletonConfigOffsets.CHEST.defaultValue - + SkeletonConfigOffsets.WAIST.defaultValue - + SkeletonConfigOffsets.HIP.defaultValue; - float chestDistance = SkeletonConfigOffsets.CHEST.defaultValue; - float waistDistance = SkeletonConfigOffsets.HIP.defaultValue; - JsonNode torsoNode = offsetsNode.get("torsoLength"); - if (torsoNode != null) - torsoLength = torsoNode.floatValue(); - JsonNode chestNode = offsetsNode.get("chestDistance"); - if (chestNode != null) - chestDistance = chestNode.floatValue(); - JsonNode waistNode = offsetsNode.get("waistDistance"); - if (waistNode != null) - waistDistance = waistNode.floatValue(); - offsetsNode.set("chestLength", offsetsNode.get("chestDistance")); - offsetsNode - .set( - "waistLength", - new FloatNode(torsoLength - chestDistance - waistDistance) - ); - offsetsNode.set("hipLength", offsetsNode.get("waistDistance")); - offsetsNode.remove("torsoLength"); - offsetsNode.remove("chestDistance"); - offsetsNode.remove("waistDistance"); - - // legsLength and kneeHeight become - // upperLegLength and lowerLegLength - float legsLength = SkeletonConfigOffsets.UPPER_LEG.defaultValue - + SkeletonConfigOffsets.LOWER_LEG.defaultValue; - float kneeHeight = SkeletonConfigOffsets.LOWER_LEG.defaultValue; - JsonNode legsNode = offsetsNode.get("legsLength"); - if (legsNode != null) - legsLength = legsNode.floatValue(); - JsonNode kneesNode = offsetsNode.get("kneeHeight"); - if (kneesNode != null) - kneeHeight = kneesNode.floatValue(); - offsetsNode.set("upperLegLength", new FloatNode(legsLength - kneeHeight)); - offsetsNode.set("lowerLegLength", new FloatNode(kneeHeight)); - offsetsNode.remove("legsLength"); - offsetsNode.remove("kneeHeight"); - - skeletonNode.set("offsets", offsetsNode); - modelData.set("skeleton", skeletonNode); - } - } - } - if (version < 6) { - // Migrate controllers offsets to hands offsets - ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton"); - if (skeletonNode != null) { - ObjectNode offsetsNode = (ObjectNode) skeletonNode.get("offsets"); - if (offsetsNode != null) { - offsetsNode.set("handDistanceY", offsetsNode.get("controllerDistanceY")); - offsetsNode.set("handDistanceZ", offsetsNode.get("controllerDistanceZ")); - } - } - } - if (version < 7) { - // Chest, hip, and elbow offsets now go the opposite direction - ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton"); - if (skeletonNode != null) { - ObjectNode offsetsNode = (ObjectNode) skeletonNode.get("offsets"); - if (offsetsNode != null) { - JsonNode chestNode = offsetsNode.get("chestOffset"); - if (chestNode != null) - offsetsNode.set("chestOffset", new FloatNode(-chestNode.floatValue())); - JsonNode hipNode = offsetsNode.get("hipOffset"); - if (hipNode != null) - offsetsNode.set("hipOffset", new FloatNode(-hipNode.floatValue())); - JsonNode elbowNode = offsetsNode.get("elbowOffset"); - if (elbowNode != null) - offsetsNode.set("elbowOffset", new FloatNode(-elbowNode.floatValue())); - } - } - } - if (version < 8) { - // reset > fullReset, quickReset > yawReset - ObjectNode keybindingsNode = (ObjectNode) modelData.get("keybindings"); - if (keybindingsNode != null) { - JsonNode fullResetNode = keybindingsNode.get("resetBinding"); - if (fullResetNode != null) - keybindingsNode.set("fullResetBinding", fullResetNode); - JsonNode yawResetNode = keybindingsNode.get("quickResetBinding"); - if (yawResetNode != null) - keybindingsNode.set("yawResetBinding", yawResetNode); - JsonNode mountingResetNode = keybindingsNode.get("resetMountingBinding"); - if (mountingResetNode != null) - keybindingsNode.set("mountingResetBinding", mountingResetNode); - - JsonNode fullDelayNode = keybindingsNode.get("resetDelay"); - if (fullDelayNode != null) - keybindingsNode.set("fullResetDelay", fullDelayNode); - JsonNode yawDelayNode = keybindingsNode.get("quickResetDelay"); - if (yawDelayNode != null) - keybindingsNode.set("yawResetDelay", yawDelayNode); - JsonNode mountingDelayNode = keybindingsNode.get("resetMountingDelay"); - if (mountingDelayNode != null) - keybindingsNode.set("mountingResetDelay", mountingDelayNode); - } - - ObjectNode tapDetectionNode = (ObjectNode) modelData.get("tapDetection"); - if (tapDetectionNode != null) { - tapDetectionNode.set("yawResetDelay", tapDetectionNode.get("quickResetDelay")); - tapDetectionNode.set("fullResetDelay", tapDetectionNode.get("resetDelay")); - tapDetectionNode - .set("yawResetEnabled", tapDetectionNode.get("quickResetEnabled")); - tapDetectionNode.set("fullResetEnabled", tapDetectionNode.get("resetEnabled")); - tapDetectionNode.set("yawResetTaps", tapDetectionNode.get("quickResetTaps")); - tapDetectionNode.set("fullResetTaps", tapDetectionNode.get("resetTaps")); - } - } - if (version < 9) { - // split chest into 2 offsets - ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton"); - if (skeletonNode != null) { - ObjectNode offsetsNode = (ObjectNode) skeletonNode.get("offsets"); - if (offsetsNode != null) { - JsonNode chestNode = offsetsNode.get("chestLength"); - if (chestNode != null) { - offsetsNode - .set("chestLength", new FloatNode(chestNode.floatValue() / 2f)); - offsetsNode - .set( - "upperChestLength", - new FloatNode(chestNode.floatValue() / 2f) - ); - } - } - } - } - if (version < 10) { - // Change default AutoBone recording length from 20 to 30 - // seconds - ObjectNode autoBoneNode = (ObjectNode) modelData.get("autoBone"); - if (autoBoneNode != null) { - JsonNode sampleCountNode = autoBoneNode.get("sampleCount"); - if (sampleCountNode != null && sampleCountNode.intValue() == 1000) { - autoBoneNode.set("sampleCount", new IntNode(1500)); - } - } - } - if (version < 11) { - // Sets HMD's designation to "body:head" - ObjectNode trackersNode = (ObjectNode) modelData.get("trackers"); - if (trackersNode != null) { - ObjectNode HMDNode = (ObjectNode) trackersNode.get("HMD"); - if (HMDNode != null) { - HMDNode - .set( - "designation", - new TextNode(TrackerPosition.HEAD.getDesignation()) - ); - trackersNode.set("HMD", HMDNode); - modelData.set("trackers", trackersNode); - } - } - } - if (version < 12) { - // Update AutoBone defaults - ObjectNode autoBoneNode = (ObjectNode) modelData.get("autoBone"); - if (autoBoneNode != null) { - JsonNode offsetSlideNode = autoBoneNode.get("offsetSlideErrorFactor"); - if (offsetSlideNode != null && offsetSlideNode.floatValue() == 2.0f) { - autoBoneNode.set("offsetSlideErrorFactor", new FloatNode(1.0f)); - } - JsonNode bodyProportionsNode = autoBoneNode.get("bodyProportionErrorFactor"); - if (bodyProportionsNode != null && bodyProportionsNode.floatValue() == 0.825f) { - autoBoneNode.set("bodyProportionErrorFactor", new FloatNode(0.25f)); - } - } - } - - if (version < 13) { - ObjectNode oldTrackersNode = (ObjectNode) modelData.get("trackers"); - if (oldTrackersNode != null) { - var fieldNamesIter = oldTrackersNode.fieldNames(); - String trackerId; - final String macAddressRegex = "udp://((?:[a-zA-Z\\d]{2}:){5}[a-zA-Z\\d]{2})/0"; - final Pattern pattern = Pattern.compile(macAddressRegex); - while (fieldNamesIter.hasNext()) { - trackerId = fieldNamesIter.next(); - var matcher = pattern.matcher(trackerId); - if (!matcher.find()) - continue; - - modelData.withArray("knownDevices").add(matcher.group(1)); - } - } - } - - if (version < 14) { - // Update AutoBone defaults - ObjectNode autoBoneNode = (ObjectNode) modelData.get("autoBone"); - if (autoBoneNode != null) { - // Move HMD height to skeleton - ObjectNode skeletonNode = (ObjectNode) modelData.get("skeleton"); - if (skeletonNode != null) { - JsonNode targetHmdHeight = autoBoneNode.get("targetHmdHeight"); - if (targetHmdHeight != null) { - skeletonNode.set("hmdHeight", targetHmdHeight); - } - } - - JsonNode offsetSlideNode = autoBoneNode.get("offsetSlideErrorFactor"); - JsonNode slideNode = autoBoneNode.get("slideErrorFactor"); - if ( - offsetSlideNode != null - && slideNode != null - && offsetSlideNode.floatValue() == 1.0f - && slideNode.floatValue() == 0.0f - ) { - autoBoneNode.set("offsetSlideErrorFactor", new FloatNode(0.0f)); - autoBoneNode.set("slideErrorFactor", new FloatNode(1.0f)); - } - JsonNode bodyProportionsNode = autoBoneNode.get("bodyProportionErrorFactor"); - if (bodyProportionsNode != null && bodyProportionsNode.floatValue() == 0.25f) { - autoBoneNode.set("bodyProportionErrorFactor", new FloatNode(0.05f)); - } - JsonNode numEpochsNode = autoBoneNode.get("numEpochs"); - if (numEpochsNode != null && numEpochsNode.intValue() == 100) { - autoBoneNode.set("numEpochs", new IntNode(50)); - } - } - } - - if (version < 15) { - ObjectNode checklistNode = (ObjectNode) modelData.get("trackingChecklist"); - if (checklistNode != null) { - ArrayNode ignoredStepsArray = (ArrayNode) checklistNode.get("ignoredStepsIds"); - if (ignoredStepsArray != null) - ignoredStepsArray.removeAll(); - } - } - } catch (Exception e) { - LogManager.severe("Error during config migration: " + e); - } - - return modelData; - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/DriftCompensationConfig.kt b/server/core/src/main/java/dev/slimevr/config/DriftCompensationConfig.kt deleted file mode 100644 index adced65a46..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/DriftCompensationConfig.kt +++ /dev/null @@ -1,26 +0,0 @@ -package dev.slimevr.config - -import dev.slimevr.VRServer - -class DriftCompensationConfig { - - // Is drift compensation enabled - var enabled = false - - // Is drift prediction enabled - var prediction = false - - // Amount of drift compensation applied - var amount = 0.8f - - // Max resets for the calculated average drift - var maxResets = 6 - - fun updateTrackersDriftCompensation() { - for (t in VRServer.instance.allTrackers) { - if (t.isImu()) { - t.resetsHandler.readDriftCompensationConfig(this) - } - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/FiltersConfig.kt b/server/core/src/main/java/dev/slimevr/config/FiltersConfig.kt deleted file mode 100644 index 4ba5303142..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/FiltersConfig.kt +++ /dev/null @@ -1,20 +0,0 @@ -package dev.slimevr.config - -import dev.slimevr.VRServer - -class FiltersConfig { - - // Type of filtering applied (none, smoothing or prediction) - var type = "prediction" - - // Amount/Intensity of the specified filtering (0 to 1) - var amount = 0.2f - - fun updateTrackersFilters() { - for (tracker in VRServer.instance.allTrackers) { - if (tracker.allowFiltering) { - tracker.filteringHandler.readFilteringConfig(this, tracker.getRotation()) - } - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/HIDConfig.kt b/server/core/src/main/java/dev/slimevr/config/HIDConfig.kt deleted file mode 100644 index 2945ee3144..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/HIDConfig.kt +++ /dev/null @@ -1,7 +0,0 @@ -package dev.slimevr.config - -import com.fasterxml.jackson.annotation.JsonIgnore - -class HIDConfig { - var trackersOverHID = false -} diff --git a/server/core/src/main/java/dev/slimevr/config/KeybindingsConfig.java b/server/core/src/main/java/dev/slimevr/config/KeybindingsConfig.java deleted file mode 100644 index a3c1b0bfe3..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/KeybindingsConfig.java +++ /dev/null @@ -1,88 +0,0 @@ -package dev.slimevr.config; - -public class KeybindingsConfig { - - private String fullResetBinding = "CTRL+ALT+SHIFT+Y"; - - private String yawResetBinding = "CTRL+ALT+SHIFT+U"; - - private String mountingResetBinding = "CTRL+ALT+SHIFT+I"; - - private String feetMountingResetBinding = "CTRL+ALT+SHIFT+P"; - - private String pauseTrackingBinding = "CTRL+ALT+SHIFT+O"; - - private long fullResetDelay = 0L; - - private long yawResetDelay = 0L; - - private long mountingResetDelay = 0L; - - private long feetMountingResetDelay = 0L; - - private long pauseTrackingDelay = 0L; - - - public KeybindingsConfig() { - } - - public String getFullResetBinding() { - return fullResetBinding; - } - - public String getYawResetBinding() { - return yawResetBinding; - } - - public String getMountingResetBinding() { - return mountingResetBinding; - } - - public String getFeetMountingResetBinding() { - return feetMountingResetBinding; - } - - public String getPauseTrackingBinding() { - return pauseTrackingBinding; - } - - public long getFullResetDelay() { - return fullResetDelay; - } - - public void setFullResetDelay(long delay) { - fullResetDelay = delay; - } - - public long getYawResetDelay() { - return yawResetDelay; - } - - public void setYawResetDelay(long delay) { - yawResetDelay = delay; - } - - public long getMountingResetDelay() { - return mountingResetDelay; - } - - public void setMountingResetDelay(long delay) { - mountingResetDelay = delay; - } - - public long getFeetMountingResetDelay() { - return feetMountingResetDelay; - } - - public void setFeetMountingResetDelay(long delay) { - feetMountingResetDelay = delay; - } - - public long getPauseTrackingDelay() { - return pauseTrackingDelay; - } - - public void setPauseTrackingDelay(long delay) { - pauseTrackingDelay = delay; - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/LegTweaksConfig.kt b/server/core/src/main/java/dev/slimevr/config/LegTweaksConfig.kt deleted file mode 100644 index 97250faa00..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/LegTweaksConfig.kt +++ /dev/null @@ -1,6 +0,0 @@ -package dev.slimevr.config - -class LegTweaksConfig { - var correctionStrength = 0.3f - var alwaysUseFloorclip = false -} diff --git a/server/core/src/main/java/dev/slimevr/config/OSCConfig.kt b/server/core/src/main/java/dev/slimevr/config/OSCConfig.kt deleted file mode 100644 index 89b317fa37..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/OSCConfig.kt +++ /dev/null @@ -1,16 +0,0 @@ -package dev.slimevr.config - -open class OSCConfig { - - // Are the OSC receiver and sender enabled? - var enabled = false - - // Port to receive OSC messages from - var portIn = 0 - - // Port to send out OSC messages at - var portOut = 0 - - // Address to send out OSC messages at - var address = "127.0.0.1" -} diff --git a/server/core/src/main/java/dev/slimevr/config/OverlayConfig.java b/server/core/src/main/java/dev/slimevr/config/OverlayConfig.java deleted file mode 100644 index 7f564e57d5..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/OverlayConfig.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.slimevr.config; - -public class OverlayConfig { - - private boolean isMirrored = false; - private boolean isVisible = false; - - - public boolean isMirrored() { - return isMirrored; - } - - public boolean isVisible() { - return isVisible; - } - - public void setMirrored(boolean mirrored) { - isMirrored = mirrored; - } - - public void setVisible(boolean visible) { - isVisible = visible; - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/ResetsConfig.kt b/server/core/src/main/java/dev/slimevr/config/ResetsConfig.kt deleted file mode 100644 index 65cc761d84..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/ResetsConfig.kt +++ /dev/null @@ -1,78 +0,0 @@ -package dev.slimevr.config - -import dev.slimevr.VRServer - -enum class ArmsResetModes(val id: Int) { - // Upper arm going back and forearm going forward - BACK(0), - - // Arms going forward - FORWARD(1), - - // Arms going up to the sides into a tpose - TPOSE_UP(2), - - // Arms going down to the sides from a tpose - TPOSE_DOWN(3), - ; - - companion object { - val values = entries.toTypedArray() - - @JvmStatic - fun fromId(id: Int): ArmsResetModes? { - for (filter in values) { - if (filter.id == id) return filter - } - return null - } - } -} - -enum class MountingMethods(val id: Int) { - MANUAL(0), - AUTOMATIC(1), - ; - - companion object { - val values = MountingMethods.entries.toTypedArray() - - @JvmStatic - fun fromId(id: Int): MountingMethods? { - for (filter in values) { - if (filter.id == id) return filter - } - return null - } - } -} - -class ResetsConfig { - - // Always reset mounting for feet - var resetMountingFeet = false - - // Reset mode used for the arms - var mode = ArmsResetModes.BACK - - // Yaw reset smoothing time in seconds - var yawResetSmoothTime = 0.0f - - // Save automatic mounting reset calibration - var saveMountingReset = false - - // Reset the HMD's pitch upon full reset - var resetHmdPitch = false - - var lastMountingMethod = MountingMethods.AUTOMATIC - - var yawResetDelay = 0.0f - var fullResetDelay = 3.0f - var mountingResetDelay = 3.0f - - fun updateTrackersResetsSettings() { - for (t in VRServer.instance.allTrackers) { - t.resetsHandler.readResetConfig(this) - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/ServerConfig.kt b/server/core/src/main/java/dev/slimevr/config/ServerConfig.kt deleted file mode 100644 index c2bbc86183..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/ServerConfig.kt +++ /dev/null @@ -1,66 +0,0 @@ -package dev.slimevr.config - -import dev.slimevr.VRServer -import dev.slimevr.tracking.trackers.udp.MagnetometerStatus -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.sync.Mutex - -class ServerConfig { - val trackerPort: Int = 6969 - - var useMagnetometerOnAllTrackers: Boolean = false - private set - - private val magMutex = Mutex() - suspend fun defineMagOnAllTrackers(state: Boolean) = coroutineScope { - magMutex.lock() - try { - if (useMagnetometerOnAllTrackers == state) return@coroutineScope - - VRServer.instance.deviceManager.devices.filter { it.magSupport }.map { - async { - // Not using 255 as it sometimes could make one of the sensors go into - // error mode (if there is more than one sensor inside the device) - if (!state) { - val trackers = it.trackers.filterValues { - it.magStatus != MagnetometerStatus.NOT_SUPPORTED - } -// if(trackers.size == it.trackers.size) { -// it.setMag(false) -// } else { - trackers.map { (_, t) -> - async { it.setMag(false, t.trackerNum) } - }.awaitAll() -// } - return@async - } - -// val every = it.trackers.all { (_, t) -> t.config.shouldHaveMagEnabled == true -// && t.magStatus != MagnetometerStatus.NOT_SUPPORTED } -// if (every) { -// it.setMag(true) -// return@async -// } - - it.trackers.filterValues { - it.config.shouldHaveMagEnabled == true && - it.magStatus != MagnetometerStatus.NOT_SUPPORTED - } - .map { (_, t) -> - async { - // FIXME: Tracker gets restarted after each setMag, what will happen for devices with 3 trackers? - it.setMag(true, t.trackerNum) - } - }.awaitAll() - } - }.awaitAll() - - useMagnetometerOnAllTrackers = state - VRServer.instance.configManager.saveConfig() - } finally { - magMutex.unlock() - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/SkeletonConfig.java b/server/core/src/main/java/dev/slimevr/config/SkeletonConfig.java deleted file mode 100644 index 27e6b3c3e8..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/SkeletonConfig.java +++ /dev/null @@ -1,63 +0,0 @@ -package dev.slimevr.config; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import com.fasterxml.jackson.databind.ser.std.StdKeySerializers; -import dev.slimevr.config.serializers.BooleanMapDeserializer; -import dev.slimevr.config.serializers.FloatMapDeserializer; - -import java.util.HashMap; -import java.util.Map; - - -public class SkeletonConfig { - - @JsonDeserialize(using = BooleanMapDeserializer.class) - @JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer.class) - public Map toggles = new HashMap<>(); - - @JsonDeserialize(using = FloatMapDeserializer.class) - @JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer.class) - public Map values = new HashMap<>(); - - @JsonDeserialize(using = FloatMapDeserializer.class) - @JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer.class) - public Map offsets = new HashMap<>(); - - private float hmdHeight = 0f; - private float floorHeight = 0f; - - public Map getToggles() { - return toggles; - } - - public Map getOffsets() { - return offsets; - } - - public Map getValues() { - return values; - } - - public float getHmdHeight() { - return hmdHeight; - } - - public void setHmdHeight(float hmdHeight) { - this.hmdHeight = hmdHeight; - } - - public float getFloorHeight() { - return floorHeight; - } - - public void setFloorHeight(float hmdHeight) { - this.floorHeight = hmdHeight; - } - - @JsonIgnore - public float getUserHeight() { - return hmdHeight - floorHeight; - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/StayAlignedConfig.kt b/server/core/src/main/java/dev/slimevr/config/StayAlignedConfig.kt deleted file mode 100644 index 318223dbbb..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/StayAlignedConfig.kt +++ /dev/null @@ -1,44 +0,0 @@ -package dev.slimevr.config - -import com.fasterxml.jackson.annotation.JsonIgnore - -class StayAlignedConfig { - - /** - * Apply yaw correction - */ - var enabled = false - - /** - * Temporarily hide the yaw correction from Stay Aligned. - * - * Players can enable this to compare to when Stay Aligned is not enabled. Useful to - * verify if Stay Aligned improved the situation. Also useful to prevent players - * from saying "Stay Aligned screwed up my trackers!!" when it's actually a tracker - * that is drifting extremely badly. - * - * Do not serialize to config so that when the server restarts, it is always false. - */ - @JsonIgnore - var hideYawCorrection = false - - /** - * Standing relaxed pose - */ - val standingRelaxedPose = StayAlignedRelaxedPoseConfig() - - /** - * Sitting relaxed pose - */ - val sittingRelaxedPose = StayAlignedRelaxedPoseConfig() - - /** - * Flat relaxed pose - */ - val flatRelaxedPose = StayAlignedRelaxedPoseConfig() - - /** - * Whether setup has been completed - */ - var setupComplete = false -} diff --git a/server/core/src/main/java/dev/slimevr/config/StayAlignedRelaxedPoseConfig.kt b/server/core/src/main/java/dev/slimevr/config/StayAlignedRelaxedPoseConfig.kt deleted file mode 100644 index 6227a655bd..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/StayAlignedRelaxedPoseConfig.kt +++ /dev/null @@ -1,25 +0,0 @@ -package dev.slimevr.config - -class StayAlignedRelaxedPoseConfig { - - /** - * Whether Stay Aligned should adjust the tracker yaws when the player is in this - * pose. - */ - var enabled = false - - /** - * Angle between the upper leg yaw and the center yaw. - */ - var upperLegAngleInDeg = 0.0f - - /** - * Angle between the lower leg yaw and the center yaw. - */ - var lowerLegAngleInDeg = 0.0f - - /** - * Angle between the foot and the center yaw. - */ - var footAngleInDeg = 0.0f -} diff --git a/server/core/src/main/java/dev/slimevr/config/TapDetectionConfig.kt b/server/core/src/main/java/dev/slimevr/config/TapDetectionConfig.kt deleted file mode 100644 index 0e5a83cdef..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/TapDetectionConfig.kt +++ /dev/null @@ -1,29 +0,0 @@ -package dev.slimevr.config - -import com.jme3.math.FastMath - -// handles the tap detection config -// this involves the number of taps, the delay, and whether or not the feature is enabled -// for each reset type -class TapDetectionConfig { - var yawResetDelay = 0.2f - var fullResetDelay = 1.0f - var mountingResetDelay = 1.0f - var yawResetEnabled = true - var fullResetEnabled = true - var mountingResetEnabled = true - var setupMode = false - var yawResetTaps = 2 - set(yawResetTaps) { - field = yawResetTaps.coerceIn(2, 10) - } - var fullResetTaps = 3 - set(fullResetTaps) { - field = fullResetTaps.coerceIn(2, 10) - } - var mountingResetTaps = 3 - set(mountingResetTaps) { - field = mountingResetTaps.coerceIn(2, 10) - } - var numberTrackersOverThreshold = 1 -} diff --git a/server/core/src/main/java/dev/slimevr/config/TrackerConfig.kt b/server/core/src/main/java/dev/slimevr/config/TrackerConfig.kt deleted file mode 100644 index 7770bec6be..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/TrackerConfig.kt +++ /dev/null @@ -1,49 +0,0 @@ -package dev.slimevr.config - -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.node.JsonNodeFactory -import dev.slimevr.VRServer -import dev.slimevr.tracking.trackers.Tracker -import io.github.axisangles.ktmath.ObjectQuaternion - -class TrackerConfig { - var customName: String? = null - var designation: String? = null - - @get:JvmName("isHide") - var hide: Boolean = false - var adjustment: ObjectQuaternion? = null - var mountingOrientation: ObjectQuaternion? = null - var mountingResetOrientation: ObjectQuaternion? = null - var allowDriftCompensation: Boolean? = null - - /** - * Only checked if [ServerConfig.useMagnetometerOnAllTrackers] enabled - */ - var shouldHaveMagEnabled: Boolean? = null - - constructor() - - constructor(tracker: Tracker) { - this.designation = if (tracker.trackerPosition != null) tracker.trackerPosition!!.designation else null - this.customName = tracker.customName - allowDriftCompensation = tracker.isImu() - shouldHaveMagEnabled = tracker.isImu() - } - - companion object { - @JvmStatic - fun toV2(v1: JsonNode, factory: JsonNodeFactory): JsonNode { - val node = factory.objectNode() - if (v1.has("customName")) node.set("customName", v1["customName"]) - if (v1.has("designation")) node.set("designation", v1["designation"]) - if (v1.has("hide")) node.set("hide", v1["hide"]) - if (v1.has("mountingRotation")) node.set("mountingRotation", v1["mountingRotation"]) - if (v1.has("adjustment")) node.set("adjustment", v1["adjustment"]) - return node - } - } -} - -val Tracker.config: TrackerConfig - get() = VRServer.instance.configManager.vrConfig.getTracker(this) diff --git a/server/core/src/main/java/dev/slimevr/config/TrackingChecklistConfig.kt b/server/core/src/main/java/dev/slimevr/config/TrackingChecklistConfig.kt deleted file mode 100644 index 182a97bfff..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/TrackingChecklistConfig.kt +++ /dev/null @@ -1,5 +0,0 @@ -package dev.slimevr.config - -class TrackingChecklistConfig { - val ignoredStepsIds: MutableList = mutableListOf() -} diff --git a/server/core/src/main/java/dev/slimevr/config/VMCConfig.kt b/server/core/src/main/java/dev/slimevr/config/VMCConfig.kt deleted file mode 100644 index b48e4d70a2..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/VMCConfig.kt +++ /dev/null @@ -1,13 +0,0 @@ -package dev.slimevr.config - -class VMCConfig : OSCConfig() { - - // Anchor the tracking at the hip? - var anchorHip = true - - // JSON part of the VRM to be used - var vrmJson: String? = null - - // Mirror the tracking before sending it (turn left <=> turn right, left leg <=> right leg) - var mirrorTracking = false -} diff --git a/server/core/src/main/java/dev/slimevr/config/VRCConfig.kt b/server/core/src/main/java/dev/slimevr/config/VRCConfig.kt deleted file mode 100644 index fd40b13dda..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/VRCConfig.kt +++ /dev/null @@ -1,6 +0,0 @@ -package dev.slimevr.config - -class VRCConfig { - // List of fields ignored in vrc warnings - @see VRCConfigValidity - val mutedWarnings: MutableList = mutableListOf() -} diff --git a/server/core/src/main/java/dev/slimevr/config/VRCOSCConfig.kt b/server/core/src/main/java/dev/slimevr/config/VRCOSCConfig.kt deleted file mode 100644 index 7d0ed248dc..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/VRCOSCConfig.kt +++ /dev/null @@ -1,24 +0,0 @@ -package dev.slimevr.config - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize -import com.fasterxml.jackson.databind.annotation.JsonSerialize -import com.fasterxml.jackson.databind.ser.std.StdKeySerializers -import dev.slimevr.config.serializers.BooleanMapDeserializer -import dev.slimevr.tracking.trackers.TrackerRole -import java.util.* - -class VRCOSCConfig : OSCConfig() { - - // Which trackers' data to send - @JsonDeserialize(using = BooleanMapDeserializer::class) - @JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer::class) - var trackers: MutableMap = HashMap() - - var oscqueryEnabled: Boolean = true - - fun getOSCTrackerRole(role: TrackerRole, def: Boolean): Boolean = trackers.getOrDefault(role.name.lowercase(Locale.getDefault()), def) - - fun setOSCTrackerRole(role: TrackerRole, `val`: Boolean) { - trackers[role.name.lowercase(Locale.getDefault())] = `val` - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/VRConfig.kt b/server/core/src/main/java/dev/slimevr/config/VRConfig.kt deleted file mode 100644 index 9ef33f5e00..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/VRConfig.kt +++ /dev/null @@ -1,145 +0,0 @@ -package dev.slimevr.config - -import com.fasterxml.jackson.databind.annotation.JsonDeserialize -import com.fasterxml.jackson.databind.annotation.JsonSerialize -import com.fasterxml.jackson.databind.ser.std.StdKeySerializers -import com.github.jonpeterson.jackson.module.versioning.JsonVersionedModel -import dev.slimevr.config.serializers.BridgeConfigMapDeserializer -import dev.slimevr.config.serializers.TrackerConfigMapDeserializer -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerRole - -@JsonVersionedModel( - currentVersion = "15", - defaultDeserializeToVersion = "15", - toCurrentConverterClass = CurrentVRConfigConverter::class, -) -class VRConfig { - val server: ServerConfig = ServerConfig() - - val filters: FiltersConfig = FiltersConfig() - - val driftCompensation: DriftCompensationConfig = DriftCompensationConfig() - - val oscRouter: OSCConfig = OSCConfig() - - val vrcOSC: VRCOSCConfig = VRCOSCConfig() - - @get:JvmName("getVMC") - val vmc: VMCConfig = VMCConfig() - - val autoBone: AutoBoneConfig = AutoBoneConfig() - - val keybindings: KeybindingsConfig = KeybindingsConfig() - - val skeleton: SkeletonConfig = SkeletonConfig() - - val legTweaks: LegTweaksConfig = LegTweaksConfig() - - val tapDetection: TapDetectionConfig = TapDetectionConfig() - - val resetsConfig: ResetsConfig = ResetsConfig() - - val stayAlignedConfig = StayAlignedConfig() - - val hidConfig = HIDConfig() - - @JsonDeserialize(using = TrackerConfigMapDeserializer::class) - @JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer::class) - private val trackers: MutableMap = HashMap() - - @JsonDeserialize(using = BridgeConfigMapDeserializer::class) - @JsonSerialize(keyUsing = StdKeySerializers.StringKeySerializer::class) - private val bridges: MutableMap = HashMap() - - val knownDevices: MutableSet = mutableSetOf() - - val overlay: OverlayConfig = OverlayConfig() - - val trackingChecklist: TrackingChecklistConfig = TrackingChecklistConfig() - - val vrcConfig: VRCConfig = VRCConfig() - - init { - // Initialize default settings for OSC Router - oscRouter.portIn = 9002 - oscRouter.portOut = 9000 - - // Initialize default settings for VRC OSC - vrcOSC.portIn = 9001 - vrcOSC.portOut = 9000 - vrcOSC - .setOSCTrackerRole( - TrackerRole.WAIST, - vrcOSC.getOSCTrackerRole(TrackerRole.WAIST, true), - ) - vrcOSC - .setOSCTrackerRole( - TrackerRole.LEFT_FOOT, - vrcOSC.getOSCTrackerRole(TrackerRole.WAIST, true), - ) - vrcOSC - .setOSCTrackerRole( - TrackerRole.RIGHT_FOOT, - vrcOSC.getOSCTrackerRole(TrackerRole.WAIST, true), - ) - - // Initialize default settings for VMC - vmc.portIn = 39540 - vmc.portOut = 39539 - } - - fun getTrackers(): Map = trackers - - fun getBridges(): Map = bridges - - fun hasTrackerByName(name: String): Boolean = trackers.containsKey(name) - - fun getTracker(tracker: Tracker): TrackerConfig { - var config = trackers[tracker.name] - if (config == null) { - config = TrackerConfig(tracker) - trackers[tracker.name] = config - } - return config - } - - fun readTrackerConfig(tracker: Tracker) { - if (tracker.userEditable) { - val config = getTracker(tracker) - tracker.readConfig(config) - if (tracker.isImu()) tracker.resetsHandler.readDriftCompensationConfig(driftCompensation) - tracker.resetsHandler.readResetConfig(resetsConfig) - if (tracker.allowReset) { - tracker.saveMountingResetOrientation(config) - } - if (tracker.allowFiltering) { - tracker - .filteringHandler - .readFilteringConfig(filters, tracker.getRotation()) - } - } - } - - fun writeTrackerConfig(tracker: Tracker?) { - if (tracker?.userEditable == true) { - val tc = getTracker(tracker) - tracker.writeConfig(tc) - } - } - - fun getBridge(bridgeKey: String): BridgeConfig { - var config = bridges[bridgeKey] - if (config == null) { - config = BridgeConfig() - bridges[bridgeKey] = config - } - return config - } - - fun isKnownDevice(mac: String?): Boolean = knownDevices.contains(mac) - - fun addKnownDevice(mac: String): Boolean = knownDevices.add(mac) - - fun forgetKnownDevice(mac: String): Boolean = knownDevices.remove(mac) -} diff --git a/server/core/src/main/java/dev/slimevr/config/serializers/BooleanMapDeserializer.java b/server/core/src/main/java/dev/slimevr/config/serializers/BooleanMapDeserializer.java deleted file mode 100644 index 7ef56dc848..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/serializers/BooleanMapDeserializer.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.slimevr.config.serializers; - -/** - * This class allows the use of the utility super class MapDeserializer that - * takes the Value of a map as its Generic parameter. It is so you can use that - * class in a @JsonDeserialize annotation on the Map field inside the config - * instance - * - * @see dev.slimevr.config.VRConfig - */ -public class BooleanMapDeserializer extends MapDeserializer { - public BooleanMapDeserializer() { - super(Boolean.class); - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/serializers/BridgeConfigMapDeserializer.java b/server/core/src/main/java/dev/slimevr/config/serializers/BridgeConfigMapDeserializer.java deleted file mode 100644 index 6f4ddef52f..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/serializers/BridgeConfigMapDeserializer.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.slimevr.config.serializers; - -import dev.slimevr.config.BridgeConfig; - - -/** - * This class allows the use of the utility super class MapDeserializer that - * takes the Value of a map as its Generic parameter. It is so you can use that - * class in a @JsonDeserialize annotation on the Map field inside the config - * instance - * - * @see dev.slimevr.config.VRConfig - */ -public class BridgeConfigMapDeserializer extends MapDeserializer { - public BridgeConfigMapDeserializer() { - super(BridgeConfig.class); - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/serializers/FloatMapDeserializer.java b/server/core/src/main/java/dev/slimevr/config/serializers/FloatMapDeserializer.java deleted file mode 100644 index c7340db17f..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/serializers/FloatMapDeserializer.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.slimevr.config.serializers; - -/** - * This class allows the use of the utility super class MapDeserializer that - * takes the Value of a map as its Generic parameter. It is so you can use that - * class in a @JsonDeserialize annotation on the Map field inside the config - * instance - * - * @see dev.slimevr.config.VRConfig - */ -public class FloatMapDeserializer extends MapDeserializer { - public FloatMapDeserializer() { - super(Float.class); - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/serializers/MapDeserializer.java b/server/core/src/main/java/dev/slimevr/config/serializers/MapDeserializer.java deleted file mode 100644 index 4c8ecfc11a..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/serializers/MapDeserializer.java +++ /dev/null @@ -1,36 +0,0 @@ -package dev.slimevr.config.serializers; - -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.type.MapType; -import com.fasterxml.jackson.databind.type.TypeFactory; - -import java.io.IOException; -import java.util.HashMap; - - -/** - * This class is a utility class that allows to write Map serializers easily to - * be used in the VRConfig (@see {@link dev.slimevr.config.VRConfig}) - * - * @see BooleanMapDeserializer to see how it is used - */ -public abstract class MapDeserializer extends JsonDeserializer> { - - private final Class valueClass; - - public MapDeserializer(Class valueClass) { - super(); - this.valueClass = valueClass; - } - - @Override - public HashMap deserialize(JsonParser p, DeserializationContext dc) - throws IOException { - TypeFactory typeFactory = dc.getTypeFactory(); - MapType mapType = typeFactory - .constructMapType(HashMap.class, String.class, valueClass); - return dc.readValue(p, mapType); - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/serializers/QuaternionDeserializer.java b/server/core/src/main/java/dev/slimevr/config/serializers/QuaternionDeserializer.java deleted file mode 100644 index 346c374a51..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/serializers/QuaternionDeserializer.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.slimevr.config.serializers; - -import com.fasterxml.jackson.core.JacksonException; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonNode; -import io.github.axisangles.ktmath.ObjectQuaternion; - -import java.io.IOException; - - -public class QuaternionDeserializer extends JsonDeserializer { - @Override - public ObjectQuaternion deserialize(JsonParser p, DeserializationContext ctxt) - throws IOException, JacksonException { - JsonNode node = p.getCodec().readTree(p); - - return new ObjectQuaternion( - (float) node.get("w").asDouble(), - (float) node.get("x").asDouble(), - (float) node.get("y").asDouble(), - (float) node.get("z").asDouble() - ); - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/serializers/QuaternionSerializer.java b/server/core/src/main/java/dev/slimevr/config/serializers/QuaternionSerializer.java deleted file mode 100644 index bbd0faf82b..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/serializers/QuaternionSerializer.java +++ /dev/null @@ -1,23 +0,0 @@ -package dev.slimevr.config.serializers; - -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; -import io.github.axisangles.ktmath.ObjectQuaternion; - -import java.io.IOException; - - -public class QuaternionSerializer extends JsonSerializer { - - @Override - public void serialize(ObjectQuaternion value, JsonGenerator gen, SerializerProvider serializers) - throws IOException { - gen.writeStartObject(); - gen.writeNumberField("x", value.getX()); - gen.writeNumberField("y", value.getY()); - gen.writeNumberField("z", value.getZ()); - gen.writeNumberField("w", value.getW()); - gen.writeEndObject(); - } -} diff --git a/server/core/src/main/java/dev/slimevr/config/serializers/TrackerConfigMapDeserializer.java b/server/core/src/main/java/dev/slimevr/config/serializers/TrackerConfigMapDeserializer.java deleted file mode 100644 index 2e624273f9..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/serializers/TrackerConfigMapDeserializer.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.slimevr.config.serializers; - -import dev.slimevr.config.TrackerConfig; - - -/** - * This class allows the use of the utility super class MapDeserializer that - * takes the Value of a map as its Generic parameter. It is so you can use that - * class in a @JsonDeserialize annotation on the Map field inside the config - * instance - * - * @see dev.slimevr.config.VRConfig - */ -public class TrackerConfigMapDeserializer extends MapDeserializer { - public TrackerConfigMapDeserializer() { - super(TrackerConfig.class); - } -} diff --git a/server/core/src/main/java/dev/slimevr/filtering/CircularArrayList.java b/server/core/src/main/java/dev/slimevr/filtering/CircularArrayList.java deleted file mode 100644 index cccd08342f..0000000000 --- a/server/core/src/main/java/dev/slimevr/filtering/CircularArrayList.java +++ /dev/null @@ -1,117 +0,0 @@ -package dev.slimevr.filtering; - -import java.util.*; - - -/** - * If you use this code, please consider notifying isak at du-preez dot com with - * a brief description of your application. - *

- * This is free and unencumbered software released into the public domain. - * Anyone is free to copy, modify, publish, use, compile, sell, or distribute - * this software, either in source code form or as a compiled binary, for any - * purpose, commercial or non-commercial, and by any means. - */ - -public class CircularArrayList extends AbstractList implements RandomAccess { - - private final int n; // buffer length - private final List buf; // a List implementing RandomAccess - private int head = 0; - private int tail = 0; - - public CircularArrayList(int capacity) { - n = capacity + 1; - buf = new ArrayList<>(Collections.nCopies(n, null)); - } - - public int capacity() { - return n - 1; - } - - private int wrapIndex(int i) { - int m = i % n; - if (m < 0) { // java modulus can be negative - m += n; - } - return m; - } - - // This method is O(n) but will never be called if the - // CircularArrayList is used in its typical/intended role. - private void shiftBlock(int startIndex, int endIndex) { - assert (endIndex > startIndex); - for (int i = endIndex - 1; i >= startIndex; i--) { - set(i + 1, get(i)); - } - } - - @Override - public int size() { - return tail - head + (tail < head ? n : 0); - } - - @Override - public E get(int i) { - if (i < 0 || i >= size()) { - throw new IndexOutOfBoundsException(); - } - return buf.get(wrapIndex(head + i)); - } - - public E getLatest() { - return buf.get(wrapIndex(head + size() - 1)); - } - - @Override - public E set(int i, E e) { - if (i < 0 || i >= size()) { - throw new IndexOutOfBoundsException(); - } - return buf.set(wrapIndex(head + i), e); - } - - @Override - public void add(int i, E e) { - int s = size(); - if (s == n - 1) { - throw new IllegalStateException( - "CircularArrayList is filled to capacity. " - + "(You may want to remove from front" - + " before adding more to back.)" - ); - } - if (i < 0 || i > s) { - throw new IndexOutOfBoundsException(); - } - tail = wrapIndex(tail + 1); - if (i < s) { - shiftBlock(i, s); - } - set(i, e); - } - - @Override - public E remove(int i) { - int s = size(); - if (i < 0 || i >= s) { - throw new IndexOutOfBoundsException(); - } - E e = get(i); - if (i > 0) { - shiftBlock(0, i); - } - head = wrapIndex(head + 1); - return e; - } - - public E removeLast() { - int s = size(); - if (0 == s) { - throw new IndexOutOfBoundsException(); - } - E e = get(0); - head = wrapIndex(head + 1); - return e; - } -} diff --git a/server/core/src/main/java/dev/slimevr/filtering/QuaternionMovingAverage.kt b/server/core/src/main/java/dev/slimevr/filtering/QuaternionMovingAverage.kt deleted file mode 100644 index b968068890..0000000000 --- a/server/core/src/main/java/dev/slimevr/filtering/QuaternionMovingAverage.kt +++ /dev/null @@ -1,127 +0,0 @@ -package dev.slimevr.filtering - -import com.jme3.system.NanoTimer -import dev.slimevr.VRServer -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Quaternion.Companion.IDENTITY - -// influences the range of smoothFactor. -private const val SMOOTH_MULTIPLIER = 42f -private const val SMOOTH_MIN = 11f - -// influences the range of predictFactor -private const val PREDICT_MULTIPLIER = 15f -private const val PREDICT_MIN = 10f - -// how many past rotations are used for prediction. -private const val PREDICT_BUFFER = 6 - -class QuaternionMovingAverage( - val type: TrackerFilters, - var amount: Float = 0f, - initialRotation: Quaternion = IDENTITY, -) { - var filteredQuaternion = IDENTITY - var filteringImpact = 0f - private var smoothFactor = 0f - private var predictFactor = 0f - private var rotBuffer: CircularArrayList? = null - private var latestQuaternion = IDENTITY - private var smoothingQuaternion = IDENTITY - private val fpsTimer = if (VRServer.instanceInitialized) VRServer.instance.fpsTimer else NanoTimer() - private var timeSinceUpdate = 0f - - init { - // amount should range from 0 to 1. - // GUI should clamp it from 0.01 (1%) or 0.1 (10%) - // to 1 (100%). - amount = amount.coerceAtLeast(0f) - if (type == TrackerFilters.SMOOTHING) { - // lower smoothFactor = more smoothing - smoothFactor = SMOOTH_MULTIPLIER * (1 - amount.coerceAtMost(1f)) + SMOOTH_MIN - // Totally a hack - if (amount > 1) { - smoothFactor /= amount - } - } - if (type == TrackerFilters.PREDICTION) { - // higher predictFactor = more prediction - predictFactor = PREDICT_MULTIPLIER * amount + PREDICT_MIN - rotBuffer = CircularArrayList(PREDICT_BUFFER) - } - - // We have no reference at the start, so just use the initial rotation - resetQuats(initialRotation, initialRotation) - } - - // Runs at up to 1000hz. We use a timer to make it framerate-independent - // since it runs a bit below 1000hz in practice. - @Synchronized - fun update() { - if (type == TrackerFilters.PREDICTION) { - val rotBuf = rotBuffer - if (rotBuf != null && rotBuf.isNotEmpty()) { - // Applies the past rotations to the current rotation - val predictRot = rotBuf.fold(latestQuaternion) { buf, rot -> buf * rot } - - // Calculate how much to slerp - // Limit slerp by a reasonable amount so low TPS doesn't break tracking - val amt = (predictFactor * fpsTimer.timePerFrame).coerceAtMost(1f) - - // Slerps the target rotation to that predicted rotation by amt - filteredQuaternion = filteredQuaternion.interpQ(predictRot, amt) - } - } else if (type == TrackerFilters.SMOOTHING) { - // Make it framerate-independent - timeSinceUpdate += fpsTimer.timePerFrame - - // Calculate the slerp factor based off the smoothFactor and smoothingCounter - // limit to 1 to not overshoot - val amt = (smoothFactor * timeSinceUpdate).coerceAtMost(1f) - - // Smooth towards the target rotation by the slerp factor - filteredQuaternion = smoothingQuaternion.interpQ(latestQuaternion, amt) - } - - filteringImpact = latestQuaternion.angleToR(filteredQuaternion) - } - - @Synchronized - fun addQuaternion(q: Quaternion) { - val oldQ = latestQuaternion - val newQ = q.twinNearest(oldQ) - latestQuaternion = newQ - - if (type == TrackerFilters.PREDICTION) { - if (rotBuffer!!.size == rotBuffer!!.capacity()) { - rotBuffer?.removeLast() - } - - // Gets and stores the rotation between the last 2 quaternions - rotBuffer?.add(oldQ.inv().times(newQ)) - } else if (type == TrackerFilters.SMOOTHING) { - timeSinceUpdate = 0f - smoothingQuaternion = filteredQuaternion - } else { - // No filtering; just keep track of rotations (for going over 180 degrees) - filteredQuaternion = newQ - } - } - - /** - * Aligns the quaternion space of [q] to the [reference] and sets the latest - * [filteredQuaternion] immediately - */ - @Synchronized - fun resetQuats(q: Quaternion, reference: Quaternion) { - // Assume a rotation within 180 degrees of the reference - // TODO: Currently the reference is the headset, this restricts all trackers to - // have at most a 180 degree rotation from the HMD during a reset, we can - // probably do better using a hierarchy - val rot = q.twinNearest(reference) - rotBuffer?.clear() - latestQuaternion = rot - filteredQuaternion = rot - addQuaternion(rot) - } -} diff --git a/server/core/src/main/java/dev/slimevr/filtering/TrackerFilters.kt b/server/core/src/main/java/dev/slimevr/filtering/TrackerFilters.kt deleted file mode 100644 index f98553cd01..0000000000 --- a/server/core/src/main/java/dev/slimevr/filtering/TrackerFilters.kt +++ /dev/null @@ -1,34 +0,0 @@ -package dev.slimevr.filtering - -import java.util.* - -enum class TrackerFilters(val id: Int, val configKey: String) { - NONE(0, "none"), - SMOOTHING(1, "smoothing"), - PREDICTION(2, "prediction"), - ; - - companion object { - private val byConfigkey: MutableMap = HashMap() - - init { - for (configVal in values()) { - byConfigkey[configVal.configKey.lowercase(Locale.getDefault())] = - configVal - } - } - - val values = values() - - @JvmStatic - fun fromId(id: Int): TrackerFilters? { - for (filter in values) { - if (filter.id == id) return filter - } - return null - } - - @JvmStatic - fun getByConfigkey(configKey: String?): TrackerFilters? = if (configKey == null) null else byConfigkey[configKey.lowercase(Locale.getDefault())] - } -} diff --git a/server/core/src/main/java/dev/slimevr/firmware/FirmwareUpdateHandler.kt b/server/core/src/main/java/dev/slimevr/firmware/FirmwareUpdateHandler.kt deleted file mode 100644 index ecc3e160c0..0000000000 --- a/server/core/src/main/java/dev/slimevr/firmware/FirmwareUpdateHandler.kt +++ /dev/null @@ -1,539 +0,0 @@ -package dev.slimevr.firmware - -import com.mayakapps.kache.InMemoryKache -import com.mayakapps.kache.KacheStrategy -import dev.llelievr.espflashkotlin.Flasher -import dev.llelievr.espflashkotlin.FlashingProgressListener -import dev.slimevr.VRServer -import dev.slimevr.serial.ProvisioningListener -import dev.slimevr.serial.ProvisioningStatus -import dev.slimevr.serial.SerialPort -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerStatus -import dev.slimevr.tracking.trackers.TrackerStatusListener -import dev.slimevr.tracking.trackers.udp.UDPDevice -import io.eiren.util.logging.LogManager -import kotlinx.coroutines.* -import solarxr_protocol.rpc.FirmwarePartT -import solarxr_protocol.rpc.FirmwareUpdateRequestT -import java.io.ByteArrayOutputStream -import java.io.IOException -import java.io.InputStream -import java.net.URL -import java.security.MessageDigest -import java.util.* -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CopyOnWriteArrayList -import java.util.stream.Collectors -import kotlin.concurrent.scheduleAtFixedRate - -data class DownloadedFirmwarePart( - val firmware: ByteArray, - val offset: Long?, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as DownloadedFirmwarePart - - if (!firmware.contentEquals(other.firmware)) return false - if (offset != other.offset) return false - - return true - } - - override fun hashCode(): Int { - var result = firmware.contentHashCode() - result = 31 * result + (offset?.hashCode() ?: 0) - return result - } -} - -class FirmwareUpdateHandler(private val server: VRServer) : - TrackerStatusListener, - ProvisioningListener, - SerialRebootListener { - - private val updateTickTimer = Timer("StatusUpdateTimer") - private val runningJobs: MutableList = CopyOnWriteArrayList() - private val watchRestartQueue: MutableList, () -> Unit>> = - CopyOnWriteArrayList() - private val updatingDevicesStatus: MutableMap, UpdateStatusEvent<*>> = - ConcurrentHashMap() - private val listeners: MutableList = CopyOnWriteArrayList() - private val mainScope: CoroutineScope = CoroutineScope(SupervisorJob()) - private var clearJob: Deferred? = null - - private var serialRebootHandler: SerialRebootHandler = SerialRebootHandler(watchRestartQueue, server, this) - - fun addListener(channel: FirmwareUpdateListener) { - listeners.add(channel) - } - - fun removeListener(channel: FirmwareUpdateListener) { - listeners.removeIf { channel == it } - } - - init { - server.addTrackerStatusListener(this) - server.provisioningHandler.addListener(this) - server.serialHandler.addListener(serialRebootHandler) - - this.updateTickTimer.scheduleAtFixedRate(0, 1000) { - checkUpdateTimeout() - } - } - - private suspend fun startOtaUpdate( - part: DownloadedFirmwarePart, - deviceId: UpdateDeviceId, - ): Unit = suspendCancellableCoroutine { c -> - val udpDevice: UDPDevice? = - (server.deviceManager.devices.find { device -> device is UDPDevice && device.id == deviceId.id }) as UDPDevice? - - if (udpDevice == null) { - onStatusChange( - UpdateStatusEvent( - deviceId, - FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND, - ), - ) - return@suspendCancellableCoroutine - } - val task = OTAUpdateTask( - part.firmware, - deviceId, - udpDevice.ipAddress, - ::onStatusChange, - ) - c.invokeOnCancellation { - task.cancel() - } - task.run() - } - - private fun startSerialUpdate( - firmwares: Array, - deviceId: UpdateDeviceId, - needManualReboot: Boolean, - ssid: String, - password: String, - ) { - // Can't use .toList() on Android - val serialPort = this.server.serialHandler.knownPorts.collect(Collectors.toList()) - .find { port -> deviceId.id == port.portLocation } - - if (serialPort == null) { - onStatusChange( - UpdateStatusEvent( - deviceId, - FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND, - ), - ) - return - } - - val flashingHandler = this.server.serialFlashingHandler - - if (flashingHandler == null) { - onStatusChange( - UpdateStatusEvent( - deviceId, - FirmwareUpdateStatus.ERROR_UNSUPPORTED_METHOD, - ), - ) - return - } - - try { - val flasher = Flasher(flashingHandler) - - for (part in firmwares) { - if (part.offset == null) { - error("Offset is empty") - } - flasher.addBin(part.firmware, part.offset.toInt()) - } - - flasher.addProgressListener(object : FlashingProgressListener { - override fun progress(progress: Float) { - onStatusChange( - UpdateStatusEvent( - deviceId, - FirmwareUpdateStatus.UPLOADING, - (progress * 100).toInt(), - ), - ) - } - }) - - onStatusChange( - UpdateStatusEvent( - deviceId, - FirmwareUpdateStatus.SYNCING_WITH_MCU, - ), - ) - flasher.flash(serialPort) - if (needManualReboot) { - if (watchRestartQueue.find { it.first == deviceId } != null) { - LogManager.info("[FirmwareUpdateHandler] Device is already updating, skipping") - } - - onStatusChange(UpdateStatusEvent(deviceId, FirmwareUpdateStatus.NEED_MANUAL_REBOOT)) - server.serialHandler.openSerial(deviceId.id, false) - watchRestartQueue.add( - Pair(deviceId) { - onStatusChange( - UpdateStatusEvent( - deviceId, - FirmwareUpdateStatus.REBOOTING, - ), - ) - server.provisioningHandler.start( - ssid, - password, - serialPort.portLocation, - ) - }, - ) - } else { - onStatusChange(UpdateStatusEvent(deviceId, FirmwareUpdateStatus.REBOOTING)) - server.provisioningHandler.start(ssid, password, serialPort.portLocation) - } - } catch (e: Exception) { - LogManager.severe("[FirmwareUpdateHandler] Upload failed", e) - onStatusChange( - UpdateStatusEvent( - deviceId, - FirmwareUpdateStatus.ERROR_UPLOAD_FAILED, - ), - ) - } - } - - fun queueFirmwareUpdate( - request: FirmwareUpdateRequestT, - deviceId: UpdateDeviceId<*>, - ) = mainScope.launch { - val method = FirmwareUpdateMethod.getById(request.method.type) ?: error("Unknown method") - - clearJob?.await() - if (method == FirmwareUpdateMethod.OTA) { - if (watchRestartQueue.find { it.first == deviceId } != null) { - LogManager.info("[FirmwareUpdateHandler] Device is already updating, skipping") - } - - val udpDevice: UDPDevice? = - (server.deviceManager.devices.find { device -> device is UDPDevice && device.id == deviceId.id }) as UDPDevice? - if (udpDevice === null) { - error("invalid state - device does not exist") - } - - if (udpDevice.protocolVersion <= 20) { - onStatusChange( - UpdateStatusEvent( - deviceId, - FirmwareUpdateStatus.NEED_MANUAL_REBOOT, - ), - ) - watchRestartQueue.add( - Pair(deviceId) { - mainScope.launch { - startFirmwareUpdateJob( - request, - deviceId, - ) - } - }, - ) - } else { - startFirmwareUpdateJob( - request, - deviceId, - ) - } - } else { - if (updatingDevicesStatus[deviceId] != null) { - LogManager.info("[FirmwareUpdateHandler] Device is already updating, skipping") - return@launch - } - - startFirmwareUpdateJob( - request, - deviceId, - ) - } - } - - fun cancelUpdates() { - val oldClearJob = clearJob - clearJob = mainScope.async { - oldClearJob?.await() - watchRestartQueue.clear() - runningJobs.forEach { it.cancelAndJoin() } - runningJobs.clear() - LogManager.info("[FirmwareUpdateHandler] Update jobs canceled") - } - } - - private fun getFirmwareParts(request: FirmwareUpdateRequestT): ArrayList { - val parts = ArrayList() - val method = FirmwareUpdateMethod.getById(request.method.type) ?: error("Unknown method") - when (method) { - FirmwareUpdateMethod.OTA -> { - val updateReq = request.method.asOTAFirmwareUpdate() - parts.add(updateReq.firmwarePart) - } - - FirmwareUpdateMethod.SERIAL -> { - val updateReq = request.method.asSerialFirmwareUpdate() - parts.addAll(updateReq.firmwarePart) - } - - FirmwareUpdateMethod.NONE -> error("Method should not be NONE") - } - return parts - } - - private suspend fun startFirmwareUpdateJob( - request: FirmwareUpdateRequestT, - deviceId: UpdateDeviceId<*>, - ) = coroutineScope { - onStatusChange( - UpdateStatusEvent( - deviceId, - FirmwareUpdateStatus.DOWNLOADING, - ), - ) - - try { - val toDownloadParts = getFirmwareParts(request) - val firmwareParts = try { - withTimeoutOrNull(30_000) { - toDownloadParts.map { - val firmware = downloadFirmware(it.url, it.digest) - DownloadedFirmwarePart( - firmware, - it.offset, - ) - }.toTypedArray() - } - } catch (e: Exception) { - onStatusChange( - UpdateStatusEvent( - deviceId, - FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED, - ), - ) - LogManager.severe("[FirmwareUpdateHandler] Unable to download firmware", e) - return@coroutineScope - } - - val job = launch { - withTimeout(2 * 60 * 1000) { - if (firmwareParts.isNullOrEmpty()) { - onStatusChange( - UpdateStatusEvent( - deviceId, - FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED, - ), - ) - return@withTimeout - } - - val method = FirmwareUpdateMethod.getById(request.method.type) ?: error("Unknown method") - when (method) { - FirmwareUpdateMethod.NONE -> error("unsupported method") - - FirmwareUpdateMethod.OTA -> { - if (deviceId.id !is Int) { - error("invalid state, the device id is not an int") - } - if (firmwareParts.size > 1) { - error("invalid state, ota only use one firmware file") - } - startOtaUpdate( - firmwareParts.first(), - UpdateDeviceId( - FirmwareUpdateMethod.OTA, - deviceId.id, - ), - ) - } - - FirmwareUpdateMethod.SERIAL -> { - val req = request.method.asSerialFirmwareUpdate() - if (deviceId.id !is String) { - error("invalid state, the device id is not a string") - } - startSerialUpdate( - firmwareParts, - UpdateDeviceId( - FirmwareUpdateMethod.SERIAL, - deviceId.id, - ), - req.needManualReboot, - req.ssid, - req.password, - ) - } - } - } - } - runningJobs.add(job) - } catch (e: Exception) { - onStatusChange( - UpdateStatusEvent( - deviceId, - if (e is TimeoutCancellationException) FirmwareUpdateStatus.ERROR_TIMEOUT else FirmwareUpdateStatus.ERROR_UNKNOWN, - ), - ) - if (e !is TimeoutCancellationException) { - LogManager.severe("[FirmwareUpdateHandler] Update process timed out", e) - e.printStackTrace() - } - return@coroutineScope - } - } - - private fun onStatusChange(event: UpdateStatusEvent) { - this.updatingDevicesStatus[event.deviceId] = event - - if (event.status == FirmwareUpdateStatus.DONE || event.status.isError()) { - this.updatingDevicesStatus.remove(event.deviceId) - - // we remove the device from the restart queue - val queuedDevice = watchRestartQueue.find { it.first.id == event.deviceId } - if (queuedDevice != null) { - watchRestartQueue.remove(queuedDevice) - if (event.deviceId.type == FirmwareUpdateMethod.SERIAL && server.serialHandler.isConnected) { - server.serialHandler.closeSerial() - } - } - - // We make sure to stop the provisioning routine if the tracker is done - // flashing - if (event.deviceId.type == FirmwareUpdateMethod.SERIAL) { - this.server.provisioningHandler.stop() - } - } - listeners.forEach { l -> l.onUpdateStatusChange(event) } - } - - private fun checkUpdateTimeout() { - updatingDevicesStatus.forEach { (id, device) -> - // if more than 30s between two events, consider the update as stuck - // We do not timeout on the Downloading step as it has it own timeout - // We do not timeout on the Done step as it is the end of the update process - if (!device.status.isError() && - !intArrayOf(FirmwareUpdateStatus.DONE.id, FirmwareUpdateStatus.DOWNLOADING.id).contains(device.status.id) && - System.currentTimeMillis() - device.time > 30 * 1000 - ) { - onStatusChange( - UpdateStatusEvent( - id, - FirmwareUpdateStatus.ERROR_TIMEOUT, - ), - ) - } - } - } - - // this only works for OTA trackers as the device id - // only exists when the usb connection is created - override fun onTrackerStatusChanged( - tracker: Tracker, - oldStatus: TrackerStatus, - newStatus: TrackerStatus, - ) { - val device = tracker.device - if (device !is UDPDevice) return - - if (oldStatus == TrackerStatus.DISCONNECTED && newStatus == TrackerStatus.OK) { - val queuedDevice = watchRestartQueue.find { it.first.id == device.id } - - if (queuedDevice != null) { - queuedDevice.second() // we start the queued update task - watchRestartQueue.remove(queuedDevice) // then we remove it from the queue - return - } - - // We can only filter OTA method here as the device id is only provided when using Wi-Fi - val deviceStatusKey = - updatingDevicesStatus.keys.find { it.type == FirmwareUpdateMethod.OTA && it.id == device.id } - ?: return - val updateStatus = updatingDevicesStatus[deviceStatusKey] ?: return - // We check for the reconnection of the tracker, once the tracker reconnected we notify the user that the update is completed - if (updateStatus.status == FirmwareUpdateStatus.REBOOTING) { - onStatusChange( - UpdateStatusEvent( - updateStatus.deviceId, - FirmwareUpdateStatus.DONE, - ), - ) - } - } - } - - override fun onProvisioningStatusChange( - status: ProvisioningStatus, - port: SerialPort?, - ) { - fun update(s: FirmwareUpdateStatus) { - val deviceStatusKey = - updatingDevicesStatus.keys.find { it.type == FirmwareUpdateMethod.SERIAL && it.id == port?.portLocation } - ?: return - val updateStatus = updatingDevicesStatus[deviceStatusKey] ?: return - onStatusChange(UpdateStatusEvent(updateStatus.deviceId, s)) - } - - when (status) { - ProvisioningStatus.PROVISIONING -> update(FirmwareUpdateStatus.PROVISIONING) - ProvisioningStatus.DONE -> update(FirmwareUpdateStatus.DONE) - ProvisioningStatus.CONNECTION_ERROR, ProvisioningStatus.COULD_NOT_FIND_SERVER -> update(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED) - else -> {} - } - } - - override fun onSerialDeviceReconnect(deviceHandle: Pair, () -> Unit>) { - deviceHandle.second() - watchRestartQueue.remove(deviceHandle) - } -} - -fun downloadFirmware(url: String, expectedDigest: String): ByteArray { - val outputStream = ByteArrayOutputStream() - - val chunk = ByteArray(4096) - var bytesRead: Int - val stream: InputStream = URL(url).openStream() - while (stream.read(chunk).also { bytesRead = it } > 0) { - outputStream.write(chunk, 0, bytesRead) - } - - val downloadedData = outputStream.toByteArray() - - if (!verifyChecksum(downloadedData, expectedDigest)) { - error("Checksum verification failed for $url") - } - - return downloadedData -} - -fun verifyChecksum(data: ByteArray, expectedDigest: String): Boolean { - val parts = expectedDigest.split(":", limit = 2) - if (parts.size != 2) { - error("Invalid digest format. Expected 'algorithm:hash' got $expectedDigest") - } - - val algorithm = parts[0].uppercase().replace("-", "") - val expectedHash = parts[1].lowercase() - - val messageDigest = MessageDigest.getInstance(algorithm) - val actualHash = messageDigest.digest(data).joinToString("") { - "%02x".format(it) - } - - return actualHash == expectedHash -} diff --git a/server/core/src/main/java/dev/slimevr/firmware/FirmwareUpdateListener.kt b/server/core/src/main/java/dev/slimevr/firmware/FirmwareUpdateListener.kt deleted file mode 100644 index 3f048828ac..0000000000 --- a/server/core/src/main/java/dev/slimevr/firmware/FirmwareUpdateListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package dev.slimevr.firmware - -interface FirmwareUpdateListener { - fun onUpdateStatusChange(event: UpdateStatusEvent<*>) -} diff --git a/server/core/src/main/java/dev/slimevr/firmware/FirmwareUpdateMethod.kt b/server/core/src/main/java/dev/slimevr/firmware/FirmwareUpdateMethod.kt deleted file mode 100644 index 7aaddf974e..0000000000 --- a/server/core/src/main/java/dev/slimevr/firmware/FirmwareUpdateMethod.kt +++ /dev/null @@ -1,14 +0,0 @@ -package dev.slimevr.firmware - -enum class FirmwareUpdateMethod(val id: Byte) { - NONE(solarxr_protocol.rpc.FirmwareUpdateMethod.NONE), - OTA(solarxr_protocol.rpc.FirmwareUpdateMethod.OTAFirmwareUpdate), - SERIAL(solarxr_protocol.rpc.FirmwareUpdateMethod.SerialFirmwareUpdate), - ; - - companion object { - fun getById(id: Byte): FirmwareUpdateMethod? = byId[id] - } -} - -private val byId = FirmwareUpdateMethod.entries.associateBy { it.id } diff --git a/server/core/src/main/java/dev/slimevr/firmware/FirmwareUpdateStatus.kt b/server/core/src/main/java/dev/slimevr/firmware/FirmwareUpdateStatus.kt deleted file mode 100644 index 4edb320b44..0000000000 --- a/server/core/src/main/java/dev/slimevr/firmware/FirmwareUpdateStatus.kt +++ /dev/null @@ -1,29 +0,0 @@ -package dev.slimevr.firmware - -enum class FirmwareUpdateStatus(val id: Int) { - DOWNLOADING(solarxr_protocol.rpc.FirmwareUpdateStatus.DOWNLOADING), - AUTHENTICATING(solarxr_protocol.rpc.FirmwareUpdateStatus.AUTHENTICATING), - UPLOADING(solarxr_protocol.rpc.FirmwareUpdateStatus.UPLOADING), - SYNCING_WITH_MCU(solarxr_protocol.rpc.FirmwareUpdateStatus.SYNCING_WITH_MCU), - REBOOTING(solarxr_protocol.rpc.FirmwareUpdateStatus.REBOOTING), - NEED_MANUAL_REBOOT(solarxr_protocol.rpc.FirmwareUpdateStatus.NEED_MANUAL_REBOOT), - PROVISIONING(solarxr_protocol.rpc.FirmwareUpdateStatus.PROVISIONING), - DONE(solarxr_protocol.rpc.FirmwareUpdateStatus.DONE), - ERROR_DEVICE_NOT_FOUND(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND), - ERROR_TIMEOUT(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_TIMEOUT), - ERROR_DOWNLOAD_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED), - ERROR_AUTHENTICATION_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_AUTHENTICATION_FAILED), - ERROR_UPLOAD_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_UPLOAD_FAILED), - ERROR_PROVISIONING_FAILED(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED), - ERROR_UNSUPPORTED_METHOD(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_UNSUPPORTED_METHOD), - ERROR_UNKNOWN(solarxr_protocol.rpc.FirmwareUpdateStatus.ERROR_UNKNOWN), - ; - - fun isError(): Boolean = id in ERROR_DEVICE_NOT_FOUND.id..ERROR_UNKNOWN.id - - companion object { - fun getById(id: Int): FirmwareUpdateStatus? = byId[id] - } -} - -private val byId = FirmwareUpdateStatus.entries.associateBy { it.id } diff --git a/server/core/src/main/java/dev/slimevr/firmware/OTAUpdateTask.kt b/server/core/src/main/java/dev/slimevr/firmware/OTAUpdateTask.kt deleted file mode 100644 index 7d934b8588..0000000000 --- a/server/core/src/main/java/dev/slimevr/firmware/OTAUpdateTask.kt +++ /dev/null @@ -1,205 +0,0 @@ -package dev.slimevr.firmware - -import io.eiren.util.logging.LogManager -import java.io.DataInputStream -import java.io.DataOutputStream -import java.io.EOFException -import java.io.IOException -import java.net.DatagramPacket -import java.net.DatagramSocket -import java.net.InetAddress -import java.net.ServerSocket -import java.net.Socket -import java.security.MessageDigest -import java.security.NoSuchAlgorithmException -import java.util.* -import java.util.function.Consumer -import kotlin.math.min - -class OTAUpdateTask( - private val firmware: ByteArray, - private val deviceId: UpdateDeviceId, - private val deviceIp: InetAddress, - private val statusCallback: Consumer>, -) { - private val receiveBuffer: ByteArray = ByteArray(38) - var socketServer: ServerSocket? = null - var uploadSocket: Socket? = null - var authSocket: DatagramSocket? = null - var canceled: Boolean = false - - @Throws(NoSuchAlgorithmException::class) - private fun bytesToMd5(bytes: ByteArray): String { - val md5 = MessageDigest.getInstance("MD5") - md5.update(bytes) - val digest = md5.digest() - val md5str = StringBuilder() - for (b in digest) { - md5str.append(String.format("%02x", b)) - } - return md5str.toString() - } - - private fun authenticate(localPort: Int): Boolean { - try { - DatagramSocket().use { socket -> - authSocket = socket - statusCallback.accept(UpdateStatusEvent(deviceId, FirmwareUpdateStatus.AUTHENTICATING)) - LogManager.info("[OTAUpdate] Sending OTA invitation to: $deviceIp") - - val fileMd5 = bytesToMd5(firmware) - val message = "$FLASH $localPort ${firmware.size} $fileMd5\n" - - socket.send(DatagramPacket(message.toByteArray(), message.length, deviceIp, PORT)) - socket.soTimeout = 10000 - - val authPacket = DatagramPacket(receiveBuffer, receiveBuffer.size) - socket.receive(authPacket) - - val data = String(authPacket.data, 0, authPacket.length) - - // if we received OK directly from the MCU, we do not need to authenticate - if (data == "OK") return true - - val args = data.split(" ") - - // The expected auth payload should look like "AUTH AUTH_TOKEN" - // if we have less than those two args it means that we are in an invalid state - if (args.size != 2 || args[0] != "AUTH") return false - - LogManager.info("[OTAUpdate] Authenticating...") - - val authToken = args[1] - val signature = bytesToMd5(UUID.randomUUID().toString().toByteArray()) - val hashedPassword = bytesToMd5(PASSWORD.toByteArray()) - val resultText = "$hashedPassword:$authToken:$signature" - val payload = bytesToMd5(resultText.toByteArray()) - - val authMessage = "$AUTH $signature $payload\n" - - socket.soTimeout = 10000 - socket.send( - DatagramPacket( - authMessage.toByteArray(), - authMessage.length, - deviceIp, - PORT, - ), - ) - - val authResponsePacket = DatagramPacket(receiveBuffer, receiveBuffer.size) - socket.receive(authResponsePacket) - - val authResponse = String(authResponsePacket.data, 0, authResponsePacket.length) - - return authResponse == "OK" - } - } catch (e: Exception) { - LogManager.severe("OTA Authentication exception", e) - return false - } - } - - private fun upload(serverSocket: ServerSocket): Boolean { - var connection: Socket? = null - try { - LogManager.info("[OTAUpdate] Starting on: ${serverSocket.localPort}") - LogManager.info("[OTAUpdate] Waiting for device...") - - connection = serverSocket.accept() - this.uploadSocket = connection - connection.setSoTimeout(1000) - val dos = DataOutputStream(connection.getOutputStream()) - val dis = DataInputStream(connection.getInputStream()) - - LogManager.info("[OTAUpdate] Upload size: ${firmware.size} bytes") - var offset = 0 - val chunkSize = 2048 - while (offset != firmware.size && !canceled) { - statusCallback.accept( - UpdateStatusEvent( - deviceId, - FirmwareUpdateStatus.UPLOADING, - ((offset.toDouble() / firmware.size) * 100).toInt(), - ), - ) - - val chunkLen = min(chunkSize, (firmware.size - offset)) - dos.write(firmware, offset, chunkLen) - dos.flush() - offset += chunkLen - - // Those skipped bytes are the size written to the MCU. We do not really need that information, - // so we simply skip it. - // The reason those bytes are skipped here is to not have to skip all of them when checking - // for the OK response. Saving time - val bytesSkipped = dis.skipBytes(4) - // Replicate behaviour of .skipNBytes() - if (bytesSkipped != 4) { - throw IOException("Unexpected number of bytes skipped: $bytesSkipped") - } - } - if (canceled) return false - - LogManager.info("[OTAUpdate] Waiting for result...") - // We set the timeout of the connection bigger as it can take some time for the MCU - // to confirm that everything is ok - connection.setSoTimeout(10000) - val responseBytes = dis.readBytes() - val response = String(responseBytes) - - return response.contains("OK") - } catch (e: Exception) { - LogManager.severe("Unable to upload the firmware using ota", e) - return false - } finally { - connection?.close() - } - } - - fun run() { - ServerSocket(0).use { serverSocket -> - socketServer = serverSocket - if (!authenticate(serverSocket.localPort)) { - statusCallback.accept( - UpdateStatusEvent( - deviceId, - FirmwareUpdateStatus.ERROR_AUTHENTICATION_FAILED, - ), - ) - return - } - - if (!upload(serverSocket)) { - statusCallback.accept( - UpdateStatusEvent( - deviceId, - FirmwareUpdateStatus.ERROR_UPLOAD_FAILED, - ), - ) - return - } - - statusCallback.accept( - UpdateStatusEvent( - deviceId, - FirmwareUpdateStatus.REBOOTING, - ), - ) - } - } - - fun cancel() { - canceled = true - socketServer?.close() - authSocket?.close() - uploadSocket?.close() - } - - companion object { - private const val FLASH = 0 - private const val PORT = 8266 - private const val PASSWORD = "SlimeVR-OTA" - private const val AUTH = 200 - } -} diff --git a/server/core/src/main/java/dev/slimevr/firmware/SerialFlashingHandler.kt b/server/core/src/main/java/dev/slimevr/firmware/SerialFlashingHandler.kt deleted file mode 100644 index 1564ab3341..0000000000 --- a/server/core/src/main/java/dev/slimevr/firmware/SerialFlashingHandler.kt +++ /dev/null @@ -1,5 +0,0 @@ -package dev.slimevr.firmware - -import dev.llelievr.espflashkotlin.FlasherSerialInterface - -interface SerialFlashingHandler : FlasherSerialInterface diff --git a/server/core/src/main/java/dev/slimevr/firmware/SerialRebootHandler.kt b/server/core/src/main/java/dev/slimevr/firmware/SerialRebootHandler.kt deleted file mode 100644 index 7131e56838..0000000000 --- a/server/core/src/main/java/dev/slimevr/firmware/SerialRebootHandler.kt +++ /dev/null @@ -1,66 +0,0 @@ -package dev.slimevr.firmware - -import dev.slimevr.VRServer -import dev.slimevr.serial.SerialListener -import dev.slimevr.serial.SerialPort -import java.util.concurrent.CopyOnWriteArrayList - -interface SerialRebootListener { - fun onSerialDeviceReconnect(deviceHandle: Pair, () -> Unit>) -} - -/** - * This class watch for a serial device to disconnect then reconnect. - * This is used to watch the user progress through the firmware update process - */ -class SerialRebootHandler( - private val watchRestartQueue: MutableList, () -> Unit>>, - private val server: VRServer, - // Could be moved to a list of listeners later - private val serialRebootListener: SerialRebootListener, -) : SerialListener { - - private var currentPort: SerialPort? = null - private val disconnectedDevices: MutableList = CopyOnWriteArrayList() - - override fun onSerialConnected(port: SerialPort) { - currentPort = port - } - - override fun onSerialDisconnected() { - currentPort = null - } - - override fun onSerialLog(str: String, ignored: Boolean) { - if (str.contains("starting up...")) { - val foundPort = watchRestartQueue.find { it.first.id == currentPort?.portLocation } - if (foundPort != null) { - disconnectedDevices.remove(currentPort) - serialRebootListener.onSerialDeviceReconnect(foundPort) - // once the restart detected we close the connection - if (server.serialHandler.isConnected) { - server.serialHandler.closeSerial() - } - } - } - } - - override fun onNewSerialDevice(port: SerialPort) { - val foundPort = watchRestartQueue.find { it.first.id == port.portLocation } - if (foundPort != null && disconnectedDevices.contains(port)) { - disconnectedDevices.remove(port) - serialRebootListener.onSerialDeviceReconnect(foundPort) - // once the restart detected we close the connection - if (server.serialHandler.isConnected) { - server.serialHandler.closeSerial() - } - } - } - - override fun onSerialDeviceDeleted(port: SerialPort) { - val foundPort = watchRestartQueue.find { it.first.id == port.portLocation } - if (foundPort != null) { - disconnectedDevices.add(port) - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/firmware/UpdateDeviceId.kt b/server/core/src/main/java/dev/slimevr/firmware/UpdateDeviceId.kt deleted file mode 100644 index 5bc6dc5899..0000000000 --- a/server/core/src/main/java/dev/slimevr/firmware/UpdateDeviceId.kt +++ /dev/null @@ -1,24 +0,0 @@ -package dev.slimevr.firmware - -data class UpdateDeviceId( - val type: FirmwareUpdateMethod, - val id: T, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as UpdateDeviceId<*> - - if (type != other.type) return false - if (id != other.id) return false - - return true - } - - override fun hashCode(): Int { - var result = type.hashCode() - result = 31 * result + (id?.hashCode() ?: 0) - return result - } -} diff --git a/server/core/src/main/java/dev/slimevr/firmware/UpdateStatusEvent.kt b/server/core/src/main/java/dev/slimevr/firmware/UpdateStatusEvent.kt deleted file mode 100644 index 85b485a95b..0000000000 --- a/server/core/src/main/java/dev/slimevr/firmware/UpdateStatusEvent.kt +++ /dev/null @@ -1,8 +0,0 @@ -package dev.slimevr.firmware - -data class UpdateStatusEvent( - val deviceId: UpdateDeviceId, - val status: FirmwareUpdateStatus, - val progress: Int = 0, - val time: Long = System.currentTimeMillis(), -) 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 deleted file mode 100644 index 257c33b3d6..0000000000 --- a/server/core/src/main/java/dev/slimevr/games/vrchat/VRCConfigHandler.kt +++ /dev/null @@ -1,217 +0,0 @@ -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 calibrationRangeOk: Boolean, - val calibrationVisualsOk: Boolean, - val trackerModelOk: Boolean, - val spineModeOk: Boolean, - val avatarMeasurementTypeOk: 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, muted: List) -} - -class VRChatConfigManager(val server: VRServer, private val handler: VRCConfigHandler) { - - private val listeners: MutableList = CopyOnWriteArrayList() - var currentValues: VRCConfigValues? = null - var currentValidity: VRCConfigValidity? = null - - val isSupported: Boolean - get() = handler.isSupported - - init { - handler.initHandler(::onChange) - } - - fun toggleMuteWarning(key: String) { - val keys = VRCConfigValidity::class.java.declaredFields.asSequence().map { p -> p.name } - if (!keys.contains(key)) return - - if (!server.configManager.vrConfig.vrcConfig.mutedWarnings.contains(key)) { - server.configManager.vrConfig.vrcConfig.mutedWarnings.add(key) - } else { - server.configManager.vrConfig.vrcConfig.mutedWarnings.remove(key) - } - - server.configManager.saveConfig() - - val recommended = recommendedValues() - val validity = currentValidity ?: return - val values = currentValues ?: return - listeners.forEach { - it.onChange( - validity, - values, - recommended, - server.configManager.vrConfig.vrcConfig.mutedWarnings, - ) - } - } - - /** - * 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) - val values = currentValues ?: return - val recommended = recommendedValues() - val validity = checkValidity(values, recommended) - listener.onChange(validity, values, recommended, server.configManager.vrConfig.vrcConfig.mutedWarnings) - } - - 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), - trackerModelOk = values.trackerModel == recommended.trackerModel, - calibrationRangeOk = abs(values.calibrationRange - recommended.calibrationRange) < 0.1, - userHeightOk = abs(server.humanPoseManager.realUserHeight - values.userHeight) < 0.1, - calibrationVisualsOk = values.calibrationVisuals == recommended.calibrationVisuals, - avatarMeasurementTypeOk = values.avatarMeasurementType == recommended.avatarMeasurementType, - shoulderWidthCompensationOk = values.shoulderWidthCompensation == recommended.shoulderWidthCompensation, - ) - - fun forceUpdate() { - val values = currentValues - if (values != null) { - this.onChange(values) - } - } - - fun onChange(values: VRCConfigValues) { - val recommended = recommendedValues() - val validity = checkValidity(values, recommended) - currentValidity = validity - currentValues = values - listeners.forEach { - it.onChange(validity, values, recommended, server.configManager.vrConfig.vrcConfig.mutedWarnings) - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/guards/ServerGuards.kt b/server/core/src/main/java/dev/slimevr/guards/ServerGuards.kt deleted file mode 100644 index 0b522011b1..0000000000 --- a/server/core/src/main/java/dev/slimevr/guards/ServerGuards.kt +++ /dev/null @@ -1,28 +0,0 @@ -package dev.slimevr.guards - -import java.util.Timer -import java.util.TimerTask -import kotlin.concurrent.schedule - -class ServerGuards { - - var canDoMounting: Boolean = false - var canDoYawReset: Boolean = false - var canDoUserHeightCalibration: Boolean = false - - private val timer = Timer() - private var mountingTimeoutTask: TimerTask? = null - - fun onFullReset() { - canDoMounting = true - canDoYawReset = true - mountingTimeoutTask?.cancel() - mountingTimeoutTask = timer.schedule(MOUNTING_RESET_TIMEOUT) { - canDoMounting = false - } - } - - companion object { - const val MOUNTING_RESET_TIMEOUT = 2 * 60 * 1000L - } -} diff --git a/server/core/src/main/java/dev/slimevr/math/Angle.kt b/server/core/src/main/java/dev/slimevr/math/Angle.kt deleted file mode 100644 index 7bcad5c658..0000000000 --- a/server/core/src/main/java/dev/slimevr/math/Angle.kt +++ /dev/null @@ -1,67 +0,0 @@ -package dev.slimevr.math - -import com.jme3.math.FastMath -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import kotlin.math.* - -/** - * An angle between [-PI, PI). - */ -@JvmInline -value class Angle(private val rad: Float) { - - fun toRad() = rad - - fun toDeg() = rad * FastMath.RAD_TO_DEG - - operator fun unaryPlus() = this - - operator fun unaryMinus() = Angle(normalize(-rad)) - - operator fun plus(other: Angle) = Angle(normalize(rad + other.rad)) - - operator fun minus(other: Angle) = Angle(normalize(rad - other.rad)) - - operator fun times(scale: Float) = Angle(normalize(rad * scale)) - - operator fun div(scale: Float) = Angle(normalize(rad / scale)) - - operator fun compareTo(other: Angle) = rad.compareTo(other.rad) - - override fun toString() = "${toDeg()} deg" - - companion object { - val ZERO = Angle(0.0f) - - fun ofRad(rad: Float) = Angle(normalize(rad)) - - fun ofDeg(deg: Float) = Angle(normalize(deg * FastMath.DEG_TO_RAD)) - - // Angle between two vectors - fun absBetween(a: Vector3, b: Vector3) = Angle(normalize(a.angleTo(b))) - - // Angle between two rotations in rotation space - fun absBetween(a: Quaternion, b: Quaternion) = Angle(normalize(a.angleToR(b))) - - /** - * Normalizes an angle to [-PI, PI) - */ - private fun normalize(rad: Float): Float { - // Normalize to [0, 2*PI) - val r = - if (rad < 0.0f || rad >= FastMath.TWO_PI) { - rad - floor(rad * FastMath.INV_TWO_PI) * FastMath.TWO_PI - } else { - rad - } - - // Normalize to [-PI, PI) - return if (r > FastMath.PI) { - r - FastMath.TWO_PI - } else { - r - } - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/math/AngleAverage.kt b/server/core/src/main/java/dev/slimevr/math/AngleAverage.kt deleted file mode 100644 index 98fbfccb72..0000000000 --- a/server/core/src/main/java/dev/slimevr/math/AngleAverage.kt +++ /dev/null @@ -1,36 +0,0 @@ -package dev.slimevr.math - -import kotlin.math.* - -/** - * Averages angles by summing vectors. - * - * See https://www.themathdoctors.org/averaging-angles/ - */ -class AngleAverage { - - private var sumX = 0.0f - private var sumY = 0.0f - - /** - * Adds another angle to the average. - */ - fun add(angle: Angle, weight: Float = 1.0f) { - sumX += cos(angle.toRad()) * weight - sumY += sin(angle.toRad()) * weight - } - - /** - * Gets the average angle. - */ - fun toAngle(): Angle = if (isEmpty()) { - Angle.ZERO - } else { - Angle.ofRad(atan2(sumY, sumX)) - } - - /** - * Whether there are any angles to average. - */ - fun isEmpty() = sumX == 0.0f && sumY == 0.0f -} diff --git a/server/core/src/main/java/dev/slimevr/math/AngleErrors.kt b/server/core/src/main/java/dev/slimevr/math/AngleErrors.kt deleted file mode 100644 index 9d0431ac70..0000000000 --- a/server/core/src/main/java/dev/slimevr/math/AngleErrors.kt +++ /dev/null @@ -1,14 +0,0 @@ -package dev.slimevr.math - -import kotlin.math.* - -class AngleErrors { - - private var sumSqrErrors = 0.0f - - fun add(error: Angle) { - sumSqrErrors += error.toRad() * error.toRad() - } - - fun toL2Norm() = Angle.ofRad(sqrt(sumSqrErrors)) -} diff --git a/server/core/src/main/java/dev/slimevr/osc/OSCHandler.java b/server/core/src/main/java/dev/slimevr/osc/OSCHandler.java deleted file mode 100644 index fb0f0d2459..0000000000 --- a/server/core/src/main/java/dev/slimevr/osc/OSCHandler.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.slimevr.osc; - -import com.illposed.osc.transport.OSCPortIn; -import com.illposed.osc.transport.OSCPortOut; - -import java.net.InetAddress; - - -public interface OSCHandler { - - public void refreshSettings(boolean refreshRouterSettings); - - public void updateOscReceiver(int portIn, String[] args); - - public void updateOscSender(int portOut, String address); - - public void update(); - - public OSCPortOut getOscSender(); - - public int getPortOut(); - - public InetAddress getAddress(); - - public OSCPortIn getOscReceiver(); - - public int getPortIn(); - -} diff --git a/server/core/src/main/java/dev/slimevr/osc/OSCRouter.java b/server/core/src/main/java/dev/slimevr/osc/OSCRouter.java deleted file mode 100644 index 0621dfa2b8..0000000000 --- a/server/core/src/main/java/dev/slimevr/osc/OSCRouter.java +++ /dev/null @@ -1,179 +0,0 @@ -package dev.slimevr.osc; - -import com.illposed.osc.*; -import com.illposed.osc.messageselector.OSCPatternAddressMessageSelector; -import com.illposed.osc.transport.OSCPortIn; -import com.illposed.osc.transport.OSCPortOut; -import dev.slimevr.config.OSCConfig; -import io.eiren.util.collections.FastList; -import io.eiren.util.logging.LogManager; - -import java.io.IOException; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.UnknownHostException; - - -public class OSCRouter { - private OSCPortIn oscReceiver; - private OSCPortOut oscSender; - private final OSCConfig config; - private final FastList oscHandlers; - private int lastPortIn; - private int lastPortOut; - private InetAddress lastAddress; - private long timeAtLastError; - - public OSCRouter( - OSCConfig oscConfig, - FastList oscHandlers - ) { - this.config = oscConfig; - this.oscHandlers = oscHandlers; - - refreshSettings(false); - } - - public void refreshSettings(boolean refreshHandlersSettings) { - if (refreshHandlersSettings) { - for (OSCHandler oscHandler : oscHandlers) { - oscHandler.refreshSettings(false); - } - } - - // Stops listening and closes OSC port - boolean wasListening = oscReceiver != null && oscReceiver.isListening(); - if (wasListening) { - oscReceiver.stopListening(); - } - oscReceiver = null; - boolean wasConnected = oscSender != null && oscSender.isConnected(); - if (wasConnected) { - try { - oscSender.close(); - } catch (IOException e) { - LogManager.severe("[OSCRouter] Error closing the OSC sender: " + e); - } - } - oscSender = null; - - if (config.getEnabled()) { - // Instantiates the OSC receiver - int portIn = config.getPortIn(); - // Check if another OSC receiver with same port exists - for (OSCHandler oscHandler : oscHandlers) { - if (oscHandler.getPortIn() == portIn) { - if (oscHandler.getOscReceiver().isListening()) { - oscReceiver = oscHandler.getOscReceiver(); - LogManager.info("[OSCRouter] Listening to port " + portIn); - } - } - } - // Else, create our own OSC receiver - if (oscReceiver == null) { - try { - oscReceiver = new OSCPortIn(portIn); - if (lastPortIn != portIn || !wasListening) { - LogManager.info("[OSCRouter] Listening to port " + portIn); - } - lastPortIn = portIn; - } catch (IOException e) { - LogManager - .severe( - "[OSCRouter] Error listening to the port " - + config.getPortIn() - + ": " - + e - ); - } - } - - // Instantiate the OSC sender - int portOut = config.getPortOut(); - InetAddress address; - try { - address = InetAddress.getByName(config.getAddress()); - } catch (UnknownHostException e) { - throw new RuntimeException(e); - } - // Check if another OSC sender with same port and address exists - for (OSCHandler oscHandler : oscHandlers) { - if (oscHandler.getPortOut() == portOut && oscHandler.getAddress() == address) { - if (oscHandler.getOscSender().isConnected()) { - oscSender = oscHandler.getOscSender(); - LogManager - .info( - "[OSCRouter] Sending to port " - + portOut - + " at address " - + address.toString() - ); - } - } - } - // Else, create our own OSC sender - if (oscSender == null) { - try { - oscSender = new OSCPortOut(new InetSocketAddress(address, portOut)); - if ((lastPortOut != portOut && lastAddress != address) || !wasConnected) { - LogManager - .info( - "[OSCRouter] Sending to port " - + portOut - + " at address " - + address.toString() - ); - } - lastPortOut = portOut; - lastAddress = address; - - oscSender.connect(); - } catch (IOException e) { - LogManager - .severe( - "[OSCRouter] Error connecting to port " - + config.getPortOut() - + " at the address " - + config.getAddress() - + ": " - + e - ); - } - } - - // Starts listening to messages - if (oscReceiver != null) { - OSCMessageListener listener = this::handleReceivedMessage; - // Listens for any message ("//" is a wildcard) - MessageSelector selector = new OSCPatternAddressMessageSelector("//"); - oscReceiver.getDispatcher().addListener(selector, listener); - if (!oscReceiver.isListening()) - oscReceiver.startListening(); - } - } - } - - void handleReceivedMessage(OSCMessageEvent event) { - if (oscSender != null && oscSender.isConnected()) { - OSCMessage oscMessage = new OSCMessage( - event.getMessage().getAddress(), - event.getMessage().getArguments() - ); - try { - oscSender.send(oscMessage); - } catch (IOException | OSCSerializeException e) { - // Avoid spamming AsynchronousCloseException too many - // times per second - if (System.currentTimeMillis() - timeAtLastError > 100) { - timeAtLastError = System.currentTimeMillis(); - LogManager - .warning( - "[OSCRouter] Error sending OSC packet: " - + e - ); - } - } - } - } -} - diff --git a/server/core/src/main/java/dev/slimevr/osc/UnityArmature.kt b/server/core/src/main/java/dev/slimevr/osc/UnityArmature.kt deleted file mode 100644 index c355766a81..0000000000 --- a/server/core/src/main/java/dev/slimevr/osc/UnityArmature.kt +++ /dev/null @@ -1,332 +0,0 @@ -package dev.slimevr.osc - -import com.jme3.math.FastMath -import dev.slimevr.tracking.processor.TransformNode -import io.github.axisangles.ktmath.EulerAngles -import io.github.axisangles.ktmath.EulerOrder -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 - -/** - * TODO make this class use Bone.kt - */ -class UnityArmature(localRot: Boolean) { - - // Head - private val headNode = TransformNode(localRotation = localRot) - private val neckTailNode = TransformNode(localRotation = localRot) - private val neckHeadNode = TransformNode(localRotation = localRot) - - // Spine - private val upperChestNode = TransformNode(localRotation = localRot) - private val chestNode = TransformNode(localRotation = localRot) - private val spineTailNode = TransformNode(localRotation = localRot) - private val spineHeadNode = TransformNode(localRotation = localRot) - private val hipsNode = TransformNode(localRotation = localRot) - private val leftHipNode = TransformNode(localRotation = localRot) - private val rightHipNode = TransformNode(localRotation = localRot) - - // Legs - private val leftKneeNode = TransformNode(localRotation = localRot) - private val leftAnkleNode = TransformNode(localRotation = localRot) - private val leftFootNode = TransformNode(localRotation = localRot) - private val rightKneeNode = TransformNode(localRotation = localRot) - private val rightAnkleNode = TransformNode(localRotation = localRot) - private val rightFootNode = TransformNode(localRotation = localRot) - - // Arms - private val leftShoulderHeadNode = TransformNode(localRotation = localRot) - private val rightShoulderHeadNode = TransformNode(localRotation = localRot) - private val leftShoulderTailNode = TransformNode(localRotation = localRot) - private val rightShoulderTailNode = TransformNode(localRotation = localRot) - private val leftElbowNode = TransformNode(localRotation = localRot) - private val rightElbowNode = TransformNode(localRotation = localRot) - private val leftWristNode = TransformNode(localRotation = localRot) - private val rightWristNode = TransformNode(localRotation = localRot) - private val leftHandNode = TransformNode(localRotation = !localRot) - private val rightHandNode = TransformNode(localRotation = !localRot) - - // Fingers - val leftThumbProximalHeadNode = TransformNode(localRotation = localRot) - val leftThumbProximalTailNode = TransformNode(localRotation = localRot) - val leftThumbIntermediateNode = TransformNode(localRotation = localRot) - val leftThumbDistalNode = TransformNode(localRotation = localRot) - val leftIndexProximalHeadNode = TransformNode(localRotation = localRot) - val leftIndexProximalTailNode = TransformNode(localRotation = localRot) - val leftIndexIntermediateNode = TransformNode(localRotation = localRot) - val leftIndexDistalNode = TransformNode(localRotation = localRot) - val leftMiddleProximalHeadNode = TransformNode(localRotation = localRot) - val leftMiddleProximalTailNode = TransformNode(localRotation = localRot) - val leftMiddleIntermediateNode = TransformNode(localRotation = localRot) - val leftMiddleDistalNode = TransformNode(localRotation = localRot) - val leftRingProximalHeadNode = TransformNode(localRotation = localRot) - val leftRingProximalTailNode = TransformNode(localRotation = localRot) - val leftRingIntermediateNode = TransformNode(localRotation = localRot) - val leftRingDistalNode = TransformNode(localRotation = localRot) - val leftLittleProximalHeadNode = TransformNode(localRotation = localRot) - val leftLittleProximalTailNode = TransformNode(localRotation = localRot) - val leftLittleIntermediateNode = TransformNode(localRotation = localRot) - val leftLittleDistalNode = TransformNode(localRotation = localRot) - val rightThumbProximalHeadNode = TransformNode(localRotation = localRot) - val rightThumbProximalTailNode = TransformNode(localRotation = localRot) - val rightThumbIntermediateNode = TransformNode(localRotation = localRot) - val rightThumbDistalNode = TransformNode(localRotation = localRot) - val rightIndexProximalHeadNode = TransformNode(localRotation = localRot) - val rightIndexProximalTailNode = TransformNode(localRotation = localRot) - val rightIndexIntermediateNode = TransformNode(localRotation = localRot) - val rightIndexDistalNode = TransformNode(localRotation = localRot) - val rightMiddleProximalHeadNode = TransformNode(localRotation = localRot) - val rightMiddleProximalTailNode = TransformNode(localRotation = localRot) - val rightMiddleIntermediateNode = TransformNode(localRotation = localRot) - val rightMiddleDistalNode = TransformNode(localRotation = localRot) - val rightRingProximalHeadNode = TransformNode(localRotation = localRot) - val rightRingProximalTailNode = TransformNode(localRotation = localRot) - val rightRingIntermediateNode = TransformNode(localRotation = localRot) - val rightRingDistalNode = TransformNode(localRotation = localRot) - val rightLittleProximalHeadNode = TransformNode(localRotation = localRot) - val rightLittleProximalTailNode = TransformNode(localRotation = localRot) - val rightLittleIntermediateNode = TransformNode(localRotation = localRot) - val rightLittleDistalNode = TransformNode(localRotation = localRot) - - private var rootPosition = Vector3.NULL - private var rootRotation = Quaternion.IDENTITY - - init { - // Attach nodes - // Spine - hipsNode.attachChild(spineHeadNode) - spineHeadNode.attachChild(spineTailNode) - spineTailNode.attachChild(chestNode) - chestNode.attachChild(upperChestNode) - upperChestNode.attachChild(neckHeadNode) - neckHeadNode.attachChild(neckTailNode) - neckTailNode.attachChild(headNode) - - // Legs - hipsNode.attachChild(leftHipNode) - hipsNode.attachChild(rightHipNode) - leftHipNode.attachChild(leftKneeNode) - rightHipNode.attachChild(rightKneeNode) - leftKneeNode.attachChild(leftAnkleNode) - rightKneeNode.attachChild(rightAnkleNode) - leftAnkleNode.attachChild(leftFootNode) - rightAnkleNode.attachChild(rightFootNode) - - // Arms - upperChestNode.attachChild(leftShoulderHeadNode) - upperChestNode.attachChild(rightShoulderHeadNode) - leftShoulderHeadNode.attachChild(leftShoulderTailNode) - rightShoulderHeadNode.attachChild(rightShoulderTailNode) - leftShoulderTailNode.attachChild(leftElbowNode) - rightShoulderTailNode.attachChild(rightElbowNode) - leftElbowNode.attachChild(leftWristNode) - rightElbowNode.attachChild(rightWristNode) - leftWristNode.attachChild(leftHandNode) - rightWristNode.attachChild(rightHandNode) - - // Fingers - leftHandNode.attachChild(leftThumbProximalHeadNode) - leftThumbProximalHeadNode.attachChild(leftThumbProximalTailNode) - leftThumbProximalTailNode.attachChild(leftThumbIntermediateNode) - leftThumbIntermediateNode.attachChild(leftThumbDistalNode) - leftHandNode.attachChild(leftIndexProximalHeadNode) - leftIndexProximalHeadNode.attachChild(leftIndexProximalTailNode) - leftIndexProximalTailNode.attachChild(leftIndexIntermediateNode) - leftIndexIntermediateNode.attachChild(leftIndexDistalNode) - leftHandNode.attachChild(leftMiddleProximalHeadNode) - leftMiddleProximalHeadNode.attachChild(leftMiddleProximalTailNode) - leftMiddleProximalTailNode.attachChild(leftMiddleIntermediateNode) - leftMiddleIntermediateNode.attachChild(leftMiddleDistalNode) - leftHandNode.attachChild(leftRingProximalHeadNode) - leftRingProximalHeadNode.attachChild(leftRingProximalTailNode) - leftRingProximalTailNode.attachChild(leftRingIntermediateNode) - leftRingIntermediateNode.attachChild(leftRingDistalNode) - leftHandNode.attachChild(leftLittleProximalHeadNode) - leftLittleProximalHeadNode.attachChild(leftLittleProximalTailNode) - leftLittleProximalTailNode.attachChild(leftLittleIntermediateNode) - leftLittleIntermediateNode.attachChild(leftLittleDistalNode) - rightHandNode.attachChild(rightThumbProximalHeadNode) - rightThumbProximalHeadNode.attachChild(rightThumbProximalTailNode) - rightThumbProximalTailNode.attachChild(rightThumbIntermediateNode) - rightThumbIntermediateNode.attachChild(rightThumbDistalNode) - rightHandNode.attachChild(rightIndexProximalHeadNode) - rightIndexProximalHeadNode.attachChild(rightIndexProximalTailNode) - rightIndexProximalTailNode.attachChild(rightIndexIntermediateNode) - rightIndexIntermediateNode.attachChild(rightIndexDistalNode) - rightHandNode.attachChild(rightMiddleProximalHeadNode) - rightMiddleProximalHeadNode.attachChild(rightMiddleProximalTailNode) - rightMiddleProximalTailNode.attachChild(rightMiddleIntermediateNode) - rightMiddleIntermediateNode.attachChild(rightMiddleDistalNode) - rightHandNode.attachChild(rightRingProximalHeadNode) - rightRingProximalHeadNode.attachChild(rightRingProximalTailNode) - rightRingProximalTailNode.attachChild(rightRingIntermediateNode) - rightRingIntermediateNode.attachChild(rightRingDistalNode) - rightHandNode.attachChild(rightLittleProximalHeadNode) - rightLittleProximalHeadNode.attachChild(rightLittleProximalTailNode) - rightLittleProximalTailNode.attachChild(rightLittleIntermediateNode) - rightLittleIntermediateNode.attachChild(rightLittleDistalNode) - } - - fun update() { - // Set the upper chest node's rotation to the chest's - upperChestNode.localTransform.rotation = chestNode.localTransform.rotation - // Update the root node - hipsNode.update() - } - - fun setRootPose(globalPos: Vector3, globalRot: Quaternion) { - rootPosition = globalPos - rootRotation = globalRot - } - - fun setGlobalRotationForBone(unityBone: UnityBone, globalRot: Quaternion) { - val node = getHeadNodeOfBone(unityBone) - if (node != null) { - node.localTransform.rotation = if (UnityBone.isLeftArmBone(unityBone)) { - globalRot * LEFT_SHOULDER_OFFSET - } else if (UnityBone.isRightArmBone(unityBone)) { - globalRot * RIGHT_SHOULDER_OFFSET - } else { - globalRot - } - } - } - - fun setLocalRotationForBone(unityBone: UnityBone, localRot: Quaternion) { - val node = getHeadNodeOfBone(unityBone) - if (node != null) { - if (unityBone == UnityBone.HIPS) { - node.worldTransform.rotation = localRot - } else { - node.localTransform.rotation = if (UnityBone.isLeftStartOfArmOrFingerBone(unityBone)) { - localRot * RIGHT_SHOULDER_OFFSET - } else if (UnityBone.isRightStartOfArmOrFingerBone(unityBone)) { - localRot * LEFT_SHOULDER_OFFSET - } else { - localRot - } - } - } - } - - fun getGlobalTranslationForBone(unityBone: UnityBone): Vector3 { - val node = getHeadNodeOfBone(unityBone) - return if (node != null) { - if (unityBone == UnityBone.HIPS) { - val hipsAverage = ( - leftHipNode.worldTransform.translation + - rightHipNode.worldTransform.translation - ) * - 0.5f - node.worldTransform.translation * 2f - hipsAverage + rootPosition - } else { - node.worldTransform.translation + rootPosition - } - } else { - Vector3.NULL - } - } - - fun getLocalTranslationForBone(unityBone: UnityBone): Vector3 { - val node = getHeadNodeOfBone(unityBone) - return if (node != null) { - if (unityBone == UnityBone.HIPS) { - val hipsAverage = ( - leftHipNode.worldTransform.translation + - rightHipNode.worldTransform.translation - ) * - 0.5f - node.worldTransform.translation * 2f - hipsAverage + rootPosition - } else { - node.localTransform.translation - } - } else { - Vector3.NULL - } - } - - fun getGlobalRotationForBone(unityBone: UnityBone?): Quaternion { - val node = getHeadNodeOfBone(unityBone) - return if (node != null) { - node.worldTransform.rotation * rootRotation - } else { - Quaternion.IDENTITY - } - } - - fun getLocalRotationForBone(unityBone: UnityBone): Quaternion { - val node = getHeadNodeOfBone(unityBone) - return if (node != null) { - if (unityBone == UnityBone.HIPS) { - node.worldTransform.rotation * rootRotation - } else { - node.parent!!.worldTransform.rotation.inv() * node.worldTransform.rotation - } - } else { - Quaternion.IDENTITY - } - } - - fun getHeadNodeOfBone(unityBone: UnityBone?): TransformNode? = if (unityBone == null) { - null - } else { - when (unityBone) { - UnityBone.HEAD -> neckTailNode - UnityBone.NECK -> neckHeadNode - UnityBone.UPPER_CHEST -> chestNode - UnityBone.CHEST -> spineTailNode - UnityBone.SPINE -> spineHeadNode - UnityBone.HIPS -> hipsNode - UnityBone.LEFT_UPPER_LEG -> leftHipNode - UnityBone.RIGHT_UPPER_LEG -> rightHipNode - UnityBone.LEFT_LOWER_LEG -> leftKneeNode - UnityBone.RIGHT_LOWER_LEG -> rightKneeNode - UnityBone.LEFT_FOOT -> leftAnkleNode - UnityBone.RIGHT_FOOT -> rightAnkleNode - UnityBone.LEFT_SHOULDER -> leftShoulderHeadNode - UnityBone.RIGHT_SHOULDER -> rightShoulderHeadNode - UnityBone.LEFT_UPPER_ARM -> leftShoulderTailNode - UnityBone.RIGHT_UPPER_ARM -> rightShoulderTailNode - UnityBone.LEFT_LOWER_ARM -> leftElbowNode - UnityBone.RIGHT_LOWER_ARM -> rightElbowNode - UnityBone.LEFT_HAND -> leftWristNode - UnityBone.RIGHT_HAND -> rightWristNode - UnityBone.LEFT_THUMB_PROXIMAL -> leftThumbProximalHeadNode - UnityBone.LEFT_THUMB_INTERMEDIATE -> leftThumbProximalTailNode - UnityBone.LEFT_THUMB_DISTAL -> leftThumbIntermediateNode - UnityBone.LEFT_INDEX_PROXIMAL -> leftIndexProximalHeadNode - UnityBone.LEFT_INDEX_INTERMEDIATE -> leftIndexProximalTailNode - UnityBone.LEFT_INDEX_DISTAL -> leftIndexIntermediateNode - UnityBone.LEFT_MIDDLE_PROXIMAL -> leftMiddleProximalHeadNode - UnityBone.LEFT_MIDDLE_INTERMEDIATE -> leftMiddleProximalTailNode - UnityBone.LEFT_MIDDLE_DISTAL -> leftMiddleIntermediateNode - UnityBone.LEFT_RING_PROXIMAL -> leftRingProximalHeadNode - UnityBone.LEFT_RING_INTERMEDIATE -> leftRingProximalTailNode - UnityBone.LEFT_RING_DISTAL -> leftRingIntermediateNode - UnityBone.LEFT_LITTLE_PROXIMAL -> leftLittleProximalHeadNode - UnityBone.LEFT_LITTLE_INTERMEDIATE -> leftLittleProximalTailNode - UnityBone.LEFT_LITTLE_DISTAL -> leftLittleIntermediateNode - UnityBone.RIGHT_THUMB_PROXIMAL -> rightThumbProximalHeadNode - UnityBone.RIGHT_THUMB_INTERMEDIATE -> rightThumbProximalTailNode - UnityBone.RIGHT_THUMB_DISTAL -> rightThumbIntermediateNode - UnityBone.RIGHT_INDEX_PROXIMAL -> rightIndexProximalHeadNode - UnityBone.RIGHT_INDEX_INTERMEDIATE -> rightIndexProximalTailNode - UnityBone.RIGHT_INDEX_DISTAL -> rightIndexIntermediateNode - UnityBone.RIGHT_MIDDLE_PROXIMAL -> rightMiddleProximalHeadNode - UnityBone.RIGHT_MIDDLE_INTERMEDIATE -> rightMiddleProximalTailNode - UnityBone.RIGHT_MIDDLE_DISTAL -> rightMiddleIntermediateNode - UnityBone.RIGHT_RING_PROXIMAL -> rightRingProximalHeadNode - UnityBone.RIGHT_RING_INTERMEDIATE -> rightRingProximalTailNode - UnityBone.RIGHT_RING_DISTAL -> rightRingIntermediateNode - UnityBone.RIGHT_LITTLE_PROXIMAL -> rightLittleProximalHeadNode - UnityBone.RIGHT_LITTLE_INTERMEDIATE -> rightLittleProximalTailNode - UnityBone.RIGHT_LITTLE_DISTAL -> rightLittleIntermediateNode - else -> null - } - } - - companion object { - private val LEFT_SHOULDER_OFFSET = EulerAngles(EulerOrder.YZX, 0f, 0f, FastMath.HALF_PI).toQuaternion() - private val RIGHT_SHOULDER_OFFSET = EulerAngles(EulerOrder.YZX, 0f, 0f, -FastMath.HALF_PI).toQuaternion() - } -} diff --git a/server/core/src/main/java/dev/slimevr/osc/UnityBone.kt b/server/core/src/main/java/dev/slimevr/osc/UnityBone.kt deleted file mode 100644 index 29d89f8f0e..0000000000 --- a/server/core/src/main/java/dev/slimevr/osc/UnityBone.kt +++ /dev/null @@ -1,346 +0,0 @@ -package dev.slimevr.osc - -import dev.slimevr.tracking.processor.BoneType -import dev.slimevr.tracking.trackers.TrackerPosition -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -/** - * Unity HumanBodyBones from: - * https://docs.unity3d.com/ScriptReference/HumanBodyBones.html - */ -@Serializable -enum class UnityBone( - val stringVal: String, - val boneType: BoneType?, - val trackerPosition: TrackerPosition?, -) { - @SerialName("hips") - HIPS("Hips", BoneType.HIP, TrackerPosition.HIP), - - @SerialName("leftUpperLeg") - LEFT_UPPER_LEG("LeftUpperLeg", BoneType.LEFT_UPPER_LEG, TrackerPosition.LEFT_UPPER_LEG), - - @SerialName("rightUpperLeg") - RIGHT_UPPER_LEG("RightUpperLeg", BoneType.RIGHT_UPPER_LEG, TrackerPosition.RIGHT_UPPER_LEG), - - @SerialName("leftLowerLeg") - LEFT_LOWER_LEG("LeftLowerLeg", BoneType.LEFT_LOWER_LEG, TrackerPosition.LEFT_LOWER_LEG), - - @SerialName("rightLowerLeg") - RIGHT_LOWER_LEG("RightLowerLeg", BoneType.RIGHT_LOWER_LEG, TrackerPosition.RIGHT_LOWER_LEG), - - @SerialName("leftFoot") - LEFT_FOOT("LeftFoot", BoneType.LEFT_FOOT, TrackerPosition.LEFT_FOOT), - - @SerialName("rightFoot") - RIGHT_FOOT("RightFoot", BoneType.RIGHT_FOOT, TrackerPosition.RIGHT_FOOT), - - @SerialName("spine") - SPINE("Spine", BoneType.WAIST, TrackerPosition.WAIST), - - @SerialName("chest") - CHEST("Chest", BoneType.CHEST, TrackerPosition.CHEST), - - @SerialName("upperChest") - UPPER_CHEST("UpperChest", BoneType.CHEST, TrackerPosition.CHEST), - - @SerialName("neck") - NECK("Neck", BoneType.NECK, TrackerPosition.NECK), - - @SerialName("head") - HEAD("Head", BoneType.HEAD, TrackerPosition.HEAD), - - @SerialName("leftShoulder") - LEFT_SHOULDER("LeftShoulder", BoneType.LEFT_SHOULDER, TrackerPosition.LEFT_SHOULDER), - - @SerialName("rightShoulder") - RIGHT_SHOULDER("RightShoulder", BoneType.RIGHT_SHOULDER, TrackerPosition.RIGHT_SHOULDER), - - @SerialName("leftUpperArm") - LEFT_UPPER_ARM("LeftUpperArm", BoneType.LEFT_UPPER_ARM, TrackerPosition.LEFT_UPPER_ARM), - - @SerialName("rightUpperArm") - RIGHT_UPPER_ARM("RightUpperArm", BoneType.RIGHT_UPPER_ARM, TrackerPosition.RIGHT_UPPER_ARM), - - @SerialName("leftLowerArm") - LEFT_LOWER_ARM("LeftLowerArm", BoneType.LEFT_LOWER_ARM, TrackerPosition.LEFT_LOWER_ARM), - - @SerialName("rightLowerArm") - RIGHT_LOWER_ARM("RightLowerArm", BoneType.RIGHT_LOWER_ARM, TrackerPosition.RIGHT_LOWER_ARM), - - @SerialName("leftHand") - LEFT_HAND("LeftHand", BoneType.LEFT_HAND, TrackerPosition.LEFT_HAND), - - @SerialName("rightHand") - RIGHT_HAND("RightHand", BoneType.RIGHT_HAND, TrackerPosition.RIGHT_HAND), - - @SerialName("leftToes") - LEFT_TOES("LeftToes", null, null), - - @SerialName("rightToes") - RIGHT_TOES("RightToes", null, null), - - @SerialName("leftEye") - LEFT_EYE("LeftEye", null, null), - - @SerialName("rightEye") - RIGHT_EYE("RightEye", null, null), - - @SerialName("jaw") - JAW("Jaw", null, null), - - @SerialName("leftThumbMetacarpal") - LEFT_THUMB_PROXIMAL("LeftThumbProximal", BoneType.LEFT_THUMB_METACARPAL, TrackerPosition.LEFT_THUMB_METACARPAL), - - @SerialName("leftThumbProximal") - LEFT_THUMB_INTERMEDIATE("LeftThumbIntermediate", BoneType.LEFT_THUMB_PROXIMAL, TrackerPosition.LEFT_THUMB_PROXIMAL), - - @SerialName("leftThumbDistal") - LEFT_THUMB_DISTAL("LeftThumbDistal", BoneType.LEFT_THUMB_DISTAL, TrackerPosition.LEFT_THUMB_DISTAL), - - @SerialName("leftIndexProximal") - LEFT_INDEX_PROXIMAL("LeftIndexProximal", BoneType.LEFT_INDEX_PROXIMAL, TrackerPosition.LEFT_INDEX_PROXIMAL), - - @SerialName("leftIndexIntermediate") - LEFT_INDEX_INTERMEDIATE("LeftIndexIntermediate", BoneType.LEFT_INDEX_INTERMEDIATE, TrackerPosition.LEFT_INDEX_INTERMEDIATE), - - @SerialName("leftIndexDistal") - LEFT_INDEX_DISTAL("LeftIndexDistal", BoneType.LEFT_INDEX_DISTAL, TrackerPosition.LEFT_INDEX_DISTAL), - - @SerialName("leftMiddleProximal") - LEFT_MIDDLE_PROXIMAL("LeftMiddleProximal", BoneType.LEFT_MIDDLE_PROXIMAL, TrackerPosition.LEFT_MIDDLE_PROXIMAL), - - @SerialName("leftMiddleIntermediate") - LEFT_MIDDLE_INTERMEDIATE("LeftMiddleIntermediate", BoneType.LEFT_MIDDLE_INTERMEDIATE, TrackerPosition.LEFT_MIDDLE_INTERMEDIATE), - - @SerialName("leftMiddleDistal") - LEFT_MIDDLE_DISTAL("LeftMiddleDistal", BoneType.LEFT_MIDDLE_DISTAL, TrackerPosition.LEFT_MIDDLE_DISTAL), - - @SerialName("leftRingProximal") - LEFT_RING_PROXIMAL("LeftRingProximal", BoneType.LEFT_RING_PROXIMAL, TrackerPosition.LEFT_RING_PROXIMAL), - - @SerialName("leftRingIntermediate") - LEFT_RING_INTERMEDIATE("LeftRingIntermediate", BoneType.LEFT_RING_INTERMEDIATE, TrackerPosition.LEFT_RING_INTERMEDIATE), - - @SerialName("leftRingDistal") - LEFT_RING_DISTAL("LeftRingDistal", BoneType.LEFT_RING_DISTAL, TrackerPosition.LEFT_RING_DISTAL), - - @SerialName("leftLittleProximal") - LEFT_LITTLE_PROXIMAL("LeftLittleProximal", BoneType.LEFT_LITTLE_PROXIMAL, TrackerPosition.LEFT_LITTLE_PROXIMAL), - - @SerialName("leftLittleIntermediate") - LEFT_LITTLE_INTERMEDIATE("LeftLittleIntermediate", BoneType.LEFT_LITTLE_INTERMEDIATE, TrackerPosition.LEFT_LITTLE_INTERMEDIATE), - - @SerialName("leftLittleDistal") - LEFT_LITTLE_DISTAL("LeftLittleDistal", BoneType.LEFT_LITTLE_DISTAL, TrackerPosition.LEFT_LITTLE_DISTAL), - - @SerialName("rightThumbMetacarpal") - RIGHT_THUMB_PROXIMAL("RightThumbProximal", BoneType.RIGHT_THUMB_METACARPAL, TrackerPosition.RIGHT_THUMB_METACARPAL), - - @SerialName("rightThumbProximal") - RIGHT_THUMB_INTERMEDIATE("RightThumbIntermediate", BoneType.RIGHT_THUMB_PROXIMAL, TrackerPosition.RIGHT_THUMB_PROXIMAL), - - @SerialName("rightThumbDistal") - RIGHT_THUMB_DISTAL("RightThumbDistal", BoneType.RIGHT_THUMB_DISTAL, TrackerPosition.RIGHT_THUMB_DISTAL), - - @SerialName("rightIndexProximal") - RIGHT_INDEX_PROXIMAL("RightIndexProximal", BoneType.RIGHT_INDEX_PROXIMAL, TrackerPosition.RIGHT_INDEX_PROXIMAL), - - @SerialName("rightIndexIntermediate") - RIGHT_INDEX_INTERMEDIATE("RightIndexIntermediate", BoneType.RIGHT_INDEX_INTERMEDIATE, TrackerPosition.RIGHT_INDEX_INTERMEDIATE), - - @SerialName("rightIndexDistal") - RIGHT_INDEX_DISTAL("RightIndexDistal", BoneType.RIGHT_INDEX_DISTAL, TrackerPosition.RIGHT_INDEX_DISTAL), - - @SerialName("rightMiddleProximal") - RIGHT_MIDDLE_PROXIMAL("RightMiddleProximal", BoneType.RIGHT_MIDDLE_PROXIMAL, TrackerPosition.RIGHT_MIDDLE_PROXIMAL), - - @SerialName("rightMiddleIntermediate") - RIGHT_MIDDLE_INTERMEDIATE("RightMiddleIntermediate", BoneType.RIGHT_MIDDLE_INTERMEDIATE, TrackerPosition.RIGHT_MIDDLE_INTERMEDIATE), - - @SerialName("rightMiddleDistal") - RIGHT_MIDDLE_DISTAL("RightMiddleDistal", BoneType.RIGHT_MIDDLE_DISTAL, TrackerPosition.RIGHT_MIDDLE_DISTAL), - - @SerialName("rightRingProximal") - RIGHT_RING_PROXIMAL("RightRingProximal", BoneType.RIGHT_RING_PROXIMAL, TrackerPosition.RIGHT_RING_PROXIMAL), - - @SerialName("rightRingIntermediate") - RIGHT_RING_INTERMEDIATE("RightRingIntermediate", BoneType.RIGHT_RING_INTERMEDIATE, TrackerPosition.RIGHT_RING_INTERMEDIATE), - - @SerialName("rightRingDistal") - RIGHT_RING_DISTAL("RightRingDistal", BoneType.RIGHT_RING_DISTAL, TrackerPosition.RIGHT_RING_DISTAL), - - @SerialName("rightLittleProximal") - RIGHT_LITTLE_PROXIMAL("RightLittleProximal", BoneType.RIGHT_LITTLE_PROXIMAL, TrackerPosition.RIGHT_LITTLE_PROXIMAL), - - @SerialName("rightLittleIntermediate") - RIGHT_LITTLE_INTERMEDIATE("RightLittleIntermediate", BoneType.RIGHT_LITTLE_INTERMEDIATE, TrackerPosition.RIGHT_LITTLE_INTERMEDIATE), - - @SerialName("rightLittleDistal") - RIGHT_LITTLE_DISTAL("RightLittleDistal", BoneType.RIGHT_LITTLE_DISTAL, TrackerPosition.RIGHT_LITTLE_DISTAL), - - LAST_BONE("LastBone", null, null), - ; - - companion object { - private val byStringVal: Map = values().associateBy { it.stringVal.lowercase() } - - @JvmStatic - fun getByStringVal(stringVal: String): UnityBone? = byStringVal[stringVal.lowercase()] - - /** - * Returns the bone on the opposite limb, or the original bone if - * it not a limb bone. - */ - fun tryGetOppositeArmBone(bone: UnityBone): UnityBone = when (bone) { - LEFT_SHOULDER -> RIGHT_SHOULDER - LEFT_UPPER_ARM -> RIGHT_UPPER_ARM - LEFT_LOWER_ARM -> RIGHT_LOWER_ARM - LEFT_HAND -> RIGHT_HAND - RIGHT_SHOULDER -> LEFT_SHOULDER - RIGHT_UPPER_ARM -> LEFT_UPPER_ARM - RIGHT_LOWER_ARM -> LEFT_LOWER_ARM - RIGHT_HAND -> LEFT_HAND - LEFT_UPPER_LEG -> RIGHT_UPPER_LEG - LEFT_LOWER_LEG -> RIGHT_LOWER_LEG - LEFT_FOOT -> RIGHT_FOOT - RIGHT_UPPER_LEG -> LEFT_UPPER_LEG - RIGHT_LOWER_LEG -> LEFT_LOWER_LEG - RIGHT_FOOT -> LEFT_FOOT - LEFT_THUMB_PROXIMAL -> RIGHT_THUMB_PROXIMAL - LEFT_THUMB_INTERMEDIATE -> RIGHT_THUMB_INTERMEDIATE - LEFT_THUMB_DISTAL -> RIGHT_THUMB_DISTAL - LEFT_INDEX_PROXIMAL -> RIGHT_INDEX_PROXIMAL - LEFT_INDEX_INTERMEDIATE -> RIGHT_INDEX_INTERMEDIATE - LEFT_INDEX_DISTAL -> RIGHT_INDEX_DISTAL - LEFT_MIDDLE_PROXIMAL -> RIGHT_MIDDLE_PROXIMAL - LEFT_MIDDLE_INTERMEDIATE -> RIGHT_MIDDLE_INTERMEDIATE - LEFT_MIDDLE_DISTAL -> RIGHT_MIDDLE_DISTAL - LEFT_RING_PROXIMAL -> RIGHT_RING_PROXIMAL - LEFT_RING_INTERMEDIATE -> RIGHT_RING_INTERMEDIATE - LEFT_RING_DISTAL -> RIGHT_RING_DISTAL - LEFT_LITTLE_PROXIMAL -> RIGHT_LITTLE_PROXIMAL - LEFT_LITTLE_INTERMEDIATE -> RIGHT_LITTLE_INTERMEDIATE - LEFT_LITTLE_DISTAL -> RIGHT_LITTLE_DISTAL - RIGHT_THUMB_PROXIMAL -> LEFT_THUMB_PROXIMAL - RIGHT_THUMB_INTERMEDIATE -> LEFT_THUMB_INTERMEDIATE - RIGHT_THUMB_DISTAL -> LEFT_THUMB_DISTAL - RIGHT_INDEX_PROXIMAL -> LEFT_INDEX_PROXIMAL - RIGHT_INDEX_INTERMEDIATE -> LEFT_INDEX_INTERMEDIATE - RIGHT_INDEX_DISTAL -> LEFT_INDEX_DISTAL - RIGHT_MIDDLE_PROXIMAL -> LEFT_MIDDLE_PROXIMAL - RIGHT_MIDDLE_INTERMEDIATE -> LEFT_MIDDLE_INTERMEDIATE - RIGHT_MIDDLE_DISTAL -> LEFT_MIDDLE_DISTAL - RIGHT_RING_PROXIMAL -> LEFT_RING_PROXIMAL - RIGHT_RING_INTERMEDIATE -> LEFT_RING_INTERMEDIATE - RIGHT_RING_DISTAL -> LEFT_RING_DISTAL - RIGHT_LITTLE_PROXIMAL -> LEFT_LITTLE_PROXIMAL - RIGHT_LITTLE_INTERMEDIATE -> LEFT_LITTLE_INTERMEDIATE - RIGHT_LITTLE_DISTAL -> LEFT_LITTLE_DISTAL - else -> bone - } - - /** - * Returns true if the bone is part of the left arm (incl. fingers, excl. shoulder) - */ - fun isLeftArmBone(bone: UnityBone): Boolean = bone == LEFT_UPPER_ARM || - bone == LEFT_LOWER_ARM || - bone == LEFT_HAND || - bone == LEFT_THUMB_PROXIMAL || - bone == LEFT_THUMB_INTERMEDIATE || - bone == LEFT_THUMB_DISTAL || - bone == LEFT_INDEX_PROXIMAL || - bone == LEFT_INDEX_INTERMEDIATE || - bone == LEFT_INDEX_DISTAL || - bone == LEFT_MIDDLE_PROXIMAL || - bone == LEFT_MIDDLE_INTERMEDIATE || - bone == LEFT_MIDDLE_DISTAL || - bone == LEFT_RING_PROXIMAL || - bone == LEFT_RING_INTERMEDIATE || - bone == LEFT_RING_DISTAL || - bone == LEFT_LITTLE_PROXIMAL || - bone == LEFT_LITTLE_INTERMEDIATE || - bone == LEFT_LITTLE_DISTAL - - /** - * Returns true if the bone is part of the right arm (incl. fingers, excl. shoulder) - */ - fun isRightArmBone(bone: UnityBone): Boolean = bone == RIGHT_UPPER_ARM || - bone == RIGHT_LOWER_ARM || - bone == RIGHT_HAND || - bone == RIGHT_THUMB_PROXIMAL || - bone == RIGHT_THUMB_INTERMEDIATE || - bone == RIGHT_THUMB_DISTAL || - bone == RIGHT_INDEX_PROXIMAL || - bone == RIGHT_INDEX_INTERMEDIATE || - bone == RIGHT_INDEX_DISTAL || - bone == RIGHT_MIDDLE_PROXIMAL || - bone == RIGHT_MIDDLE_INTERMEDIATE || - bone == RIGHT_MIDDLE_DISTAL || - bone == RIGHT_RING_PROXIMAL || - bone == RIGHT_RING_INTERMEDIATE || - bone == RIGHT_RING_DISTAL || - bone == RIGHT_LITTLE_PROXIMAL || - bone == RIGHT_LITTLE_INTERMEDIATE || - bone == RIGHT_LITTLE_DISTAL - - /** - * Returns true if the bone is the left upper arm or proximal left finger bone - */ - fun isLeftStartOfArmOrFingerBone(bone: UnityBone): Boolean = bone == LEFT_UPPER_ARM || - bone == LEFT_THUMB_PROXIMAL || - bone == LEFT_INDEX_PROXIMAL || - bone == LEFT_MIDDLE_PROXIMAL || - bone == LEFT_RING_PROXIMAL || - bone == LEFT_LITTLE_PROXIMAL - - /** - * Returns true if the bone is the right upper arm or proximal right finger bone - */ - fun isRightStartOfArmOrFingerBone(bone: UnityBone): Boolean = bone == RIGHT_UPPER_ARM || - bone == RIGHT_THUMB_PROXIMAL || - bone == RIGHT_INDEX_PROXIMAL || - bone == RIGHT_MIDDLE_PROXIMAL || - bone == RIGHT_RING_PROXIMAL || - bone == RIGHT_LITTLE_PROXIMAL - - /** - * Returns true if the bone is part of the left fingers - */ - fun isLeftFingerBone(bone: UnityBone): Boolean = bone == LEFT_THUMB_PROXIMAL || - bone == LEFT_THUMB_INTERMEDIATE || - bone == LEFT_THUMB_DISTAL || - bone == LEFT_INDEX_PROXIMAL || - bone == LEFT_INDEX_INTERMEDIATE || - bone == LEFT_INDEX_DISTAL || - bone == LEFT_MIDDLE_PROXIMAL || - bone == LEFT_MIDDLE_INTERMEDIATE || - bone == LEFT_MIDDLE_DISTAL || - bone == LEFT_RING_PROXIMAL || - bone == LEFT_RING_INTERMEDIATE || - bone == LEFT_RING_DISTAL || - bone == LEFT_LITTLE_PROXIMAL || - bone == LEFT_LITTLE_INTERMEDIATE || - bone == LEFT_LITTLE_DISTAL - - /** - * Returns true if the bone part of the right fingers - */ - fun isRightFingerBone(bone: UnityBone): Boolean = bone == RIGHT_THUMB_PROXIMAL || - bone == RIGHT_THUMB_INTERMEDIATE || - bone == RIGHT_THUMB_DISTAL || - bone == RIGHT_INDEX_PROXIMAL || - bone == RIGHT_INDEX_INTERMEDIATE || - bone == RIGHT_INDEX_DISTAL || - bone == RIGHT_MIDDLE_PROXIMAL || - bone == RIGHT_MIDDLE_INTERMEDIATE || - bone == RIGHT_MIDDLE_DISTAL || - bone == RIGHT_RING_PROXIMAL || - bone == RIGHT_RING_INTERMEDIATE || - bone == RIGHT_RING_DISTAL || - bone == RIGHT_LITTLE_PROXIMAL || - bone == RIGHT_LITTLE_INTERMEDIATE || - bone == RIGHT_LITTLE_DISTAL - } -} diff --git a/server/core/src/main/java/dev/slimevr/osc/VMCHandler.kt b/server/core/src/main/java/dev/slimevr/osc/VMCHandler.kt deleted file mode 100644 index 52b40325e5..0000000000 --- a/server/core/src/main/java/dev/slimevr/osc/VMCHandler.kt +++ /dev/null @@ -1,511 +0,0 @@ -package dev.slimevr.osc - -import com.illposed.osc.OSCBundle -import com.illposed.osc.OSCMessage -import com.illposed.osc.OSCMessageEvent -import com.illposed.osc.OSCMessageListener -import com.illposed.osc.OSCSerializeException -import com.illposed.osc.messageselector.OSCPatternAddressMessageSelector -import com.illposed.osc.transport.OSCPortIn -import com.illposed.osc.transport.OSCPortOut -import dev.slimevr.VRServer -import dev.slimevr.VRServer.Companion.currentLocalTrackerId -import dev.slimevr.VRServer.Companion.getNextLocalTrackerId -import dev.slimevr.config.VMCConfig -import dev.slimevr.osc.UnityBone.Companion.getByStringVal -import dev.slimevr.tracking.processor.BoneType -import dev.slimevr.tracking.processor.HumanPoseManager -import dev.slimevr.tracking.trackers.Device -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerPosition -import dev.slimevr.tracking.trackers.TrackerStatus -import io.eiren.util.collections.FastList -import io.eiren.util.logging.LogManager -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Quaternion.Companion.IDENTITY -import io.github.axisangles.ktmath.Vector3 -import io.github.axisangles.ktmath.Vector3.Companion.NULL -import io.github.axisangles.ktmath.Vector3.Companion.POS_Y -import java.io.IOException -import java.net.InetAddress -import java.net.InetSocketAddress - -/** - * VMC documentation: https://protocol.vmc.info/english - * - * - * Notes: VMC uses local rotation from hip (unlike SlimeVR, which uses rotations - * from head). VMC works with Unity's coordinate system, which means - * Quaternions' z and w components and Vectors' z components need to be inverse - */ -class VMCHandler( - private val server: VRServer, - private val humanPoseManager: HumanPoseManager, - private val config: VMCConfig, -) : OSCHandler { - private var oscReceiver: OSCPortIn? = null - private var oscSender: OSCPortOut? = null - private val computedTrackers: MutableList = FastList() - private val oscArgs = FastList() - private val startTime = System.currentTimeMillis() - private val byTrackerNameTracker: MutableMap = HashMap() - private var yawOffset = IDENTITY - private var inputUnityArmature: UnityArmature? = null - private var outputUnityArmature: UnityArmature? = null - private var vrmHeight = 0f - private var trackerDevice: Device? = null - private var timeAtLastError: Long = 0 - private var timeAtLastSend: Long = 0 - private var anchorHip = false - private var mirrorTracking = false - private var lastPortIn = 0 - private var lastPortOut = 0 - private var lastAddress: InetAddress? = null - - init { - refreshSettings(false) - } - - override fun refreshSettings(refreshRouterSettings: Boolean) { - anchorHip = config.anchorHip - mirrorTracking = config.mirrorTracking - - updateOscReceiver( - config.portIn, - arrayOf( - "/VMC/Ext/Bone/Pos", - "/VMC/Ext/Hmd/Pos", - "/VMC/Ext/Con/Pos", - "/VMC/Ext/Tra/Pos", - "/VMC/Ext/Root/Pos", - ), - ) - updateOscSender(config.portOut, config.address) - - if (config.enabled) { - // Load VRM data - if (outputUnityArmature != null && config.vrmJson != null) { - val vrmReader = VRMReader(config.vrmJson!!) - for (unityBone in UnityBone.entries) { - val node = outputUnityArmature!!.getHeadNodeOfBone(unityBone) - if (node != null) { - val offset = if (unityBone == UnityBone.HIPS) { - // For the hip bone, add average of upper leg offsets (which are negative). The hip bone's offset is global. - vrmReader.getOffsetForBone(UnityBone.HIPS) + - ((vrmReader.getOffsetForBone(UnityBone.LEFT_UPPER_LEG) + vrmReader.getOffsetForBone(UnityBone.RIGHT_UPPER_LEG)) / 2f) - } else { - vrmReader.getOffsetForBone(unityBone) - } - - node.localTransform.translation = offset - } - } - // Make sure to account for the upper legs because of the hip. - vrmHeight = ( - vrmReader.getOffsetForBone(UnityBone.HIPS) + - ((vrmReader.getOffsetForBone(UnityBone.LEFT_UPPER_LEG) + vrmReader.getOffsetForBone(UnityBone.RIGHT_UPPER_LEG))) + - vrmReader.getOffsetForBone(UnityBone.SPINE) + - vrmReader.getOffsetForBone(UnityBone.CHEST) + - vrmReader.getOffsetForBone(UnityBone.UPPER_CHEST) + - vrmReader.getOffsetForBone(UnityBone.NECK) + - vrmReader.getOffsetForBone(UnityBone.HEAD) - ).y - } - } - - if (refreshRouterSettings) server.oSCRouter.refreshSettings(false) - } - - override fun updateOscReceiver(portIn: Int, args: Array) { - // Stops listening and closes OSC port - val wasListening = oscReceiver != null && oscReceiver!!.isListening - if (wasListening) { - oscReceiver!!.stopListening() - } - - if (config.enabled) { - // Instantiates the OSC receiver - try { - oscReceiver = OSCPortIn(portIn) - if (lastPortIn != portIn || !wasListening) { - LogManager.info("[VMCHandler] Listening to port $portIn") - } - lastPortIn = portIn - } catch (e: IOException) { - LogManager - .severe( - "[VMCHandler] Error listening to the port $portIn: $e", - ) - } - - // Starts listening for VMC messages - if (oscReceiver != null) { - val listener = OSCMessageListener { event: OSCMessageEvent -> this.handleReceivedMessage(event) } - - for (address in args) { - oscReceiver!! - .dispatcher - .addListener(OSCPatternAddressMessageSelector(address), listener) - } - - oscReceiver!!.startListening() - } - } - } - - override fun updateOscSender(portOut: Int, ip: String) { - // Stop sending - val wasConnected = oscSender != null && oscSender!!.isConnected - if (wasConnected) { - try { - oscSender!!.close() - } catch (e: IOException) { - LogManager.severe("[VMCHandler] Error closing the OSC sender: $e") - } - } - - if (config.enabled) { - // Instantiate the OSC sender - try { - val addr = InetAddress.getByName(ip) - oscSender = OSCPortOut(InetSocketAddress(addr, portOut)) - if ((lastPortOut != portOut && lastAddress != addr) || !wasConnected) { - LogManager - .info( - "[VMCHandler] Sending to port $portOut at address $ip", - ) - } - lastPortOut = portOut - lastAddress = addr - - oscSender!!.connect() - outputUnityArmature = UnityArmature(false) - } catch (e: IOException) { - LogManager - .severe( - "[VMCHandler] Error connecting to port $portOut at the address $ip: $e", - ) - } - } - } - - private fun handleReceivedMessage(event: OSCMessageEvent) { - when (event.message.address) { - // Is bone (rotation) - "/VMC/Ext/Bone/Pos" -> { - var trackerPosition: TrackerPosition? = null - val bone = getByStringVal(event.message.arguments[0].toString()) - if (bone != null) trackerPosition = bone.trackerPosition - - // If received bone is part of SlimeVR's skeleton - if (trackerPosition != null) { - handleReceivedTracker( - "VMC-Bone-" + event.message.arguments[0], - trackerPosition, - null, - Quaternion( - -(event.message.arguments[7] as Float), - event.message.arguments[4] as Float, - event.message.arguments[5] as Float, - -(event.message.arguments[6] as Float), - ), - true, - getByStringVal( - event.message.arguments[0].toString(), - ), - ) - } - } - - // Is tracker (position + rotation) - "/VMC/Ext/Hmd/Pos", "/VMC/Ext/Con/Pos", "/VMC/Ext/Tra/Pos" -> - handleReceivedTracker( - "VMC-Tracker-" + event.message.arguments[0], - null, - Vector3( - event.message.arguments[1] as Float, - event.message.arguments[2] as Float, - -(event.message.arguments[3] as Float), - ), - Quaternion( - -(event.message.arguments[7] as Float), - event.message.arguments[4] as Float, - event.message.arguments[5] as Float, - -(event.message.arguments[6] as Float), - ), - false, - null, - ) - - // Is VMC tracking root (offsets all rotations) - "/VMC/Ext/Root/Pos" -> { - if (inputUnityArmature != null) { - inputUnityArmature!! - .setRootPose( - Vector3( - event.message.arguments[1] as Float, - event.message.arguments[2] as Float, - -(event.message.arguments[3] as Float), - ), - Quaternion( - -(event.message.arguments[7] as Float), - event.message.arguments[4] as Float, - event.message.arguments[5] as Float, - -(event.message.arguments[6] as Float), - ), - ) - } - } - } - } - - private fun handleReceivedTracker( - name: String, - trackerPosition: TrackerPosition?, - position: Vector3?, - rotation: Quaternion, - localRotation: Boolean, - unityBone: UnityBone?, - ) { - // Create device if it doesn't exist - var rot = rotation - if (trackerDevice == null) { - trackerDevice = server.deviceManager.createDevice("VMC receiver", "1.0", "VMC") - server.deviceManager.addDevice(trackerDevice!!) - } - - // Try to get tracker - var tracker = byTrackerNameTracker[name] - - // Create tracker if trying to get it returned null - if (tracker == null) { - tracker = Tracker( - trackerDevice, - getNextLocalTrackerId(), - name, - "VMC Tracker #$currentLocalTrackerId", - trackerPosition, - hasPosition = position != null, - hasRotation = true, - userEditable = true, - isComputed = position != null, - usesTimeout = true, - allowReset = position != null, - ) - trackerDevice!!.trackers[trackerDevice!!.trackers.size] = tracker - byTrackerNameTracker[name] = tracker - server.registerTracker(tracker) - } - tracker.status = TrackerStatus.OK - - // Set position - if (position != null) { - tracker.position = position - } - - // Set rotation - if (localRotation) { - // Instantiate unityHierarchy if not done - if (inputUnityArmature == null) inputUnityArmature = UnityArmature(true) - inputUnityArmature!!.setLocalRotationForBone(unityBone!!, rot) - rot = inputUnityArmature!!.getGlobalRotationForBone(unityBone) - rot = yawOffset.times(rot) - } - tracker.setRotation(rot) - - tracker.dataTick() - } - - override fun update() { - // Update unity hierarchy - if (inputUnityArmature != null) inputUnityArmature!!.update() - - val currentTime = System.currentTimeMillis() - if (currentTime - timeAtLastSend > 3) { // 200hz to not crash VSF - timeAtLastSend = currentTime - // Send OSC data - if (oscSender != null && oscSender!!.isConnected) { - // Create new OSC Bundle - val oscBundle = OSCBundle() - - // Add our relative time - oscArgs.clear() - oscArgs.add((System.currentTimeMillis() - startTime) / 1000f) - oscBundle.addPacket(OSCMessage("/VMC/Ext/T", oscArgs.clone())) - - if (humanPoseManager.isSkeletonPresent) { - // Indicate tracking is available - oscArgs.clear() - oscArgs.add(1) - oscBundle.addPacket(OSCMessage("/VMC/Ext/OK", oscArgs.clone())) - - oscArgs.clear() - oscArgs.add("root") - addTransformToArgs(NULL, IDENTITY) - oscBundle.addPacket(OSCMessage("/VMC/Ext/Root/Pos", oscArgs.clone())) - - for (unityBone in UnityBone.entries) { - // Get opposite bone if tracking must be mirrored - val boneType = (if (mirrorTracking) UnityBone.tryGetOppositeArmBone(unityBone) else unityBone).boneType - - if (boneType == null) continue - - // Get SlimeVR bone - val bone = humanPoseManager.getBone(boneType) - - // Update unity hierarchy from bone's global rotation - val boneRotation = if (mirrorTracking) { - // Mirror tracking horizontally - val rotBuf = bone.getGlobalRotation() * bone.rotationOffset.inv() - Quaternion(rotBuf.w, rotBuf.x, -rotBuf.y, -rotBuf.z) - } else { - bone.getGlobalRotation() * bone.rotationOffset.inv() - } - outputUnityArmature?.setGlobalRotationForBone(unityBone, boneRotation) - } - - if (!anchorHip) { - // Anchor from head - outputUnityArmature?.let { unityArmature -> - // Scale the SlimeVR neck position with the VRM model - // We're only getting the height up to the neck because we don't want to factor the neck's length into the scaling - val slimevrScaledRootPos = humanPoseManager.getBone(BoneType.NECK).getTailPosition() * - (vrmHeight / humanPoseManager.userNeckHeightFromConfig) - - // Get the VRM head and hip positions - val vrmHeadPos = unityArmature.getHeadNodeOfBone(UnityBone.HEAD)!!.parent!!.worldTransform.translation - val vrmHipPos = unityArmature.getHeadNodeOfBone(UnityBone.HIPS)!!.worldTransform.translation - - // Calculate the new VRM hip position by subtracting the difference head-hip distance from the SlimeVR head - val calculatedVrmHipPos = slimevrScaledRootPos - (vrmHeadPos - vrmHipPos) - - // Set the VRM's hip position - unityArmature.getHeadNodeOfBone(UnityBone.HIPS)?.localTransform?.translation = calculatedVrmHipPos - } - } - - // Update Unity skeleton - outputUnityArmature?.update() - - // Add Unity humanoid bones transforms - for (unityBone in UnityBone.entries) { - // Don't send bones for which we don't have an equivalent - // Don't send fingers if we don't have any tracker for them - // Don't send arm bones if we're tracking from the controller - if (unityBone.boneType != null && - (!UnityBone.isLeftFingerBone(unityBone) || humanPoseManager.skeleton.hasLeftFingerTracker || (mirrorTracking && humanPoseManager.skeleton.hasRightFingerTracker)) && - (!UnityBone.isRightFingerBone(unityBone) || humanPoseManager.skeleton.hasRightFingerTracker || (mirrorTracking && humanPoseManager.skeleton.hasLeftFingerTracker)) && - !(humanPoseManager.isTrackingLeftArmFromController && (UnityBone.isLeftArmBone(unityBone) || unityBone == UnityBone.LEFT_SHOULDER)) && - !(humanPoseManager.isTrackingRightArmFromController && (UnityBone.isRightArmBone(unityBone) || unityBone == UnityBone.RIGHT_SHOULDER)) - ) { - oscArgs.clear() - oscArgs.add(unityBone.stringVal) - outputUnityArmature?.let { - addTransformToArgs( - it.getLocalTranslationForBone(unityBone), - it.getLocalRotationForBone(unityBone), - ) - } - - oscBundle.addPacket(OSCMessage("/VMC/Ext/Bone/Pos", oscArgs.clone())) - } - } - } - - for (tracker in computedTrackers) { - if (!tracker.status.reset) { - oscArgs.clear() - - val name = tracker.name - oscArgs.add(name) - - addTransformToArgs( - tracker.position, - tracker.getRotation(), - ) - - var address: String - val role = tracker.trackerPosition - address = if (role == TrackerPosition.HEAD) { - "/VMC/Ext/Hmd/Pos" - } else if (role == TrackerPosition.LEFT_HAND || role == TrackerPosition.RIGHT_HAND) { - "/VMC/Ext/Con/Pos" - } else { - "/VMC/Ext/Tra/Pos" - } - oscBundle - .addPacket( - OSCMessage( - address, - oscArgs.clone(), - ), - ) - } - } - - // Send OSC packets as bundle - try { - oscSender!!.send(oscBundle) - } catch (e: IOException) { - // Avoid spamming AsynchronousCloseException too many - // times per second - if (System.currentTimeMillis() - timeAtLastError > 100) { - timeAtLastError = System.currentTimeMillis() - LogManager - .warning( - "[VMCHandler] Error sending OSC packets: " + - e, - ) - } - } catch (e: OSCSerializeException) { - if (System.currentTimeMillis() - timeAtLastError > 100) { - timeAtLastError = System.currentTimeMillis() - LogManager - .warning( - "[VMCHandler] Error sending OSC packets: " + - e, - ) - } - } - } - } - } - - /** - * Set the Quaternion to shift the received VMC tracking rotations' yaw - * - * @param reference the head's rotation - */ - fun alignVMCTracking(reference: Quaternion) { - yawOffset = reference.project(POS_Y).unit() - } - - /** - * Add a computed tracker to the list of trackers to send. - * - * @param computedTracker the computed tracker - */ - fun addComputedTracker(computedTracker: Tracker) { - computedTrackers.add(computedTracker) - } - - private fun addTransformToArgs(pos: Vector3, rot: Quaternion) { - oscArgs.add(pos.x) - oscArgs.add(pos.y) - oscArgs.add(-pos.z) - oscArgs.add(rot.x) - oscArgs.add(rot.y) - oscArgs.add(-rot.z) - oscArgs.add(-rot.w) - } - - override fun getOscSender(): OSCPortOut = oscSender!! - - override fun getPortOut(): Int = lastPortOut - - override fun getAddress(): InetAddress = lastAddress!! - - override fun getOscReceiver(): OSCPortIn = oscReceiver!! - - override fun getPortIn(): Int = lastPortIn -} diff --git a/server/core/src/main/java/dev/slimevr/osc/VRCOSCHandler.kt b/server/core/src/main/java/dev/slimevr/osc/VRCOSCHandler.kt deleted file mode 100644 index 1690a8b593..0000000000 --- a/server/core/src/main/java/dev/slimevr/osc/VRCOSCHandler.kt +++ /dev/null @@ -1,560 +0,0 @@ -package dev.slimevr.osc - -import com.illposed.osc.OSCBundle -import com.illposed.osc.OSCMessage -import com.illposed.osc.OSCMessageEvent -import com.illposed.osc.OSCMessageListener -import com.illposed.osc.OSCSerializeException -import com.illposed.osc.messageselector.OSCPatternAddressMessageSelector -import com.illposed.osc.transport.OSCPortIn -import com.illposed.osc.transport.OSCPortOut -import com.jme3.math.FastMath -import com.jme3.system.NanoTimer -import dev.slimevr.VRServer -import dev.slimevr.config.VRCOSCConfig -import dev.slimevr.protocol.rpc.setup.RPCUtil -import dev.slimevr.tracking.trackers.Device -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerPosition -import dev.slimevr.tracking.trackers.TrackerStatus -import io.eiren.util.collections.FastList -import io.eiren.util.logging.LogManager -import io.github.axisangles.ktmath.EulerAngles -import io.github.axisangles.ktmath.EulerOrder -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import java.io.IOException -import java.net.InetAddress -import java.net.InetSocketAddress - -private const val OFFSET_SLERP_FACTOR = 0.5f // Guessed from eyeing VRChat - -/** - * VRChat OSCTracker documentation: https://docs.vrchat.com/docs/osc-trackers - */ -class VRCOSCHandler( - private val server: VRServer, - private val config: VRCOSCConfig, - private val computedTrackers: List, -) : OSCHandler { - private val localIp = RPCUtil.getLocalIp() - private val loopbackIp = InetAddress.getLoopbackAddress().hostAddress - private val vrsystemTrackersAddresses = arrayOf( - "/tracking/vrsystem/head/pose", - "/tracking/vrsystem/leftwrist/pose", - "/tracking/vrsystem/rightwrist/pose", - ) - private val oscTrackersAddresses = arrayOf( - "/tracking/trackers/*/position", - "/tracking/trackers/*/rotation", - ) - private var oscReceiver: OSCPortIn? = null - private var oscSender: OSCPortOut? = null - private var oscQuerySender: OSCPortOut? = null - private var oscMessage: OSCMessage? = null - private var headTracker: Tracker? = null - private var oscTrackersDevice: Device? = null - private var vrsystemTrackersDevice: Device? = null - private val oscArgs = FastList(3) - private val trackersEnabled: BooleanArray = BooleanArray(computedTrackers.size) - private var oscPortIn = 0 - private var oscPortOut = 0 - private var oscIp: InetAddress? = null - private var oscQuerySenderState = false - private var oscQueryPortOut = 0 - private var oscQueryIp: String? = null - private var timeAtLastError: Long = 0 - private var receivingPositionOffset = Vector3.NULL - private var postReceivingPositionOffset = Vector3.NULL - private var receivingRotationOffset = Quaternion.IDENTITY - private var receivingRotationOffsetGoal = Quaternion.IDENTITY - private val postReceivingOffset = EulerAngles(EulerOrder.YXZ, 0f, FastMath.PI, 0f).toQuaternion() - private var timeAtLastReceivedRotationOffset = System.currentTimeMillis() - private var fpsTimer: NanoTimer? = null - private var vrcOscQueryHandler: VRCOSCQueryHandler? = null - - init { - refreshSettings(false) - } - - override fun refreshSettings(refreshRouterSettings: Boolean) { - // Sets which trackers are enabled and force head and hands to false - for (i in computedTrackers.indices) { - if (computedTrackers[i].trackerPosition != TrackerPosition.HEAD || computedTrackers[i].trackerPosition != TrackerPosition.LEFT_HAND || computedTrackers[i].trackerPosition != TrackerPosition.RIGHT_HAND) { - trackersEnabled[i] = config - .getOSCTrackerRole( - computedTrackers[i].trackerPosition!!.trackerRole!!, - false, - ) - } else { - trackersEnabled[i] = false - } - } - - updateOscReceiver(config.portIn, vrsystemTrackersAddresses + oscTrackersAddresses) - updateOscSender(config.portOut, config.address) - - if (vrcOscQueryHandler == null && config.enabled && config.oscqueryEnabled) { - try { - vrcOscQueryHandler = VRCOSCQueryHandler(this) - } catch (e: Throwable) { - LogManager.severe("Unable to initialize OSCQuery: $e", e) - } - } else if (vrcOscQueryHandler != null && (!config.enabled || !config.oscqueryEnabled)) { - vrcOscQueryHandler?.close() - vrcOscQueryHandler = null - } - - if (refreshRouterSettings) { - server.oSCRouter.refreshSettings(false) - } - } - - /** - * Adds an OSC Sender from OSCQuery - */ - fun addOSCQuerySender(oscPortOut: Int, oscIP: String) { - val addr = InetAddress.getByName(oscIP) - oscQuerySenderState = true - oscQueryIp = oscIP - oscQueryPortOut = oscPortOut - if (oscPortOut != portOut || (oscIP != address.hostName && !(oscIP == localIp && address.hostName == loopbackIp))) { - try { - oscQuerySender = OSCPortOut(InetSocketAddress(addr, oscPortOut)) - oscQuerySender?.connect() - LogManager.info("[VRCOSCHandler] OSCQuery sender sending to port $oscPortOut at address $oscIP") - } catch (e: IOException) { - LogManager.severe("[VRCOSCHandler] Error connecting to port $oscPortOut at the address $oscIP: $e") - } - } - } - - /** - * Close/remove the osc query sender - */ - fun closeOscQuerySender(newState: Boolean) { - oscQuerySender?.let { - try { - it.close() - oscQuerySender = null - oscQuerySenderState = newState - } catch (e: IOException) { - LogManager.severe("[VRCOSCHandler] Error closing the OSC sender: $e") - } - } - } - - override fun updateOscReceiver(portIn: Int, args: Array) { - // Stop listening - val wasListening = oscReceiver != null && oscReceiver!!.isListening - if (wasListening) { - oscReceiver!!.stopListening() - } - - if (config.enabled) { - // Instantiates the OSC receiver - try { - oscReceiver = OSCPortIn(portIn) - if (oscPortIn != portIn || !wasListening) { - LogManager.info("[VRCOSCHandler] Listening to port $portIn") - } - oscPortIn = portIn - vrcOscQueryHandler?.updateOSCQuery(portIn.toUShort()) - } catch (e: IOException) { - LogManager - .severe( - "[VRCOSCHandler] Error listening to the port $portIn: $e", - ) - } - - // Starts listening for VRC or OSCTrackers messages - oscReceiver?.let { - val listener = OSCMessageListener { event: OSCMessageEvent -> - handleReceivedMessage(event) - } - for (address in args) { - it.dispatcher.addListener( - OSCPatternAddressMessageSelector(address), - listener, - ) - } - it.startListening() - } - } - } - - override fun updateOscSender(portOut: Int, ip: String) { - // Stop sending - val wasConnected = oscSender != null && oscSender!!.isConnected - if (wasConnected) { - try { - oscSender!!.close() - } catch (e: IOException) { - LogManager.severe("[VRCOSCHandler] Error closing the OSC sender: $e") - } - } - - if (config.enabled) { - // Instantiate the OSC sender - try { - val addr = InetAddress.getByName(ip) - oscSender = OSCPortOut(InetSocketAddress(addr, portOut)) - if ((oscPortOut != portOut && oscIp != addr) || !wasConnected) { - LogManager.info("[VRCOSCHandler] Sending to port $portOut at address $ip") - } - oscPortOut = portOut - oscIp = addr - oscSender?.connect() - } catch (e: IOException) { - LogManager - .severe( - "[VRCOSCHandler] Error connecting to port $portOut at the address $ip: $e", - ) - return - } - - if (oscQueryPortOut == portOut && (oscQueryIp == ip || (oscQueryIp == localIp && ip == loopbackIp))) { - if (oscQuerySender != null) { - // Close the oscQuerySender if it has the same port/ip - closeOscQuerySender(true) - } - } else if (oscQuerySender == null && oscQuerySenderState) { - // Instantiate the oscQuerySender if it could not be instantiated. - addOSCQuerySender(oscQueryPortOut, oscQueryIp!!) - } - } - } - - private fun handleReceivedMessage(event: OSCMessageEvent) { - if (vrsystemTrackersAddresses.contains(event.message.address)) { - // Receiving Head and Wrist pose data thanks to OSCQuery - // Create device if it doesn't exist - if (vrsystemTrackersDevice == null) { - // Instantiate OSC Trackers device - vrsystemTrackersDevice = server.deviceManager.createDevice("VRC VRSystem", null, "VRChat") - server.deviceManager.addDevice(vrsystemTrackersDevice!!) - } - - // Look at xxx in "/tracking/vrsystem/xxx/pose" to know TrackerPosition - var name = "VRChat " - val trackerPosition = when (event.message.address.split('/')[3]) { - "head" -> { - name += "head" - TrackerPosition.HEAD - } - - "leftwrist" -> { - name += "left hand" - TrackerPosition.LEFT_HAND - } - - "rightwrist" -> { - name += "right hand" - TrackerPosition.RIGHT_HAND - } - - else -> { - LogManager.warning("[VRCOSCHandler] Received invalid body part in message \"${event.message.address}\"") - return - } - } - - // Try to get the tracker - var tracker = vrsystemTrackersDevice!!.trackers[trackerPosition.ordinal] - - // Build the tracker if it doesn't exist - if (tracker == null) { - tracker = Tracker( - device = vrsystemTrackersDevice, - id = VRServer.getNextLocalTrackerId(), - name = name, - displayName = name, - trackerNum = trackerPosition.ordinal, - trackerPosition = trackerPosition, - hasRotation = true, - hasPosition = true, - userEditable = true, - isComputed = true, - allowReset = trackerPosition != TrackerPosition.HEAD, - usesTimeout = true, - ) - vrsystemTrackersDevice!!.trackers[trackerPosition.ordinal] = tracker - server.registerTracker(tracker) - } - - // Sets the tracker status to OK - tracker.status = TrackerStatus.OK - - // Update tracker position - tracker.position = Vector3( - event.message.arguments[0] as Float, - event.message.arguments[1] as Float, - -(event.message.arguments[2] as Float), - ) - - // Update tracker rotation - val (w, x, y, z) = EulerAngles( - EulerOrder.YXZ, - event.message.arguments[3] as Float * FastMath.DEG_TO_RAD, - event.message.arguments[4] as Float * FastMath.DEG_TO_RAD, - event.message.arguments[5] as Float * FastMath.DEG_TO_RAD, - ).toQuaternion() - val rot = Quaternion(w, -x, -y, z) - tracker.setRotation(rot) - - tracker.dataTick() - } else { - // Receiving OSC Trackers data. This is not from VRChat. - if (oscTrackersDevice == null) { - // Instantiate OSC Trackers device - oscTrackersDevice = server.deviceManager.createDevice("OSC Tracker", null, "OSC Trackers") - server.deviceManager.addDevice(oscTrackersDevice!!) - } - - // Extract the xxx in "/tracking/trackers/xxx/..." - val splitAddress = event.message.address.split('/') - val trackerStringValue = splitAddress[3] - val dataType = event.message.address.split('/')[4] - if (trackerStringValue == "head") { - // Head data - if (dataType == "position") { - // Position offset - receivingPositionOffset = Vector3( - event.message.arguments[0] as Float, - event.message.arguments[1] as Float, - -(event.message.arguments[2] as Float), - ) - - headTracker?.let { - if (it.hasPosition) { - postReceivingPositionOffset = it.position - } - } - } else { - // Rotation offset - val (w, x, y, z) = EulerAngles(EulerOrder.YXZ, event.message.arguments[0] as Float * FastMath.DEG_TO_RAD, event.message.arguments[1] as Float * FastMath.DEG_TO_RAD, event.message.arguments[2] as Float * FastMath.DEG_TO_RAD).toQuaternion() - receivingRotationOffsetGoal = Quaternion(w, -x, -y, z).inv() - - headTracker.let { - receivingRotationOffsetGoal = if (it != null && it.hasRotation) { - it.getRotation().project(Vector3.POS_Y).unit() * receivingRotationOffsetGoal - } else { - receivingRotationOffsetGoal - } - } - - // If greater than 300ms, snap to rotation - if (System.currentTimeMillis() - timeAtLastReceivedRotationOffset > 300) { - receivingRotationOffset = receivingRotationOffsetGoal - } - - // Update time variable - timeAtLastReceivedRotationOffset = System.currentTimeMillis() - } - } else { - // Trackers data (1-8) - val trackerId = trackerStringValue.toInt() - var tracker = oscTrackersDevice!!.trackers[trackerId] - - if (tracker == null) { - tracker = Tracker( - device = oscTrackersDevice, - id = VRServer.getNextLocalTrackerId(), - name = "OSC Tracker #$trackerId", - displayName = "OSC Tracker #$trackerId", - trackerNum = trackerId, - trackerPosition = null, - hasRotation = true, - hasPosition = true, - userEditable = true, - isComputed = true, - allowReset = true, - usesTimeout = true, - ) - oscTrackersDevice!!.trackers[trackerId] = tracker - server.registerTracker(tracker) - } - - // Sets the tracker status to OK - tracker.status = TrackerStatus.OK - - if (dataType == "position") { - // Update tracker position - tracker.position = receivingRotationOffset.sandwich( - Vector3( - event.message.arguments[0] as Float, - event.message.arguments[1] as Float, - -(event.message.arguments[2] as Float), - ) - - receivingPositionOffset, - ) + - postReceivingPositionOffset - } else { - // Update tracker rotation - val (w, x, y, z) = EulerAngles( - EulerOrder.YXZ, - event.message.arguments[0] as Float * FastMath.DEG_TO_RAD, - event.message.arguments[1] as Float * FastMath.DEG_TO_RAD, - event.message.arguments[2] as Float * FastMath.DEG_TO_RAD, - ).toQuaternion() - val rot = Quaternion(w, -x, -y, z) - tracker.setRotation(receivingRotationOffset * rot * postReceivingOffset) - } - - tracker.dataTick() - } - } - } - - override fun update() { - // Gets timer from vrServer - if (fpsTimer == null) { - fpsTimer = VRServer.instance.fpsTimer - } - // Update received trackers' offset rotation slerp - if (receivingRotationOffset != receivingRotationOffsetGoal) { - receivingRotationOffset = receivingRotationOffset.interpR(receivingRotationOffsetGoal, OFFSET_SLERP_FACTOR * (fpsTimer?.timePerFrame ?: 1f)) - } - - // Update current time - val currentTime = System.currentTimeMillis().toFloat() - - // Send OSC data - if (oscSender != null && oscSender!!.isConnected) { - // Create new bundle - val bundle = OSCBundle() - - for (i in computedTrackers.indices) { - if (trackersEnabled[i]) { - // Send regular trackers' positions - val (x, y, z) = computedTrackers[i].position - oscArgs.clear() - oscArgs.add(x) - oscArgs.add(y) - oscArgs.add(-z) - bundle.addPacket( - OSCMessage( - "/tracking/trackers/${getVRCOSCTrackersId(computedTrackers[i].trackerPosition)}/position", - oscArgs.clone(), - ), - ) - - // Send regular trackers' rotations - val (w, x1, y1, z1) = computedTrackers[i].getRotation() - // We flip the X and Y components of the quaternion because - // we flip the z direction when communicating from - // our right-handed API to VRChat's left-handed API. - // X quaternion represents a rotation from y to z - // Y quaternion represents a rotation from z to x - // When we negate the z direction, X and Y quaternion - // components must be negated. - val (_, x2, y2, z2) = Quaternion( - w, - -x1, - -y1, - z1, - ).toEulerAngles(EulerOrder.YXZ) - oscArgs.clear() - oscArgs.add(x2 * FastMath.RAD_TO_DEG) - oscArgs.add(y2 * FastMath.RAD_TO_DEG) - oscArgs.add(z2 * FastMath.RAD_TO_DEG) - bundle.addPacket( - OSCMessage( - "/tracking/trackers/${getVRCOSCTrackersId(computedTrackers[i].trackerPosition)}/rotation", - oscArgs.clone(), - ), - ) - } - if (computedTrackers[i].trackerPosition == TrackerPosition.HEAD) { - // Send HMD position - val (x, y, z) = computedTrackers[i].position - oscArgs.clear() - oscArgs.add(x) - oscArgs.add(y) - oscArgs.add(-z) - bundle.addPacket( - OSCMessage( - "/tracking/trackers/head/position", - oscArgs.clone(), - ), - ) - } - } - - try { - oscSender?.send(bundle) - oscQuerySender?.send(bundle) - } catch (e: IOException) { - // Avoid spamming AsynchronousCloseException too many - // times per second - if (currentTime - timeAtLastError > 100) { - timeAtLastError = System.currentTimeMillis() - LogManager.warning("[VRCOSCHandler] Error sending OSC message to VRChat: $e") - } - } catch (e: OSCSerializeException) { - if (currentTime - timeAtLastError > 100) { - timeAtLastError = System.currentTimeMillis() - LogManager.warning("[VRCOSCHandler] Error sending OSC message to VRChat: $e") - } - } - } - } - - private fun getVRCOSCTrackersId(trackerPosition: TrackerPosition?): Int { - // Needs to range from 1-8. - // Don't change as third party applications may rely - // on this for mapping trackers to body parts. - return when (trackerPosition) { - TrackerPosition.HIP -> 1 - TrackerPosition.LEFT_FOOT -> 2 - TrackerPosition.RIGHT_FOOT -> 3 - TrackerPosition.LEFT_UPPER_LEG -> 4 - TrackerPosition.RIGHT_UPPER_LEG -> 5 - TrackerPosition.UPPER_CHEST -> 6 - TrackerPosition.LEFT_UPPER_ARM -> 7 - TrackerPosition.RIGHT_UPPER_ARM -> 8 - else -> -1 - } - } - - fun setHeadTracker(headTracker: Tracker?) { - this.headTracker = headTracker - } - - /** - * Sends the expected HMD rotation upon reset to align the trackers in VRC - */ - fun yawAlign(headRot: Quaternion) { - if (oscSender != null && oscSender!!.isConnected) { - val (_, _, y, _) = headRot.toEulerAngles(EulerOrder.YXZ) - oscArgs.clear() - oscArgs.add(0f) - oscArgs.add(-y * FastMath.RAD_TO_DEG) - oscArgs.add(0f) - oscMessage = OSCMessage( - "/tracking/trackers/head/rotation", - oscArgs, - ) - try { - oscSender?.send(oscMessage) - oscQuerySender?.send(oscMessage) - } catch (e: IOException) { - LogManager - .warning("[VRCOSCHandler] Error sending OSC message to VRChat: $e") - } catch (e: OSCSerializeException) { - LogManager - .warning("[VRCOSCHandler] Error sending OSC message to VRChat: $e") - } - } - } - - override fun getOscSender(): OSCPortOut = oscSender!! - - override fun getPortOut(): Int = oscPortOut - - override fun getAddress(): InetAddress = oscIp!! - - override fun getOscReceiver(): OSCPortIn = oscReceiver!! - - override fun getPortIn(): Int = oscPortIn -} diff --git a/server/core/src/main/java/dev/slimevr/osc/VRCOSCQueryHandler.kt b/server/core/src/main/java/dev/slimevr/osc/VRCOSCQueryHandler.kt deleted file mode 100644 index 827d597a3e..0000000000 --- a/server/core/src/main/java/dev/slimevr/osc/VRCOSCQueryHandler.kt +++ /dev/null @@ -1,86 +0,0 @@ -package dev.slimevr.osc - -import OSCQueryNode -import OSCQueryServer -import ServiceInfo -import dev.slimevr.protocol.rpc.setup.RPCUtil -import io.eiren.util.logging.LogManager -import randomFreePort -import java.io.IOException -import kotlin.concurrent.thread - -private const val serviceStartsWith = "VRChat-Client" -private const val queryPath = "/tracking/vrsystem" - -/** - * Handler for OSCQuery for VRChat using our library - * https://github.com/SlimeVR/oscquery-kt - */ -class VRCOSCQueryHandler( - private val vrcOscHandler: VRCOSCHandler, -) { - private val oscQueryServer: OSCQueryServer - - init { - // Request data - val localIp = RPCUtil.getLocalIp() ?: throw IllegalStateException("No local IP address found for OSCQuery to bind to") - val httpPort = randomFreePort() - oscQueryServer = OSCQueryServer( - "SlimeVR-Server-$httpPort", - OscTransport.UDP, - localIp, - vrcOscHandler.portIn.toUShort(), - httpPort, - ) - oscQueryServer.rootNode.addNode(OSCQueryNode(queryPath)) - oscQueryServer.init() - LogManager.info("[VRCOSCQueryHandler] SlimeVR OSCQueryServer started at http://$localIp:$httpPort") - - try { - // Add service listener - LogManager.info("[VRCOSCQueryHandler] Listening for VRChat OSCQuery") - oscQueryServer.service.addServiceListener( - "_osc._udp.local.", - onServiceAdded = ::serviceAdded, - ) - } catch (e: IOException) { - LogManager.warning("[VRCOSCQueryHandler] " + e.message) - } - } - - /** - * Updates the OSC service's port - */ - fun updateOSCQuery(port: UShort) { - if (oscQueryServer.oscPort != port) { - thread(start = true) { - oscQueryServer.updateOscService(port) - } - } - } - - /** - * Called when a service is added - */ - private fun serviceAdded(info: ServiceInfo) { - // Check the service name - if (!info.name.startsWith(serviceStartsWith)) return - - // Get url from ServiceInfo - val ip = info.inetAddresses[0].hostAddress - val port = info.port - - // create a new OSCHandler for this service - vrcOscHandler.addOSCQuerySender(port, ip) - } - - /** - * Closes the OSCQueryServer and the associated OSC sender. - */ - fun close() { - vrcOscHandler.closeOscQuerySender(false) - thread(start = true) { - oscQueryServer.close() - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/osc/VRMReader.kt b/server/core/src/main/java/dev/slimevr/osc/VRMReader.kt deleted file mode 100644 index a567eedb7d..0000000000 --- a/server/core/src/main/java/dev/slimevr/osc/VRMReader.kt +++ /dev/null @@ -1,103 +0,0 @@ -package dev.slimevr.osc - -import io.eiren.util.logging.LogManager -import io.github.axisangles.ktmath.Vector3 -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import java.util.* - -private val jsonIgnoreKeys = Json { ignoreUnknownKeys = true } -class VRMReader(vrmJson: String) { - - private val data: GLTF = jsonIgnoreKeys.decodeFromString(vrmJson) - - fun getOffsetForBone(unityBone: UnityBone): Vector3 { - val node = try { - if (data.extensions.vrmV1 != null) { - if (data.extensions.vrmV1.specVersion != "1.0") { - LogManager.warning("[VRMReader] VRM version is not 1.0") - } - data.extensions.vrmV1.humanoid.humanBones.getValue(unityBone).node - } else { - data.extensions.vrmV0?.humanoid?.humanBones?.first { - it.bone.equals(unityBone.stringVal, ignoreCase = true) - }?.node - } - } catch (_: NoSuchElementException) { - LogManager.warning("[VRMReader] Bone ${unityBone.stringVal} not found in JSON") - null - } ?: return Vector3.NULL - - val translationNode = data.nodes[node].translation ?: return Vector3.NULL - - return Vector3(translationNode[0].toFloat(), translationNode[1].toFloat(), translationNode[2].toFloat()) - } -} - -@Serializable -data class GLTF( - val extensions: Extensions, - val extensionsUsed: List, - val nodes: List, -) - -@Serializable -data class Extensions( - @SerialName("VRM") - val vrmV0: VRMV0? = null, - @SerialName("VRMC_vrm") - val vrmV1: VRMV1? = null, -) - -@Serializable -data class VRMV1( - val specVersion: String, - val humanoid: HumanoidV1, -) - -@Serializable -data class HumanoidV1( - val humanBones: Map, -) - -@Serializable -data class HumanBoneV1( - val node: Int, -) - -@Serializable -data class VRMV0( - val humanoid: HumanoidV0, -) - -@Serializable -data class HumanoidV0( - val humanBones: List, - val armStretch: Double, - val legStretch: Double, - val upperArmTwist: Double, - val lowerArmTwist: Double, - val upperLegTwist: Double, - val lowerLegTwist: Double, - val feetSpacing: Double, - val hasTranslationDoF: Boolean, -) - -@Serializable -data class HumanBoneV0( - val bone: String, - val node: Int, - val useDefaultValues: Boolean, -) - -@Serializable -data class Node( - val translation: List? = null, - val rotation: List? = null, - val scale: List? = null, - // GLTF says that there can be a matrix instead of translation, - // rotation and scale, so we need to support that too in the future. - // val matrix: List>, - val children: List = emptyList(), -) diff --git a/server/core/src/main/java/dev/slimevr/poseframeformat/PfrIO.kt b/server/core/src/main/java/dev/slimevr/poseframeformat/PfrIO.kt deleted file mode 100644 index bfeb2908ac..0000000000 --- a/server/core/src/main/java/dev/slimevr/poseframeformat/PfrIO.kt +++ /dev/null @@ -1,189 +0,0 @@ -package dev.slimevr.poseframeformat - -import dev.slimevr.poseframeformat.trackerdata.TrackerFrame -import dev.slimevr.poseframeformat.trackerdata.TrackerFrameData -import dev.slimevr.poseframeformat.trackerdata.TrackerFrames -import dev.slimevr.tracking.trackers.TrackerPosition -import dev.slimevr.tracking.trackers.TrackerPosition.Companion.getByDesignation -import io.eiren.util.collections.FastList -import io.eiren.util.logging.LogManager -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import java.io.BufferedInputStream -import java.io.BufferedOutputStream -import java.io.DataInputStream -import java.io.DataOutputStream -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.io.IOException - -object PfrIO { - @Throws(IOException::class) - private fun writeVector3f(outputStream: DataOutputStream, vector: Vector3) { - outputStream.writeFloat(vector.x) - outputStream.writeFloat(vector.y) - outputStream.writeFloat(vector.z) - } - - @Throws(IOException::class) - private fun writeQuaternion(outputStream: DataOutputStream, quaternion: Quaternion) { - outputStream.writeFloat(quaternion.x) - outputStream.writeFloat(quaternion.y) - outputStream.writeFloat(quaternion.z) - outputStream.writeFloat(quaternion.w) - } - - fun writeFrame(outputStream: DataOutputStream, trackerFrame: TrackerFrame?) { - if (trackerFrame == null) { - outputStream.writeInt(0) - return - } - - var dataFlags = trackerFrame.dataFlags - - // Don't write destination strings anymore, replace with - // the enum - if (trackerFrame.hasData(TrackerFrameData.DESIGNATION_STRING)) { - dataFlags = TrackerFrameData.TRACKER_POSITION_ENUM - .add(TrackerFrameData.DESIGNATION_STRING.remove(dataFlags)) - } - outputStream.writeInt(dataFlags) - if (trackerFrame.hasData(TrackerFrameData.ROTATION)) { - writeQuaternion(outputStream, trackerFrame.rotation!!) - } - if (trackerFrame.hasData(TrackerFrameData.POSITION)) { - writeVector3f(outputStream, trackerFrame.position!!) - } - if (TrackerFrameData.TRACKER_POSITION_ENUM.check(dataFlags)) { - // ID is offset by 1 for historical reasons - outputStream.writeInt(trackerFrame.trackerPosition!!.id - 1) - } - if (trackerFrame.hasData(TrackerFrameData.ACCELERATION)) { - writeVector3f(outputStream, trackerFrame.acceleration!!) - } - if (trackerFrame.hasData(TrackerFrameData.RAW_ROTATION)) { - writeQuaternion(outputStream, trackerFrame.rawRotation!!) - } - } - - fun writeFrames(outputStream: DataOutputStream, frames: PoseFrames) { - outputStream.writeInt(frames.frameHolders.size) - for (tracker in frames.frameHolders) { - outputStream.writeUTF(tracker.name) - outputStream.writeInt(tracker.frames.size) - for (i in 0 until tracker.frames.size) { - writeFrame(outputStream, tracker.tryGetFrame(i)) - } - } - } - - fun tryWriteFrames(outputStream: DataOutputStream, frames: PoseFrames): Boolean = try { - writeFrames(outputStream, frames) - true - } catch (e: Exception) { - LogManager.severe("Error writing frame to stream.", e) - false - } - - fun writeToFile(file: File, frames: PoseFrames) { - DataOutputStream( - BufferedOutputStream(FileOutputStream(file)), - ).use { writeFrames(it, frames) } - } - - fun tryWriteToFile(file: File, frames: PoseFrames): Boolean = try { - writeToFile(file, frames) - true - } catch (e: Exception) { - LogManager.severe("Error writing frames to file.", e) - false - } - - @Throws(IOException::class) - private fun readVector3f(inputStream: DataInputStream): Vector3 = Vector3( - inputStream.readFloat(), - inputStream.readFloat(), - inputStream.readFloat(), - ) - - @Throws(IOException::class) - private fun readQuaternion(inputStream: DataInputStream): Quaternion { - val x = inputStream.readFloat() - val y = inputStream.readFloat() - val z = inputStream.readFloat() - val w = inputStream.readFloat() - - return Quaternion(w, x, y, z) - } - - fun readFrame(inputStream: DataInputStream): TrackerFrame { - val dataFlags = inputStream.readInt() - - var designation: TrackerPosition? = null - if (TrackerFrameData.DESIGNATION_STRING.check(dataFlags)) { - designation = getByDesignation(inputStream.readUTF()) - } - var rotation: Quaternion? = null - if (TrackerFrameData.ROTATION.check(dataFlags)) { - rotation = readQuaternion(inputStream) - } - var position: Vector3? = null - if (TrackerFrameData.POSITION.check(dataFlags)) { - position = readVector3f(inputStream) - } - if (TrackerFrameData.TRACKER_POSITION_ENUM.check(dataFlags)) { - // ID is offset by 1 for historical reasons - designation = TrackerPosition.getById(inputStream.readInt() + 1) - } - var acceleration: Vector3? = null - if (TrackerFrameData.ACCELERATION.check(dataFlags)) { - acceleration = readVector3f(inputStream) - } - var rawRotation: Quaternion? = null - if (TrackerFrameData.RAW_ROTATION.check(dataFlags)) { - rawRotation = readQuaternion(inputStream) - } - - return TrackerFrame( - designation, - rotation, - position, - acceleration, - rawRotation, - ) - } - - fun readFrames(inputStream: DataInputStream): PoseFrames { - val trackerCount = inputStream.readInt() - val trackers = FastList(trackerCount) - for (i in 0 until trackerCount) { - val name = inputStream.readUTF() - val trackerFrameCount = inputStream.readInt() - val trackerFrames = FastList( - trackerFrameCount, - ) - for (j in 0 until trackerFrameCount) { - trackerFrames.add(readFrame(inputStream)) - } - trackers.add(TrackerFrames(name, trackerFrames)) - } - return PoseFrames(trackers) - } - - fun tryReadFrames(inputStream: DataInputStream): PoseFrames? = try { - readFrames(inputStream) - } catch (e: Exception) { - LogManager.severe("Error reading frames from stream.", e) - null - } - - fun readFromFile(file: File): PoseFrames = DataInputStream(BufferedInputStream(FileInputStream(file))).use { readFrames(it) } - - fun tryReadFromFile(file: File): PoseFrames? = try { - readFromFile(file) - } catch (e: Exception) { - LogManager.severe("Error reading frames from file.", e) - null - } -} diff --git a/server/core/src/main/java/dev/slimevr/poseframeformat/PfsIO.kt b/server/core/src/main/java/dev/slimevr/poseframeformat/PfsIO.kt deleted file mode 100644 index d8b3d13e33..0000000000 --- a/server/core/src/main/java/dev/slimevr/poseframeformat/PfsIO.kt +++ /dev/null @@ -1,197 +0,0 @@ -package dev.slimevr.poseframeformat - -import dev.slimevr.config.SkeletonConfig -import dev.slimevr.poseframeformat.trackerdata.TrackerFrame -import dev.slimevr.poseframeformat.trackerdata.TrackerFrames -import io.eiren.util.logging.LogManager -import java.io.BufferedInputStream -import java.io.BufferedOutputStream -import java.io.DataInputStream -import java.io.DataOutputStream -import java.io.EOFException -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.io.IOException - -/** - * PoseFrameStream File IO, designed to handle the internal PoseFrames format with - * the new file format for streaming and storing additional debugging info - */ -object PfsIO { - private fun writeRecordingDef(stream: DataOutputStream, frameInterval: Float) { - stream.writeByte(PfsPackets.RECORDING_DEFINITION.id) - stream.writeFloat(frameInterval) - } - - private fun writeTrackerDef(stream: DataOutputStream, id: Int, name: String) { - stream.writeByte(PfsPackets.TRACKER_DEFINITION.id) - stream.writeByte(id) - stream.writeUTF(name) - } - - private fun writeTrackerFrame(stream: DataOutputStream, id: Int, frameIndex: Int, frame: TrackerFrame) { - stream.writeByte(PfsPackets.TRACKER_FRAME.id) - stream.writeByte(id) - stream.writeInt(frameIndex) - // Write frame data (same format as PFR) - PfrIO.writeFrame(stream, frame) - } - - private fun writeBodyProportions(stream: DataOutputStream, skeletonConfig: SkeletonConfig) { - stream.writeByte(PfsPackets.PROPORTIONS_CONFIG.id) - // HMD height - stream.writeFloat(skeletonConfig.hmdHeight) - // Floor height - stream.writeFloat(skeletonConfig.floorHeight) - // Write config map - stream.writeShort(skeletonConfig.offsets.size) - for ((key, value) in skeletonConfig.offsets) { - stream.writeUTF(key) - stream.writeFloat(value) - } - } - - fun writeFrames(stream: DataOutputStream, frames: PoseFrames) { - // Give trackers IDs (max 255) - val trackers = frames.frameHolders.mapIndexed { i, t -> i to t } - - // Write recording definition - writeRecordingDef(stream, frames.frameInterval) - - // Write tracker definitions - for (tracker in trackers) { - writeTrackerDef(stream, tracker.first, tracker.second.name) - } - - // Write tracker frames - for (i in 0 until frames.maxFrameCount) { - for (tracker in trackers) { - // If the tracker has a frame at the index - val frame = tracker.second.tryGetFrame(i) - if (frame != null) { - writeTrackerFrame(stream, tracker.first, i, frame) - } - } - } - } - - fun tryWriteFrames(stream: DataOutputStream, frames: PoseFrames): Boolean = try { - writeFrames(stream, frames) - true - } catch (e: Exception) { - LogManager.severe("[PfsIO] Error writing frame to stream.", e) - false - } - - fun writeToFile(file: File, frames: PoseFrames) { - DataOutputStream( - BufferedOutputStream(FileOutputStream(file)), - ).use { writeFrames(it, frames) } - } - - fun tryWriteToFile(file: File, frames: PoseFrames): Boolean = try { - writeToFile(file, frames) - true - } catch (e: Exception) { - LogManager.severe("[PfsIO] Error writing frames to file.", e) - false - } - - fun readFrame(stream: DataInputStream, poseFrames: PoseFrames, trackers: MutableMap) { - val packetId = stream.readUnsignedByte() - val packetType = PfsPackets.byId[packetId] - - when (packetType) { - null -> { - throw IOException("Encountered unknown packet ID ($packetId) while deserializing PFS stream.") - } - - PfsPackets.RECORDING_DEFINITION -> { - // Unused, useful for debugging - val frameInterval = stream.readFloat() - poseFrames.frameInterval = frameInterval - LogManager.debug("[PfsIO] Frame interval: $frameInterval s") - } - - PfsPackets.TRACKER_DEFINITION -> { - val trackerId = stream.readUnsignedByte() - val name = stream.readUTF() - - // Get or make tracker and set its name - trackers.getOrPut(trackerId) { - TrackerFrames(name) - }.name = name - } - - PfsPackets.TRACKER_FRAME -> { - val trackerId = stream.readUnsignedByte() - val tracker = trackers.getOrPut(trackerId) { - // If tracker doesn't exist yet, make one - TrackerFrames() - } - val frameNum = stream.readInt() - val frame = PfrIO.readFrame(stream) - - tracker.frames.add(frameNum, frame) - } - - PfsPackets.PROPORTIONS_CONFIG -> { - // Unused, useful for debugging - - val hmdHeight = stream.readFloat() - val floorHeight = stream.readFloat() - LogManager.debug("[PfsIO] HMD height: $hmdHeight, Floor height: $floorHeight") - - // Currently just prints JSON format config to console - val configCount = stream.readUnsignedShort() - val sb = StringBuilder("[PfsIO] Body proportion configs ($configCount): {") - for (i in 0 until configCount) { - if (i > 0) { - sb.append(", ") - } - sb.append(stream.readUTF()) - sb.append(": ") - sb.append(stream.readFloat()) - } - sb.append('}') - - LogManager.debug(sb.toString()) - } - } - } - - fun readFrames(stream: DataInputStream): PoseFrames { - val poseFrames = PoseFrames() - val trackers = mutableMapOf() - - while (true) { - try { - readFrame(stream, poseFrames, trackers) - } catch (_: EOFException) { - // Reached end of stream, stop reading and return the recording - // LogManager.debug("[PfsIO] Reached end of PFS stream.", e) - break - } - } - - poseFrames.frameHolders.addAll(trackers.values) - return poseFrames - } - - fun tryReadFrames(stream: DataInputStream): PoseFrames? = try { - readFrames(stream) - } catch (e: Exception) { - LogManager.severe("[PfsIO] Error reading frames from stream.", e) - null - } - - fun readFromFile(file: File): PoseFrames = DataInputStream(BufferedInputStream(FileInputStream(file))).use { readFrames(it) } - - fun tryReadFromFile(file: File): PoseFrames? = try { - readFromFile(file) - } catch (e: Exception) { - LogManager.severe("[PfsIO] Error reading frames from file.", e) - null - } -} diff --git a/server/core/src/main/java/dev/slimevr/poseframeformat/PfsPackets.kt b/server/core/src/main/java/dev/slimevr/poseframeformat/PfsPackets.kt deleted file mode 100644 index f0c6d0da76..0000000000 --- a/server/core/src/main/java/dev/slimevr/poseframeformat/PfsPackets.kt +++ /dev/null @@ -1,40 +0,0 @@ -package dev.slimevr.poseframeformat - -/** - * Packet ID ([UByte]), - * Packet data (see [PfsPackets], implemented in [PfsIO]) - */ -enum class PfsPackets(val id: Int) { - /** - * Frame interval ([Float] seconds) - */ - RECORDING_DEFINITION(0), - - /** - * Tracker ID ([UByte]), - * Tracker name (UTF-8 [String]) - */ - TRACKER_DEFINITION(1), - - /** - * Tracker ID ([UByte]), - * Frame number ([UInt]), - * PFR frame data (see [PfrIO.writeFrame] & [PfrIO.readFrame]) - */ - TRACKER_FRAME(2), - - /** - * Hmd height ([Float]), - * Floor height ([Float]), - * Body proportion configs (Count ([UShort]) x (Key (UTF-8 [String]), Value ([Float])) - */ - PROPORTIONS_CONFIG(3), - ; - - val byteId = id.toUByte() - - companion object { - val byId = entries.associateBy { it.id } - val byByteId = entries.associateBy { it.byteId } - } -} diff --git a/server/core/src/main/java/dev/slimevr/poseframeformat/PoseFrames.kt b/server/core/src/main/java/dev/slimevr/poseframeformat/PoseFrames.kt deleted file mode 100644 index 40842e2691..0000000000 --- a/server/core/src/main/java/dev/slimevr/poseframeformat/PoseFrames.kt +++ /dev/null @@ -1,164 +0,0 @@ -package dev.slimevr.poseframeformat - -import dev.slimevr.poseframeformat.trackerdata.TrackerFrame -import dev.slimevr.poseframeformat.trackerdata.TrackerFrames -import dev.slimevr.tracking.trackers.TrackerPosition -import io.eiren.util.collections.FastList - -class PoseFrames : Iterable> { - val frameHolders: FastList - - /** - * Frame interval in seconds - */ - var frameInterval: Float = 0.02f - - /** - * Creates a [PoseFrames] object with the provided list of - * [TrackerFrames]s as the internal [TrackerFrames] list. - * - * @see [FastList] - * @see [TrackerFrames] - */ - constructor(frameHolders: FastList) { - this.frameHolders = frameHolders - } - - /** - * Creates a [PoseFrames] object with the specified initial tracker - * capacity. - * - * @see [PoseFrames] - */ - constructor(initialCapacity: Int = 5) { - frameHolders = FastList(initialCapacity) - } - - /** - * @return The [TrackerFrames] associated with [position] at frame - * index [index]. - */ - fun getTrackerForPosition(position: TrackerPosition, index: Int = 0): TrackerFrames? { - for (tracker in frameHolders) { - if (tracker.tryGetFrame(index)?.trackerPosition == position) return tracker - } - return null - } - - // region Data Utilities - - /** - * @return The maximum Y value of the [TrackerFrames] associated with the - * [TrackerPosition.HEAD] tracker position on the first frame, otherwise `0f` if - * no [TrackerFrames] is associated with [TrackerPosition.HEAD] or if there are no - * valid positions. - * @see [getMaxHeight] - */ - val maxHmdHeight: Float - get() { - return getMaxHeight( - getTrackerForPosition(TrackerPosition.HEAD) - ?: return 0f, - ) - } - - /** - * @return The maximum Y value of the [trackerFrames], otherwise `0f` if - * there are no valid positions. - * @see [TrackerPosition] - */ - fun getMaxHeight(trackerFrames: TrackerFrames): Float { - var maxHeight = 0f - for (frame in trackerFrames.frames) { - val framePosition = frame?.tryGetPosition() ?: continue - - if (framePosition.y > maxHeight) { - maxHeight = framePosition.y - } - } - - return maxHeight - } - // endregion - - /** - * @return The maximum number of [TrackerFrame]s contained within each - * [TrackerFrames] in the internal [TrackerFrames] list. - * @see [TrackerFrames.frames] - * @see [List.size] - */ - val maxFrameCount: Int - get() { - return frameHolders.maxOfOrNull { tracker -> tracker.frames.size } ?: 0 - } - - /** - * Using the provided array buffer, get the [TrackerFrame]s contained - * within each [TrackerFrames] in the internal - * [TrackerFrames] list at the specified index. - * - * @return The number of frames written to the buffer. - * @see [TrackerFrames.tryGetFrame] - */ - fun getFrames(frameIndex: Int, buffer: Array): Int { - var frameCount = 0 - for (tracker in frameHolders) { - if (tracker == null) { - continue - } - val frame = tracker.tryGetFrame(frameIndex) ?: continue - buffer[frameCount++] = frame - } - return frameCount - } - - /** - * Using the provided [List] buffer, get the [TrackerFrame]s - * contained within each [TrackerFrames] in the internal - * [TrackerFrames] list at the specified index. - * - * @return The number of frames written to the buffer. - * @see [TrackerFrames.tryGetFrame] - */ - fun getFrames(frameIndex: Int, buffer: MutableList): Int { - var frameCount = 0 - for (tracker in frameHolders) { - if (tracker == null) { - continue - } - val frame = tracker.tryGetFrame(frameIndex) ?: continue - buffer[frameCount++] = frame - } - return frameCount - } - - /** - * @return The [TrackerFrame]s contained within each - * [TrackerFrames] in the internal [TrackerFrames] list at - * the specified index. - * @see [TrackerFrames.tryGetFrame] - */ - fun getFrames(frameIndex: Int): Array { - val trackerFrames = arrayOfNulls(frameHolders.size) - getFrames(frameIndex, trackerFrames) - return trackerFrames - } - - override fun iterator(): Iterator> = PoseFrameIterator(this) - - inner class PoseFrameIterator(private val poseFrame: PoseFrames) : Iterator> { - private val trackerFrameBuffer: Array = arrayOfNulls(poseFrame.frameHolders.size) - private val maxCursor = poseFrame.maxFrameCount - private var cursor = 0 - - override fun hasNext(): Boolean = frameHolders.isNotEmpty() && cursor < maxCursor - - override fun next(): Array { - if (!hasNext()) { - throw NoSuchElementException() - } - poseFrame.getFrames(cursor++, trackerFrameBuffer) - return trackerFrameBuffer - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/poseframeformat/PoseRecorder.kt b/server/core/src/main/java/dev/slimevr/poseframeformat/PoseRecorder.kt deleted file mode 100644 index a2d1ee8e83..0000000000 --- a/server/core/src/main/java/dev/slimevr/poseframeformat/PoseRecorder.kt +++ /dev/null @@ -1,162 +0,0 @@ -package dev.slimevr.poseframeformat - -import dev.slimevr.VRServer -import dev.slimevr.poseframeformat.trackerdata.TrackerFrames -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.util.TickReducer -import dev.slimevr.util.ann.VRServerThread -import io.eiren.util.collections.FastList -import io.eiren.util.logging.LogManager -import org.apache.commons.lang3.tuple.Pair -import java.util.concurrent.CompletableFuture -import java.util.concurrent.ExecutionException -import java.util.concurrent.Future -import java.util.function.Consumer - -class PoseRecorder(private val server: VRServer) { - inner class RecordingProgress(val frame: Int, val totalFrames: Int) - - private var poseFrame: PoseFrames? = null - private var numFrames = -1 - private var frameCursor = 0 - - // Default 50 TPS - private val ticker = TickReducer({ onTick() }, 0.02f) - - private var recordingFuture: CompletableFuture? = null - private var frameCallback: Consumer? = null - var trackers = FastList>() - - init { - server.addOnTick { - if (numFrames > 0) { - ticker.tick(server.fpsTimer.timePerFrame) - } - } - } - - // Make sure it's synchronized since this is the server thread interacting with - // an unknown outside thread controlling this class - @Synchronized - @VRServerThread - fun onTick() { - if (frameCursor >= numFrames) { - // If done and hasn't yet, send finished recording - stopFrameRecording() - return - } - - // A stopped recording will be accounted for by an empty "trackers" list - val cursor = frameCursor++ - for (tracker in trackers) { - // Add a frame for each tracker - tracker.right.addFrameFromTracker(cursor, tracker.left) - } - - // Send the number of finished frames - frameCallback?.accept(RecordingProgress(frameCursor, numFrames)) - // If done, send finished recording - if (frameCursor >= numFrames) { - stopFrameRecording() - } - } - - @Synchronized - fun startFrameRecording( - numFrames: Int, - interval: Float, - trackers: List = server.allTrackers, - frameCallback: Consumer? = null, - ): Future { - require(numFrames >= 1) { "numFrames must at least have a value of 1." } - require(interval > 0) { "interval must be greater than 0." } - require(trackers.isNotEmpty()) { "trackers must have at least one entry." } - - cancelFrameRecording() - val poseFrame = PoseFrames(trackers.size) - poseFrame.frameInterval = interval - - // Update tracker list - this.trackers.ensureCapacity(trackers.size) - for (tracker in trackers) { - // Ignore null and internal trackers - if (tracker == null || tracker.isInternal) { - continue - } - - // Create a tracker recording - val trackerFrames = TrackerFrames(tracker, numFrames) - poseFrame.frameHolders.add(trackerFrames) - - // Pair tracker with recording - this.trackers.add(Pair.of(tracker, trackerFrames)) - } - require(this.trackers.isNotEmpty()) { "trackers must have at least one valid tracker." } - - // Ticking setup - ticker.interval = interval - ticker.reset() - - val recordingFuture = CompletableFuture() - this.recordingFuture = recordingFuture - this.frameCallback = frameCallback - - // Recording setup - this.poseFrame = poseFrame - frameCursor = 0 - this.numFrames = numFrames - - LogManager.info( - "[PoseRecorder] Recording $numFrames samples at a $interval s frame interval", - ) - - return recordingFuture - } - - @Synchronized - private fun internalStopFrameRecording(cancel: Boolean) { - val currentRecording = recordingFuture - if (currentRecording != null && !currentRecording.isDone) { - val currentFrames = poseFrame - if (cancel || currentFrames == null) { - // If it's supposed to be cancelled or there's actually no recording, - // then cancel the recording and return nothing - currentRecording.cancel(true) - } else { - // Stop the recording, returning the frames recorded - currentRecording.complete(currentFrames) - } - } - numFrames = -1 - frameCursor = 0 - trackers.clear() - poseFrame = null - } - - @Synchronized - fun stopFrameRecording() { - internalStopFrameRecording(false) - } - - @Synchronized - fun cancelFrameRecording() { - internalStopFrameRecording(true) - } - - val isReadyToRecord: Boolean - get() = server.trackersCount > 0 - - val isRecording: Boolean - get() = numFrames > frameCursor - - fun hasRecording(): Boolean = recordingFuture != null - - val framesAsync: Future? - get() = recordingFuture - - @get:Throws(ExecutionException::class, InterruptedException::class) - val frames: PoseFrames? - get() { - return recordingFuture?.get() - } -} diff --git a/server/core/src/main/java/dev/slimevr/poseframeformat/player/PlayerTracker.kt b/server/core/src/main/java/dev/slimevr/poseframeformat/player/PlayerTracker.kt deleted file mode 100644 index 0421613820..0000000000 --- a/server/core/src/main/java/dev/slimevr/poseframeformat/player/PlayerTracker.kt +++ /dev/null @@ -1,72 +0,0 @@ -package dev.slimevr.poseframeformat.player - -import dev.slimevr.poseframeformat.trackerdata.TrackerFrames -import dev.slimevr.tracking.trackers.Tracker - -class PlayerTracker(val trackerFrames: TrackerFrames, val tracker: Tracker, private var internalCursor: Int = 0, private var internalScale: Float = 1f) { - - var cursor: Int - get() = internalCursor - set(value) { - val limitedCursor = limitCursor(value) - internalCursor = limitedCursor - setTrackerStateFromIndex(limitedCursor) - } - - var scale: Float - get() = internalScale - set(value) { - internalScale = value - setTrackerStateFromIndex() - } - - init { - setTrackerStateFromIndex(limitCursor()) - } - - fun limitCursor(cursor: Int): Int { - return if (cursor < 0 || trackerFrames.frames.isEmpty()) { - return 0 - } else if (cursor >= trackerFrames.frames.size) { - return trackerFrames.frames.size - 1 - } else { - cursor - } - } - - fun limitCursor(): Int { - val limitedCursor = limitCursor(internalCursor) - internalCursor = limitedCursor - return limitedCursor - } - - private fun setTrackerStateFromIndex(index: Int = internalCursor) { - val frame = trackerFrames.tryGetFrame(index) ?: return - - /* - * TODO: No way to set adjusted rotation manually? That might be nice to have... - * for now we'll stick with just setting the final rotation as raw and not - * enabling any adjustments - */ - - val trackerPosition = frame.tryGetTrackerPosition() - if (trackerPosition != null) { - tracker.trackerPosition = trackerPosition - } - - val rotation = frame.tryGetRotation() - if (rotation != null) { - tracker.setRotation(rotation) - } - - val position = frame.tryGetPosition() - if (position != null) { - tracker.position = position * internalScale - } - - val acceleration = frame.tryGetAcceleration() - if (acceleration != null) { - tracker.setAcceleration(acceleration * internalScale) - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/poseframeformat/player/TrackerFramesPlayer.kt b/server/core/src/main/java/dev/slimevr/poseframeformat/player/TrackerFramesPlayer.kt deleted file mode 100644 index d65b1b7250..0000000000 --- a/server/core/src/main/java/dev/slimevr/poseframeformat/player/TrackerFramesPlayer.kt +++ /dev/null @@ -1,44 +0,0 @@ -package dev.slimevr.poseframeformat.player - -import dev.slimevr.poseframeformat.PoseFrames -import dev.slimevr.poseframeformat.trackerdata.TrackerFrame -import dev.slimevr.poseframeformat.trackerdata.TrackerFrames -import dev.slimevr.tracking.trackers.Tracker - -class TrackerFramesPlayer(vararg val frameHolders: TrackerFrames) { - - val playerTrackers: Array = frameHolders.map { trackerFrames -> - PlayerTracker( - trackerFrames, - trackerFrames.toTracker(), - ) - }.toTypedArray() - - val trackers: Array = - playerTrackers.map { playerTracker -> playerTracker.tracker }.toTypedArray() - - /** - * @return The maximum number of [TrackerFrame]s contained within each - * [TrackerFrames] in the internal [TrackerFrames] array. - * @see [TrackerFrames.frames] - * @see [List.size] - */ - val maxFrameCount: Int - get() { - return frameHolders.maxOfOrNull { tracker -> tracker.frames.size } ?: 0 - } - - constructor(poseFrames: PoseFrames) : this(frameHolders = poseFrames.frameHolders.toTypedArray()) - - fun setCursors(index: Int) { - for (playerTracker in playerTrackers) { - playerTracker.cursor = index - } - } - - fun setScales(scale: Float) { - for (playerTracker in playerTrackers) { - playerTracker.scale = scale - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/poseframeformat/trackerdata/TrackerFrame.kt b/server/core/src/main/java/dev/slimevr/poseframeformat/trackerdata/TrackerFrame.kt deleted file mode 100644 index 33747f98b1..0000000000 --- a/server/core/src/main/java/dev/slimevr/poseframeformat/trackerdata/TrackerFrame.kt +++ /dev/null @@ -1,117 +0,0 @@ -package dev.slimevr.poseframeformat.trackerdata - -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerPosition -import dev.slimevr.tracking.trackers.TrackerStatus -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 - -data class TrackerFrame( - val trackerPosition: TrackerPosition? = null, - val rotation: Quaternion? = null, - val position: Vector3? = null, - val acceleration: Vector3? = null, - val rawRotation: Quaternion? = null, -) { - val dataFlags: Int - - val name: String - get() = "TrackerFrame:/${trackerPosition?.designation ?: "null"}" - - init { - var initDataFlags = 0 - - if (trackerPosition != null) { - initDataFlags = TrackerFrameData.TRACKER_POSITION_ENUM.add(initDataFlags) - } - if (rotation != null) { - initDataFlags = TrackerFrameData.ROTATION.add(initDataFlags) - } - if (position != null) { - initDataFlags = TrackerFrameData.POSITION.add(initDataFlags) - } - if (acceleration != null) { - initDataFlags = TrackerFrameData.ACCELERATION.add(initDataFlags) - } - if (rawRotation != null) { - initDataFlags = TrackerFrameData.RAW_ROTATION.add(initDataFlags) - } - - dataFlags = initDataFlags - } - - fun hasData(flag: TrackerFrameData): Boolean = flag.check(dataFlags) - - // region Tracker Try Getters - fun tryGetTrackerPosition(): TrackerPosition? = if (hasData(TrackerFrameData.TRACKER_POSITION_ENUM) || hasData(TrackerFrameData.DESIGNATION_STRING)) { - trackerPosition - } else { - null - } - - fun tryGetRotation(): Quaternion? = if (hasData(TrackerFrameData.ROTATION)) { - rotation - } else { - null - } - - fun tryGetRawRotation(): Quaternion? = if (hasData(TrackerFrameData.RAW_ROTATION)) { - rawRotation - } else { - null - } - - fun tryGetPosition(): Vector3? = if (hasData(TrackerFrameData.POSITION)) { - position - } else { - null - } - - fun tryGetAcceleration(): Vector3? = if (hasData(TrackerFrameData.ACCELERATION)) { - acceleration - } else { - null - } - - fun hasRotation(): Boolean = hasData(TrackerFrameData.ROTATION) - - fun hasPosition(): Boolean = hasData(TrackerFrameData.POSITION) - - fun hasAcceleration(): Boolean = hasData(TrackerFrameData.ACCELERATION) - // endregion - - companion object { - val empty = TrackerFrame() - - fun fromTracker(tracker: Tracker): TrackerFrame? { - // If the tracker is not ready - if (tracker.status != TrackerStatus.OK && tracker.status != TrackerStatus.BUSY && tracker.status != TrackerStatus.OCCLUDED) { - return null - } - - val trackerPosition = tracker.trackerPosition - - // If tracker has no data at all, there's no point in writing a frame - // Note: This includes rawRotation because of `!tracker.hasRotation` - if (trackerPosition == null && !tracker.hasRotation && !tracker.hasPosition && !tracker.hasAcceleration) { - return null - } - - val rotation: Quaternion? = if (tracker.hasRotation) tracker.getRotation() else null - val position: Vector3? = if (tracker.hasPosition) tracker.position else null - val acceleration: Vector3? = if (tracker.hasAcceleration) tracker.getAcceleration() else null - - var rawRotation: Quaternion? = if (tracker.hasAdjustedRotation) tracker.getRawRotation() else null - // If the rawRotation is the same as rotation, there's no point in saving it, set it back to null - if (rawRotation == rotation) rawRotation = null - - return TrackerFrame( - trackerPosition, - rotation, - position, - acceleration, - rawRotation, - ) - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/poseframeformat/trackerdata/TrackerFrameData.kt b/server/core/src/main/java/dev/slimevr/poseframeformat/trackerdata/TrackerFrameData.kt deleted file mode 100644 index f6ea7e2a54..0000000000 --- a/server/core/src/main/java/dev/slimevr/poseframeformat/trackerdata/TrackerFrameData.kt +++ /dev/null @@ -1,23 +0,0 @@ -package dev.slimevr.poseframeformat.trackerdata - -enum class TrackerFrameData(val id: Int) { - DESIGNATION_STRING(0), - ROTATION(1), - POSITION(2), - TRACKER_POSITION_ENUM(3), - ACCELERATION(4), - RAW_ROTATION(5), - ; - - val flag: Int = 1 shl id - - /* - * Inline is fine for these, there's no negative to inlining them as they'll never - * change, so any warning about it can be safely ignored - */ - inline fun check(dataFlags: Int): Boolean = dataFlags and flag != 0 - - inline fun add(dataFlags: Int): Int = dataFlags or flag - - inline fun remove(dataFlags: Int): Int = dataFlags xor flag -} diff --git a/server/core/src/main/java/dev/slimevr/poseframeformat/trackerdata/TrackerFrames.kt b/server/core/src/main/java/dev/slimevr/poseframeformat/trackerdata/TrackerFrames.kt deleted file mode 100644 index 49021ea593..0000000000 --- a/server/core/src/main/java/dev/slimevr/poseframeformat/trackerdata/TrackerFrames.kt +++ /dev/null @@ -1,51 +0,0 @@ -package dev.slimevr.poseframeformat.trackerdata - -import dev.slimevr.VRServer -import dev.slimevr.poseframeformat.trackerdata.TrackerFrame.Companion.fromTracker -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerStatus -import io.eiren.util.collections.FastList - -data class TrackerFrames(var name: String = "", val frames: FastList) { - - constructor(name: String = "", initialCapacity: Int = 5) : this(name, FastList(initialCapacity)) - constructor(baseTracker: Tracker, frames: FastList) : this(baseTracker.name, frames) - constructor(baseTracker: Tracker, initialCapacity: Int = 5) : this(baseTracker, FastList(initialCapacity)) - - fun addFrameFromTracker(index: Int, tracker: Tracker): TrackerFrame? { - val trackerFrame = fromTracker(tracker) - frames.add(index, trackerFrame) - return trackerFrame - } - - fun addFrameFromTracker(tracker: Tracker): TrackerFrame? { - val trackerFrame = fromTracker(tracker) - frames.add(trackerFrame) - return trackerFrame - } - - fun tryGetFrame(index: Int): TrackerFrame? = if (index < 0 || index >= frames.size) null else frames[index] - - fun tryGetFirstNotNullFrame(): TrackerFrame? = frames.firstOrNull { frame -> frame != null } - - fun toTracker(): Tracker { - val firstFrame = tryGetFirstNotNullFrame() ?: TrackerFrame.empty - val tracker = Tracker( - device = null, - id = VRServer.getNextLocalTrackerId(), - name = name, - trackerPosition = firstFrame.tryGetTrackerPosition(), - hasPosition = firstFrame.hasPosition(), - hasRotation = firstFrame.hasRotation(), - hasAcceleration = firstFrame.hasAcceleration(), - // Make sure this is false!! Otherwise HumanSkeleton ignores it - isInternal = false, - isComputed = true, - trackRotDirection = false, - ) - - tracker.status = TrackerStatus.OK - - return tracker - } -} diff --git a/server/core/src/main/java/dev/slimevr/posestreamer/BVHFileStream.kt b/server/core/src/main/java/dev/slimevr/posestreamer/BVHFileStream.kt deleted file mode 100644 index 3712ef12f9..0000000000 --- a/server/core/src/main/java/dev/slimevr/posestreamer/BVHFileStream.kt +++ /dev/null @@ -1,213 +0,0 @@ -package dev.slimevr.posestreamer - -import com.jme3.math.FastMath -import dev.slimevr.tracking.processor.Bone -import dev.slimevr.tracking.processor.skeleton.HumanSkeleton -import io.github.axisangles.ktmath.EulerOrder -import io.github.axisangles.ktmath.Quaternion -import org.apache.commons.lang3.StringUtils -import java.io.BufferedWriter -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.io.OutputStream -import java.io.OutputStreamWriter - -class BVHFileStream : PoseDataStream { - var bvhSettings: BVHSettings = BVHSettings.BLENDER - - private val writer: BufferedWriter - private var frameCount: Long = 0 - private var frameCountOffset: Long = 0 - - constructor(outputStream: OutputStream, bvhSettings: BVHSettings = BVHSettings.BLENDER) : super(outputStream) { - this.bvhSettings = bvhSettings - writer = BufferedWriter(OutputStreamWriter(outputStream), 4096) - } - - constructor(file: File, bvhSettings: BVHSettings = BVHSettings.BLENDER) : super(file) { - this.bvhSettings = bvhSettings - writer = BufferedWriter(OutputStreamWriter(outputStream), 4096) - } - - constructor(file: String, bvhSettings: BVHSettings = BVHSettings.BLENDER) : super(file) { - this.bvhSettings = bvhSettings - writer = BufferedWriter(OutputStreamWriter(outputStream), 4096) - } - - private fun getBufferedFrameCount(frameCount: Long): String { - val frameString = frameCount.toString() - val bufferCount = LONG_MAX_VALUE_DIGITS - frameString.length - - return if (bufferCount > 0) frameString + StringUtils.repeat(' ', bufferCount) else frameString - } - - private fun internalNavigateSkeleton( - bone: Bone, - header: (bone: Bone, lastBone: Bone?, invertParentRot: Quaternion, distance: Int, hasBranch: Boolean, isParent: Boolean) -> Unit, - footer: (distance: Int) -> Unit, - lastBone: Bone? = null, - invertParentRot: Quaternion = Quaternion.IDENTITY, - distance: Int = 0, - isParent: Boolean = false, - ) { - val parent = bone.parent - // If we're visiting the parents or at root, continue to the next parent - val visitParent = (isParent || lastBone == null) && parent != null - - val children = bone.children - val childCount = children.size - (if (isParent) 1 else 0) - - val hasBranch = visitParent || childCount > 0 - - header(bone, lastBone, invertParentRot, distance, hasBranch, isParent) - - if (hasBranch) { - // Cache this inverted rotation to reduce computation for each branch - val thisInvertRot = bone.getGlobalRotation().inv() - - if (visitParent) { - internalNavigateSkeleton(parent, header, footer, bone, thisInvertRot, distance + 1, true) - } - - for (child in children) { - // If we're a parent, ignore the child - if (isParent && child == lastBone) continue - internalNavigateSkeleton(child, header, footer, bone, thisInvertRot, distance + 1, false) - } - } - - footer(distance) - } - - private fun navigateSkeleton( - root: Bone, - header: (bone: Bone, lastBone: Bone?, invertParentRot: Quaternion, distance: Int, hasBranch: Boolean, isParent: Boolean) -> Unit, - footer: (distance: Int) -> Unit = {}, - ) { - internalNavigateSkeleton(root, header, footer) - } - - private fun writeBoneDefHeader(bone: Bone?, lastBone: Bone?, invertParentRot: Quaternion, distance: Int, hasBranch: Boolean, isParent: Boolean) { - val indentLevel = StringUtils.repeat("\t", distance) - val nextIndentLevel = indentLevel + "\t" - - // Handle ends - if (bone == null) { - writer.write("${indentLevel}End Site\n") - } else { - writer - .write("${indentLevel}${if (distance > 0) "JOINT" else "ROOT"} ${bone.boneType}\n") - } - writer.write("$indentLevel{\n") - - // Ignore the root and endpoint offsets - if (bone != null && lastBone != null) { - writer.write( - "${nextIndentLevel}OFFSET 0.0 ${(if (isParent) lastBone.length else -lastBone.length) * bvhSettings.offsetScale} 0.0\n", - ) - } else { - writer.write("${nextIndentLevel}OFFSET 0.0 0.0 0.0\n") - } - - // Define channels - if (bone != null) { - // Only give position for root - if (lastBone != null) { - writer.write("${nextIndentLevel}CHANNELS 3 Zrotation Xrotation Yrotation\n") - } else { - writer.write( - "${nextIndentLevel}CHANNELS 6 Xposition Yposition Zposition Zrotation Xrotation Yrotation\n", - ) - } - - // Write an empty end bone if there are no branches - // We use null for convenience and treat it as an end node (no bone) - if (!hasBranch) { - val endDistance = distance + 1 - writeBoneDefHeader(null, bone, Quaternion.IDENTITY, endDistance, false, false) - writeBoneDefFooter(endDistance) - } - } - } - - private fun writeBoneDefFooter(level: Int) { - // Closing bracket - writer.write("${StringUtils.repeat("\t", level)}}\n") - } - - private fun writeSkeletonDef(rootBone: Bone) { - navigateSkeleton(rootBone, ::writeBoneDefHeader, ::writeBoneDefFooter) - } - - @Throws(IOException::class) - override fun writeHeader(skeleton: HumanSkeleton, streamer: PoseStreamer) { - writer.write("HIERARCHY\n") - writeSkeletonDef(skeleton.getBone(bvhSettings.rootBone)) - - writer.write("MOTION\n") - writer.write("Frames: ") - - // Get frame offset for finishing writing the file - if (outputStream is FileOutputStream) { - // Flush buffer to get proper offset - writer.flush() - frameCountOffset = outputStream.channel.position() - } - - writer.write(getBufferedFrameCount(frameCount) + "\n") - - // Frame time in seconds - writer.write("Frame Time: ${streamer.frameInterval}\n") - } - - private fun writeBoneRot(bone: Bone, lastBone: Bone?, invertParentRot: Quaternion, distance: Int, hasBranch: Boolean, isParent: Boolean) { - val rot = invertParentRot * bone.getGlobalRotation() - val angles = rot.toEulerAngles(EulerOrder.ZXY) - - // Output in order of roll (Z), pitch (X), yaw (Y) (extrinsic) - // Assume spacing is needed at the start (we start with position with no following space) - writer - .write(" ${angles.z * FastMath.RAD_TO_DEG} ${angles.x * FastMath.RAD_TO_DEG} ${angles.y * FastMath.RAD_TO_DEG}") - } - - @Throws(IOException::class) - override fun writeFrame(skeleton: HumanSkeleton) { - val rootBone = skeleton.getBone(bvhSettings.rootBone) - - val rootPos = rootBone.getPosition() - - // Write root position - val positionScale = bvhSettings.positionScale - writer - .write("${rootPos.x * positionScale} ${rootPos.y * positionScale} ${rootPos.z * positionScale}") - - navigateSkeleton(rootBone, ::writeBoneRot) - writer.newLine() - - frameCount++ - } - - @Throws(IOException::class) - override fun writeFooter(skeleton: HumanSkeleton) { - // Write the final frame count for files - if (outputStream is FileOutputStream) { - // Flush before anything else - writer.flush() - // Seek to the count offset - outputStream.channel.position(frameCountOffset) - // Overwrite the count with a new value - writer.write(frameCount.toString()) - } - } - - @Throws(IOException::class) - override fun close() { - writer.close() - super.close() - } - - companion object { - private const val LONG_MAX_VALUE_DIGITS = Long.MAX_VALUE.toString().length - } -} diff --git a/server/core/src/main/java/dev/slimevr/posestreamer/BVHRecorder.kt b/server/core/src/main/java/dev/slimevr/posestreamer/BVHRecorder.kt deleted file mode 100644 index 8fe09c92be..0000000000 --- a/server/core/src/main/java/dev/slimevr/posestreamer/BVHRecorder.kt +++ /dev/null @@ -1,67 +0,0 @@ -package dev.slimevr.posestreamer - -import dev.slimevr.VRServer -import io.eiren.util.logging.LogManager -import java.io.File -import java.io.IOException -import java.nio.file.Path - -class BVHRecorder(server: VRServer) { - private val poseStreamer: ServerPoseStreamer = ServerPoseStreamer(server) - private var poseDataStream: PoseDataStream? = null - - val isRecording: Boolean - get() = poseDataStream != null - - fun startRecording(path: Path) { - val filePath = path.toFile() - - val file = if (filePath.isDirectory()) { - getBvhFile(filePath) ?: return - } else { - filePath - } - - try { - val stream = BVHFileStream(file) - poseDataStream = stream - // 100 FPS - poseStreamer.setOutput(stream, 1f / 100f) - } catch (_: IOException) { - LogManager.severe("[BVH] Failed to create the recording file \"${file.path}\".") - } - } - - fun endRecording() { - try { - val stream = poseDataStream - if (stream != null) { - poseStreamer.closeOutput(stream) - } - } catch (e1: Exception) { - LogManager.severe("[BVH] Exception while closing poseDataStream", e1) - } finally { - poseDataStream = null - } - } - - private fun getBvhFile(bvhSaveDir: File): File? { - if (bvhSaveDir.isDirectory() || bvhSaveDir.mkdirs()) { - var saveRecording: File? - var recordingIndex = 1 - do { - saveRecording = - File(bvhSaveDir, "BVH-Recording${recordingIndex++}.bvh") - } while (saveRecording.exists()) - - return saveRecording - } else { - LogManager - .severe( - "[BVH] Failed to create the recording directory \"${bvhSaveDir.path}\".", - ) - } - - return null - } -} diff --git a/server/core/src/main/java/dev/slimevr/posestreamer/BVHSettings.kt b/server/core/src/main/java/dev/slimevr/posestreamer/BVHSettings.kt deleted file mode 100644 index 62e1e8b7a6..0000000000 --- a/server/core/src/main/java/dev/slimevr/posestreamer/BVHSettings.kt +++ /dev/null @@ -1,41 +0,0 @@ -package dev.slimevr.posestreamer - -import dev.slimevr.tracking.processor.BoneType - -class BVHSettings { - var offsetScale: Float = 100f - private set - var positionScale: Float = 100f - private set - var rootBone: BoneType = BoneType.HIP - private set - - constructor() - - constructor(source: BVHSettings) { - this.offsetScale = source.offsetScale - this.positionScale = source.positionScale - } - - fun setOffsetScale(offsetScale: Float): BVHSettings { - this.offsetScale = offsetScale - return this - } - - fun setPositionScale(positionScale: Float): BVHSettings { - this.positionScale = positionScale - return this - } - - fun setRootBone(rootBone: BoneType): BVHSettings { - this.rootBone = rootBone - return this - } - - companion object { - val DEFAULT: BVHSettings = BVHSettings() - val BLENDER: BVHSettings = BVHSettings(DEFAULT) - .setOffsetScale(1f) - .setPositionScale(1f) - } -} diff --git a/server/core/src/main/java/dev/slimevr/posestreamer/PoseDataStream.kt b/server/core/src/main/java/dev/slimevr/posestreamer/PoseDataStream.kt deleted file mode 100644 index 406615e656..0000000000 --- a/server/core/src/main/java/dev/slimevr/posestreamer/PoseDataStream.kt +++ /dev/null @@ -1,33 +0,0 @@ -package dev.slimevr.posestreamer - -import dev.slimevr.tracking.processor.skeleton.HumanSkeleton -import java.io.File -import java.io.FileOutputStream -import java.io.IOException -import java.io.OutputStream -import java.lang.AutoCloseable - -abstract class PoseDataStream protected constructor(protected val outputStream: OutputStream) : AutoCloseable { - var isClosed: Boolean = false - protected set - - protected constructor(file: File) : this(FileOutputStream(file)) - protected constructor(file: String) : this(FileOutputStream(file)) - - @Throws(IOException::class) - open fun writeHeader(skeleton: HumanSkeleton, streamer: PoseStreamer) { - } - - @Throws(IOException::class) - abstract fun writeFrame(skeleton: HumanSkeleton) - - @Throws(IOException::class) - open fun writeFooter(skeleton: HumanSkeleton) { - } - - @Throws(IOException::class) - override fun close() { - outputStream.close() - this.isClosed = true - } -} diff --git a/server/core/src/main/java/dev/slimevr/posestreamer/PoseFrameStreamer.kt b/server/core/src/main/java/dev/slimevr/posestreamer/PoseFrameStreamer.kt deleted file mode 100644 index 1bf6040554..0000000000 --- a/server/core/src/main/java/dev/slimevr/posestreamer/PoseFrameStreamer.kt +++ /dev/null @@ -1,35 +0,0 @@ -package dev.slimevr.posestreamer - -import dev.slimevr.poseframeformat.PfrIO.readFromFile -import dev.slimevr.poseframeformat.PoseFrames -import dev.slimevr.poseframeformat.player.TrackerFramesPlayer -import dev.slimevr.tracking.processor.HumanPoseManager -import java.io.File - -class PoseFrameStreamer : PoseStreamer { - - val player: TrackerFramesPlayer - val hpm: HumanPoseManager - - private constructor( - player: TrackerFramesPlayer, - hpm: HumanPoseManager, - ) : super(hpm.skeleton) { - this.player = player - this.hpm = hpm - } - - constructor(player: TrackerFramesPlayer) : this(player, HumanPoseManager(player.trackers.toList())) - constructor(poseFrames: PoseFrames) : this(TrackerFramesPlayer(poseFrames)) - constructor(file: File) : this(readFromFile(file)) - constructor(path: String) : this(File(path)) - - @Synchronized - fun streamAllFrames() { - for (i in 0 until player.maxFrameCount) { - player.setCursors(i) - hpm.update() - captureFrame() - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/posestreamer/PoseStreamer.kt b/server/core/src/main/java/dev/slimevr/posestreamer/PoseStreamer.kt deleted file mode 100644 index f1630d5c55..0000000000 --- a/server/core/src/main/java/dev/slimevr/posestreamer/PoseStreamer.kt +++ /dev/null @@ -1,74 +0,0 @@ -package dev.slimevr.posestreamer - -import dev.slimevr.tracking.processor.skeleton.HumanSkeleton -import io.eiren.util.logging.LogManager -import java.io.IOException - -open class PoseStreamer(skeleton: HumanSkeleton) { - // 60 FPS - private var intervalInternal: Float = 1f / 60f - private var stream: PoseDataStream? = null - - var skeleton: HumanSkeleton = skeleton - protected set - - @Synchronized - fun captureFrame() { - // Make sure the stream is open before trying to write - val stream = stream - if (stream == null || stream.isClosed) { - return - } - - try { - stream.writeFrame(skeleton) - } catch (e: Exception) { - // Handle any exceptions without crashing the program - LogManager.severe("[PoseStreamer] Exception while saving frame", e) - } - } - - open var frameInterval: Float - get() = intervalInternal - set(interval) { - require(interval > 0f) { "interval must be a value greater than 0" } - this.intervalInternal = interval - } - - @Synchronized - @Throws(IOException::class) - fun setOutput(poseFileStream: PoseDataStream, interval: Float) { - this.frameInterval = interval - this.output = poseFileStream - } - - @set:Throws(IOException::class) - @set:Synchronized - open var output: PoseDataStream? - get() = stream - set(stream) { - requireNotNull(stream) { "stream must not be null" } - stream.writeHeader(skeleton, this) - this.stream = stream - } - - val hasOutput - get() = output?.isClosed == false - - @Synchronized - @Throws(IOException::class) - fun closeOutput() { - val stream = this.stream - if (stream != null) { - closeOutput(stream) - this.stream = null - } - } - - @Synchronized - @Throws(IOException::class) - fun closeOutput(stream: PoseDataStream) { - stream.writeFooter(skeleton) - stream.close() - } -} diff --git a/server/core/src/main/java/dev/slimevr/posestreamer/ServerPoseStreamer.kt b/server/core/src/main/java/dev/slimevr/posestreamer/ServerPoseStreamer.kt deleted file mode 100644 index e6566709ee..0000000000 --- a/server/core/src/main/java/dev/slimevr/posestreamer/ServerPoseStreamer.kt +++ /dev/null @@ -1,30 +0,0 @@ -package dev.slimevr.posestreamer - -import dev.slimevr.VRServer -import dev.slimevr.tracking.processor.skeleton.HumanSkeleton -import dev.slimevr.util.ann.VRServerThread - -class ServerPoseStreamer(val server: VRServer) : TickPoseStreamer(server.humanPoseManager.skeleton) { - - init { - // Register callbacks/events - server.addSkeletonUpdatedCallback { skeleton: HumanSkeleton? -> - this.onSkeletonUpdated( - skeleton, - ) - } - server.addOnTick { this.tick() } - } - - @VRServerThread - fun onSkeletonUpdated(skeleton: HumanSkeleton?) { - if (skeleton != null) { - this.skeleton = skeleton - } - } - - @VRServerThread - fun tick() { - super.tick(server.fpsTimer.timePerFrame) - } -} diff --git a/server/core/src/main/java/dev/slimevr/posestreamer/TickPoseStreamer.kt b/server/core/src/main/java/dev/slimevr/posestreamer/TickPoseStreamer.kt deleted file mode 100644 index 327986f796..0000000000 --- a/server/core/src/main/java/dev/slimevr/posestreamer/TickPoseStreamer.kt +++ /dev/null @@ -1,33 +0,0 @@ -package dev.slimevr.posestreamer - -import dev.slimevr.tracking.processor.skeleton.HumanSkeleton -import dev.slimevr.util.TickReducer -import java.io.IOException - -open class TickPoseStreamer(skeleton: HumanSkeleton) : PoseStreamer(skeleton) { - private val ticker = TickReducer({ captureFrame() }, frameInterval) - - @set:Throws(IOException::class) - @set:Synchronized - override var output: PoseDataStream? - get() = super.output - set(value) { - super.output = value - ticker.reset() - } - - @set:Synchronized - override var frameInterval: Float - get() = super.frameInterval - set(value) { - super.frameInterval = value - ticker.interval = value - } - - fun tick(tickDelta: Float) { - // Only tick if there is an output - if (hasOutput) { - ticker.tick(tickDelta) - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/ConnectionContext.kt b/server/core/src/main/java/dev/slimevr/protocol/ConnectionContext.kt deleted file mode 100644 index ea72472db7..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/ConnectionContext.kt +++ /dev/null @@ -1,10 +0,0 @@ -package dev.slimevr.protocol - -class ConnectionContext { - val dataFeedList: MutableList = mutableListOf() - val subscribedTopics: MutableList = mutableListOf() - - var useSerial: Boolean = false - var useProvisioning: Boolean = false - var useAutoBone: Boolean = false -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/DataFeed.kt b/server/core/src/main/java/dev/slimevr/protocol/DataFeed.kt deleted file mode 100644 index 8e72f31ab4..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/DataFeed.kt +++ /dev/null @@ -1,7 +0,0 @@ -package dev.slimevr.protocol - -import solarxr_protocol.data_feed.DataFeedConfigT - -class DataFeed(val config: DataFeedConfigT) { - var timeLastSent: Long = System.currentTimeMillis() -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/GenericConnection.kt b/server/core/src/main/java/dev/slimevr/protocol/GenericConnection.kt deleted file mode 100644 index c857157d9f..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/GenericConnection.kt +++ /dev/null @@ -1,12 +0,0 @@ -package dev.slimevr.protocol - -import java.nio.ByteBuffer -import java.util.UUID - -interface GenericConnection { - val connectionId: UUID - - val context: ConnectionContext - - fun send(bytes: ByteBuffer) -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/ProtocolAPI.kt b/server/core/src/main/java/dev/slimevr/protocol/ProtocolAPI.kt deleted file mode 100644 index 485fd4d5b7..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/ProtocolAPI.kt +++ /dev/null @@ -1,47 +0,0 @@ -package dev.slimevr.protocol - -import dev.slimevr.VRServer -import dev.slimevr.protocol.datafeed.DataFeedHandler -import dev.slimevr.protocol.pubsub.PubSubHandler -import dev.slimevr.protocol.rpc.RPCHandler -import solarxr_protocol.MessageBundle -import java.nio.ByteBuffer - -class ProtocolAPI(val server: VRServer) { - val apiServers: MutableList = ArrayList() - val dataFeedHandler: DataFeedHandler = DataFeedHandler(this) - val pubSubHandler: PubSubHandler = PubSubHandler(this) - val rpcHandler: RPCHandler = RPCHandler(this) - - fun onMessage(conn: GenericConnection, message: ByteBuffer) { - val messageBundle = MessageBundle.getRootAsMessageBundle(message) - - try { - for (index in 0.. -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/ProtocolHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/ProtocolHandler.kt deleted file mode 100644 index 96ff4e14c7..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/ProtocolHandler.kt +++ /dev/null @@ -1,25 +0,0 @@ -package dev.slimevr.protocol - -import java.util.function.BiConsumer - -abstract class ProtocolHandler { - @JvmField - val handlers: Array?> - - init { - this.handlers = arrayOfNulls(this.messagesCount()) - } - - abstract fun onMessage(conn: GenericConnection, message: H) - - abstract fun messagesCount(): Int - - fun registerPacketListener(packetType: Byte, consumer: BiConsumer) { - val packetInt = packetType.toInt() - if (handlers[packetInt] != null) { - handlers[packetInt] = handlers[packetInt]!!.andThen(consumer) - } else { - handlers[packetInt] = consumer - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/datafeed/DataFeedBuilder.kt b/server/core/src/main/java/dev/slimevr/protocol/datafeed/DataFeedBuilder.kt deleted file mode 100644 index 8d1f2cb67d..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/datafeed/DataFeedBuilder.kt +++ /dev/null @@ -1,506 +0,0 @@ -package dev.slimevr.protocol.datafeed - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.guards.ServerGuards -import dev.slimevr.tracking.processor.Bone -import dev.slimevr.tracking.processor.skeleton.HumanSkeleton -import dev.slimevr.tracking.processor.stayaligned.poses.RelaxedPose -import dev.slimevr.tracking.processor.stayaligned.trackers.RestDetector -import dev.slimevr.tracking.processor.stayaligned.trackers.StayAlignedTrackerState -import dev.slimevr.tracking.trackers.Device -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.udp.MagnetometerStatus -import dev.slimevr.tracking.trackers.udp.UDPDevice -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import solarxr_protocol.data_feed.DataFeedUpdate -import solarxr_protocol.data_feed.device_data.DeviceData -import solarxr_protocol.data_feed.device_data.DeviceDataMaskT -import solarxr_protocol.data_feed.stay_aligned.StayAlignedPose -import solarxr_protocol.data_feed.stay_aligned.StayAlignedTracker -import solarxr_protocol.data_feed.tracker.TrackerData -import solarxr_protocol.data_feed.tracker.TrackerDataMaskT -import solarxr_protocol.data_feed.tracker.TrackerInfo -import solarxr_protocol.datatypes.DeviceId -import solarxr_protocol.datatypes.Ipv4Address -import solarxr_protocol.datatypes.Temperature -import solarxr_protocol.datatypes.TrackerId -import solarxr_protocol.datatypes.hardware_info.HardwareInfo -import solarxr_protocol.datatypes.hardware_info.HardwareStatus -import solarxr_protocol.datatypes.math.Quat -import solarxr_protocol.datatypes.math.Vec3f -import java.nio.ByteBuffer -import java.util.function.Consumer - -fun createHardwareInfo(fbb: FlatBufferBuilder, device: Device): Int { - val nameOffset = if (device.firmwareVersion != null) { - fbb.createString(device.firmwareVersion) - } else { - 0 - } - - val manufacturerOffset = if (device.manufacturer != null) { - fbb.createString(device.manufacturer) - } else { - 0 - } - - val firmwareDateOffset = if (device.firmwareDate != null) { - fbb.createString(device.firmwareDate) - } else { - 0 - } - - val hardwareIdentifierOffset = fbb.createString(device.hardwareIdentifier) - - HardwareInfo.startHardwareInfo(fbb) - HardwareInfo.addFirmwareVersion(fbb, nameOffset) - HardwareInfo.addFirmwareDate(fbb, firmwareDateOffset) - HardwareInfo.addManufacturer(fbb, manufacturerOffset) - HardwareInfo.addHardwareIdentifier(fbb, hardwareIdentifierOffset) - - if (device is UDPDevice) { - val address = device.ipAddress.address - HardwareInfo - .addIpAddress( - fbb, - Ipv4Address - .createIpv4Address( - fbb, - ByteBuffer.wrap(address).getInt().toLong(), - ), - ) - - HardwareInfo.addNetworkProtocolVersion(fbb, device.protocolVersion) - } - - // BRUH MOMENT - // TODO need support: HardwareInfo.addHardwareRevision(fbb, - // hardwareRevisionOffset); - // TODO need support: HardwareInfo.addDisplayName(fbb, de); - HardwareInfo.addMcuId(fbb, device.mcuType.getSolarType()) - HardwareInfo.addOfficialBoardType(fbb, device.boardType.getSolarType()) - return HardwareInfo.endHardwareInfo(fbb) -} - -fun createTrackerId(fbb: FlatBufferBuilder, tracker: Tracker): Int { - TrackerId.startTrackerId(fbb) - - TrackerId.addTrackerNum(fbb, tracker.trackerNum) - if (tracker.device != null) { - TrackerId.addDeviceId( - fbb, - DeviceId.createDeviceId(fbb, tracker.device.id), - ) - } - - return TrackerId.endTrackerId(fbb) -} - -fun createVec3(fbb: FlatBufferBuilder, vec: Vector3): Int = Vec3f - .createVec3f( - fbb, - vec.x, - vec.y, - vec.z, - ) - -fun createQuat(fbb: FlatBufferBuilder, quaternion: Quaternion): Int = Quat - .createQuat( - fbb, - quaternion.x, - quaternion.y, - quaternion.z, - quaternion.w, - ) - -fun createTrackerInfos( - fbb: FlatBufferBuilder, - infoMask: Boolean, - tracker: Tracker, -): Int { - if (!infoMask) return 0 - - val displayNameOffset = fbb.createString(tracker.displayName) - val customNameOffset = if (tracker.customName != null) { - fbb.createString(tracker.customName) - } else { - 0 - } - - TrackerInfo.startTrackerInfo(fbb) - if (tracker.trackerPosition != null) { - TrackerInfo.addBodyPart( - fbb, - tracker.trackerPosition!!.bodyPart, - ) - } - TrackerInfo.addEditable(fbb, tracker.userEditable) - TrackerInfo.addIsComputed(fbb, tracker.isComputed) - TrackerInfo.addDisplayName(fbb, displayNameOffset) - TrackerInfo.addCustomName(fbb, customNameOffset) - if (tracker.imuType != null) { - TrackerInfo.addImuType(fbb, tracker.imuType.getSolarType()) - } - - // TODO need support: TrackerInfo.addPollRate(fbb, tracker.); - if (tracker.isImu()) { - TrackerInfo.addIsImu(fbb, true) - TrackerInfo - .addAllowDriftCompensation( - fbb, - tracker.resetsHandler.allowDriftCompensation, - ) - } else { - TrackerInfo.addIsImu(fbb, false) - TrackerInfo.addAllowDriftCompensation(fbb, false) - } - - if (tracker.allowMounting) { - val quaternion = tracker.resetsHandler.mountingOrientation - val mountResetFix = tracker.resetsHandler.mountRotFix - TrackerInfo.addMountingOrientation(fbb, createQuat(fbb, quaternion)) - TrackerInfo.addMountingResetOrientation(fbb, createQuat(fbb, mountResetFix)) - } - - TrackerInfo.addMagnetometer(fbb, tracker.magStatus.getSolarType()) - TrackerInfo.addIsHmd(fbb, tracker.isHmd) - - TrackerInfo.addDataSupport(fbb, tracker.trackerDataType.getSolarType()) - - return TrackerInfo.endTrackerInfo(fbb) -} - -fun createTrackerPosition(fbb: FlatBufferBuilder, tracker: Tracker): Int = createVec3(fbb, tracker.position) - -fun createTrackerRotation(fbb: FlatBufferBuilder, tracker: Tracker): Int = createQuat(fbb, tracker.getRawRotation()) - -fun createTrackerAcceleration(fbb: FlatBufferBuilder, tracker: Tracker): Int = createVec3(fbb, tracker.getAcceleration()) - -fun createTrackerMagneticVector(fbb: FlatBufferBuilder, tracker: Tracker): Int = createVec3(fbb, tracker.getMagVector()) - -fun createTrackerTemperature(fbb: FlatBufferBuilder, tracker: Tracker): Int { - if (tracker.temperature == null) return 0 - return Temperature.createTemperature(fbb, tracker.temperature!!) -} - -fun createTrackerData( - fbb: FlatBufferBuilder, - mask: TrackerDataMaskT, - tracker: Tracker, -): Int { - val trackerInfosOffset = createTrackerInfos(fbb, mask.info, tracker) - val trackerIdOffset = createTrackerId(fbb, tracker) - - var stayAlignedOffset = 0 - if (mask.stayAligned) { - stayAlignedOffset = - createTrackerStayAlignedTracker(fbb, tracker.stayAligned) - } - - TrackerData.startTrackerData(fbb) - - TrackerData.addTrackerId(fbb, trackerIdOffset) - - if (trackerInfosOffset != 0) TrackerData.addInfo(fbb, trackerInfosOffset) - if (mask.status) TrackerData.addStatus(fbb, tracker.status.id + 1) - if (mask.position && tracker.hasPosition) { - TrackerData.addPosition( - fbb, - createTrackerPosition(fbb, tracker), - ) - } - if (mask.rotation && tracker.hasRotation) { - TrackerData.addRotation( - fbb, - createTrackerRotation(fbb, tracker), - ) - } - if (mask.linearAcceleration && tracker.hasAcceleration) { - TrackerData - .addLinearAcceleration( - fbb, - createTrackerAcceleration(fbb, tracker), - ) - } - if (mask.temp) { - val trackerTemperatureOffset = createTrackerTemperature(fbb, tracker) - if (trackerTemperatureOffset != 0) { - TrackerData.addTemp( - fbb, - trackerTemperatureOffset, - ) - } - } - if (tracker.allowMounting && tracker.hasRotation) { - if (mask.rotationReferenceAdjusted) { - TrackerData - .addRotationReferenceAdjusted( - fbb, - createQuat(fbb, tracker.getRotation()), - ) - } - if (mask.rotationIdentityAdjusted) { - TrackerData - .addRotationIdentityAdjusted( - fbb, - createQuat(fbb, tracker.getIdentityAdjustedRotation()), - ) - } - } else if (tracker.allowReset && tracker.hasRotation) { - if (mask.rotationReferenceAdjusted) { - TrackerData - .addRotationReferenceAdjusted( - fbb, - createQuat(fbb, tracker.getRotation()), - ) - } - if (mask.rotationIdentityAdjusted) { - TrackerData - .addRotationIdentityAdjusted( - fbb, - createQuat(fbb, tracker.getRawRotation()), - ) - } - } - if (mask.tps) { - TrackerData.addTps(fbb, tracker.tps.toInt()) - } - if (mask.rawMagneticVector && tracker.magStatus == MagnetometerStatus.ENABLED) { - TrackerData.addRawMagneticVector( - fbb, - createTrackerMagneticVector(fbb, tracker), - ) - } - if (mask.stayAligned) { - TrackerData.addStayAligned(fbb, stayAlignedOffset) - } - - return TrackerData.endTrackerData(fbb) -} - -fun createTrackersData( - fbb: FlatBufferBuilder, - mask: DeviceDataMaskT, - device: Device, -): Int { - if (mask.trackerData == null) return 0 - - val trackersOffsets: MutableList = ArrayList() - - device - .trackers - .forEach { (_: Int, value: Tracker) -> - trackersOffsets - .add(createTrackerData(fbb, mask.trackerData, value)) - } - - DeviceData.startTrackersVector(fbb, trackersOffsets.size) - trackersOffsets.forEach( - Consumer { offset: Int -> - DeviceData.addTrackers( - fbb, - offset, - ) - }, - ) - return fbb.endVector() -} - -fun createDeviceData( - fbb: FlatBufferBuilder, - id: Int, - mask: DeviceDataMaskT, - device: Device, -): Int { - if (!mask.deviceData) return 0 - - if (device.trackers.isEmpty()) return 0 - - var firstTracker = device.trackers[0] - if (firstTracker == null) { - // Not actually the "first" tracker, but do we care? - firstTracker = device.trackers.entries.iterator().next().value - } - - val tracker: Tracker = firstTracker - - HardwareStatus.startHardwareStatus(fbb) - HardwareStatus.addErrorStatus(fbb, tracker.status.id) - - if (tracker.batteryVoltage != null) { - HardwareStatus.addBatteryVoltage(fbb, tracker.batteryVoltage!!) - } - if (tracker.batteryLevel != null) { - HardwareStatus.addBatteryPctEstimate(fbb, tracker.batteryLevel!!.toInt()) - } - if (tracker.ping != null) { - HardwareStatus.addPing(fbb, tracker.ping!!) - } - if (tracker.signalStrength != null) { - HardwareStatus.addRssi(fbb, tracker.signalStrength!!.toShort()) - } - if (tracker.packetLoss != null) { - HardwareStatus.addPacketLoss(fbb, tracker.packetLoss!!) - } - if (tracker.packetsLost != null) { - HardwareStatus.addPacketsLost(fbb, tracker.packetsLost!!) - } - if (tracker.packetsReceived != null) { - HardwareStatus.addPacketsReceived(fbb, tracker.packetsReceived!!) - } - if (tracker.batteryRemainingRuntime != null) { - HardwareStatus.addBatteryRuntimeEstimate(fbb, tracker.batteryRemainingRuntime!!) - } - - val hardwareDataOffset = HardwareStatus.endHardwareStatus(fbb) - val hardwareInfoOffset = createHardwareInfo(fbb, device) - val trackersOffset = createTrackersData(fbb, mask, device) - - val nameOffset = if (device.name != null) { - fbb.createString(device.name) - } else { - 0 - } - - DeviceData.startDeviceData(fbb) - DeviceData.addCustomName(fbb, nameOffset) - DeviceData.addId(fbb, DeviceId.createDeviceId(fbb, id)) - DeviceData.addHardwareStatus(fbb, hardwareDataOffset) - DeviceData.addHardwareInfo(fbb, hardwareInfoOffset) - DeviceData.addTrackers(fbb, trackersOffset) - - return DeviceData.endDeviceData(fbb) -} - -fun createSyntheticTrackersData( - fbb: FlatBufferBuilder, - trackerDataMaskT: TrackerDataMaskT?, - trackers: MutableList, -): Int { - if (trackerDataMaskT == null) return 0 - - val trackerOffsets: MutableList = ArrayList() - - trackers - .forEach( - Consumer { tracker: Tracker -> - trackerOffsets - .add(createTrackerData(fbb, trackerDataMaskT, tracker)) - }, - ) - - DataFeedUpdate.startSyntheticTrackersVector(fbb, trackerOffsets.size) - trackerOffsets.forEach( - ( - Consumer { tracker: Int -> - DataFeedUpdate.addSyntheticTrackers( - fbb, - tracker, - ) - } - ), - ) - return fbb.endVector() -} - -fun createDevicesData( - fbb: FlatBufferBuilder, - deviceDataMaskT: DeviceDataMaskT?, - devices: MutableList, -): Int { - if (deviceDataMaskT == null) return 0 - - val devicesDataOffsets = IntArray(devices.size) - for (i in devices.indices) { - val device = devices[i] - devicesDataOffsets[i] = - createDeviceData(fbb, device.id, deviceDataMaskT, device) - } - - return DataFeedUpdate.createDevicesVector(fbb, devicesDataOffsets) -} - -fun createBonesData( - fbb: FlatBufferBuilder, - shouldSend: Boolean, - bones: MutableList, -): Int { - if (!shouldSend) { - return 0 - } - - val boneOffsets = IntArray(bones.size) - for (i in bones.indices) { - val bi = bones[i] - - val headPosG = - bi.getPosition() - val rotG = - bi.getGlobalRotation() - val length = bi.length - - solarxr_protocol.data_feed.Bone.startBone(fbb) - - val rotGOffset = createQuat(fbb, rotG) - solarxr_protocol.data_feed.Bone.addRotationG(fbb, rotGOffset) - val headPosGOffset = Vec3f - .createVec3f(fbb, headPosG.x, headPosG.y, headPosG.z) - solarxr_protocol.data_feed.Bone.addHeadPositionG(fbb, headPosGOffset) - solarxr_protocol.data_feed.Bone.addBodyPart(fbb, bi.boneType.bodyPart) - solarxr_protocol.data_feed.Bone.addBoneLength(fbb, length) - - boneOffsets[i] = solarxr_protocol.data_feed.Bone.endBone(fbb) - } - - return DataFeedUpdate.createBonesVector(fbb, boneOffsets) -} - -fun createStayAlignedPose( - fbb: FlatBufferBuilder, - humanSkeleton: HumanSkeleton, -): Int { - val relaxedPose = RelaxedPose.fromTrackers(humanSkeleton) - - StayAlignedPose.startStayAlignedPose(fbb) - - StayAlignedPose.addUpperLegAngleInDeg(fbb, relaxedPose.upperLeg.toDeg()) - StayAlignedPose.addLowerLegAngleInDeg(fbb, relaxedPose.lowerLeg.toDeg()) - StayAlignedPose.addFootAngleInDeg(fbb, relaxedPose.foot.toDeg()) - - return StayAlignedPose.endStayAlignedPose(fbb) -} - -fun createTrackerStayAlignedTracker( - fbb: FlatBufferBuilder, - state: StayAlignedTrackerState, -): Int { - StayAlignedTracker.startStayAlignedTracker(fbb) - - StayAlignedTracker.addYawCorrectionInDeg(fbb, state.yawCorrection.toDeg()) - StayAlignedTracker.addLockedErrorInDeg( - fbb, - state.yawErrors.lockedError.toL2Norm().toDeg(), - ) - StayAlignedTracker.addCenterErrorInDeg( - fbb, - state.yawErrors.centerError.toL2Norm().toDeg(), - ) - StayAlignedTracker.addNeighborErrorInDeg( - fbb, - state.yawErrors.neighborError.toL2Norm().toDeg(), - ) - StayAlignedTracker.addLocked( - fbb, - state.restDetector.state == RestDetector.State.AT_REST, - ) - - return StayAlignedTracker.endStayAlignedTracker(fbb) -} - -fun createServerGuard(fbb: FlatBufferBuilder, serverGuards: ServerGuards): Int = solarxr_protocol.data_feed.server.ServerGuards.createServerGuards( - fbb, - serverGuards.canDoMounting, - serverGuards.canDoYawReset, - serverGuards.canDoUserHeightCalibration, -) diff --git a/server/core/src/main/java/dev/slimevr/protocol/datafeed/DataFeedHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/datafeed/DataFeedHandler.kt deleted file mode 100644 index dfb3d5dd6f..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/datafeed/DataFeedHandler.kt +++ /dev/null @@ -1,177 +0,0 @@ -package dev.slimevr.protocol.datafeed - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.protocol.DataFeed -import dev.slimevr.protocol.GenericConnection -import dev.slimevr.protocol.ProtocolAPI -import dev.slimevr.protocol.ProtocolAPIServer -import dev.slimevr.protocol.ProtocolHandler -import dev.slimevr.tracking.trackers.Tracker -import io.eiren.util.logging.LogManager -import solarxr_protocol.MessageBundle -import solarxr_protocol.data_feed.DataFeedConfigT -import solarxr_protocol.data_feed.DataFeedMessage -import solarxr_protocol.data_feed.DataFeedMessageHeader -import solarxr_protocol.data_feed.DataFeedUpdate -import solarxr_protocol.data_feed.PollDataFeed -import solarxr_protocol.data_feed.StartDataFeed -import java.util.function.Consumer -import java.util.stream.Collectors - -class DataFeedHandler(private val api: ProtocolAPI) : ProtocolHandler() { - init { - registerPacketListener(DataFeedMessage.StartDataFeed, ::onStartDataFeed) - registerPacketListener(DataFeedMessage.PollDataFeed, ::onPollDataFeedRequest) - this.api.server.addOnTick { this.sendDataFeedUpdate() } - } - - private fun onStartDataFeed(conn: GenericConnection, header: DataFeedMessageHeader) { - val req = header.message(StartDataFeed()) as StartDataFeed? ?: return - val dataFeeds = req.dataFeedsLength() - - val feedList = conn.context.dataFeedList - synchronized(feedList) { - feedList.clear() - for (i in 0.. - server.apiConnections.forEach { conn: GenericConnection -> - var fbb: FlatBufferBuilder? = null - val feedList = conn.context.dataFeedList - synchronized(feedList) { - val configsCount = feedList.size - val data = IntArray(configsCount) - for (index in 0.. configT.minimumTimeSinceLast) { - if (fbb == null) { - // That way we create a buffer only when needed - fbb = FlatBufferBuilder(300) - } - - val messageOffset = this.buildDatafeed(fbb, configT, index) - - DataFeedMessageHeader.startDataFeedMessageHeader(fbb) - DataFeedMessageHeader.addMessage(fbb, messageOffset) - DataFeedMessageHeader.addMessageType(fbb, DataFeedMessage.DataFeedUpdate) - data[index] = DataFeedMessageHeader.endDataFeedMessageHeader(fbb) - - feed.timeLastSent = currTime - val messages = MessageBundle.createDataFeedMsgsVector(fbb, data) - val packet = createMessage(fbb, messages) - fbb.finish(packet) - conn.send(fbb.dataBuffer()) - } - } - } - } - }, - ) - } - - override fun onMessage(conn: GenericConnection, message: DataFeedMessageHeader) { - val consumer = this.handlers[message.messageType().toInt()] - if (consumer != null) { - consumer.accept(conn, message) - } else { - LogManager - .info( - "[ProtocolAPI] Unhandled Datafeed packet received id: " + message.messageType(), - ) - } - } - - override fun messagesCount(): Int = DataFeedMessage.names.size - - fun createMessage(fbb: FlatBufferBuilder, datafeedMessagesOffset: Int): Int { - MessageBundle.startMessageBundle(fbb) - if (datafeedMessagesOffset > -1) MessageBundle.addDataFeedMsgs(fbb, datafeedMessagesOffset) - return MessageBundle.endMessageBundle(fbb) - } -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/pubsub/HashedTopicId.kt b/server/core/src/main/java/dev/slimevr/protocol/pubsub/HashedTopicId.kt deleted file mode 100644 index 44fccae153..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/pubsub/HashedTopicId.kt +++ /dev/null @@ -1,29 +0,0 @@ -package dev.slimevr.protocol.pubsub - -import solarxr_protocol.pub_sub.TopicIdT - -// This class is so the HashMap referencing the TopicId as key works -// it needs a unique hashcode based on the topicId and also an equals function -// because equals hashcode does not mean equals strings -class HashedTopicId(val inner: TopicIdT) { - private val hashcode: Int = ( - ( - inner.appName + - "." + - inner.organization + - "." + - inner.topic - ) - ).hashCode() - - override fun hashCode(): Int = hashcode - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || javaClass != other.javaClass) return false - val that = other as HashedTopicId - return inner.organization == that.inner.organization && - inner.appName == that.inner.appName && - inner.topic == that.inner.topic - } -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/pubsub/PubSubHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/pubsub/PubSubHandler.kt deleted file mode 100644 index fe9d24f8d0..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/pubsub/PubSubHandler.kt +++ /dev/null @@ -1,170 +0,0 @@ -package dev.slimevr.protocol.pubsub - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.protocol.GenericConnection -import dev.slimevr.protocol.ProtocolAPI -import dev.slimevr.protocol.ProtocolAPIServer -import dev.slimevr.protocol.ProtocolHandler -import io.eiren.util.logging.LogManager -import solarxr_protocol.MessageBundle -import solarxr_protocol.pub_sub.Message -import solarxr_protocol.pub_sub.PubSubHeader -import solarxr_protocol.pub_sub.PubSubUnion -import solarxr_protocol.pub_sub.SubscriptionRequest -import solarxr_protocol.pub_sub.Topic -import solarxr_protocol.pub_sub.TopicHandle -import solarxr_protocol.pub_sub.TopicHandleRequest -import solarxr_protocol.pub_sub.TopicId -import solarxr_protocol.pub_sub.TopicIdT -import solarxr_protocol.pub_sub.TopicMapping -import java.util.concurrent.atomic.AtomicInteger -import java.util.function.Consumer - -class PubSubHandler(private val api: ProtocolAPI) : ProtocolHandler() { - // Two ways maps for faster reading when handling lots of packets - var topicsHandle: HashMap = HashMap() - var handleTopics: HashMap = HashMap() - - var nextLocalHandle: AtomicInteger = AtomicInteger() - - init { - registerPacketListener(PubSubUnion.SubscriptionRequest, ::onSubscriptionRequest) - registerPacketListener(PubSubUnion.TopicHandleRequest, ::onTopicHandleRequest) - registerPacketListener(PubSubUnion.Message, ::onTopicMessage) - } - - private fun getTopicHandle(topicIdT: TopicIdT): Int { - val hashedTopicId = HashedTopicId(topicIdT) - var handleT = topicsHandle.get(hashedTopicId) - // if no handle exists for this topic id we create one and return it - // anyway - if (handleT == null) { - handleT = nextLocalHandle.incrementAndGet() - topicsHandle[hashedTopicId] = handleT - handleTopics[handleT] = hashedTopicId - } - - return handleT - } - - fun onSubscriptionRequest(conn: GenericConnection, messageHeader: PubSubHeader) { - val req = - messageHeader.u(SubscriptionRequest()) as SubscriptionRequest? ?: return - - var subHandle = -1 - if (req.topicType() == Topic.TopicHandle) { - val handle = req.topic(TopicHandle()) as TopicHandle? - if (handle != null && handleTopics.containsKey(handle.id())) subHandle = handle.id() - } else if (req.topicType() == Topic.TopicId) { - val topicId = req.topic(TopicId()) as TopicId? - if (topicId != null) subHandle = getTopicHandle(topicId.unpack()) - } - - assert(subHandle != -1) - - val finalSubHandle = subHandle - val first = conn - .context - .subscribedTopics - .stream() - .filter { handle: Int -> handle == finalSubHandle } - .findFirst() - if (!first.isPresent) { - conn.context.subscribedTopics.add(finalSubHandle) - } - - val fbb = FlatBufferBuilder(32) - val topicIdOffset = TopicId.pack(fbb, handleTopics.get(finalSubHandle)!!.inner) - val topicHandleOffset = TopicHandle.createTopicHandle(fbb, finalSubHandle) - - val outbound = createMessage( - fbb, - PubSubUnion.TopicMapping, - TopicMapping.createTopicMapping(fbb, topicIdOffset, topicHandleOffset), - ) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - - fun onTopicHandleRequest(conn: GenericConnection, messageHeader: PubSubHeader) { - val req = messageHeader.u(TopicHandleRequest()) as TopicHandleRequest? ?: return - - val topicRequest = req.unpack() - val handle = getTopicHandle(topicRequest.id) - - val fbb = FlatBufferBuilder(32) - val topicIdOffset = TopicId.pack(fbb, topicRequest.id) - val topicHandleOffset = TopicHandle.createTopicHandle(fbb, handle) - - val outbound = createMessage( - fbb, - PubSubUnion.TopicMapping, - TopicMapping.createTopicMapping(fbb, topicIdOffset, topicHandleOffset), - ) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - - fun onTopicMessage(c: GenericConnection, messageHeader: PubSubHeader) { - val req = messageHeader.u(Message()) as Message? ?: return - - val messageT = req.unpack() - - var subHandle = 1 - if (messageT.topic.type == Topic.TopicHandle) { - subHandle = messageT.topic.asTopicHandle().id - } else if (messageT.topic.type == Topic.TopicId) { - subHandle = getTopicHandle(messageT.topic.asTopicId()) - } - - assert(subHandle != -1) - - val finalSubHandle = subHandle - - this.api.apiServers.forEach( - Consumer { server: ProtocolAPIServer -> - server.apiConnections.forEach { conn: GenericConnection -> - // Make sure that we are not sending a message to ourselves - // And check that the receiver has subscribed to the topic - if (conn.connectionId != c.connectionId && - conn.context.subscribedTopics - .contains(finalSubHandle) - ) { - val fbb = FlatBufferBuilder(32) - val outbound = createMessage( - fbb, - PubSubUnion.Message, - Message.pack(fbb, messageT), - ) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - } - }, - ) - } - - override fun onMessage(conn: GenericConnection, message: PubSubHeader) { - val consumer = this.handlers[message.uType().toInt()] - if (consumer != null) { - consumer.accept(conn, message) - } else { - LogManager - .info("[ProtocolAPI] Unhandled PubSub packet received id: " + message.uType()) - } - } - - override fun messagesCount(): Int = PubSubUnion.names.size - - fun createMessage(fbb: FlatBufferBuilder, messageType: Byte, messageOffset: Int): Int { - val data = IntArray(1) - - data[0] = PubSubHeader.createPubSubHeader(fbb, messageType, messageOffset) - - val messages = MessageBundle.createPubSubMsgsVector(fbb, data) - - MessageBundle.startMessageBundle(fbb) - MessageBundle.addPubSubMsgs(fbb, messages) - return MessageBundle.endMessageBundle(fbb) - } -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCBuilder.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCBuilder.kt deleted file mode 100644 index 89bb3be931..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCBuilder.kt +++ /dev/null @@ -1,25 +0,0 @@ -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 - -fun createSkeletonConfig( - fbb: FlatBufferBuilder, - humanPoseManager: HumanPoseManager, -): Int { - val partsOffsets = IntArray(SkeletonConfigOffsets.entries.size) - - for (index in SkeletonConfigOffsets.entries.toTypedArray().indices) { - val `val` = SkeletonConfigOffsets.values[index] - val part = SkeletonPart - .createSkeletonPart(fbb, `val`.id, humanPoseManager.getOffset(`val`)) - partsOffsets[index] = part - } - - val parts = SkeletonConfigResponse.createSkeletonPartsVector(fbb, partsOffsets) - val userHeight = humanPoseManager.userHeightFromConfig - return SkeletonConfigResponse.createSkeletonConfigResponse(fbb, parts, userHeight) -} 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 deleted file mode 100644 index d1c63d76f0..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCHandler.kt +++ /dev/null @@ -1,616 +0,0 @@ -package dev.slimevr.protocol.rpc - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.config.MountingMethods -import dev.slimevr.config.config -import dev.slimevr.protocol.GenericConnection -import dev.slimevr.protocol.ProtocolAPI -import dev.slimevr.protocol.ProtocolHandler -import dev.slimevr.protocol.datafeed.createTrackerId -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 -import dev.slimevr.protocol.rpc.settings.RPCSettingsHandler -import dev.slimevr.protocol.rpc.settings.createSettingsResponse -import dev.slimevr.protocol.rpc.setup.RPCHandshakeHandler -import dev.slimevr.protocol.rpc.setup.RPCTapSetupHandler -import dev.slimevr.protocol.rpc.setup.RPCUtil.getLocalIp -import dev.slimevr.protocol.rpc.status.RPCStatusHandler -import dev.slimevr.protocol.rpc.trackingchecklist.RPCTrackingChecklistHandler -import dev.slimevr.protocol.rpc.trackingpause.RPCTrackingPause -import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets -import dev.slimevr.tracking.processor.stayaligned.poses.RelaxedPose -import dev.slimevr.tracking.trackers.TrackerPosition -import dev.slimevr.tracking.trackers.TrackerPosition.Companion.getByBodyPart -import dev.slimevr.tracking.trackers.TrackerStatus -import dev.slimevr.tracking.trackers.TrackerUtils.getTrackerForSkeleton -import io.eiren.util.logging.LogManager -import io.github.axisangles.ktmath.Quaternion -import kotlinx.coroutines.* -import solarxr_protocol.MessageBundle -import solarxr_protocol.datatypes.TransactionId -import solarxr_protocol.rpc.* -import kotlin.io.path.Path - -class RPCHandler(private val api: ProtocolAPI) : ProtocolHandler() { - private val mainScope = CoroutineScope(SupervisorJob()) - - init { - RPCResetHandler(this, api) - RPCSerialHandler(this, api) - RPCProvisioningHandler(this, api) - RPCSettingsHandler(this, api) - RPCTapSetupHandler(this, api) - RPCStatusHandler(this, api) - RPCAutoBoneHandler(this, api) - RPCHandshakeHandler(this, api) - RPCTrackingPause(this, api) - RPCFirmwareUpdateHandler(this, api) - RPCVRChatHandler(this, api) - RPCTrackingChecklistHandler(this, api) - RPCUserHeightCalibration(this, api) - - registerPacketListener( - RpcMessage.AssignTrackerRequest, - ::onAssignTrackerRequest, - ) - - registerPacketListener( - RpcMessage.ClearDriftCompensationRequest, - ::onClearDriftCompensationRequest, - ) - - registerPacketListener( - RpcMessage.RecordBVHRequest, - ::onRecordBVHRequest, - ) - - registerPacketListener( - RpcMessage.RecordBVHStatusRequest, - ::onBVHStatusRequest, - ) - - registerPacketListener( - RpcMessage.SkeletonResetAllRequest, - ::onSkeletonResetAllRequest, - ) - registerPacketListener( - RpcMessage.SkeletonConfigRequest, - ::onSkeletonConfigRequest, - ) - registerPacketListener( - RpcMessage.ChangeSkeletonConfigRequest, - ::onChangeSkeletonConfigRequest, - ) - - registerPacketListener( - RpcMessage.OverlayDisplayModeChangeRequest, - ::onOverlayDisplayModeChangeRequest, - ) - registerPacketListener( - RpcMessage.OverlayDisplayModeRequest, - ::onOverlayDisplayModeRequest, - ) - - registerPacketListener( - RpcMessage.ServerInfosRequest, - ::onServerInfosRequest, - ) - - registerPacketListener( - RpcMessage.LegTweaksTmpChange, - ::onLegTweaksTmpChange, - ) - - registerPacketListener( - RpcMessage.LegTweaksTmpClear, - ::onLegTweaksTmpClear, - ) - - registerPacketListener( - RpcMessage.StatusSystemRequest, - ::onStatusSystemRequest, - ) - - registerPacketListener( - RpcMessage.SetPauseTrackingRequest, - ::onSetPauseTrackingRequest, - ) - - registerPacketListener( - RpcMessage.HeightRequest, - ::onHeightRequest, - ) - - registerPacketListener( - RpcMessage.MagToggleRequest, - ::onMagToggleRequest, - ) - - registerPacketListener( - RpcMessage.ChangeMagToggleRequest, - ::onChangeMagToggleRequest, - ) - - registerPacketListener( - RpcMessage.EnableStayAlignedRequest, - ::onEnableStayAlignedRequest, - ) - - registerPacketListener( - RpcMessage.DetectStayAlignedRelaxedPoseRequest, - ::onDetectStayAlignedRelaxedPoseRequest, - ) - - registerPacketListener( - RpcMessage.ResetStayAlignedRelaxedPoseRequest, - ::onResetStayAlignedRelaxedPoseRequest, - ) - } - - private fun onServerInfosRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val fbb = FlatBufferBuilder(32) - - val localIp = getLocalIp() ?: return - val response = ServerInfosResponse - .createServerInfosResponse(fbb, fbb.createString(localIp)) - val outbound = this.createRPCMessage(fbb, RpcMessage.ServerInfosResponse, response, messageHeader) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - - private fun onOverlayDisplayModeRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val fbb = FlatBufferBuilder(32) - val config = api.server.configManager.vrConfig.overlay - val response = OverlayDisplayModeResponse - .createOverlayDisplayModeResponse(fbb, config.isVisible, config.isMirrored) - val outbound = this.createRPCMessage(fbb, RpcMessage.OverlayDisplayModeResponse, response, messageHeader) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - - private fun onOverlayDisplayModeChangeRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val req = messageHeader - .message(OverlayDisplayModeChangeRequest()) as? OverlayDisplayModeChangeRequest ?: return - val config = api.server.configManager.vrConfig.overlay - config.isMirrored = req.isMirrored - config.isVisible = req.isVisible - - api.server.configManager.saveConfig() - } - - fun onSkeletonResetAllRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - if (messageHeader - .message(SkeletonResetAllRequest()) !is SkeletonResetAllRequest - ) { - return - } - - api.server.queueTask { - api.server.humanPoseManager.resetOffsets() - api.server.humanPoseManager.saveConfig() - api.server.configManager.saveConfig() - - api.server.trackingChecklistManager.resetMountingCompleted = false - api.server.trackingChecklistManager.feetResetMountingCompleted = false - - // might not be a good idea maybe let the client ask again - val fbb = FlatBufferBuilder(300) - val config = createSkeletonConfig(fbb, api.server.humanPoseManager) - val outbound = this.createRPCMessage(fbb, RpcMessage.SkeletonConfigResponse, config, messageHeader) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - } - - fun onSkeletonConfigRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - if (messageHeader - .message(SkeletonConfigRequest()) !is SkeletonConfigRequest - ) { - return - } - - val fbb = FlatBufferBuilder(300) - val config = createSkeletonConfig(fbb, api.server.humanPoseManager) - val outbound = this.createRPCMessage(fbb, RpcMessage.SkeletonConfigResponse, config, messageHeader) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - - fun onChangeSkeletonConfigRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val req = messageHeader - .message(ChangeSkeletonConfigRequest()) as? ChangeSkeletonConfigRequest ?: return - - val joint = SkeletonConfigOffsets.getById(req.bone()) - - api.server.humanPoseManager.setOffset(joint, req.value()) - api.server.humanPoseManager.saveConfig() - api.server.configManager.saveConfig() - } - - fun onRecordBVHRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val req = messageHeader.message(RecordBVHRequest()) as? RecordBVHRequest ?: return - - if (req.stop()) { - if (api.server.bvhRecorder.isRecording) { - api.server.bvhRecorder.endRecording() - } - } else { - if (!api.server.bvhRecorder.isRecording) { - api.server.bvhRecorder.startRecording(Path(req.path())) - } - } - - val fbb = FlatBufferBuilder(40) - val status = RecordBVHStatus - .createRecordBVHStatus(fbb, api.server.bvhRecorder.isRecording) - val outbound = this.createRPCMessage(fbb, RpcMessage.RecordBVHStatus, status, messageHeader) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - - fun onBVHStatusRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - if (messageHeader.message(RecordBVHStatusRequest()) !is RecordBVHStatusRequest) return - - val fbb = FlatBufferBuilder(40) - val status = RecordBVHStatus - .createRecordBVHStatus(fbb, api.server.bvhRecorder.isRecording) - val outbound = this.createRPCMessage(fbb, RpcMessage.RecordBVHStatus, status, messageHeader) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - - fun onAssignTrackerRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val req = messageHeader - .message(AssignTrackerRequest()) as? AssignTrackerRequest ?: return - - val tracker = api.server.getTrackerById(req.trackerId().unpack()) ?: return - - val pos = getByBodyPart(req.bodyPosition()) - val previousTracker = if (pos != null) { - getTrackerForSkeleton(api.server.allTrackers, pos) - } else { - null - } - if (previousTracker != null) { - previousTracker.trackerPosition = null - api.server.trackerUpdated(previousTracker) - } - tracker.trackerPosition = pos - - if (req.mountingOrientation() != null) { - if (tracker.allowMounting) { - tracker - .resetsHandler - .mountingOrientation = Quaternion( - req.mountingOrientation().w(), - req.mountingOrientation().x(), - req.mountingOrientation().y(), - req.mountingOrientation().z(), - ) - api.server.configManager.vrConfig.resetsConfig.lastMountingMethod = - MountingMethods.MANUAL - } - } - - if (req.displayName() != null) { - tracker.customName = req.displayName() - } - - if (tracker.isImu()) { - tracker.resetsHandler.allowDriftCompensation = req.allowDriftCompensation() - } - - api.server.trackerUpdated(tracker) - } - - fun onClearDriftCompensationRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - if (messageHeader - .message(ClearDriftCompensationRequest()) !is ClearDriftCompensationRequest - ) { - return - } - - api.server.clearTrackersDriftCompensation() - } - - fun onLegTweaksTmpChange( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val req = messageHeader - .message(LegTweaksTmpChange()) as? LegTweaksTmpChange ?: return - - api.server.humanPoseManager - .setLegTweaksStateTemp( - req.skatingCorrection(), - req.floorClip(), - req.toeSnap(), - req.footPlant(), - ) - } - - fun onLegTweaksTmpClear( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val req = messageHeader - .message(LegTweaksTmpClear()) as? LegTweaksTmpClear ?: return - - api.server.humanPoseManager - .clearLegTweaksStateTemp( - req.skatingCorrection(), - req.floorClip(), - req.toeSnap(), - req.footPlant(), - ) - } - - override fun onMessage(conn: GenericConnection, message: RpcMessageHeader) { - val consumer = handlers[message.messageType().toInt()] - if (consumer != null) { - consumer.accept(conn, message) - } else { - LogManager - .info("[ProtocolAPI] Unhandled RPC packet received id: ${message.messageType()}") - } - } - - @JvmOverloads - fun createRPCMessage(fbb: FlatBufferBuilder, messageType: Byte, messageOffset: Int, respondTo: RpcMessageHeader? = null): Int { - val data = IntArray(1) - - RpcMessageHeader.startRpcMessageHeader(fbb) - RpcMessageHeader.addMessage(fbb, messageOffset) - RpcMessageHeader.addMessageType(fbb, messageType) - respondTo?.txId()?.let { txId -> - RpcMessageHeader.addTxId(fbb, TransactionId.createTransactionId(fbb, txId.id())) - } - data[0] = RpcMessageHeader.endRpcMessageHeader(fbb) - - val messages = MessageBundle.createRpcMsgsVector(fbb, data) - - MessageBundle.startMessageBundle(fbb) - MessageBundle.addRpcMsgs(fbb, messages) - return MessageBundle.endMessageBundle(fbb) - } - - override fun messagesCount(): Int = RpcMessage.names.size - - fun onStatusSystemRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val req = messageHeader - .message(StatusSystemRequest()) as? StatusSystemRequest ?: return - - val statuses = api.server.statusSystem.getStatuses() - - val fbb = FlatBufferBuilder( - statuses.size * RPCStatusHandler.STATUS_EXPECTED_SIZE, - ) - val response = StatusSystemResponseT() - response.currentStatuses = statuses - val offset = StatusSystemResponse.pack(fbb, response) - val outbound = this.createRPCMessage(fbb, RpcMessage.StatusSystemResponse, offset, messageHeader) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - - fun onSetPauseTrackingRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val req = messageHeader - .message(SetPauseTrackingRequest()) as? SetPauseTrackingRequest ?: return - - api.server.setPauseTracking(req.pauseTracking(), RPCResetHandler.RESET_SOURCE_NAME) - } - - fun onHeightRequest(conn: GenericConnection, messageHeader: RpcMessageHeader?) { - val fbb = FlatBufferBuilder(32) - - val posTrackers = api.server.allTrackers.filter { !it.isInternal && it.status == TrackerStatus.OK && it.hasPosition && it.trackerPosition != null } - val response = if (posTrackers.isNotEmpty()) { - HeightResponse - .createHeightResponse( - fbb, - posTrackers.minOf { it.position.y }, - posTrackers.find { it.trackerPosition == TrackerPosition.HEAD }?.position?.y - ?: posTrackers.maxOf { it.position.y }, - ) - } else { - HeightResponse - .createHeightResponse( - fbb, - 0f, - 0f, - ) - } - fbb.finish(createRPCMessage(fbb, RpcMessage.HeightResponse, response, messageHeader)) - conn.send(fbb.dataBuffer()) - } - - fun onMagToggleRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val req = messageHeader - .message(MagToggleRequest()) as? MagToggleRequest ?: return - val fbb = FlatBufferBuilder(32) - - if (req.trackerId() == null) { - val response = MagToggleResponse.createMagToggleResponse( - fbb, - 0, - api.server.configManager.vrConfig.server.useMagnetometerOnAllTrackers, - ) - fbb.finish(createRPCMessage(fbb, RpcMessage.MagToggleResponse, response, messageHeader)) - conn.send(fbb.dataBuffer()) - return - } - - val tracker = api.server.getTrackerById(req.trackerId().unpack()) ?: return - val trackerId = createTrackerId(fbb, tracker) - val response = MagToggleResponse.createMagToggleResponse( - fbb, - trackerId, - tracker.config.shouldHaveMagEnabled == true, - ) - fbb.finish(createRPCMessage(fbb, RpcMessage.MagToggleResponse, response, messageHeader)) - conn.send(fbb.dataBuffer()) - } - - fun onChangeMagToggleRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val req = messageHeader - .message(ChangeMagToggleRequest()) as? ChangeMagToggleRequest ?: return - - if (req.trackerId() == null) { - mainScope.launch { - withTimeoutOrNull(MAG_TIMEOUT) { - api.server.configManager.vrConfig.server.defineMagOnAllTrackers(req.enable()) - } - - val fbb = FlatBufferBuilder(32) - val response = MagToggleResponse.createMagToggleResponse( - fbb, - 0, - api.server.configManager.vrConfig.server.useMagnetometerOnAllTrackers, - ) - fbb.finish(createRPCMessage(fbb, RpcMessage.MagToggleResponse, response, messageHeader)) - conn.send(fbb.dataBuffer()) - } - return - } - - val tracker = api.server.getTrackerById(req.trackerId().unpack()) ?: return - if (tracker.device == null || tracker.config.shouldHaveMagEnabled == req.enable()) return - val state = req.enable() - tracker.config.shouldHaveMagEnabled = state - // Don't apply magnetometer setting if use magnetometer global setting is not enabled - if (!api.server.configManager.vrConfig.server.useMagnetometerOnAllTrackers) { - val fbb = FlatBufferBuilder(32) - val trackerId = createTrackerId(fbb, tracker) - val response = MagToggleResponse.createMagToggleResponse( - fbb, - trackerId, - state, - ) - fbb.finish(createRPCMessage(fbb, RpcMessage.MagToggleResponse, response, messageHeader)) - conn.send(fbb.dataBuffer()) - return - } - - mainScope.launch { - withTimeoutOrNull(MAG_TIMEOUT) { - tracker.device.setMag(state, tracker.trackerNum) - } - - val fbb = FlatBufferBuilder(32) - val trackerId = createTrackerId(fbb, tracker) - val response = MagToggleResponse.createMagToggleResponse( - fbb, - trackerId, - state, - ) - fbb.finish(createRPCMessage(fbb, RpcMessage.MagToggleResponse, response, messageHeader)) - conn.send(fbb.dataBuffer()) - } - } - - private fun onEnableStayAlignedRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val request = - messageHeader.message(EnableStayAlignedRequest()) as? EnableStayAlignedRequest - ?: return - - val configManager = api.server.configManager - - val config = configManager.vrConfig.stayAlignedConfig - config.enabled = request.enable() - if (request.enable()) { - config.setupComplete = true - } - - configManager.saveConfig() - - sendSettingsChangedResponse(conn, messageHeader) - } - - private fun onDetectStayAlignedRelaxedPoseRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val request = - messageHeader.message(ResetStayAlignedRelaxedPoseRequest()) as? ResetStayAlignedRelaxedPoseRequest - ?: return - - val configManager = api.server.configManager - val config = configManager.vrConfig.stayAlignedConfig - - val pose = request.pose() - - val poseConfig = - when (pose) { - StayAlignedRelaxedPose.STANDING -> config.standingRelaxedPose - StayAlignedRelaxedPose.SITTING -> config.sittingRelaxedPose - StayAlignedRelaxedPose.FLAT -> config.flatRelaxedPose - else -> return - } - - val relaxedPose = RelaxedPose.fromTrackers(api.server.humanPoseManager.skeleton) - - poseConfig.enabled = true - poseConfig.upperLegAngleInDeg = relaxedPose.upperLeg.toDeg() - poseConfig.lowerLegAngleInDeg = relaxedPose.lowerLeg.toDeg() - poseConfig.footAngleInDeg = relaxedPose.foot.toDeg() - - configManager.saveConfig() - - LogManager.info("[detectStayAlignedRelaxedPose] pose=$pose $relaxedPose") - - sendSettingsChangedResponse(conn, messageHeader) - } - - private fun onResetStayAlignedRelaxedPoseRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val request = - messageHeader.message(ResetStayAlignedRelaxedPoseRequest()) as? ResetStayAlignedRelaxedPoseRequest - ?: return - - val configManager = api.server.configManager - val config = configManager.vrConfig.stayAlignedConfig - - val pose = request.pose() - - val poseConfig = - when (pose) { - StayAlignedRelaxedPose.STANDING -> config.standingRelaxedPose - StayAlignedRelaxedPose.SITTING -> config.sittingRelaxedPose - StayAlignedRelaxedPose.FLAT -> config.flatRelaxedPose - else -> return - } - - poseConfig.enabled = false - poseConfig.upperLegAngleInDeg = 0.0f - poseConfig.lowerLegAngleInDeg = 0.0f - poseConfig.footAngleInDeg = 0.0f - - LogManager.info("[resetStayAlignedRelaxedPose] pose=$pose") - - sendSettingsChangedResponse(conn, messageHeader) - } - - fun sendSettingsChangedResponse(conn: GenericConnection, messageHeader: RpcMessageHeader?) { - val fbb = FlatBufferBuilder(32) - val settings = createSettingsResponse(fbb, api.server) - val outbound = createRPCMessage(fbb, RpcMessage.SettingsResponse, settings, messageHeader) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } -} -const val MAG_TIMEOUT: Long = 10_000L diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCUserHeightCalibration.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCUserHeightCalibration.kt deleted file mode 100644 index 4f100c5dbd..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/RPCUserHeightCalibration.kt +++ /dev/null @@ -1,65 +0,0 @@ -package dev.slimevr.protocol.rpc - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.protocol.GenericConnection -import dev.slimevr.protocol.ProtocolAPI -import dev.slimevr.tracking.processor.skeleton.UserHeightCalibrationListener -import solarxr_protocol.rpc.RpcMessage -import solarxr_protocol.rpc.RpcMessageHeader -import solarxr_protocol.rpc.UserHeightRecordingStatusResponse -import solarxr_protocol.rpc.UserHeightRecordingStatusResponseT - -class RPCUserHeightCalibration(var rpcHandler: RPCHandler, var api: ProtocolAPI) : UserHeightCalibrationListener { - val userHeightCal = this.api.server.humanPoseManager.skeleton.userHeightCalibration - - init { - userHeightCal?.addListener( - this, - ) ?: error( - "unavailable", - ) - - rpcHandler.registerPacketListener( - RpcMessage.StartUserHeightCalibration, - ::onStartUserHeightCalibration, - ) - rpcHandler.registerPacketListener( - RpcMessage.CancelUserHeightCalibration, - ::onCancelUserHeightCalibration, - ) - } - - fun onCancelUserHeightCalibration( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - userHeightCal?.clear() - } - - fun onStartUserHeightCalibration( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - userHeightCal?.start() - } - - override fun onStatusChange(status: UserHeightRecordingStatusResponseT) { - val fbb = FlatBufferBuilder(32) - - val res = UserHeightRecordingStatusResponse.pack(fbb, status) - - val outbound = rpcHandler.createRPCMessage( - fbb, - RpcMessage.UserHeightRecordingStatusResponse, - res, - ) - fbb.finish(outbound) - - api - .apiServers.forEach { server -> - server.apiConnections.forEach { conn -> - conn.send(fbb.dataBuffer()) - } - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/autobone/RPCAutoBoneHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/autobone/RPCAutoBoneHandler.kt deleted file mode 100644 index c00b9851ef..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/autobone/RPCAutoBoneHandler.kt +++ /dev/null @@ -1,181 +0,0 @@ -package dev.slimevr.protocol.rpc.autobone - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.autobone.AutoBone.Epoch -import dev.slimevr.autobone.AutoBoneListener -import dev.slimevr.autobone.AutoBoneProcessType -import dev.slimevr.autobone.AutoBoneProcessType.Companion.getById -import dev.slimevr.poseframeformat.PoseFrames -import dev.slimevr.protocol.GenericConnection -import dev.slimevr.protocol.ProtocolAPI -import dev.slimevr.protocol.rpc.RPCHandler -import dev.slimevr.protocol.rpc.createSkeletonConfig -import dev.slimevr.tracking.processor.config.SkeletonConfigOffsets -import solarxr_protocol.rpc.AutoBoneEpochResponse -import solarxr_protocol.rpc.AutoBoneProcessRequest -import solarxr_protocol.rpc.AutoBoneProcessStatusResponse -import solarxr_protocol.rpc.RpcMessage -import solarxr_protocol.rpc.RpcMessageHeader -import solarxr_protocol.rpc.SkeletonPart -import java.util.* - -class RPCAutoBoneHandler( - private val rpcHandler: RPCHandler, - val api: ProtocolAPI, -) : AutoBoneListener { - init { - rpcHandler.registerPacketListener( - RpcMessage.AutoBoneProcessRequest, - ::onAutoBoneProcessRequest, - ) - rpcHandler.registerPacketListener( - RpcMessage.AutoBoneApplyRequest, - ::onAutoBoneApplyRequest, - ) - rpcHandler.registerPacketListener( - RpcMessage.AutoBoneStopRecordingRequest, - ::onAutoBoneStopRecordingRequest, - ) - rpcHandler.registerPacketListener( - RpcMessage.AutoBoneCancelRecordingRequest, - ::onAutoBoneCancelRecordingRequest, - ) - - this.api.server.autoBoneHandler.addListener(this) - } - - fun onAutoBoneProcessRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val req = messageHeader - .message(AutoBoneProcessRequest()) as AutoBoneProcessRequest - if (conn.context.useAutoBone) return - conn.context.useAutoBone = true - api.server - .autoBoneHandler - .startProcessByType(getById(req.processType())) - } - - override fun onAutoBoneProcessStatus( - processType: AutoBoneProcessType, - message: String?, - current: Long, - total: Long, - eta: Float, - completed: Boolean, - success: Boolean, - ) { - forAllListeners { conn -> - if (!conn.context.useAutoBone) { - return@forAllListeners - } - - val fbb = FlatBufferBuilder(32) - - AutoBoneProcessStatusResponse.startAutoBoneProcessStatusResponse(fbb) - AutoBoneProcessStatusResponse.addProcessType( - fbb, - processType.id, - ) - - AutoBoneProcessStatusResponse.addCurrent(fbb, current) - AutoBoneProcessStatusResponse.addTotal(fbb, total) - AutoBoneProcessStatusResponse.addEta(fbb, eta) - AutoBoneProcessStatusResponse.addCompleted(fbb, completed) - AutoBoneProcessStatusResponse.addSuccess(fbb, success) - - val update = AutoBoneProcessStatusResponse - .endAutoBoneProcessStatusResponse(fbb) - val outbound: Int = rpcHandler.createRPCMessage( - fbb, - RpcMessage.AutoBoneProcessStatusResponse, - update, - ) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - if (completed) { - conn.context.useAutoBone = false - } - } - } - - override fun onAutoBoneRecordingEnd(recording: PoseFrames) { - // Do nothing, this is broadcasted by "onAutoBoneProcessStatus" uwu - } - - override fun onAutoBoneEpoch(epoch: Epoch) { - forAllListeners { conn -> - if (!conn.context.useAutoBone) { - return@forAllListeners - } - - val fbb = FlatBufferBuilder(32) - - val skeletonPartsOffset = AutoBoneEpochResponse - .createAdjustedSkeletonPartsVector( - fbb, - epoch.configValues.map { (key, value) -> - SkeletonPart.createSkeletonPart(fbb, key.id, value) - }.toIntArray(), - ) - val update = AutoBoneEpochResponse - .createAutoBoneEpochResponse( - fbb, - epoch.epoch.toLong(), - epoch.totalEpochs.toLong(), - epoch.epochError.mean, - skeletonPartsOffset, - ) - val outbound: Int = rpcHandler.createRPCMessage( - fbb, - RpcMessage.AutoBoneEpochResponse, - update, - ) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - } - - override fun onAutoBoneEnd(configValues: EnumMap) { - // Do nothing, the last epoch from "onAutoBoneEpoch" should be all - // that's needed - } - - private fun onAutoBoneApplyRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - api.server.autoBoneHandler.applyValues() - - // Send the new body proportions, this is to update the listener's state - val fbb = FlatBufferBuilder(300) - val outbound = rpcHandler.createRPCMessage( - fbb, - RpcMessage.SkeletonConfigResponse, - createSkeletonConfig(fbb, api.server.humanPoseManager), - ) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - - private fun onAutoBoneStopRecordingRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - api.server.autoBoneHandler.stopRecording() - } - - private fun onAutoBoneCancelRecordingRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - api.server.autoBoneHandler.cancelRecording() - } - - private fun forAllListeners(action: (GenericConnection) -> Unit) { - api.apiServers.forEach { - it.apiConnections.forEach(action) - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/firmware/RPCFirmwareUpdateHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/firmware/RPCFirmwareUpdateHandler.kt deleted file mode 100644 index b8cf4168ad..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/firmware/RPCFirmwareUpdateHandler.kt +++ /dev/null @@ -1,133 +0,0 @@ -package dev.slimevr.protocol.rpc.firmware - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.firmware.FirmwareUpdateListener -import dev.slimevr.firmware.FirmwareUpdateMethod -import dev.slimevr.firmware.UpdateDeviceId -import dev.slimevr.firmware.UpdateStatusEvent -import dev.slimevr.protocol.GenericConnection -import dev.slimevr.protocol.ProtocolAPI -import dev.slimevr.protocol.rpc.RPCHandler -import solarxr_protocol.datatypes.DeviceIdT -import solarxr_protocol.datatypes.DeviceIdTableT -import solarxr_protocol.rpc.FirmwareUpdateDeviceId -import solarxr_protocol.rpc.FirmwareUpdateDeviceIdUnion -import solarxr_protocol.rpc.FirmwareUpdateRequest -import solarxr_protocol.rpc.FirmwareUpdateRequestT -import solarxr_protocol.rpc.FirmwareUpdateStatusResponse -import solarxr_protocol.rpc.RpcMessage -import solarxr_protocol.rpc.RpcMessageHeader -import solarxr_protocol.rpc.SerialDevicePortT - -class RPCFirmwareUpdateHandler( - private val rpcHandler: RPCHandler, - var api: ProtocolAPI, -) : FirmwareUpdateListener { - - init { - api.server.firmwareUpdateHandler.addListener(this) - rpcHandler.registerPacketListener( - RpcMessage.FirmwareUpdateRequest, - this::onFirmwareUpdateRequest, - ) - rpcHandler.registerPacketListener( - RpcMessage.FirmwareUpdateStopQueuesRequest, - this::onFirmwareUpdateStopQueuesRequest, - ) - } - - private fun onFirmwareUpdateStopQueuesRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - api.server.firmwareUpdateHandler.cancelUpdates() - } - - private fun onFirmwareUpdateRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val req = - (messageHeader.message(FirmwareUpdateRequest()) as FirmwareUpdateRequest).unpack() - val updateDeviceId = buildUpdateDeviceId(req) ?: return - - api.server.firmwareUpdateHandler.queueFirmwareUpdate( - req, - updateDeviceId, - ) - } - - override fun onUpdateStatusChange(event: UpdateStatusEvent<*>) { - val fbb = FlatBufferBuilder(32) - - val dataUnion = FirmwareUpdateDeviceIdUnion() - dataUnion.type = event.deviceId.type.id - dataUnion.value = createUpdateDeviceId(event.deviceId) - - val deviceIdOffset = FirmwareUpdateDeviceIdUnion.pack(fbb, dataUnion) - - FirmwareUpdateStatusResponse.startFirmwareUpdateStatusResponse(fbb) - FirmwareUpdateStatusResponse.addStatus(fbb, event.status.id) - FirmwareUpdateStatusResponse.addDeviceIdType(fbb, dataUnion.type) - FirmwareUpdateStatusResponse.addDeviceId(fbb, deviceIdOffset) - FirmwareUpdateStatusResponse.addProgress(fbb, event.progress.toByte()) - - val update = FirmwareUpdateStatusResponse.endFirmwareUpdateStatusResponse(fbb) - val outbound = rpcHandler.createRPCMessage( - fbb, - RpcMessage.FirmwareUpdateStatusResponse, - update, - ) - fbb.finish(outbound) - - api - .apiServers.forEach { server -> - server.apiConnections.forEach { conn -> - conn.send(fbb.dataBuffer()) - } - } - } - - private fun buildUpdateDeviceId(req: FirmwareUpdateRequestT): UpdateDeviceId? { - when (req.method.type) { - FirmwareUpdateDeviceId.solarxr_protocol_datatypes_DeviceIdTable -> { - return UpdateDeviceId( - FirmwareUpdateMethod.OTA, - req.method.asOTAFirmwareUpdate().deviceId.id, - ) - } - - FirmwareUpdateDeviceId.SerialDevicePort -> { - return UpdateDeviceId( - FirmwareUpdateMethod.SERIAL, - req.method.asSerialFirmwareUpdate().deviceId.port, - ) - } - } - return null - } - - private fun createUpdateDeviceId(data: UpdateDeviceId<*>): Any = when (data.type) { - FirmwareUpdateMethod.NONE -> error("Unsupported method") - - FirmwareUpdateMethod.OTA -> { - if (data.id !is Int) { - error("Invalid state, the id type should be Int") - } - DeviceIdTableT().apply { - id = DeviceIdT().apply { - id = data.id - } - } - } - - FirmwareUpdateMethod.SERIAL -> { - if (data.id !is String) { - error("Invalid state, the id type should be String") - } - SerialDevicePortT().apply { - port = data.id - } - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/games/vrchat/RPCVRCBuilder.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/games/vrchat/RPCVRCBuilder.kt deleted file mode 100644 index 62b2e3744d..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/games/vrchat/RPCVRCBuilder.kt +++ /dev/null @@ -1,84 +0,0 @@ -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.calibrationRangeOk) - VRCConfigValidity.addCalibrationVisualsOk(fbb, validity.calibrationVisualsOk) - VRCConfigValidity.addSpineModeOk(fbb, validity.spineModeOk) - VRCConfigValidity.addLegacyModeOk(fbb, validity.legacyModeOk) - VRCConfigValidity.addShoulderTrackingOk(fbb, validity.shoulderTrackingOk) - VRCConfigValidity.addTrackerModelOk(fbb, validity.trackerModelOk) - VRCConfigValidity.addUserHeightOk(fbb, validity.userHeightOk) - VRCConfigValidity.addAvatarMeasurementTypeOk(fbb, validity.avatarMeasurementTypeOk) - 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?, - muted: List, -): 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) - val mutedOffset = VRCConfigStateChangeResponse.createMutedVector(fbb, muted.map { fbb.createString(it) }.toIntArray()) - - VRCConfigStateChangeResponse.startVRCConfigStateChangeResponse(fbb) - VRCConfigStateChangeResponse.addIsSupported(fbb, true) - VRCConfigStateChangeResponse.addValidity(fbb, validityOffset) - VRCConfigStateChangeResponse.addState(fbb, valuesOffset) - VRCConfigStateChangeResponse.addRecommended(fbb, recommendedOffset) - VRCConfigStateChangeResponse.addMuted(fbb, mutedOffset) - return VRCConfigStateChangeResponse.endVRCConfigStateChangeResponse(fbb) -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/games/vrchat/RPCVRChatHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/games/vrchat/RPCVRChatHandler.kt deleted file mode 100644 index 481e918d35..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/games/vrchat/RPCVRChatHandler.kt +++ /dev/null @@ -1,81 +0,0 @@ -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, ::onConfigStateRequest) - rpcHandler.registerPacketListener(RpcMessage.VRCConfigSettingToggleMute, ::onToggleMuteRequest) - } - - 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(), - muted = api.server.configManager.vrConfig.vrcConfig.mutedWarnings, - ) - - val outbound = rpcHandler.createRPCMessage( - fbb, - RpcMessage.VRCConfigStateChangeResponse, - response, - ) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - - private fun onToggleMuteRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val req = messageHeader.message(VRCConfigSettingToggleMute()) as VRCConfigSettingToggleMute? - ?: return - api.server.vrcConfigManager.toggleMuteWarning(req.key()) - } - - override fun onChange(validity: VRCConfigValidity, values: VRCConfigValues, recommended: VRCConfigRecommendedValues, muted: List) { - val fbb = FlatBufferBuilder(32) - - val response = buildVRCConfigStateResponse( - fbb, - isSupported = api.server.vrcConfigManager.isSupported, - validity = validity, - values = values, - recommended = recommended, - muted, - ) - - 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/protocol/rpc/reset/RPCResetHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/reset/RPCResetHandler.kt deleted file mode 100644 index 2ae1b934d7..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/reset/RPCResetHandler.kt +++ /dev/null @@ -1,139 +0,0 @@ -package dev.slimevr.protocol.rpc.reset - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.protocol.GenericConnection -import dev.slimevr.protocol.ProtocolAPI -import dev.slimevr.protocol.ProtocolAPIServer -import dev.slimevr.protocol.rpc.RPCHandler -import dev.slimevr.reset.ResetListener -import solarxr_protocol.rpc.ClearMountingResetRequest -import solarxr_protocol.rpc.ResetRequest -import solarxr_protocol.rpc.ResetResponse -import solarxr_protocol.rpc.ResetStatus -import solarxr_protocol.rpc.ResetType -import solarxr_protocol.rpc.RpcMessage -import solarxr_protocol.rpc.RpcMessageHeader -import java.util.function.Consumer - -class RPCResetHandler(var rpcHandler: RPCHandler, var api: ProtocolAPI) : ResetListener { - val resetsConfig = api.server.configManager.vrConfig.resetsConfig - - init { - this.api.server.resetHandler.addListener(this) - - rpcHandler.registerPacketListener(RpcMessage.ResetRequest, ::onResetRequest) - rpcHandler.registerPacketListener(RpcMessage.ClearMountingResetRequest, ::onClearMountingResetRequest) - } - - fun onResetRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val req = messageHeader.message(ResetRequest()) as? ResetRequest ?: return - - // Get the list of bodyparts we want to reset - // If empty, check in HumanSkeleton will reset all - val bodyParts = mutableListOf() - if (req.bodyPartsLength() > 0) { - val buffer = req.bodyPartsAsByteBuffer() - while (buffer.hasRemaining()) { - bodyParts.add(buffer.get().toInt()) - } - } - - if (req.resetType() == ResetType.Yaw) { - val delay = if (req.hasDelay()) { - req.delay() - } else { - resetsConfig.yawResetDelay - } - if (bodyParts.isEmpty()) { - api.server.scheduleResetTrackersYaw(RESET_SOURCE_NAME, (delay * 1000).toLong()) - } else { - api.server.scheduleResetTrackersYaw(RESET_SOURCE_NAME, (delay * 1000).toLong(), bodyParts.toList()) - } - } - if (req.resetType() == ResetType.Full) { - val delay = if (req.hasDelay()) { - req.delay() - } else { - resetsConfig.fullResetDelay - } - if (bodyParts.isEmpty()) { - api.server.scheduleResetTrackersFull(RESET_SOURCE_NAME, (delay * 1000).toLong()) - } else { - api.server.scheduleResetTrackersFull(RESET_SOURCE_NAME, (delay * 1000).toLong(), bodyParts.toList()) - } - } - if (req.resetType() == ResetType.Mounting) { - val delay = if (req.hasDelay()) { - req.delay() - } else { - resetsConfig.mountingResetDelay - } - if (bodyParts.isEmpty()) { - api.server.scheduleResetTrackersMounting(RESET_SOURCE_NAME, (delay * 1000).toLong()) - } else { - api.server.scheduleResetTrackersMounting(RESET_SOURCE_NAME, (delay * 1000).toLong(), bodyParts.toList()) - } - } - } - - fun sendResetStatusResponse(resetType: Int, status: Int, bodyParts: List? = null, progress: Int = 0, duration: Int = 0) { - val fbb = FlatBufferBuilder(32) - - val bodyPartsOffset = if (bodyParts != null) ResetResponse.createBodyPartsVector(fbb, bodyParts.map { it.toByte() }.toByteArray()) else 0 - - ResetResponse.startResetResponse(fbb) - ResetResponse.addResetType(fbb, resetType) - ResetResponse.addStatus(fbb, status) - if (bodyPartsOffset >= 0) { - ResetResponse.addBodyParts(fbb, bodyPartsOffset) - } - ResetResponse.addProgress(fbb, progress) - ResetResponse.addDuration(fbb, duration) - val update = ResetResponse.endResetResponse(fbb) - val outbound = rpcHandler.createRPCMessage(fbb, RpcMessage.ResetResponse, update) - fbb.finish(outbound) - - this.forAllListeners( - Consumer { conn: GenericConnection -> - conn.send(fbb.dataBuffer()) - }, - ) - } - - fun onClearMountingResetRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - if (messageHeader - .message(ClearMountingResetRequest()) !is ClearMountingResetRequest - ) { - return - } - - api.server.clearTrackersMounting(RESET_SOURCE_NAME) - } - - override fun onStarted(resetType: Int, bodyParts: List?, progress: Int, duration: Int) { - sendResetStatusResponse(resetType, ResetStatus.STARTED, bodyParts, progress, duration) - } - - override fun onFinished(resetType: Int, bodyParts: List?, duration: Int) { - sendResetStatusResponse(resetType, ResetStatus.FINISHED, bodyParts, duration, duration) - } - - fun forAllListeners(action: Consumer?) { - this.api - .apiServers - .forEach( - Consumer { server: ProtocolAPIServer -> - server - .apiConnections - .forEach(action) - }, - ) - } - - companion object { - const val RESET_SOURCE_NAME = "WebSocketAPI" - } -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/serial/RPCProvisioningHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/serial/RPCProvisioningHandler.kt deleted file mode 100644 index b1cd0387f2..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/serial/RPCProvisioningHandler.kt +++ /dev/null @@ -1,68 +0,0 @@ -package dev.slimevr.protocol.rpc.serial - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.protocol.GenericConnection -import dev.slimevr.protocol.ProtocolAPI -import dev.slimevr.protocol.ProtocolAPIServer -import dev.slimevr.protocol.rpc.RPCHandler -import dev.slimevr.serial.ProvisioningListener -import dev.slimevr.serial.ProvisioningStatus -import dev.slimevr.serial.SerialPort -import solarxr_protocol.rpc.* -import java.util.function.Consumer - -class RPCProvisioningHandler(var rpcHandler: RPCHandler, var api: ProtocolAPI) : ProvisioningListener { - init { - rpcHandler.registerPacketListener(RpcMessage.StartWifiProvisioningRequest, ::onStartWifiProvisioningRequest) - rpcHandler.registerPacketListener(RpcMessage.StopWifiProvisioningRequest, ::onStopWifiProvisioningRequest) - this.api.server.provisioningHandler.addListener(this) - } - - fun onStartWifiProvisioningRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val req = messageHeader - .message(StartWifiProvisioningRequest()) as StartWifiProvisioningRequest? - if (req == null) return - this.api.server.provisioningHandler.start(req.ssid(), req.password(), req.port()) - conn.context.useProvisioning = true - } - - fun onStopWifiProvisioningRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val req = messageHeader - .message(StopWifiProvisioningRequest()) as StopWifiProvisioningRequest? - if (req == null) return - conn.context.useProvisioning = false - this.api.server.provisioningHandler.stop() - } - - override fun onProvisioningStatusChange(status: ProvisioningStatus, port: SerialPort?) { - val fbb = FlatBufferBuilder(32) - - WifiProvisioningStatusResponse.startWifiProvisioningStatusResponse(fbb) - WifiProvisioningStatusResponse.addStatus(fbb, status.id) - val update = WifiProvisioningStatusResponse.endWifiProvisioningStatusResponse(fbb) - val outbound = rpcHandler - .createRPCMessage(fbb, RpcMessage.WifiProvisioningStatusResponse, update) - fbb.finish(outbound) - - this.forAllListeners(Consumer { conn: GenericConnection -> conn.send(fbb.dataBuffer()) }) - } - - private fun forAllListeners(action: Consumer?) { - this.api - .apiServers - .forEach( - Consumer { server: ProtocolAPIServer -> - server - .apiConnections - .filter { conn: GenericConnection -> conn.context.useProvisioning } - .forEach(action) - }, - ) - } -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/serial/RPCSerialHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/serial/RPCSerialHandler.kt deleted file mode 100644 index e621390af3..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/serial/RPCSerialHandler.kt +++ /dev/null @@ -1,263 +0,0 @@ -package dev.slimevr.protocol.rpc.serial - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.protocol.GenericConnection -import dev.slimevr.protocol.ProtocolAPI -import dev.slimevr.protocol.ProtocolAPIServer -import dev.slimevr.protocol.rpc.RPCHandler -import dev.slimevr.serial.SerialListener -import dev.slimevr.serial.SerialPort -import io.eiren.util.logging.LogManager -import solarxr_protocol.rpc.* -import java.util.* -import java.util.function.Consumer - -class RPCSerialHandler(var rpcHandler: RPCHandler, var api: ProtocolAPI) : SerialListener { - init { - rpcHandler.registerPacketListener(RpcMessage.SerialTrackerRebootRequest, ::onSerialTrackerRebootRequest) - rpcHandler.registerPacketListener(RpcMessage.SerialTrackerGetInfoRequest, ::onSerialTrackerGetInfoRequest) - rpcHandler.registerPacketListener(RpcMessage.SerialTrackerFactoryResetRequest, ::onSerialTrackerFactoryResetRequest) - rpcHandler.registerPacketListener(RpcMessage.SerialTrackerGetWifiScanRequest, ::onSerialTrackerGetWifiScanRequest) - rpcHandler.registerPacketListener(RpcMessage.SerialTrackerCustomCommandRequest, ::onSerialTrackerCustomCommandRequest) - rpcHandler.registerPacketListener(RpcMessage.SetWifiRequest, ::onSetWifiRequest) - rpcHandler.registerPacketListener(RpcMessage.OpenSerialRequest, ::onOpenSerialRequest) - rpcHandler.registerPacketListener(RpcMessage.CloseSerialRequest, ::onCloseSerialRequest) - rpcHandler.registerPacketListener(RpcMessage.SerialDevicesRequest, ::onRequestSerialDevices) - this.api.server.serialHandler.addListener(this) - } - - override fun onSerialDisconnected() { - val fbb = FlatBufferBuilder(32) - - SerialUpdateResponse.startSerialUpdateResponse(fbb) - SerialUpdateResponse.addClosed(fbb, true) - val update = SerialUpdateResponse.endSerialUpdateResponse(fbb) - val outbound = rpcHandler.createRPCMessage(fbb, RpcMessage.SerialUpdateResponse, update) - fbb.finish(outbound) - - this.forAllListeners( - Consumer { conn: GenericConnection -> - conn.send(fbb.dataBuffer()) - conn.context.useSerial = false - }, - ) - } - - override fun onSerialLog(str: String, server: Boolean) { - val fbb = FlatBufferBuilder(32) - - val logOffset = fbb.createString(str) - - SerialUpdateResponse.startSerialUpdateResponse(fbb) - SerialUpdateResponse.addLog(fbb, logOffset) - val update = SerialUpdateResponse.endSerialUpdateResponse(fbb) - val outbound = rpcHandler.createRPCMessage(fbb, RpcMessage.SerialUpdateResponse, update) - fbb.finish(outbound) - - this.forAllListeners( - Consumer { conn: GenericConnection -> - conn.send(fbb.dataBuffer()) - }, - ) - } - - override fun onNewSerialDevice(port: SerialPort) { - val fbb = FlatBufferBuilder(32) - - val portOffset = fbb.createString(port.portLocation) - val nameOffset = fbb.createString(port.descriptivePortName) - val deviceOffset = SerialDevice.createSerialDevice(fbb, portOffset, nameOffset) - val newSerialOffset = NewSerialDeviceResponse - .createNewSerialDeviceResponse(fbb, deviceOffset) - val outbound = rpcHandler - .createRPCMessage(fbb, RpcMessage.NewSerialDeviceResponse, newSerialOffset) - fbb.finish(outbound) - - this.api - .apiServers - .forEach( - Consumer { server: ProtocolAPIServer -> - server - .apiConnections - .forEach { conn: GenericConnection -> - conn.send(fbb.dataBuffer()) - } - }, - ) - } - - override fun onSerialConnected(port: SerialPort) { - val fbb = FlatBufferBuilder(32) - - SerialUpdateResponse.startSerialUpdateResponse(fbb) - SerialUpdateResponse.addClosed(fbb, false) - val update = SerialUpdateResponse.endSerialUpdateResponse(fbb) - val outbound = rpcHandler.createRPCMessage(fbb, RpcMessage.SerialUpdateResponse, update) - fbb.finish(outbound) - - this.forAllListeners( - Consumer { conn: GenericConnection -> - conn.send(fbb.dataBuffer()) - }, - ) - } - - fun onSerialTrackerRebootRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val req = messageHeader - .message(SerialTrackerRebootRequest()) as SerialTrackerRebootRequest? - if (req == null) return - - this.api.server.serialHandler.rebootRequest() - } - - fun onSerialTrackerGetInfoRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val req = messageHeader - .message(SerialTrackerGetInfoRequest()) as SerialTrackerGetInfoRequest? - if (req == null) return - - this.api.server.serialHandler.infoRequest() - } - - fun onSerialTrackerFactoryResetRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val req = messageHeader - .message(SerialTrackerFactoryResetRequest()) as SerialTrackerFactoryResetRequest? - if (req == null) return - - this.api.server.serialHandler.factoryResetRequest() - } - - fun onSerialTrackerGetWifiScanRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val req = messageHeader - .message(SerialTrackerGetWifiScanRequest()) as SerialTrackerGetWifiScanRequest? - if (req == null) return - - this.api.server.serialHandler.wifiScanRequest() - } - - fun onSerialTrackerCustomCommandRequest( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val req = messageHeader - .message(SerialTrackerCustomCommandRequest()) as SerialTrackerCustomCommandRequest? - - if (req == null || req.command() == null) return - - this.api.server.serialHandler.customCommandRequest(Objects.requireNonNull(req.command())) - } - - private fun onRequestSerialDevices(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val req = messageHeader - .message(SerialDevicesRequest()) as SerialDevicesRequest? - if (req == null) return - - val fbb = FlatBufferBuilder(32) - - val devicesOffsets: MutableList = ArrayList() - - try { - this.api.server.serialHandler.knownPorts.forEach { port: SerialPort -> - val portOffset = fbb.createString(port.portLocation) - val nameOffset = fbb.createString(port.descriptivePortName) - devicesOffsets.add(SerialDevice.createSerialDevice(fbb, portOffset, nameOffset)) - } - } catch (e: Throwable) { - LogManager.severe("Using serial ports is not supported on this platform", e) - } - - SerialDevicesResponse.startDevicesVector(fbb, devicesOffsets.size) - devicesOffsets.forEach(Consumer { offset: Int -> SerialDevicesResponse.addDevices(fbb, offset) }) - val devices = fbb.endVector() - val serialDeviceOffsets = SerialDevicesResponse.createSerialDevicesResponse(fbb, devices) - val outbound = rpcHandler - .createRPCMessage(fbb, RpcMessage.SerialDevicesResponse, serialDeviceOffsets) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - - fun onSetWifiRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val req = messageHeader.message(SetWifiRequest()) as SetWifiRequest? ?: return - - if (req.password() == null || req.ssid() == null || !this.api.server.serialHandler.isConnected) { - return - } - this.api.server.serialHandler.setWifi(req.ssid(), req.password()) - } - - fun onOpenSerialRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val req = - messageHeader.message(OpenSerialRequest()) as OpenSerialRequest? ?: return - - conn.context.useSerial = true - - this.api.server.queueTask { - try { - this.api.server.serialHandler.openSerial(req.port(), req.auto()) - } catch (e: Exception) { - LogManager.severe("Unable to open serial port", e) - } catch (e: Throwable) { - LogManager.severe( - "Using serial ports is not supported on this platform", - e, - ) - } - val fbb = FlatBufferBuilder(32) - SerialUpdateResponse.startSerialUpdateResponse(fbb) - SerialUpdateResponse.addClosed( - fbb, - !this.api.server.serialHandler.isConnected, - ) - val update = SerialUpdateResponse.endSerialUpdateResponse(fbb) - val outbound = rpcHandler - .createRPCMessage(fbb, RpcMessage.SerialUpdateResponse, update) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - } - - fun onCloseSerialRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val req = messageHeader - .message(CloseSerialRequest()) as CloseSerialRequest? - if (req == null) return - - conn.context.useSerial = false - - this.api.server.serialHandler.closeSerial() - - val fbb = FlatBufferBuilder(32) - SerialUpdateResponse.startSerialUpdateResponse(fbb) - SerialUpdateResponse.addClosed(fbb, !this.api.server.serialHandler.isConnected) - val update = SerialUpdateResponse.endSerialUpdateResponse(fbb) - val outbound = rpcHandler.createRPCMessage(fbb, RpcMessage.SerialUpdateResponse, update) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - - fun forAllListeners(action: Consumer?) { - this.api - .apiServers - .forEach( - Consumer { server: ProtocolAPIServer -> - server - .apiConnections - .filter { conn: GenericConnection -> conn.context.useSerial } - .forEach(action) - }, - ) - } - - override fun onSerialDeviceDeleted(port: SerialPort) { - } -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/settings/RPCSettingsBuilder.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/settings/RPCSettingsBuilder.kt deleted file mode 100644 index a933bf1936..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/settings/RPCSettingsBuilder.kt +++ /dev/null @@ -1,458 +0,0 @@ -package dev.slimevr.protocol.rpc.settings - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.VRServer -import dev.slimevr.bridge.ISteamVRBridge -import dev.slimevr.config.AutoBoneConfig -import dev.slimevr.config.DriftCompensationConfig -import dev.slimevr.config.FiltersConfig -import dev.slimevr.config.HIDConfig -import dev.slimevr.config.LegTweaksConfig -import dev.slimevr.config.OSCConfig -import dev.slimevr.config.ResetsConfig -import dev.slimevr.config.SkeletonConfig -import dev.slimevr.config.StayAlignedConfig -import dev.slimevr.config.TapDetectionConfig -import dev.slimevr.config.VMCConfig -import dev.slimevr.config.VRCOSCConfig -import dev.slimevr.filtering.TrackerFilters.Companion.getByConfigkey -import dev.slimevr.tracking.processor.HumanPoseManager -import dev.slimevr.tracking.processor.config.SkeletonConfigToggles -import dev.slimevr.tracking.processor.config.SkeletonConfigValues -import dev.slimevr.tracking.trackers.TrackerRole -import solarxr_protocol.rpc.AutoBoneSettings -import solarxr_protocol.rpc.DriftCompensationSettings -import solarxr_protocol.rpc.FilteringSettings -import solarxr_protocol.rpc.HIDSettings -import solarxr_protocol.rpc.OSCRouterSettings -import solarxr_protocol.rpc.OSCSettings -import solarxr_protocol.rpc.OSCTrackersSetting -import solarxr_protocol.rpc.ResetsSettings -import solarxr_protocol.rpc.SettingsResponse -import solarxr_protocol.rpc.StayAlignedSettings -import solarxr_protocol.rpc.SteamVRTrackersSetting -import solarxr_protocol.rpc.TapDetectionSettings -import solarxr_protocol.rpc.VMCOSCSettings -import solarxr_protocol.rpc.VRCOSCSettings -import solarxr_protocol.rpc.settings.LegTweaksSettings -import solarxr_protocol.rpc.settings.ModelRatios -import solarxr_protocol.rpc.settings.ModelSettings -import solarxr_protocol.rpc.settings.ModelToggles -import solarxr_protocol.rpc.settings.SkeletonHeight - -fun createOSCRouterSettings( - fbb: FlatBufferBuilder, - config: OSCConfig, -): Int { - val addressStringOffset = fbb.createString(config.address) - - val oscSettingOffset = OSCSettings - .createOSCSettings( - fbb, - config.enabled, - config.portIn, - config.portOut, - addressStringOffset, - ) - - OSCRouterSettings.startOSCRouterSettings(fbb) - OSCRouterSettings.addOscSettings(fbb, oscSettingOffset) - - return OSCRouterSettings.endOSCRouterSettings(fbb) -} - -fun createVRCOSCSettings( - fbb: FlatBufferBuilder, - config: VRCOSCConfig, -): Int { - val addressStringOffset = fbb.createString(config.address) - val generalSettingOffset = OSCSettings - .createOSCSettings( - fbb, - config.enabled, - config.portIn, - config.portOut, - addressStringOffset, - ) - val oscSettingOffset = OSCTrackersSetting - .createOSCTrackersSetting( - fbb, - config.getOSCTrackerRole(TrackerRole.HEAD, false), - config.getOSCTrackerRole(TrackerRole.CHEST, false), - config.getOSCTrackerRole(TrackerRole.WAIST, false), - config.getOSCTrackerRole(TrackerRole.LEFT_KNEE, false) && - config.getOSCTrackerRole(TrackerRole.RIGHT_KNEE, false), - config.getOSCTrackerRole(TrackerRole.LEFT_FOOT, false) && - config.getOSCTrackerRole(TrackerRole.RIGHT_FOOT, false), - config.getOSCTrackerRole(TrackerRole.LEFT_ELBOW, false) && - config.getOSCTrackerRole(TrackerRole.RIGHT_ELBOW, false), - config.getOSCTrackerRole(TrackerRole.LEFT_HAND, false) && - config.getOSCTrackerRole(TrackerRole.RIGHT_HAND, false), - ) - VRCOSCSettings.startVRCOSCSettings(fbb) - VRCOSCSettings.addOscSettings(fbb, generalSettingOffset) - VRCOSCSettings.addTrackers(fbb, oscSettingOffset) - VRCOSCSettings.addOscqueryEnabled(fbb, config.oscqueryEnabled) - - return VRCOSCSettings.endVRCOSCSettings(fbb) -} - -fun createVMCOSCSettings( - fbb: FlatBufferBuilder, - config: VMCConfig, -): Int { - val addressStringOffset = fbb.createString(config.address) - val generalSettingOffset = OSCSettings - .createOSCSettings( - fbb, - config.enabled, - config.portIn, - config.portOut, - addressStringOffset, - ) - - val vrmJson = config.vrmJson - var vrmJsonOffset = 0 - if (vrmJson != null) vrmJsonOffset = fbb.createString(vrmJson) - - VMCOSCSettings.startVMCOSCSettings(fbb) - VMCOSCSettings.addOscSettings(fbb, generalSettingOffset) - if (vrmJson != null) VMCOSCSettings.addVrmJson(fbb, vrmJsonOffset) - VMCOSCSettings.addAnchorHip(fbb, config.anchorHip) - VMCOSCSettings.addMirrorTracking(fbb, config.mirrorTracking) - - return VMCOSCSettings.endVMCOSCSettings(fbb) -} - -fun createFilterSettings( - fbb: FlatBufferBuilder, - filtersConfig: FiltersConfig, -): Int = FilteringSettings - .createFilteringSettings( - fbb, - getByConfigkey(filtersConfig.type)!!.id, - filtersConfig.amount, - ) - -fun createDriftCompensationSettings( - fbb: FlatBufferBuilder, - driftCompensationConfig: DriftCompensationConfig, -): Int = DriftCompensationSettings - .createDriftCompensationSettings( - fbb, - driftCompensationConfig.enabled, - driftCompensationConfig.prediction, - driftCompensationConfig.amount, - driftCompensationConfig.maxResets, - ) - -fun createTapDetectionSettings( - fbb: FlatBufferBuilder, - tapDetectionConfig: TapDetectionConfig, -): Int = TapDetectionSettings - .createTapDetectionSettings( - fbb, - tapDetectionConfig.fullResetDelay, - tapDetectionConfig.fullResetEnabled, - tapDetectionConfig.fullResetTaps, - tapDetectionConfig.yawResetDelay, - tapDetectionConfig.yawResetEnabled, - tapDetectionConfig.yawResetTaps, - tapDetectionConfig.mountingResetDelay, - tapDetectionConfig.mountingResetEnabled, - tapDetectionConfig.mountingResetTaps, - tapDetectionConfig.setupMode, - tapDetectionConfig.numberTrackersOverThreshold, - ) - -fun createSteamVRSettings(fbb: FlatBufferBuilder, bridge: ISteamVRBridge?): Int { - var steamvrTrackerSettings = 0 - if (bridge != null) { - steamvrTrackerSettings = SteamVRTrackersSetting - .createSteamVRTrackersSetting( - fbb, - bridge.getShareSetting(TrackerRole.WAIST), - bridge.getShareSetting(TrackerRole.CHEST), - bridge.getAutomaticSharedTrackers(), - - bridge.getShareSetting(TrackerRole.LEFT_FOOT), - bridge.getShareSetting(TrackerRole.RIGHT_FOOT), - bridge.getShareSetting(TrackerRole.LEFT_KNEE), - bridge.getShareSetting(TrackerRole.RIGHT_KNEE), - bridge.getShareSetting(TrackerRole.LEFT_ELBOW), - bridge.getShareSetting(TrackerRole.RIGHT_ELBOW), - bridge.getShareSetting(TrackerRole.LEFT_HAND), - bridge.getShareSetting(TrackerRole.RIGHT_HAND), - ) - } - return steamvrTrackerSettings -} - -fun createModelSettings( - fbb: FlatBufferBuilder, - humanPoseManager: HumanPoseManager, - legTweaksConfig: LegTweaksConfig, - skeletonConfig: SkeletonConfig, -): Int { - val togglesOffset = ModelToggles - .createModelToggles( - fbb, - humanPoseManager.getToggle(SkeletonConfigToggles.EXTENDED_SPINE_MODEL), - humanPoseManager.getToggle(SkeletonConfigToggles.EXTENDED_PELVIS_MODEL), - humanPoseManager.getToggle(SkeletonConfigToggles.EXTENDED_KNEE_MODEL), - humanPoseManager.getToggle(SkeletonConfigToggles.FORCE_ARMS_FROM_HMD), - humanPoseManager.getToggle(SkeletonConfigToggles.FLOOR_CLIP), - humanPoseManager.getToggle(SkeletonConfigToggles.SKATING_CORRECTION), - humanPoseManager.getToggle(SkeletonConfigToggles.TOE_SNAP), - humanPoseManager.getToggle(SkeletonConfigToggles.FOOT_PLANT), - humanPoseManager.getToggle(SkeletonConfigToggles.SELF_LOCALIZATION), - humanPoseManager.getToggle(SkeletonConfigToggles.USE_POSITION), - humanPoseManager.getToggle(SkeletonConfigToggles.ENFORCE_CONSTRAINTS), - humanPoseManager.getToggle(SkeletonConfigToggles.CORRECT_CONSTRAINTS), - ) - val ratiosOffset = ModelRatios - .createModelRatios( - fbb, - humanPoseManager.getValue(SkeletonConfigValues.WAIST_FROM_CHEST_HIP_AVERAGING), - humanPoseManager.getValue(SkeletonConfigValues.WAIST_FROM_CHEST_LEGS_AVERAGING), - humanPoseManager.getValue(SkeletonConfigValues.HIP_FROM_CHEST_LEGS_AVERAGING), - humanPoseManager.getValue(SkeletonConfigValues.HIP_FROM_WAIST_LEGS_AVERAGING), - humanPoseManager.getValue(SkeletonConfigValues.HIP_LEGS_AVERAGING), - humanPoseManager.getValue(SkeletonConfigValues.KNEE_TRACKER_ANKLE_AVERAGING), - humanPoseManager.getValue(SkeletonConfigValues.KNEE_ANKLE_AVERAGING), - ) - val legTweaksOffset = LegTweaksSettings - .createLegTweaksSettings( - fbb, - legTweaksConfig.correctionStrength, - ) - val skeletonConfigOffset = SkeletonHeight - .createSkeletonHeight( - fbb, - skeletonConfig.hmdHeight, - skeletonConfig.floorHeight, - ) - return ModelSettings - .createModelSettings( - fbb, - togglesOffset, - ratiosOffset, - legTweaksOffset, - skeletonConfigOffset, - ) -} - -fun createAutoBoneSettings( - fbb: FlatBufferBuilder, - autoBoneConfig: AutoBoneConfig, -): Int = AutoBoneSettings - .createAutoBoneSettings( - fbb, - autoBoneConfig.cursorIncrement, - autoBoneConfig.minDataDistance, - autoBoneConfig.maxDataDistance, - autoBoneConfig.numEpochs, - autoBoneConfig.printEveryNumEpochs, - autoBoneConfig.initialAdjustRate, - autoBoneConfig.adjustRateDecay, - autoBoneConfig.slideErrorFactor, - autoBoneConfig.offsetSlideErrorFactor, - autoBoneConfig.footHeightOffsetErrorFactor, - autoBoneConfig.bodyProportionErrorFactor, - autoBoneConfig.heightErrorFactor, - autoBoneConfig.positionErrorFactor, - autoBoneConfig.positionOffsetErrorFactor, - autoBoneConfig.calcInitError, - autoBoneConfig.randomizeFrameOrder, - autoBoneConfig.scaleEachStep, - autoBoneConfig.sampleCount, - autoBoneConfig.sampleRateMs, - autoBoneConfig.saveRecordings, - autoBoneConfig.useSkeletonHeight, - autoBoneConfig.randSeed, - ) - -/** - * Writes values from AutoBoneSettings to an AutoBoneConfig. - * - * @param autoBoneSettings The settings to read from. - * @param autoBoneConfig The config to write to. - * @return The autoBoneConfig parameter. - */ -fun readAutoBoneSettings( - autoBoneSettings: AutoBoneSettings, - autoBoneConfig: AutoBoneConfig, -): AutoBoneConfig { - if (autoBoneSettings.hasCursorIncrement()) { - autoBoneConfig.cursorIncrement = autoBoneSettings.cursorIncrement() - } - if (autoBoneSettings.hasMinDataDistance()) { - autoBoneConfig.minDataDistance = autoBoneSettings.minDataDistance() - } - if (autoBoneSettings.hasMaxDataDistance()) { - autoBoneConfig.maxDataDistance = autoBoneSettings.maxDataDistance() - } - if (autoBoneSettings.hasNumEpochs()) { - autoBoneConfig.numEpochs = autoBoneSettings.numEpochs() - } - if (autoBoneSettings.hasPrintEveryNumEpochs()) { - autoBoneConfig.printEveryNumEpochs = autoBoneSettings.printEveryNumEpochs() - } - if (autoBoneSettings.hasInitialAdjustRate()) { - autoBoneConfig.initialAdjustRate = autoBoneSettings.initialAdjustRate() - } - if (autoBoneSettings.hasAdjustRateDecay()) { - autoBoneConfig.adjustRateDecay = autoBoneSettings.adjustRateDecay() - } - if (autoBoneSettings.hasSlideErrorFactor()) { - autoBoneConfig.slideErrorFactor = autoBoneSettings.slideErrorFactor() - } - if (autoBoneSettings.hasOffsetSlideErrorFactor()) { - autoBoneConfig.offsetSlideErrorFactor = - autoBoneSettings.offsetSlideErrorFactor() - } - if (autoBoneSettings.hasFootHeightOffsetErrorFactor()) { - autoBoneConfig - .footHeightOffsetErrorFactor = - autoBoneSettings.footHeightOffsetErrorFactor() - } - if (autoBoneSettings.hasBodyProportionErrorFactor()) { - autoBoneConfig - .bodyProportionErrorFactor = autoBoneSettings.bodyProportionErrorFactor() - } - if (autoBoneSettings.hasHeightErrorFactor()) { - autoBoneConfig.heightErrorFactor = autoBoneSettings.heightErrorFactor() - } - if (autoBoneSettings.hasPositionErrorFactor()) { - autoBoneConfig.positionErrorFactor = autoBoneSettings.positionErrorFactor() - } - if (autoBoneSettings.hasPositionOffsetErrorFactor()) { - autoBoneConfig - .positionOffsetErrorFactor = autoBoneSettings.positionOffsetErrorFactor() - } - if (autoBoneSettings.hasCalcInitError()) { - autoBoneConfig.calcInitError = autoBoneSettings.calcInitError() - } - if (autoBoneSettings.hasRandomizeFrameOrder()) { - autoBoneConfig.randomizeFrameOrder = autoBoneSettings.randomizeFrameOrder() - } - if (autoBoneSettings.hasScaleEachStep()) { - autoBoneConfig.scaleEachStep = autoBoneSettings.scaleEachStep() - } - if (autoBoneSettings.hasSampleCount()) { - autoBoneConfig.sampleCount = autoBoneSettings.sampleCount() - } - if (autoBoneSettings.hasSampleRateMs()) { - autoBoneConfig.sampleRateMs = autoBoneSettings.sampleRateMs() - } - if (autoBoneSettings.hasSaveRecordings()) { - autoBoneConfig.saveRecordings = autoBoneSettings.saveRecordings() - } - if (autoBoneSettings.hasUseSkeletonHeight()) { - autoBoneConfig.useSkeletonHeight = autoBoneSettings.useSkeletonHeight() - } - if (autoBoneSettings.hasRandSeed()) { - autoBoneConfig.randSeed = autoBoneSettings.randSeed() - } - - return autoBoneConfig -} - -fun createArmsResetModeSettings( - fbb: FlatBufferBuilder, - resetsConfig: ResetsConfig, -): Int = ResetsSettings - .createResetsSettings( - fbb, - resetsConfig.resetMountingFeet, - resetsConfig.mode.id, - resetsConfig.yawResetSmoothTime, - resetsConfig.saveMountingReset, - resetsConfig.resetHmdPitch, - ) - -fun createSettingsResponse(fbb: FlatBufferBuilder, server: VRServer): Int { - val bridge = server.getVRBridge(ISteamVRBridge::class.java) - - return SettingsResponse - .createSettingsResponse( - fbb, - createSteamVRSettings(fbb, bridge), - createFilterSettings( - fbb, - server.configManager.vrConfig.filters, - ), - createDriftCompensationSettings( - fbb, - server.configManager.vrConfig.driftCompensation, - ), - createOSCRouterSettings( - fbb, - server.configManager.vrConfig.oscRouter, - ), - createVRCOSCSettings( - fbb, - server.configManager.vrConfig.vrcOSC, - ), - createVMCOSCSettings( - fbb, - server.configManager.vrConfig.vmc, - ), - createModelSettings( - fbb, - server.humanPoseManager, - server.configManager.vrConfig.legTweaks, - server.configManager.vrConfig.skeleton, - ), - createTapDetectionSettings( - fbb, - server.configManager.vrConfig.tapDetection, - ), - createAutoBoneSettings( - fbb, - server.configManager.vrConfig.autoBone, - ), - createArmsResetModeSettings( - fbb, - server.configManager.vrConfig.resetsConfig, - ), - createStayAlignedSettings( - fbb, - server.configManager.vrConfig.stayAlignedConfig, - ), - createHIDSettings(fbb, server.configManager.vrConfig.hidConfig), - ) -} - -fun createStayAlignedSettings( - fbb: FlatBufferBuilder, - config: StayAlignedConfig, -): Int = StayAlignedSettings - .createStayAlignedSettings( - fbb, - config.enabled, - false, // deprecated - config.hideYawCorrection, - config.standingRelaxedPose.enabled, - config.standingRelaxedPose.upperLegAngleInDeg, - config.standingRelaxedPose.lowerLegAngleInDeg, - config.standingRelaxedPose.footAngleInDeg, - config.sittingRelaxedPose.enabled, - config.sittingRelaxedPose.upperLegAngleInDeg, - config.sittingRelaxedPose.lowerLegAngleInDeg, - config.sittingRelaxedPose.footAngleInDeg, - config.flatRelaxedPose.enabled, - config.flatRelaxedPose.upperLegAngleInDeg, - config.flatRelaxedPose.lowerLegAngleInDeg, - config.flatRelaxedPose.footAngleInDeg, - config.setupComplete, - ) - -fun createHIDSettings( - fbb: FlatBufferBuilder, - config: HIDConfig, -): Int = HIDSettings - .createHIDSettings( - fbb, - config.trackersOverHID, - ) diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/settings/RPCSettingsHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/settings/RPCSettingsHandler.kt deleted file mode 100644 index 5f0453107a..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/settings/RPCSettingsHandler.kt +++ /dev/null @@ -1,389 +0,0 @@ -package dev.slimevr.protocol.rpc.settings - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.bridge.ISteamVRBridge -import dev.slimevr.config.ArmsResetModes -import dev.slimevr.filtering.TrackerFilters -import dev.slimevr.protocol.GenericConnection -import dev.slimevr.protocol.ProtocolAPI -import dev.slimevr.protocol.rpc.RPCHandler -import dev.slimevr.tracking.processor.config.SkeletonConfigToggles -import dev.slimevr.tracking.processor.config.SkeletonConfigValues -import dev.slimevr.tracking.trackers.TrackerRole -import solarxr_protocol.rpc.ChangeSettingsRequest -import solarxr_protocol.rpc.RpcMessage -import solarxr_protocol.rpc.RpcMessageHeader -import solarxr_protocol.rpc.SettingsResponse -import kotlin.math.* - -class RPCSettingsHandler(var rpcHandler: RPCHandler, var api: ProtocolAPI) { - init { - rpcHandler.registerPacketListener(RpcMessage.SettingsRequest, ::onSettingsRequest) - rpcHandler.registerPacketListener(RpcMessage.ChangeSettingsRequest, ::onChangeSettingsRequest) - rpcHandler.registerPacketListener(RpcMessage.SettingsResetRequest, ::onSettingsResetRequest) - } - - fun onSettingsRequest(conn: GenericConnection, messageHeader: RpcMessageHeader?) { - rpcHandler.sendSettingsChangedResponse(conn, messageHeader) - } - - fun onChangeSettingsRequest(conn: GenericConnection?, messageHeader: RpcMessageHeader) { - val req = messageHeader - .message(ChangeSettingsRequest()) as? ChangeSettingsRequest ?: return - - if (req.steamVrTrackers() != null) { - val bridge = api.server - .getVRBridge(ISteamVRBridge::class.java) - - if (bridge != null) { - bridge.changeShareSettings(TrackerRole.WAIST, req.steamVrTrackers().waist()) - bridge.changeShareSettings(TrackerRole.CHEST, req.steamVrTrackers().chest()) - bridge.changeShareSettings(TrackerRole.LEFT_FOOT, req.steamVrTrackers().leftFoot()) - bridge.changeShareSettings(TrackerRole.RIGHT_FOOT, req.steamVrTrackers().rightFoot()) - bridge.changeShareSettings(TrackerRole.LEFT_KNEE, req.steamVrTrackers().leftKnee()) - bridge.changeShareSettings(TrackerRole.RIGHT_KNEE, req.steamVrTrackers().rightKnee()) - bridge.changeShareSettings(TrackerRole.LEFT_ELBOW, req.steamVrTrackers().leftElbow()) - bridge.changeShareSettings(TrackerRole.RIGHT_ELBOW, req.steamVrTrackers().rightElbow()) - bridge.changeShareSettings(TrackerRole.LEFT_HAND, req.steamVrTrackers().leftHand()) - bridge.changeShareSettings(TrackerRole.RIGHT_HAND, req.steamVrTrackers().rightHand()) - bridge.setAutomaticSharedTrackers(req.steamVrTrackers().automaticTrackerToggle()) - } - } - - if (req.filtering() != null) { - val type = TrackerFilters.fromId(req.filtering().type()) - if (type != null) { - val filtersConfig = api.server.configManager - .vrConfig - .filters - filtersConfig.type = type.configKey - filtersConfig.amount = req.filtering().amount() - filtersConfig.updateTrackersFilters() - } - } - - if (req.driftCompensation() != null) { - val driftCompensationConfig = api.server.configManager - .vrConfig - .driftCompensation - driftCompensationConfig.enabled = req.driftCompensation().enabled() - driftCompensationConfig.prediction = req.driftCompensation().prediction() - driftCompensationConfig.amount = req.driftCompensation().amount() - driftCompensationConfig.maxResets = req.driftCompensation().maxResets() - driftCompensationConfig.updateTrackersDriftCompensation() - } - - if (req.oscRouter() != null) { - val oscRouterConfig = api.server.configManager - .vrConfig - .oscRouter - val oscRouter = api.server.oSCRouter - val osc = req.oscRouter().oscSettings() - if (osc != null) { - oscRouterConfig.enabled = osc.enabled() - oscRouterConfig.portIn = osc.portIn() - oscRouterConfig.portOut = osc.portOut() - oscRouterConfig.address = osc.address() - } - - oscRouter.refreshSettings(true) - } - - if (req.vrcOsc() != null) { - val vrcOSCConfig = api.server.configManager - .vrConfig - .vrcOSC - val vrcOscHandler = api.server.vrcOSCHandler - val osc = req.vrcOsc().oscSettings() - val trackers = req.vrcOsc().trackers() - - if (osc != null) { - vrcOSCConfig.enabled = osc.enabled() - vrcOSCConfig.portIn = osc.portIn() - vrcOSCConfig.portOut = osc.portOut() - vrcOSCConfig.address = osc.address() - } - if (trackers != null) { - vrcOSCConfig.setOSCTrackerRole(TrackerRole.HEAD, trackers.head()) - vrcOSCConfig.setOSCTrackerRole(TrackerRole.CHEST, trackers.chest()) - vrcOSCConfig.setOSCTrackerRole(TrackerRole.WAIST, trackers.waist()) - vrcOSCConfig.setOSCTrackerRole(TrackerRole.LEFT_KNEE, trackers.knees()) - vrcOSCConfig.setOSCTrackerRole(TrackerRole.RIGHT_KNEE, trackers.knees()) - vrcOSCConfig.setOSCTrackerRole(TrackerRole.LEFT_FOOT, trackers.feet()) - vrcOSCConfig.setOSCTrackerRole(TrackerRole.RIGHT_FOOT, trackers.feet()) - vrcOSCConfig.setOSCTrackerRole(TrackerRole.LEFT_ELBOW, trackers.elbows()) - vrcOSCConfig.setOSCTrackerRole(TrackerRole.RIGHT_ELBOW, trackers.elbows()) - vrcOSCConfig.setOSCTrackerRole(TrackerRole.LEFT_HAND, trackers.hands()) - vrcOSCConfig.setOSCTrackerRole(TrackerRole.RIGHT_HAND, trackers.hands()) - } - vrcOSCConfig.oscqueryEnabled = req.vrcOsc().oscqueryEnabled() - - vrcOscHandler.refreshSettings(true) - } - - if (req.vmcOsc() != null) { - val vmcConfig = api.server.configManager - .vrConfig - .vmc - val vmcHandler = api.server.vMCHandler - val osc = req.vmcOsc().oscSettings() - - if (osc != null) { - vmcConfig.enabled = osc.enabled() - vmcConfig.portIn = osc.portIn() - vmcConfig.portOut = osc.portOut() - vmcConfig.address = osc.address() - } - if (req.vmcOsc().vrmJson() != null) { - vmcConfig.vrmJson = req.vmcOsc().vrmJson().ifEmpty { null } - } - vmcConfig.anchorHip = req.vmcOsc().anchorHip() - vmcConfig.mirrorTracking = req.vmcOsc().mirrorTracking() - - vmcHandler.refreshSettings(true) - } - - if (req.tapDetectionSettings() != null) { - val tapDetectionConfig = api.server.configManager - .vrConfig - .tapDetection - val tapDetectionSettings = req.tapDetectionSettings() - - if (tapDetectionSettings != null) { - // enable/disable tap detection - tapDetectionConfig.yawResetEnabled = tapDetectionSettings.yawResetEnabled() - tapDetectionConfig.fullResetEnabled = tapDetectionSettings.fullResetEnabled() - tapDetectionConfig - .mountingResetEnabled = tapDetectionSettings.mountingResetEnabled() - tapDetectionConfig.setupMode = tapDetectionSettings.setupMode() - - // set number of trackers that can have high accel before taps - // are rejected - if (tapDetectionSettings.hasNumberTrackersOverThreshold()) { - tapDetectionConfig - .numberTrackersOverThreshold = tapDetectionSettings.numberTrackersOverThreshold() - } - - // set tap detection delays - if (tapDetectionSettings.hasYawResetDelay()) { - tapDetectionConfig.yawResetDelay = tapDetectionSettings.yawResetDelay() - } - if (tapDetectionSettings.hasFullResetDelay()) { - tapDetectionConfig.fullResetDelay = tapDetectionSettings.fullResetDelay() - } - if (tapDetectionSettings.hasMountingResetDelay()) { - tapDetectionConfig - .mountingResetDelay = tapDetectionSettings.mountingResetDelay() - } - - // set the number of taps required for each action - if (tapDetectionSettings.hasYawResetTaps()) { - tapDetectionConfig - .yawResetTaps = tapDetectionSettings.yawResetTaps() - } - if (tapDetectionSettings.hasFullResetTaps()) { - tapDetectionConfig - .fullResetTaps = tapDetectionSettings.fullResetTaps() - } - if (tapDetectionSettings.hasMountingResetTaps()) { - tapDetectionConfig - .mountingResetTaps = tapDetectionSettings.mountingResetTaps() - } - - api.server.humanPoseManager.updateTapDetectionConfig() - } - } - - val modelSettings = req.modelSettings() - if (modelSettings != null) { - val hpm = api.server.humanPoseManager - val legTweaksConfig = api.server.configManager.vrConfig.legTweaks - val toggles = modelSettings.toggles() - val ratios = modelSettings.ratios() - val legTweaks = modelSettings.legTweaks() - - if (toggles != null) { - // Note: toggles.has____ returns the same as toggles._____ this - // seems like a bug - hpm.setToggle(SkeletonConfigToggles.EXTENDED_SPINE_MODEL, toggles.extendedSpine()) - hpm - .setToggle( - SkeletonConfigToggles.EXTENDED_PELVIS_MODEL, - toggles.extendedPelvis(), - ) - hpm.setToggle(SkeletonConfigToggles.EXTENDED_KNEE_MODEL, toggles.extendedKnee()) - hpm - .setToggle( - SkeletonConfigToggles.FORCE_ARMS_FROM_HMD, - toggles.forceArmsFromHmd(), - ) - hpm.setToggle(SkeletonConfigToggles.EXTENDED_SPINE_MODEL, toggles.extendedSpine()) - hpm - .setToggle( - SkeletonConfigToggles.EXTENDED_PELVIS_MODEL, - toggles.extendedPelvis(), - ) - hpm.setToggle(SkeletonConfigToggles.EXTENDED_KNEE_MODEL, toggles.extendedKnee()) - hpm - .setToggle( - SkeletonConfigToggles.FORCE_ARMS_FROM_HMD, - toggles.forceArmsFromHmd(), - ) - hpm.setToggle(SkeletonConfigToggles.FLOOR_CLIP, toggles.floorClip()) - hpm - .setToggle( - SkeletonConfigToggles.SKATING_CORRECTION, - toggles.skatingCorrection(), - ) - hpm.setToggle(SkeletonConfigToggles.TOE_SNAP, toggles.toeSnap()) - hpm.setToggle(SkeletonConfigToggles.FOOT_PLANT, toggles.footPlant()) - hpm.setToggle(SkeletonConfigToggles.SELF_LOCALIZATION, toggles.selfLocalization()) - hpm.setToggle(SkeletonConfigToggles.USE_POSITION, toggles.usePosition()) - hpm.setToggle(SkeletonConfigToggles.ENFORCE_CONSTRAINTS, toggles.enforceConstraints()) - hpm.setToggle(SkeletonConfigToggles.CORRECT_CONSTRAINTS, toggles.correctConstraints()) - } - - if (ratios != null) { - if (ratios.hasImputeWaistFromChestHip()) { - hpm - .setValue( - SkeletonConfigValues.WAIST_FROM_CHEST_HIP_AVERAGING, - max(0f, ratios.imputeWaistFromChestHip()), - ) - } - if (ratios.hasImputeWaistFromChestLegs()) { - hpm - .setValue( - SkeletonConfigValues.WAIST_FROM_CHEST_LEGS_AVERAGING, - max(0f, ratios.imputeWaistFromChestLegs()), - ) - } - if (ratios.hasImputeHipFromChestLegs()) { - hpm - .setValue( - SkeletonConfigValues.HIP_FROM_CHEST_LEGS_AVERAGING, - max(0f, ratios.imputeHipFromChestLegs()), - ) - } - if (ratios.hasImputeHipFromWaistLegs()) { - hpm - .setValue( - SkeletonConfigValues.HIP_FROM_WAIST_LEGS_AVERAGING, - max(0f, ratios.imputeHipFromWaistLegs()), - ) - } - if (ratios.hasInterpHipLegs()) { - hpm - .setValue( - SkeletonConfigValues.HIP_LEGS_AVERAGING, - max(0f, ratios.interpHipLegs()), - ) - } - if (ratios.hasInterpKneeTrackerAnkle()) { - hpm - .setValue( - SkeletonConfigValues.KNEE_TRACKER_ANKLE_AVERAGING, - max(0f, ratios.interpKneeTrackerAnkle()), - ) - } - if (ratios.hasInterpKneeAnkle()) { - hpm - .setValue( - SkeletonConfigValues.KNEE_ANKLE_AVERAGING, - max(0f, ratios.interpKneeAnkle()), - ) - } - } - - if (legTweaks != null) { - if (legTweaks.hasCorrectionStrength()) { - legTweaksConfig.correctionStrength = legTweaks.correctionStrength() - } - api.server.humanPoseManager.updateLegTweaksConfig() - } - - modelSettings.skeletonHeight()?.let { - api.server.configManager.vrConfig.skeleton.hmdHeight = it.hmdHeight() - api.server.configManager.vrConfig.skeleton.floorHeight = it.floorHeight() - } - - hpm.saveConfig() - } - - val autoBoneSettings = req.autoBoneSettings() - if (autoBoneSettings != null) { - val autoBoneConfig = api.server.configManager - .vrConfig - .autoBone - - readAutoBoneSettings(autoBoneSettings, autoBoneConfig) - } - - if (req.resetsSettings() != null) { - val resetsConfig = api.server.configManager - .vrConfig - .resetsConfig - val mode = ArmsResetModes - .fromId(max(req.resetsSettings().armsMountingResetMode(), 0)) - if (mode != null) { - resetsConfig.mode = mode - } - resetsConfig.resetMountingFeet = req.resetsSettings().resetMountingFeet() - resetsConfig.saveMountingReset = req.resetsSettings().saveMountingReset() - resetsConfig.yawResetSmoothTime = req.resetsSettings().yawResetSmoothTime() - resetsConfig.resetHmdPitch = req.resetsSettings().resetHmdPitch() - resetsConfig.updateTrackersResetsSettings() - } - - if (req.stayAligned() != null) { - val config = api.server.configManager.vrConfig.stayAlignedConfig - val requestConfig = req.stayAligned() - config.enabled = requestConfig.enabled() - config.hideYawCorrection = requestConfig.hideYawCorrection() - config.standingRelaxedPose.enabled = requestConfig.standingEnabled() - config.standingRelaxedPose.upperLegAngleInDeg = requestConfig.standingUpperLegAngle() - config.standingRelaxedPose.lowerLegAngleInDeg = requestConfig.standingLowerLegAngle() - config.standingRelaxedPose.footAngleInDeg = requestConfig.standingFootAngle() - config.sittingRelaxedPose.enabled = requestConfig.sittingEnabled() - config.sittingRelaxedPose.upperLegAngleInDeg = requestConfig.sittingUpperLegAngle() - config.sittingRelaxedPose.lowerLegAngleInDeg = requestConfig.sittingLowerLegAngle() - config.sittingRelaxedPose.footAngleInDeg = requestConfig.sittingFootAngle() - config.flatRelaxedPose.enabled = requestConfig.flatEnabled() - config.flatRelaxedPose.upperLegAngleInDeg = requestConfig.flatUpperLegAngle() - config.flatRelaxedPose.lowerLegAngleInDeg = requestConfig.flatLowerLegAngle() - config.flatRelaxedPose.footAngleInDeg = requestConfig.flatFootAngle() - } - - if (req.hidSettings() != null) { - val config = api.server.configManager.vrConfig.hidConfig - val requestConfig = req.hidSettings() - config.trackersOverHID = requestConfig.trackersOverHid() - } - - api.server.configManager.saveConfig() - } - - fun onSettingsResetRequest(conn: GenericConnection, messageHeader: RpcMessageHeader?) { - api.server.configManager.resetConfig() - } - - companion object { - fun sendSteamVRUpdatedSettings(api: ProtocolAPI, rpcHandler: RPCHandler) { - val fbb = FlatBufferBuilder(32) - val bridge: ISteamVRBridge = - api.server.getVRBridge(ISteamVRBridge::class.java) ?: return - - val settings = SettingsResponse - .createSettingsResponse( - fbb, - createSteamVRSettings(fbb, bridge), 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - ) - val outbound = - rpcHandler.createRPCMessage(fbb, RpcMessage.SettingsResponse, settings) - fbb.finish(outbound) - api.apiServers.forEach { apiServer -> - apiServer.apiConnections.forEach { it.send(fbb.dataBuffer()) } - } - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/setup/RPCHandshakeHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/setup/RPCHandshakeHandler.kt deleted file mode 100644 index 8cfb4f7786..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/setup/RPCHandshakeHandler.kt +++ /dev/null @@ -1,86 +0,0 @@ -package dev.slimevr.protocol.rpc.setup - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.protocol.GenericConnection -import dev.slimevr.protocol.ProtocolAPI -import dev.slimevr.protocol.rpc.RPCHandler -import dev.slimevr.setup.HandshakeListener -import dev.slimevr.tracking.trackers.udp.UDPDevice -import solarxr_protocol.rpc.AddUnknownDeviceRequest -import solarxr_protocol.rpc.ForgetDeviceRequest -import solarxr_protocol.rpc.RpcMessage -import solarxr_protocol.rpc.RpcMessageHeader -import solarxr_protocol.rpc.UnknownDeviceHandshakeNotification - -class RPCHandshakeHandler( - private val rpcHandler: RPCHandler, - val api: ProtocolAPI, -) : HandshakeListener { - init { - rpcHandler.registerPacketListener( - RpcMessage.AddUnknownDeviceRequest, - ::onAddUnknownDevice, - ) - - rpcHandler.registerPacketListener( - RpcMessage.ForgetDeviceRequest, - ::onForgetDevice, - ) - - this.api.server.handshakeHandler.addListener(this) - } - - override fun onUnknownHandshake(macAddress: String) { - val fbb = FlatBufferBuilder(32) - val string = fbb.createString(macAddress) - val update = - UnknownDeviceHandshakeNotification.createUnknownDeviceHandshakeNotification( - fbb, - string, - ) - val outbound = rpcHandler.createRPCMessage( - fbb, - RpcMessage.UnknownDeviceHandshakeNotification, - update, - ) - fbb.finish(outbound) - - api.apiServers.forEach { apiServer -> - apiServer.apiConnections.forEach { - it.send(fbb.dataBuffer()) - } - } - } - - fun onAddUnknownDevice( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val req = - messageHeader.message(AddUnknownDeviceRequest()) as AddUnknownDeviceRequest? - ?: return - - this.api.server.configManager.vrConfig.addKnownDevice( - req.macAddress() ?: return, - ) - this.api.server.configManager.saveConfig() - } - - fun onForgetDevice( - conn: GenericConnection, - messageHeader: RpcMessageHeader, - ) { - val req = messageHeader.message(ForgetDeviceRequest()) as ForgetDeviceRequest? - ?: return - - this.api.server.configManager.vrConfig.forgetKnownDevice( - req.macAddress() ?: return, - ) - val device = - this.api.server.deviceManager.devices.find { it.hardwareIdentifier == req.macAddress() } - if (device != null && device is UDPDevice) { - this.api.server.trackersServer.disconnectDevice(device) - } - this.api.server.configManager.saveConfig() - } -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/setup/RPCTapSetupHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/setup/RPCTapSetupHandler.kt deleted file mode 100644 index ea5ac3adc5..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/setup/RPCTapSetupHandler.kt +++ /dev/null @@ -1,45 +0,0 @@ -package dev.slimevr.protocol.rpc.setup - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.protocol.GenericConnection -import dev.slimevr.protocol.ProtocolAPI -import dev.slimevr.protocol.datafeed.createTrackerId -import dev.slimevr.protocol.rpc.RPCHandler -import dev.slimevr.setup.TapSetupListener -import dev.slimevr.tracking.trackers.Tracker -import solarxr_protocol.rpc.RpcMessage -import solarxr_protocol.rpc.TapDetectionSetupNotification - -class RPCTapSetupHandler( - private val rpcHandler: RPCHandler, - val api: ProtocolAPI, -) : TapSetupListener { - init { - this.api.server.tapSetupHandler.addListener(this) - } - - override fun onStarted(tracker: Tracker) { - val fbb = FlatBufferBuilder(32) - val idOffset = createTrackerId(fbb, tracker) - val update = TapDetectionSetupNotification.createTapDetectionSetupNotification(fbb, idOffset) - val outbound = - rpcHandler.createRPCMessage(fbb, RpcMessage.TapDetectionSetupNotification, update) - fbb.finish(outbound) - - forAllListeners { - it.send( - fbb.dataBuffer(), - ) - } - } - - private fun forAllListeners(action: (GenericConnection) -> Unit) { - api - .apiServers - .forEach { - it - .apiConnections - .forEach(action) - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/setup/RPCUtil.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/setup/RPCUtil.kt deleted file mode 100644 index ef46f2a6c3..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/setup/RPCUtil.kt +++ /dev/null @@ -1,19 +0,0 @@ -package dev.slimevr.protocol.rpc.setup - -import java.net.NetworkInterface - -object RPCUtil { - @JvmStatic - fun getLocalIp(): String? { - for (netInt in NetworkInterface.getNetworkInterfaces()) { - if (netInt.isUp && !netInt.isLoopback && !netInt.isVirtual) { - for (netAddr in netInt.interfaceAddresses) { - if (netAddr.address.isSiteLocalAddress && netAddr.broadcast != null) { - return netAddr.address.hostAddress - } - } - } - } - return null - } -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/status/RPCStatusHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/status/RPCStatusHandler.kt deleted file mode 100644 index f9df6f8341..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/status/RPCStatusHandler.kt +++ /dev/null @@ -1,76 +0,0 @@ -package dev.slimevr.protocol.rpc.status - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.protocol.ProtocolAPI -import dev.slimevr.protocol.rpc.RPCHandler -import dev.slimevr.status.StatusListener -import solarxr_protocol.rpc.RpcMessage -import solarxr_protocol.rpc.StatusDataUnion -import solarxr_protocol.rpc.StatusMessage -import solarxr_protocol.rpc.StatusSystemFixed -import solarxr_protocol.rpc.StatusSystemUpdate - -class RPCStatusHandler( - private val rpcHandler: RPCHandler, - val api: ProtocolAPI, -) : StatusListener { - - init { - api.server.statusSystem.addListener(this) - } - - override fun onStatusChanged( - id: UInt, - message: StatusDataUnion, - prioritized: Boolean, - ) { - val fbb = FlatBufferBuilder(STATUS_EXPECTED_SIZE) - - val messageOffset = StatusDataUnion.pack(fbb, message) - - StatusMessage.startStatusMessage(fbb) - StatusMessage.addData(fbb, messageOffset) - StatusMessage.addId(fbb, id.toLong()) - StatusMessage.addDataType(fbb, message.type) - StatusMessage.addPrioritized(fbb, prioritized) - val statusOffset = StatusMessage.endStatusMessage(fbb) - - StatusSystemUpdate.startStatusSystemUpdate(fbb) - StatusSystemUpdate.addNewStatus(fbb, statusOffset) - val update = StatusSystemUpdate.endStatusSystemUpdate(fbb) - - val outbound = this.rpcHandler.createRPCMessage( - fbb, - RpcMessage.StatusSystemUpdate, - update, - ) - fbb.finish(outbound) - - this.api.apiServers.forEach { apiServer -> - apiServer.apiConnections.forEach { it.send(fbb.dataBuffer()) } - } - } - - override fun onStatusRemoved(id: UInt) { - val fbb = FlatBufferBuilder(4) - - StatusSystemFixed.startStatusSystemFixed(fbb) - StatusSystemFixed.addFixedStatusId(fbb, id.toLong()) - val update = StatusSystemFixed.endStatusSystemFixed(fbb) - - val outbound = this.rpcHandler.createRPCMessage( - fbb, - RpcMessage.StatusSystemFixed, - update, - ) - fbb.finish(outbound) - - this.api.apiServers.forEach { apiServer -> - apiServer.apiConnections.forEach { it.send(fbb.dataBuffer()) } - } - } - - companion object { - const val STATUS_EXPECTED_SIZE = 32 - } -} diff --git a/server/core/src/main/java/dev/slimevr/protocol/rpc/trackingchecklist/RPCTrackingChecklistHandler.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/trackingchecklist/RPCTrackingChecklistHandler.kt deleted file mode 100644 index ca6f6b34e7..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/trackingchecklist/RPCTrackingChecklistHandler.kt +++ /dev/null @@ -1,73 +0,0 @@ -package dev.slimevr.protocol.rpc.trackingchecklist - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.protocol.GenericConnection -import dev.slimevr.protocol.ProtocolAPI -import dev.slimevr.protocol.rpc.RPCHandler -import dev.slimevr.trackingchecklist.TrackingChecklistListener -import solarxr_protocol.rpc.* - -class RPCTrackingChecklistHandler( - private val rpcHandler: RPCHandler, - var api: ProtocolAPI, -) : TrackingChecklistListener { - - init { - api.server.trackingChecklistManager.addListener(this) - - rpcHandler.registerPacketListener(RpcMessage.TrackingChecklistRequest, ::onTrackingChecklistRequest) - rpcHandler.registerPacketListener(RpcMessage.IgnoreTrackingChecklistStepRequest, ::onToggleTrackingChecklistRequest) - } - - fun buildTrackingChecklistResponse(fbb: FlatBufferBuilder): Int = TrackingChecklistResponse.pack( - fbb, - TrackingChecklistResponseT().apply { - steps = api.server.trackingChecklistManager.steps.toTypedArray() - ignoredSteps = api.server.configManager.vrConfig.trackingChecklist.ignoredStepsIds.toIntArray() - }, - ) - - private fun onTrackingChecklistRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val fbb = FlatBufferBuilder(32) - val response = buildTrackingChecklistResponse(fbb) - val outbound = rpcHandler.createRPCMessage( - fbb, - RpcMessage.TrackingChecklistResponse, - response, - ) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - - private fun onToggleTrackingChecklistRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - val req = messageHeader.message(IgnoreTrackingChecklistStepRequest()) as IgnoreTrackingChecklistStepRequest? - ?: return - val step = api.server.trackingChecklistManager.steps.find { it.id == req.stepId() } ?: error("invalid step id requested") - - api.server.trackingChecklistManager.ignoreStep(step, req.ignore()) - - val fbb = FlatBufferBuilder(32) - val response = buildTrackingChecklistResponse(fbb) - val outbound = rpcHandler.createRPCMessage( - fbb, - RpcMessage.TrackingChecklistResponse, - response, - ) - fbb.finish(outbound) - conn.send(fbb.dataBuffer()) - } - - override fun onStepsUpdate() { - val fbb = FlatBufferBuilder(32) - val response = buildTrackingChecklistResponse(fbb) - val outbound = rpcHandler.createRPCMessage( - fbb, - RpcMessage.TrackingChecklistResponse, - 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/protocol/rpc/trackingpause/RPCTrackingPause.kt b/server/core/src/main/java/dev/slimevr/protocol/rpc/trackingpause/RPCTrackingPause.kt deleted file mode 100644 index 8ff19b2458..0000000000 --- a/server/core/src/main/java/dev/slimevr/protocol/rpc/trackingpause/RPCTrackingPause.kt +++ /dev/null @@ -1,53 +0,0 @@ -package dev.slimevr.protocol.rpc.trackingpause - -import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.protocol.GenericConnection -import dev.slimevr.protocol.ProtocolAPI -import dev.slimevr.protocol.rpc.RPCHandler -import dev.slimevr.trackingpause.TrackingPauseListener -import solarxr_protocol.rpc.RpcMessage -import solarxr_protocol.rpc.RpcMessageHeader -import solarxr_protocol.rpc.TrackingPauseStateResponse -import java.nio.ByteBuffer - -class RPCTrackingPause(private val rpcHandler: RPCHandler, private val api: ProtocolAPI) : TrackingPauseListener { - - private val currentPauseState - get() = api.server.humanPoseManager.skeleton.getPauseTracking() - - init { - rpcHandler.registerPacketListener( - RpcMessage.TrackingPauseStateRequest, - ::onTrackingPauseStateRequest, - ) - - // HumanPoseManager might not be immediately available, so queue the server - // to register the listener once it's fully initialized - api.server.queueTask { - api.server.humanPoseManager.trackingPauseHandler.addListener(this) - } - } - - private fun getPauseStateResponse(trackingPaused: Boolean, messageHeader: RpcMessageHeader? = null): ByteBuffer { - val fbb = FlatBufferBuilder(32) - val state = TrackingPauseStateResponse.createTrackingPauseStateResponse(fbb, trackingPaused) - val outbound = rpcHandler.createRPCMessage(fbb, RpcMessage.TrackingPauseStateResponse, state, messageHeader) - fbb.finish(outbound) - return fbb.dataBuffer() - } - - private fun onTrackingPauseStateRequest(conn: GenericConnection, messageHeader: RpcMessageHeader) { - conn.send(getPauseStateResponse(currentPauseState, messageHeader)) - } - - override fun onTrackingPause(trackingPaused: Boolean) { - val pauseState = getPauseStateResponse(trackingPaused) - forAllListeners { it.send(pauseState) } - } - - private fun forAllListeners(action: (GenericConnection) -> Unit) { - api.apiServers.forEach { - it.apiConnections.forEach(action) - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/reset/ResetHandler.kt b/server/core/src/main/java/dev/slimevr/reset/ResetHandler.kt deleted file mode 100644 index 69a68c6cb7..0000000000 --- a/server/core/src/main/java/dev/slimevr/reset/ResetHandler.kt +++ /dev/null @@ -1,24 +0,0 @@ -package dev.slimevr.reset - -import java.util.concurrent.CopyOnWriteArrayList -import java.util.function.Consumer - -class ResetHandler { - private val listeners: MutableList = CopyOnWriteArrayList() - - fun sendStarted(resetType: Int, bodyParts: List? = null, progress: Int = 0, duration: Int = 0) { - this.listeners.forEach { listener: ResetListener -> listener.onStarted(resetType, bodyParts, progress, duration) } - } - - fun sendFinished(resetType: Int, bodyParts: List? = null, duration: Int) { - this.listeners.forEach { listener: ResetListener -> listener.onFinished(resetType, bodyParts, duration) } - } - - fun addListener(listener: ResetListener) { - this.listeners.add(listener) - } - - fun removeListener(l: ResetListener) { - listeners.removeIf { listener: ResetListener -> l === listener } - } -} diff --git a/server/core/src/main/java/dev/slimevr/reset/ResetListener.kt b/server/core/src/main/java/dev/slimevr/reset/ResetListener.kt deleted file mode 100644 index b50fd3a91d..0000000000 --- a/server/core/src/main/java/dev/slimevr/reset/ResetListener.kt +++ /dev/null @@ -1,7 +0,0 @@ -package dev.slimevr.reset - -interface ResetListener { - fun onStarted(resetType: Int, bodyParts: List? = null, progress: Int, duration: Int) - - fun onFinished(resetType: Int, bodyParts: List? = null, duration: Int) -} diff --git a/server/core/src/main/java/dev/slimevr/reset/ResetTimer.kt b/server/core/src/main/java/dev/slimevr/reset/ResetTimer.kt deleted file mode 100644 index 2734e2af33..0000000000 --- a/server/core/src/main/java/dev/slimevr/reset/ResetTimer.kt +++ /dev/null @@ -1,40 +0,0 @@ -package dev.slimevr.reset - -import java.util.Timer -import java.util.TimerTask -import kotlin.concurrent.schedule -import kotlin.math.floor -import kotlin.math.min - -class ResetTimerManager { - val timer: Timer = Timer() - val timers: ArrayList = arrayListOf() - - fun cancelTimers() { - timers.forEach { it.cancel() } - } -} - -fun resetTimer(resetTimerManager: ResetTimerManager, delay: Long, onTick: (progress: Int) -> Unit, onComplete: () -> Unit) { - resetTimerManager.cancelTimers() - - if (delay == 0L) { - onComplete() - return - } - - val ticks: Int = floor(delay / 1000f).toInt() - for (tick in 0..ticks) { - if (tick * 1000L == delay) continue - resetTimerManager.timers.add( - resetTimerManager.timer.schedule(tick * 1000L) { - onTick(tick * 1000) - }, - ) - } - resetTimerManager.timers.add( - resetTimerManager.timer.schedule(delay) { - onComplete() - }, - ) -} diff --git a/server/core/src/main/java/dev/slimevr/serial/ProvisioningHandler.java b/server/core/src/main/java/dev/slimevr/serial/ProvisioningHandler.java deleted file mode 100644 index a17b17dda4..0000000000 --- a/server/core/src/main/java/dev/slimevr/serial/ProvisioningHandler.java +++ /dev/null @@ -1,250 +0,0 @@ -package dev.slimevr.serial; - -import dev.slimevr.VRServer; -import io.eiren.util.logging.LogManager; -import kotlin.text.Regex; -import org.jetbrains.annotations.NotNull; - -import java.util.List; -import java.util.Timer; -import java.util.TimerTask; -import java.util.concurrent.CopyOnWriteArrayList; - - -public class ProvisioningHandler implements SerialListener { - - private ProvisioningStatus provisioningStatus = ProvisioningStatus.NONE; - - private boolean isRunning = false; - private final List listeners = new CopyOnWriteArrayList<>(); - - private String ssid; - private String password; - - private String preferredPort; - - private final Timer provisioningTickTimer = new Timer("ProvisioningTickTimer"); - private long lastStatusChange = -1; - private byte connectRetries = 0; - private boolean hasLogs = false; - private final byte MAX_CONNECTION_RETRIES = 1; - private final VRServer vrServer; - - public ProvisioningHandler(VRServer vrServer) { - this.vrServer = vrServer; - vrServer.serialHandler.addListener(this); - this.provisioningTickTimer.scheduleAtFixedRate(new TimerTask() { - @Override - public void run() { - if (!isRunning || provisioningStatus == ProvisioningStatus.DONE) - return; - provisioningTick(); - } - }, 0, 1000); - } - - - public void start(String ssid, String password, String port) { - this.isRunning = true; - this.hasLogs = false; - this.ssid = ssid; - this.password = password; - this.preferredPort = port; - this.provisioningStatus = ProvisioningStatus.NONE; - this.connectRetries = 0; - } - - public void stop() { - this.isRunning = false; - this.hasLogs = false; - this.ssid = null; - this.password = null; - this.connectRetries = 0; - this.changeStatus(ProvisioningStatus.NONE); - this.vrServer.serialHandler.closeSerial(); - } - - public void initSerial(String port) { - this.provisioningStatus = ProvisioningStatus.SERIAL_INIT; - this.hasLogs = false; - - try { - boolean openResult = false; - if (port != null) - openResult = vrServer.serialHandler.openSerial(port, false); - else - openResult = vrServer.serialHandler.openSerial(null, true); - if (!openResult) - LogManager.info("[SerialHandler] Serial port wasn't open..."); - } catch (Exception e) { - LogManager.severe("[SerialHandler] Unable to open serial port", e); - } catch (Throwable e) { - LogManager - .severe("[SerialHandler] Using serial ports is not supported on this platform", e); - } - - } - - public void tryObtainMacAddress() { - this.changeStatus(ProvisioningStatus.OBTAINING_MAC_ADDRESS); - vrServer.serialHandler.infoRequest(); - } - - public void tryProvisioning() { - this.changeStatus(ProvisioningStatus.PROVISIONING); - vrServer.serialHandler.setWifi(this.ssid, this.password); - } - - - public void provisioningTick() { - if (this.provisioningStatus == ProvisioningStatus.OBTAINING_MAC_ADDRESS) - this.tryObtainMacAddress(); - - if ( - !hasLogs - && this.provisioningStatus == ProvisioningStatus.OBTAINING_MAC_ADDRESS - && System.currentTimeMillis() - this.lastStatusChange > 1_000 - ) { - this.changeStatus(ProvisioningStatus.NO_SERIAL_LOGS_ERROR); - return; - } - - if ( - this.provisioningStatus == ProvisioningStatus.SERIAL_INIT - && vrServer.serialHandler.getKnownPorts().findAny().isEmpty() - && System.currentTimeMillis() - this.lastStatusChange > 15_000 - ) { - this.changeStatus(ProvisioningStatus.NO_SERIAL_DEVICE_FOUND); - return; - } - - if ( - System.currentTimeMillis() - this.lastStatusChange - > this.provisioningStatus.getTimeout() - ) { - if ( - this.provisioningStatus == ProvisioningStatus.NONE - || this.provisioningStatus == ProvisioningStatus.SERIAL_INIT - ) - this.initSerial(this.preferredPort); - else if (this.provisioningStatus == ProvisioningStatus.CONNECTING) - this.changeStatus(ProvisioningStatus.CONNECTION_ERROR); - else if (this.provisioningStatus == ProvisioningStatus.LOOKING_FOR_SERVER) - this.changeStatus(ProvisioningStatus.COULD_NOT_FIND_SERVER); - else if (!this.provisioningStatus.isError()) { - this.changeStatus(ProvisioningStatus.CONNECTION_ERROR); // TIMEOUT - } - } - } - - - @Override - public void onSerialConnected(@NotNull SerialPort port) { - if (!isRunning) - return; - this.tryObtainMacAddress(); - } - - @Override - public void onSerialDisconnected() { - if (!isRunning) - return; - this.changeStatus(ProvisioningStatus.NONE); - this.connectRetries = 0; - } - - @Override - public void onSerialLog(@NotNull String str, boolean server) { - if (!isRunning) - return; - if (!server) { - this.hasLogs = true; - if (provisioningStatus == ProvisioningStatus.NO_SERIAL_LOGS_ERROR) { - // Recover the onboarding process if the user turned on the - // tracker afterward - this.changeStatus(ProvisioningStatus.OBTAINING_MAC_ADDRESS); - } - } - - if ( - provisioningStatus == ProvisioningStatus.OBTAINING_MAC_ADDRESS && str.contains("mac:") - ) { - var match = new Regex("mac: (?([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})), ") - .find(str, str.indexOf("mac:")); - - if (match != null) { - var b = match.getGroups().get(1); - if (b != null) { - vrServer.configManager.getVrConfig().addKnownDevice(b.getValue()); - vrServer.configManager.saveConfig(); - this.tryProvisioning(); - } - } - - } - - if ( - provisioningStatus == ProvisioningStatus.PROVISIONING - && str.contains("New wifi credentials set") - ) { - this.changeStatus(ProvisioningStatus.CONNECTING); - } - - if ( - provisioningStatus == ProvisioningStatus.CONNECTING - && (str.contains("Looking for the server") - || str.contains("Searching for the server")) - ) { - this.changeStatus(ProvisioningStatus.LOOKING_FOR_SERVER); - } - - if ( - provisioningStatus == ProvisioningStatus.LOOKING_FOR_SERVER - && str.contains("Handshake successful") - ) { - this.changeStatus(ProvisioningStatus.DONE); - } - - if ( - provisioningStatus == ProvisioningStatus.CONNECTING - && str.contains("Can't connect from any credentials") - ) { - if (++connectRetries >= MAX_CONNECTION_RETRIES) { - this.changeStatus(ProvisioningStatus.CONNECTION_ERROR); - } else { - this.vrServer.serialHandler.rebootRequest(); - } - } - } - - public void changeStatus(ProvisioningStatus status) { - if (this.provisioningStatus != status) { - this.lastStatusChange = System.currentTimeMillis(); - this.listeners - .forEach( - (l) -> l - .onProvisioningStatusChange(status, vrServer.serialHandler.getCurrentPort()) - ); - this.provisioningStatus = status; - } - } - - @Override - public void onNewSerialDevice(SerialPort port) { - if (!isRunning) - return; - this.initSerial(this.preferredPort); - } - - public void addListener(ProvisioningListener channel) { - this.listeners.add(channel); - } - - public void removeListener(ProvisioningListener l) { - listeners.removeIf(listener -> l == listener); - } - - @Override - public void onSerialDeviceDeleted(@NotNull SerialPort port) { - } -} diff --git a/server/core/src/main/java/dev/slimevr/serial/ProvisioningListener.java b/server/core/src/main/java/dev/slimevr/serial/ProvisioningListener.java deleted file mode 100644 index a1cf17a03d..0000000000 --- a/server/core/src/main/java/dev/slimevr/serial/ProvisioningListener.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.slimevr.serial; - -public interface ProvisioningListener { - - void onProvisioningStatusChange(ProvisioningStatus status, SerialPort port); -} diff --git a/server/core/src/main/java/dev/slimevr/serial/ProvisioningStatus.kt b/server/core/src/main/java/dev/slimevr/serial/ProvisioningStatus.kt deleted file mode 100644 index 59ba210be2..0000000000 --- a/server/core/src/main/java/dev/slimevr/serial/ProvisioningStatus.kt +++ /dev/null @@ -1,17 +0,0 @@ -package dev.slimevr.serial - -import solarxr_protocol.rpc.WifiProvisioningStatus - -enum class ProvisioningStatus(@JvmField val id: Int, val isError: Boolean, val timeout: Int = 10_000) { - NONE(WifiProvisioningStatus.NONE, false, 3_000), - SERIAL_INIT(WifiProvisioningStatus.SERIAL_INIT, false, 3_000), - PROVISIONING(WifiProvisioningStatus.PROVISIONING, false), - OBTAINING_MAC_ADDRESS(WifiProvisioningStatus.OBTAINING_MAC_ADDRESS, false), - CONNECTING(WifiProvisioningStatus.CONNECTING, false, 30_000), - CONNECTION_ERROR(WifiProvisioningStatus.CONNECTION_ERROR, true), - LOOKING_FOR_SERVER(WifiProvisioningStatus.LOOKING_FOR_SERVER, false), - COULD_NOT_FIND_SERVER(WifiProvisioningStatus.COULD_NOT_FIND_SERVER, true), - NO_SERIAL_LOGS_ERROR(WifiProvisioningStatus.NO_SERIAL_LOGS_ERROR, true), - NO_SERIAL_DEVICE_FOUND(WifiProvisioningStatus.NO_SERIAL_DEVICE_FOUND, true), - DONE(WifiProvisioningStatus.DONE, false), -} diff --git a/server/core/src/main/java/dev/slimevr/serial/SerialHandler.kt b/server/core/src/main/java/dev/slimevr/serial/SerialHandler.kt deleted file mode 100644 index b14c22831a..0000000000 --- a/server/core/src/main/java/dev/slimevr/serial/SerialHandler.kt +++ /dev/null @@ -1,83 +0,0 @@ -package dev.slimevr.serial - -import java.util.stream.Stream - -abstract class SerialHandler { - abstract val isConnected: Boolean - abstract val knownPorts: Stream - - abstract fun addListener(channel: SerialListener) - abstract fun removeListener(channel: SerialListener) - - abstract fun openSerial(portLocation: String?, auto: Boolean): Boolean - abstract fun rebootRequest() - abstract fun factoryResetRequest() - abstract fun infoRequest() - abstract fun wifiScanRequest() - abstract fun customCommandRequest(command: String) - abstract fun closeSerial() - abstract fun write(buff: ByteArray) - abstract fun setWifi(ssid: String, passwd: String) - abstract fun getCurrentPort(): SerialPort? - - companion object { - // Please also update the udev rules when updating these in: - // gui/src-tauri/69-slimevr-devices.rules - val supportedSerial: Set> = setOf( - // / QinHeng - // CH340 - Pair(0x1A86, 0x7522), - Pair(0x1A86, 0x7523), - // CH341 - Pair(0x1A86, 0x5523), - // CH343 - Pair(0x1A86, 0x55D3), - // CH9102x - Pair(0x1A86, 0x55D4), - // / Silabs - // CP210x - Pair(0x10C4, 0xEA60), - // / Espressif - // ESP32-S3 / ESP32-C3 / ESP32-C5 / ESP32-C6 / ESP32-C61 / ESP32-H2 / ESP32-P4 - Pair(0x303A, 0x1001), - // ESP32-S2 - Pair(0x303A, 0x0002), - // / FTDI - // FT232BM/L/Q, FT245BM/L/Q - // FT232RL/Q, FT245RL/Q - // VNC1L with VDPS Firmware - // VNC2 with FT232Slave - Pair(0x0403, 0x6001), - ) - fun isKnownBoard(port: SerialPort): Boolean = supportedSerial.contains(Pair(port.vendorId, port.productId)) - } -} - -class SerialHandlerStub : SerialHandler() { - override val isConnected: Boolean = false - override val knownPorts: Stream = Stream.empty() - - override fun addListener(channel: SerialListener) {} - - override fun removeListener(channel: SerialListener) {} - - override fun openSerial(portLocation: String?, auto: Boolean): Boolean = false - - override fun rebootRequest() {} - - override fun factoryResetRequest() {} - - override fun infoRequest() {} - - override fun wifiScanRequest() {} - - override fun customCommandRequest(command: String) {} - - override fun closeSerial() {} - - override fun write(buff: ByteArray) {} - - override fun setWifi(ssid: String, passwd: String) {} - - override fun getCurrentPort(): SerialPort? = null -} diff --git a/server/core/src/main/java/dev/slimevr/serial/SerialListener.kt b/server/core/src/main/java/dev/slimevr/serial/SerialListener.kt deleted file mode 100644 index fb60905341..0000000000 --- a/server/core/src/main/java/dev/slimevr/serial/SerialListener.kt +++ /dev/null @@ -1,33 +0,0 @@ -package dev.slimevr.serial - -import java.util.* - -abstract class SerialPort { - abstract val portLocation: String - abstract val descriptivePortName: String - abstract val vendorId: Int? - abstract val productId: Int? - - override fun equals(other: Any?): Boolean { - val other: SerialPort = other as? SerialPort ?: return super.equals(other) - - return this.portLocation == other.portLocation && - this.descriptivePortName == other.descriptivePortName && - this.vendorId == other.vendorId && - this.productId == other.productId - } - - override fun hashCode(): Int = Objects.hash(portLocation, descriptivePortName, vendorId, productId) -} - -interface SerialListener { - fun onSerialConnected(port: SerialPort) - fun onSerialDisconnected() - - // var server indicates if the log is injected by the server (not an actual serial log) - fun onSerialLog(str: String, server: Boolean) - fun onNewSerialDevice(port: SerialPort) - - // This is called when the serial diver does not see the device anymore - fun onSerialDeviceDeleted(port: SerialPort) -} diff --git a/server/core/src/main/java/dev/slimevr/setup/HandshakeHandler.kt b/server/core/src/main/java/dev/slimevr/setup/HandshakeHandler.kt deleted file mode 100644 index 31b9288e0c..0000000000 --- a/server/core/src/main/java/dev/slimevr/setup/HandshakeHandler.kt +++ /dev/null @@ -1,23 +0,0 @@ -package dev.slimevr.setup - -import java.util.concurrent.CopyOnWriteArrayList - -class HandshakeHandler { - private val listeners: MutableList = CopyOnWriteArrayList() - - fun addListener(listener: HandshakeListener) { - listeners.add(listener) - } - - fun removeListener(listener: HandshakeListener) { - listeners.remove(listener) - } - - fun sendUnknownHandshake(macAddress: String) { - listeners.forEach { it.onUnknownHandshake(macAddress) } - } -} - -interface HandshakeListener { - fun onUnknownHandshake(macAddress: String) -} diff --git a/server/core/src/main/java/dev/slimevr/setup/TapSetupHandler.kt b/server/core/src/main/java/dev/slimevr/setup/TapSetupHandler.kt deleted file mode 100644 index 841dd54822..0000000000 --- a/server/core/src/main/java/dev/slimevr/setup/TapSetupHandler.kt +++ /dev/null @@ -1,24 +0,0 @@ -package dev.slimevr.setup - -import dev.slimevr.tracking.trackers.Tracker -import java.util.concurrent.CopyOnWriteArrayList - -class TapSetupHandler { - private val listeners: MutableList = CopyOnWriteArrayList() - - fun addListener(listener: TapSetupListener) { - listeners.add(listener) - } - - fun removeListener(listener: TapSetupListener) { - listeners.remove(listener) - } - - fun sendTap(tracker: Tracker) { - listeners.forEach { it.onStarted(tracker) } - } -} - -interface TapSetupListener { - fun onStarted(tracker: Tracker) -} diff --git a/server/core/src/main/java/dev/slimevr/status/StatusSystem.kt b/server/core/src/main/java/dev/slimevr/status/StatusSystem.kt deleted file mode 100644 index bd521f9a81..0000000000 --- a/server/core/src/main/java/dev/slimevr/status/StatusSystem.kt +++ /dev/null @@ -1,39 +0,0 @@ -package dev.slimevr.status - -import solarxr_protocol.rpc.StatusDataUnion -import solarxr_protocol.rpc.StatusMessageT -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.atomic.AtomicInteger - -class StatusSystem { - private val listeners: MutableList = CopyOnWriteArrayList() - private val statuses: MutableMap = ConcurrentHashMap() - private val prioritizedStatuses: MutableSet = ConcurrentHashMap.newKeySet() - private val idCounter = AtomicInteger(1) - - fun addListener(listener: StatusListener) { - listeners.add(listener) - } - - fun removeListener(listener: StatusListener) { - listeners.remove(listener) - } - - fun getStatuses(): Array = statuses.map { (id, message) -> - val status = StatusMessageT() - status.id = id.toUInt().toLong() - status.data = message - status.prioritized = prioritizedStatuses.contains(id) - status - }.toTypedArray() - - fun hasStatusType(dataType: Byte): Boolean = statuses.any { - it.value.type == dataType - } -} - -interface StatusListener { - fun onStatusChanged(id: UInt, message: StatusDataUnion, prioritized: Boolean) - fun onStatusRemoved(id: UInt) -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/Bone.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/Bone.kt deleted file mode 100644 index aed9d7768c..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/Bone.kt +++ /dev/null @@ -1,165 +0,0 @@ -package dev.slimevr.tracking.processor - -import dev.slimevr.tracking.processor.Constraint.Companion.ConstraintType -import dev.slimevr.tracking.trackers.Tracker -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import solarxr_protocol.datatypes.BodyPart -import java.util.concurrent.CopyOnWriteArrayList - -/** - * Represents a bone composed of 2 joints: headNode and tailNode. - */ -class Bone(val boneType: BoneType, val rotationConstraint: Constraint) { - private val headNode = TransformNode(true) - private val tailNode = TransformNode(false) - var parent: Bone? = null - private set - val children: MutableList = CopyOnWriteArrayList() - var rotationOffset = Quaternion.IDENTITY - var attachedTracker: Tracker? = null - - init { - headNode.attachChild(tailNode) - } - - /** - * Attach another bone as a child of this. - */ - fun attachChild(bone: Bone) { - // Attach bone - require(bone.parent == null) { "The child bone must not already have a parent." } - children.add(bone) - bone.parent = this - - // Attach node - tailNode.attachChild(bone.headNode) - } - - /** - * Detach this bone from its parent and children. - */ - fun detachWithChildren() { - // Detach bones - for (child in children) child.parent = null - children.clear() - parent?.children?.remove(this) - parent = null - - // Detach nodes - headNode.detachWithChildren() - tailNode.detachWithChildren() - - // Reattach this bone - headNode.attachChild(tailNode) - } - - /** - * Computes the rotations and positions of - * this bone and all of its children. - */ - fun update() { - headNode.update() - } - - /** - * Computes the rotations and positions of - * this bone and all of its children while - * enforcing rotation constraints. - */ - fun updateWithConstraints(correctConstraints: Boolean) { - val initialRot = getGlobalRotation() - val newRot = rotationConstraint.applyConstraint(initialRot, this) - setRotationRaw(newRot) - updateThisNode() - - // Correct tracker if applicable. Do not adjust correction for hinge constraints - // or the upper chest tracker. - if (rotationConstraint.constraintType != ConstraintType.HINGE && - rotationConstraint.constraintType != ConstraintType.LOOSE_HINGE && - boneType.bodyPart != BodyPart.UPPER_CHEST - ) { - val deltaRot = newRot * initialRot.inv() - val angle = deltaRot.angleR() - - if (correctConstraints && - angle > Constraint.ANGLE_THRESHOLD && - (attachedTracker?.filteringHandler?.getFilteringImpact() ?: 1f) < Constraint.FILTER_IMPACT_THRESHOLD && - (parent?.attachedTracker?.filteringHandler?.getFilteringImpact() ?: 0f) < Constraint.FILTER_IMPACT_THRESHOLD - ) { - attachedTracker?.resetsHandler?.updateConstraintFix(deltaRot) - } - } - - // Recursively apply constraints and update children. - for (child in children) { - child.updateWithConstraints(correctConstraints) - } - } - - /** - * Computes the rotations and positions of this bone. - * Only to be used while traversing bones from top to bottom. - */ - private fun updateThisNode() { - headNode.updateThisNode() - tailNode.updateThisNode() - } - - /** - * Returns the world-aligned rotation of the bone - */ - fun getGlobalRotation(): Quaternion = headNode.worldTransform.rotation - - /** - * Returns the rotation of the bone relative to its parent - */ - fun getLocalRotation(): Quaternion = headNode.localTransform.rotation - - /** - * Sets the global rotation of the bone - */ - fun setRotation(rotation: Quaternion) { - headNode.localTransform.rotation = rotation * rotationOffset - } - - /** - * Sets the global rotation of the bone directly - */ - fun setRotationRaw(rotation: Quaternion) { - headNode.localTransform.rotation = rotation - } - - /** - * Returns the global position of the head of the bone - */ - fun getPosition(): Vector3 = headNode.worldTransform.translation - - /** - * Returns the global position of the tail of the bone - */ - fun getTailPosition(): Vector3 = tailNode.worldTransform.translation - - /** - * Sets the global position of the head of the bone. - * Note: cannot set the global position of bones with parents, - * consider changing the bone's length instead. - */ - fun setPosition(position: Vector3) { - require(parent == null) { "Cannot set the position of a child bone." } - headNode.localTransform.translation = position - } - - /** - * The length of the bone is in meters. - * This is the local translation of the tail node. - * This is also the difference between the head position and the tail position. - */ - var length: Float - get() = tailNode.localTransform.translation.len() - set(len) = updateLength(len) - - private fun updateLength(length: Float) { - tailNode.localTransform.translation = Vector3(0f, -length, 0f) - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/BoneType.java b/server/core/src/main/java/dev/slimevr/tracking/processor/BoneType.java deleted file mode 100644 index b423640103..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/BoneType.java +++ /dev/null @@ -1,87 +0,0 @@ -package dev.slimevr.tracking.processor; - -import solarxr_protocol.datatypes.BodyPart; - - -/** - * Keys for all the bones in the skeleton. - */ -public enum BoneType { - HEAD(BodyPart.HEAD), - HEAD_TRACKER(), - NECK(BodyPart.NECK), - UPPER_CHEST(BodyPart.UPPER_CHEST), - CHEST_TRACKER, - CHEST(BodyPart.CHEST), - WAIST(BodyPart.WAIST), - HIP(BodyPart.HIP), - HIP_TRACKER, - LEFT_HIP(BodyPart.LEFT_HIP), - RIGHT_HIP(BodyPart.RIGHT_HIP), - LEFT_UPPER_LEG(BodyPart.LEFT_UPPER_LEG), - RIGHT_UPPER_LEG(BodyPart.RIGHT_UPPER_LEG), - LEFT_KNEE_TRACKER, - RIGHT_KNEE_TRACKER, - LEFT_LOWER_LEG(BodyPart.LEFT_LOWER_LEG), - RIGHT_LOWER_LEG(BodyPart.RIGHT_LOWER_LEG), - LEFT_FOOT(BodyPart.LEFT_FOOT), - RIGHT_FOOT(BodyPart.RIGHT_FOOT), - LEFT_FOOT_TRACKER(BodyPart.LEFT_FOOT), - RIGHT_FOOT_TRACKER(BodyPart.RIGHT_FOOT), - LEFT_LOWER_ARM(BodyPart.LEFT_LOWER_ARM), - RIGHT_LOWER_ARM(BodyPart.RIGHT_LOWER_ARM), - LEFT_ELBOW_TRACKER, - RIGHT_ELBOW_TRACKER, - LEFT_UPPER_ARM(BodyPart.LEFT_UPPER_ARM), - RIGHT_UPPER_ARM(BodyPart.RIGHT_UPPER_ARM), - LEFT_SHOULDER(BodyPart.LEFT_SHOULDER), - RIGHT_SHOULDER(BodyPart.RIGHT_SHOULDER), - LEFT_UPPER_SHOULDER, - RIGHT_UPPER_SHOULDER, - LEFT_HAND(BodyPart.LEFT_HAND), - RIGHT_HAND(BodyPart.RIGHT_HAND), - LEFT_HAND_TRACKER, - RIGHT_HAND_TRACKER, - LEFT_THUMB_METACARPAL(BodyPart.LEFT_THUMB_METACARPAL), - LEFT_THUMB_PROXIMAL(BodyPart.LEFT_THUMB_PROXIMAL), - LEFT_THUMB_DISTAL(BodyPart.LEFT_THUMB_DISTAL), - LEFT_INDEX_PROXIMAL(BodyPart.LEFT_INDEX_PROXIMAL), - LEFT_INDEX_INTERMEDIATE(BodyPart.LEFT_INDEX_INTERMEDIATE), - LEFT_INDEX_DISTAL(BodyPart.LEFT_INDEX_DISTAL), - LEFT_MIDDLE_PROXIMAL(BodyPart.LEFT_MIDDLE_PROXIMAL), - LEFT_MIDDLE_INTERMEDIATE(BodyPart.LEFT_MIDDLE_INTERMEDIATE), - LEFT_MIDDLE_DISTAL(BodyPart.LEFT_MIDDLE_DISTAL), - LEFT_RING_PROXIMAL(BodyPart.LEFT_RING_PROXIMAL), - LEFT_RING_INTERMEDIATE(BodyPart.LEFT_RING_INTERMEDIATE), - LEFT_RING_DISTAL(BodyPart.LEFT_RING_DISTAL), - LEFT_LITTLE_PROXIMAL(BodyPart.LEFT_LITTLE_PROXIMAL), - LEFT_LITTLE_INTERMEDIATE(BodyPart.LEFT_LITTLE_INTERMEDIATE), - LEFT_LITTLE_DISTAL(BodyPart.LEFT_LITTLE_DISTAL), - RIGHT_THUMB_METACARPAL(BodyPart.RIGHT_THUMB_METACARPAL), - RIGHT_THUMB_PROXIMAL(BodyPart.RIGHT_THUMB_PROXIMAL), - RIGHT_THUMB_DISTAL(BodyPart.RIGHT_THUMB_DISTAL), - RIGHT_INDEX_PROXIMAL(BodyPart.RIGHT_INDEX_PROXIMAL), - RIGHT_INDEX_INTERMEDIATE(BodyPart.RIGHT_INDEX_INTERMEDIATE), - RIGHT_INDEX_DISTAL(BodyPart.RIGHT_INDEX_DISTAL), - RIGHT_MIDDLE_PROXIMAL(BodyPart.RIGHT_MIDDLE_PROXIMAL), - RIGHT_MIDDLE_INTERMEDIATE(BodyPart.RIGHT_MIDDLE_INTERMEDIATE), - RIGHT_MIDDLE_DISTAL(BodyPart.RIGHT_MIDDLE_DISTAL), - RIGHT_RING_PROXIMAL(BodyPart.RIGHT_RING_PROXIMAL), - RIGHT_RING_INTERMEDIATE(BodyPart.RIGHT_RING_INTERMEDIATE), - RIGHT_RING_DISTAL(BodyPart.RIGHT_RING_DISTAL), - RIGHT_LITTLE_PROXIMAL(BodyPart.RIGHT_LITTLE_PROXIMAL), - RIGHT_LITTLE_INTERMEDIATE(BodyPart.RIGHT_LITTLE_INTERMEDIATE), - RIGHT_LITTLE_DISTAL(BodyPart.RIGHT_LITTLE_DISTAL); - - public static final BoneType[] values = values(); - - public final int bodyPart; - - BoneType() { - this.bodyPart = BodyPart.NONE; - } - - BoneType(int associatedBodyPart) { - this.bodyPart = associatedBodyPart; - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/Constraint.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/Constraint.kt deleted file mode 100644 index ac33c9d2a3..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/Constraint.kt +++ /dev/null @@ -1,190 +0,0 @@ -package dev.slimevr.tracking.processor - -import com.jme3.math.FastMath -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import kotlin.math.* - -/** - * Represents a function that applies a rotational constraint. - */ -typealias ConstraintFunction = (localRotation: Quaternion, limit1: Float, limit2: Float, limit3: Float) -> Quaternion - -/** - * Represents the rotational limits of a Bone relative to its parent, - * twist and swing are the max and min when constraintType is a hinge. - * Twist, swing, allowedDeviation, and maxDeviationFromTracker represent - * an angle in degrees. - */ -class Constraint( - val constraintType: ConstraintType, - twist: Float = 0.0f, - swing: Float = 0.0f, - allowedDeviation: Float = 0f, - maxDeviationFromTracker: Float = 15f, -) { - private val constraintFunction = constraintTypeToFunc(constraintType) - private val twistRad = twist * FastMath.DEG_TO_RAD - private val swingRad = swing * FastMath.DEG_TO_RAD - private val allowedDeviationRad = allowedDeviation * FastMath.DEG_TO_RAD - private val maxDeviationFromTrackerRad = maxDeviationFromTracker * FastMath.DEG_TO_RAD - - /** - * allowModification may be false for reasons other than a tracker being on this bone - * while hasTrackerRotation is only true if this bone has a tracker. These values are - * to be used with an IK solver and are not currently set accurately - */ - var allowModifications = true - var hasTrackerRotation = false - - /** - * The rotation before any IK solve takes place. Again this value is not currently set accurately - */ - var initialRotation = Quaternion.IDENTITY - - /** - * Apply rotational constraints and if applicable force the rotation - * to be unchanged unless it violates the constraints - */ - fun applyConstraint(rotation: Quaternion, thisBone: Bone): Quaternion { - // When constraints are being used during a IK solve the input rotation is not necessarily - // the bones global rotation, thus complete constraints must be specifically handled. - if (constraintType == ConstraintType.COMPLETE) return thisBone.getGlobalRotation() - - // If there is no parent and this is not a complete constraint accept the rotation as is. - // TODO: This was changed due to a race condition with the RPC thread, see - // https://github.com/SlimeVR/SlimeVR-Server/issues/1534 for more information. - val parent = thisBone.parent ?: return rotation - - val localRotation = getLocalRotation(rotation, thisBone, parent) - val constrainedRotation = constraintFunction(localRotation, swingRad, twistRad, allowedDeviationRad) - return getWorldRotationFromLocal(constrainedRotation, thisBone, parent) - } - - /** - * Force the given rotation to be within allowedDeviation degrees away from - * initialRotation on both the twist and swing axis - */ - fun constrainToInitialRotation(rotation: Quaternion): Quaternion { - val rotationLocal = rotation * initialRotation.inv() - var (swingQ, twistQ) = decompose(rotationLocal, Vector3.NEG_Y) - swingQ = constrain(swingQ, maxDeviationFromTrackerRad) - twistQ = constrain(twistQ, maxDeviationFromTrackerRad) - return initialRotation * (swingQ * twistQ) - } - - companion object { - const val ANGLE_THRESHOLD = 0.004f // == 0.25 degrees - const val FILTER_IMPACT_THRESHOLD = 0.0349f // == 2 degrees - - enum class ConstraintType { - TWIST_SWING, - HINGE, - LOOSE_HINGE, - COMPLETE, - } - - private fun constraintTypeToFunc(type: ConstraintType) = when (type) { - ConstraintType.COMPLETE -> completeConstraint - ConstraintType.TWIST_SWING -> twistSwingConstraint - ConstraintType.HINGE -> hingeConstraint - ConstraintType.LOOSE_HINGE -> looseHingeConstraint - } - - private fun getLocalRotation(rotation: Quaternion, thisBone: Bone, parent: Bone): Quaternion { - val localRotationOffset = parent.rotationOffset.inv() * thisBone.rotationOffset - return (parent.getGlobalRotation() * localRotationOffset).inv() * rotation - } - - private fun getWorldRotationFromLocal(rotation: Quaternion, thisBone: Bone, parent: Bone): Quaternion { - val localRotationOffset = parent.rotationOffset.inv() * thisBone.rotationOffset - return (parent.getGlobalRotation() * localRotationOffset * rotation).unit() - } - - private fun decompose( - rotation: Quaternion, - twistAxis: Vector3, - ): Pair { - val projection = rotation.project(twistAxis).unit() - val twist = Quaternion(sqrt(1.0f - projection.xyz.lenSq()) * if (rotation.w >= 0f) 1f else -1f, projection.xyz).unit() - val swing = (rotation * twist.inv()).unit() - return Pair(swing, twist) - } - - private fun constrain(rotation: Quaternion, angle: Float): Quaternion { - // Use angle to get the maximum magnitude the vector part of rotation can be - // before it has violated a constraint. - // Multiplying by 0.5 uniquely maps angles 0-180 degrees to 0-1 which works - // nicely with unit quaternions. - val magnitude = sin(angle * 0.5f) - val magnitudeSqr = magnitude * magnitude - val sign = if (rotation.w >= 0f) 1f else -1f - var vector = rotation.xyz - var rot = rotation - - if (vector.lenSq() > magnitudeSqr) { - vector = vector.unit() * magnitude - rot = Quaternion(sqrt(1.0f - magnitudeSqr) * sign, vector) - } - - return rot.unit() - } - - private fun constrain(rotation: Quaternion, minAngle: Float, maxAngle: Float, axis: Vector3): Quaternion { - val magnitudeMin = sin(minAngle * 0.5f) - val magnitudeMax = sin(maxAngle * 0.5f) - val magnitudeSqrMin = magnitudeMin * magnitudeMin * if (minAngle >= 0f) 1f else -1f - val magnitudeSqrMax = magnitudeMax * magnitudeMax * if (maxAngle >= 0f) 1f else -1f - var vector = rotation.xyz - var rot = rotation - - val rotMagnitude = vector.lenSq() * if (vector.dot(axis) * sign(rot.w) < 0) -1f else 1f - if (rotMagnitude < magnitudeSqrMin || rotMagnitude > magnitudeSqrMax) { - val distToMin = min(abs(rotMagnitude - magnitudeSqrMin), abs(rotMagnitude + magnitudeSqrMin)) - val distToMax = min(abs(rotMagnitude - magnitudeSqrMax), abs(rotMagnitude + magnitudeSqrMax)) - - val magnitude = if (distToMin < distToMax) magnitudeMin else magnitudeMax - val magnitudeSqr = abs(if (distToMin < distToMax) magnitudeSqrMin else magnitudeSqrMax) - vector = vector.unit() * -magnitude - - rot = Quaternion(sqrt(1.0f - magnitudeSqr), vector) - } - - return rot.unit() - } - - // Constraint function for TwistSwingConstraint - private val twistSwingConstraint: ConstraintFunction = - { rotation: Quaternion, swingRad: Float, twistRad: Float, _: Float -> - var (swingQ, twistQ) = decompose(rotation, Vector3.NEG_Y) - swingQ = constrain(swingQ, swingRad) - twistQ = constrain(twistQ, twistRad) - - swingQ * twistQ - } - - // Constraint function for a hinge constraint with min and max angles - private val hingeConstraint: ConstraintFunction = - { rotation: Quaternion, min: Float, max: Float, _: Float -> - val (_, hingeAxisRot) = decompose(rotation, Vector3.NEG_X) - - constrain(hingeAxisRot, min, max, Vector3.NEG_X) - } - - // Constraint function for a hinge constraint with min and max angles that allows nonHingeDeviation - // rotation on all axis but the hinge - private val looseHingeConstraint: ConstraintFunction = - { rotation: Quaternion, min: Float, max: Float, nonHingeDeviation: Float -> - var (nonHingeRot, hingeAxisRot) = decompose(rotation, Vector3.NEG_X) - hingeAxisRot = constrain(hingeAxisRot, min, max, Vector3.NEG_X) - nonHingeRot = constrain(nonHingeRot, nonHingeDeviation) - - nonHingeRot * hingeAxisRot - } - - // Constraint function for CompleteConstraint - private val completeConstraint: ConstraintFunction = { rotation: Quaternion, _: Float, _: Float, _: Float -> - rotation - } - } -} 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 deleted file mode 100644 index f1fa122695..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/HumanPoseManager.kt +++ /dev/null @@ -1,691 +0,0 @@ -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 -import dev.slimevr.tracking.processor.config.SkeletonConfigToggles -import dev.slimevr.tracking.processor.config.SkeletonConfigValues -import dev.slimevr.tracking.processor.skeleton.HumanSkeleton -import dev.slimevr.tracking.trackers.* -import dev.slimevr.trackingpause.TrackingPauseHandler -import dev.slimevr.util.ann.VRServerThread -import io.eiren.util.ann.ThreadSafe -import io.eiren.util.collections.FastList -import io.eiren.util.logging.LogManager -import io.github.axisangles.ktmath.Quaternion.Companion.IDENTITY -import io.github.axisangles.ktmath.Vector3 -import io.github.axisangles.ktmath.Vector3.Companion.POS_Y -import org.apache.commons.math3.util.Precision -import java.util.function.Consumer -import kotlin.math.* - -/** - * Class to handle communicate between classes in "skeleton" package and outside - * @param server the used VRServer - */ -class HumanPoseManager(val server: VRServer?) { - val computedTrackers: MutableList = FastList() - private val onSkeletonUpdated: MutableList> = FastList() - private val skeletonConfigManager = SkeletonConfigManager(true, this) - - @get:ThreadSafe - lateinit var skeleton: HumanSkeleton - private var timeAtLastReset: Long = 0 - val trackingPauseHandler: TrackingPauseHandler = TrackingPauseHandler() - - // #region Constructors - init { - initializeComputedHumanPoseTracker() - } - - init { - if (server != null) { - skeleton = HumanSkeleton(this, server) - // This computes all node offsets, so the defaults don't need to be - // explicitly loaded into the skeleton (no need for - // `computeAllNodeOffsets()`) - loadFromConfig(server.configManager) - for (sc in onSkeletonUpdated) sc.accept(skeleton) - } - } - - /** - * Creates a new HumanPoseManager that uses the given trackers for the - * HumanSkeleton and the default config - * - * @param trackers a list of all trackers - */ - constructor(trackers: List?) : this(server = null) { - skeleton = HumanSkeleton(this, trackers) - // Set default node offsets on the new skeleton - skeletonConfigManager.computeAllNodeOffsets() - skeletonConfigManager.updateSettingsInSkeleton() - } - - /** - * Creates a new HumanPoseManager that uses the given trackers for the - * HumanSkeleton and the given offsets for config - * - * @param trackers a list of all trackers - * @param offsetConfigs a map of the SkeletonConfigOffsets and values for - * them - */ - constructor( - trackers: List?, - offsetConfigs: Map?, - ) : this(server = null) { - skeleton = HumanSkeleton(this, trackers) - // Set offsetConfigs from given offsetConfigs on creation - // This computes all node offsets, so the defaults don't need to be - // explicitly loaded into the skeleton (no need for `computeAllNodeOffsets()`) - skeletonConfigManager.setOffsets(offsetConfigs) - skeletonConfigManager.updateSettingsInSkeleton() - } - - /** - * Creates a new HumanPoseManager that uses the given trackers for the - * HumanSkeleton and the given offsets for config - * - * @param trackers a list of all trackers - * @param offsetConfigs a map of the SkeletonConfigOffsets and values for - * them - * @param altOffsetConfigs an alternative map of the SkeletonConfigOffsets - * and values for them - */ - constructor( - trackers: List?, - offsetConfigs: Map?, - altOffsetConfigs: Map?, - ) : this(server = null) { - skeleton = HumanSkeleton(this, trackers) - // Set offsetConfigs from given offsetConfigs on creation - if (altOffsetConfigs != null) { - // Set alts first, so if there's any overlap it doesn't affect - // the values - skeletonConfigManager.setOffsets(altOffsetConfigs) - } - // This computes all node offsets, so the defaults don't need to be - // explicitly loaded into the skeleton (no need for `computeAllNodeOffsets()`) - skeletonConfigManager.setOffsets(offsetConfigs) - skeletonConfigManager.updateSettingsInSkeleton() - } - - // #endregion - // #region private methods - private fun makeComputedTracker(name: String, display: String, pos: TrackerPosition): Tracker = Tracker( - null, - getNextLocalTrackerId(), - name, - display, - pos, - hasPosition = true, - hasRotation = true, - isInternal = true, - isComputed = true, - allowFiltering = false, - // Do not track polarity, moving avg de-syncs ticks and breaks leg tweaks - trackRotDirection = false, - ) - - private fun initializeComputedHumanPoseTracker() { - computedTrackers - .add( - makeComputedTracker( - "human://HEAD", - "Computed head", - TrackerPosition.HEAD, - ), - ) - computedTrackers - .add( - makeComputedTracker( - "human://CHEST", - "Computed chest", - TrackerPosition.UPPER_CHEST, - ), - ) - computedTrackers - .add( - makeComputedTracker( - "human://WAIST", - "Computed hip", - TrackerPosition.HIP, - ), - ) - computedTrackers - .add( - makeComputedTracker( - "human://LEFT_KNEE", - "Computed left knee", - TrackerPosition.LEFT_UPPER_LEG, - ), - ) - computedTrackers - .add( - makeComputedTracker( - "human://RIGHT_KNEE", - "Computed right knee", - TrackerPosition.RIGHT_UPPER_LEG, - ), - ) - computedTrackers - .add( - makeComputedTracker( - "human://LEFT_FOOT", - "Computed left foot", - TrackerPosition.LEFT_FOOT, - ), - ) - computedTrackers - .add( - makeComputedTracker( - "human://RIGHT_FOOT", - "Computed right foot", - TrackerPosition.RIGHT_FOOT, - ), - ) - computedTrackers - .add( - makeComputedTracker( - "human://LEFT_ELBOW", - "Computed left elbow", - TrackerPosition.LEFT_UPPER_ARM, - ), - ) - computedTrackers - .add( - makeComputedTracker( - "human://RIGHT_ELBOW", - "Computed right elbow", - TrackerPosition.RIGHT_UPPER_ARM, - ), - ) - computedTrackers - .add( - makeComputedTracker( - "human://LEFT_HAND", - "Computed left hand", - TrackerPosition.LEFT_HAND, - - ), - ) - computedTrackers - .add( - makeComputedTracker( - "human://RIGHT_HAND", - "Computed right hand", - TrackerPosition.RIGHT_HAND, - ), - ) - - connectComputedHumanPoseTrackers() - } - - fun loadFromConfig(configManager: ConfigManager) { - skeletonConfigManager.loadFromConfig(configManager) - } - - @VRServerThread - fun updateSkeletonModelFromServer() { - skeleton.setTrackersFromList(server!!.allTrackers) - } - - private fun connectComputedHumanPoseTrackers() { - for (t in computedTrackers) { - t.status = TrackerStatus.OK - } - } - - // #endregion - // #region public methods - // #region skeleton methods - @VRServerThread - fun trackerAdded(tracker: Tracker?) { - } - - @VRServerThread - fun trackerUpdated(tracker: Tracker?) { - } - - @VRServerThread - fun addSkeletonUpdatedCallback(consumer: Consumer) { - onSkeletonUpdated.add(consumer) - consumer.accept(skeleton) - } - - /** - * @return False if the skeleton isn't yet initialized - */ - @get:ThreadSafe - val isSkeletonPresent: Boolean - get() = this::skeleton.isInitialized - - /** - * Updates the pose of the skeleton from trackers rotations - */ - @VRServerThread - fun update() { - skeleton.updatePose() - } - - /** - * Get the corresponding computed tracker for a given TrackerRole - * - * @param trackerRole the role of the tracker which we want - * @return the corresponding computed tracker for the trackerRole - */ - @ThreadSafe - fun getComputedTracker(trackerRole: TrackerRole): Tracker = skeleton.getComputedTracker(trackerRole) - - /** - * @return the head bone, which is the root of the skeleton - */ - @get:ThreadSafe - val headBone: Bone - get() = skeleton.headBone - - /** - * Get a specified bone from the passed BoneType - * - * @param boneType the type of the bone we want - * @return the specified bone - */ - @ThreadSafe - fun getBone(boneType: BoneType): Bone = skeleton.getBone(boneType) - - /** - * @return the HMD's y position from the skeleton - */ - @get:ThreadSafe - val hmdHeight: Float - get() = skeleton.hmdHeight - - /** - * Runs checks to know if we should (and are) performing the tracking of the - * left arm from the controller. - * - * @return a bool telling us if we are tracking the left arm from the - * controller or not. - */ - @get:ThreadSafe - val isTrackingLeftArmFromController: Boolean - get() = skeleton.isTrackingLeftArmFromController - - /** - * Runs checks to know if we should (and are) performing the tracking of the - * right arm from the controller. - * - * @return a bool telling us if we are tracking the right arm from the - * controller or not. - */ - @get:ThreadSafe - val isTrackingRightArmFromController: Boolean - get() = skeleton.isTrackingRightArmFromController - - /** - * @return All non-tracker bones - */ - @get:ThreadSafe - val allBones: List - get() = listOf(*skeleton.allHumanBones) - - // #endregion - // #region config methods - - /** - * @param key the offset from which to get the corresponding value - * @return the offset in config corresponding to the key - */ - @ThreadSafe - fun getOffset(key: SkeletonConfigOffsets?): Float = skeletonConfigManager.getOffset(key) - - /** - * @param key the offset to set the length to - * @param newLength the new attributed length to the offset - */ - @ThreadSafe - fun setOffset(key: SkeletonConfigOffsets, newLength: Float?) { - skeletonConfigManager.setOffset(key, newLength) - } - - /** - * Resets all the offsets in the current SkeletonConfigManager - */ - @ThreadSafe - fun resetOffsets() { - skeletonConfigManager.resetOffsets() - } - - /** - * @param key the toggle from which to get the corresponding value - * @return the toggle in config corresponding to the key - */ - @ThreadSafe - fun getToggle(key: SkeletonConfigToggles?): Boolean = skeletonConfigManager.getToggle(key) - - /** - * @param key the toggle to set the value to - * @param newValue the new attributed value to the toggle - */ - @ThreadSafe - fun setToggle(key: SkeletonConfigToggles, newValue: Boolean?) { - skeletonConfigManager.setToggle(key, newValue) - } - - /** - * Resets all the toggles in the current SkeletonConfigManager - */ - @ThreadSafe - fun resetToggles() { - skeletonConfigManager.resetToggles() - } - - /** - * @param key the value from which to get the corresponding value - * @return the value in config corresponding to the key - */ - @ThreadSafe - fun getValue(key: SkeletonConfigValues): Float = skeletonConfigManager.getValue(key) - - /** - * @param key the value to set the value to - * @param newValue the new attributed value to the value - */ - @ThreadSafe - fun setValue(key: SkeletonConfigValues, newValue: Float?) { - skeletonConfigManager.setValue(key, newValue) - } - - /** - * Resets all the values in the current SkeletonConfigManager - */ - @ThreadSafe - fun resetValues() { - skeletonConfigManager.resetValues() - } - - /** - * Resets all the skeleton configs in the current SkeletonConfigManager - */ - @ThreadSafe - fun resetAllConfigs() { - skeletonConfigManager.resetAllConfigs() - } - - /** - * Writes the skeleton configs - */ - @ThreadSafe - fun saveConfig() { - skeletonConfigManager.save() - } - - /** - * Update the given bone with the given offset - * - * @param boneType the type of the bone to update - * @param offset the new offset to apply to the boneType - */ - @ThreadSafe - fun updateNodeOffset(boneType: BoneType, offset: Vector3) { - if (!isSkeletonPresent) return - skeleton.updateNodeOffset(boneType, offset) - } - - /** - * Updates all the node offsets in the skeleton - */ - fun updateNodeOffsetsInSkeleton() { - if (!isSkeletonPresent) return - skeletonConfigManager.updateNodeOffsetsInSkeleton() - } - - /** - * Update the given toggle to the new given value in the skeleton - * - * @param configToggle the toggle to update - * @param newValue the new value to apply to the toggle - */ - @ThreadSafe - fun updateToggleState(configToggle: SkeletonConfigToggles, newValue: Boolean) { - skeleton.updateToggleState(configToggle, newValue) - } - - /** - * Update the given value to the new given value in the skeleton - * - * @param configValue the value to update - * @param newValue the new value to apply to the value - */ - @ThreadSafe - fun updateValueState(configValue: SkeletonConfigValues, newValue: Float) { - skeleton.updateValueState(configValue, newValue) - } - - /** - * Compute the offset for the given node and apply the new offset - * - * @param node the node to update - */ - fun computeNodeOffset(node: BoneType) { - skeletonConfigManager.computeNodeOffset(node) - } - - @JvmOverloads - fun resetTrackersFull(resetSourceName: String?, bodyParts: List = ArrayList()) { - skeleton.resetTrackersFull(resetSourceName, bodyParts) - if (server != null) { - if (skeleton.headTracker == null && skeleton.neckTracker == null) { - server.vrcOSCHandler.yawAlign(IDENTITY) - } else { - server.vrcOSCHandler - .yawAlign( - headBone.getGlobalRotation().project(POS_Y), - ) - } - server.vMCHandler.alignVMCTracking(headBone.getGlobalRotation()) - logTrackersDrift() - } - } - - @JvmOverloads - fun resetTrackersYaw(resetSourceName: String?, bodyParts: List = TrackerUtils.allBodyPartsButFingers) { - skeleton.resetTrackersYaw(resetSourceName, bodyParts) - if (server != null) { - if (skeleton.headTracker == null && skeleton.neckTracker == null) { - server.vrcOSCHandler.yawAlign(IDENTITY) - } else { - server.vrcOSCHandler - .yawAlign( - headBone.getGlobalRotation().project(POS_Y), - ) - } - server.vMCHandler.alignVMCTracking(headBone.getGlobalRotation()) - logTrackersDrift() - } - } - - private fun logTrackersDrift() { - if (timeAtLastReset == 0L) timeAtLastReset = System.currentTimeMillis() - - // Get time since last reset in seconds - val timeSinceLastReset = (System.currentTimeMillis() - timeAtLastReset) / 1000L - timeAtLastReset = System.currentTimeMillis() - - // Build String for trackers drifts - val trackersDriftText = StringBuilder() - for (tracker in server!!.allTrackers) { - if (( - tracker.isImu() && - tracker.allowReset - ) && - tracker.resetsHandler.lastResetQuaternion != null - ) { - if (trackersDriftText.isNotEmpty()) { - trackersDriftText.append(" | ") - } - - // Get the difference between last reset and now - val difference = tracker - .getRotationNoResetSmooth() * - tracker.resetsHandler.lastResetQuaternion!!.inv() - // Get the pure yaw - var trackerDriftAngle = abs( - ( - atan2(difference.y, difference.w) * - 2 * - FastMath.RAD_TO_DEG - ), - ) - // Fix for polarity or something - if (trackerDriftAngle > 180) trackerDriftAngle = abs((trackerDriftAngle - 360)) - - // Calculate drift per minute - val driftPerMin = trackerDriftAngle / (timeSinceLastReset / 60f) - - trackersDriftText.append(tracker.name) - val trackerPosition = tracker.trackerPosition - if (trackerPosition != null) trackersDriftText.append(" (").append(trackerPosition.name).append(")") - - trackersDriftText - .append(", ") - .append(Precision.round(trackerDriftAngle, 4)) - .append(" deg (") - .append(Precision.round(driftPerMin, 4)) - .append(" deg/min)") - } - } - - if (trackersDriftText.isNotEmpty()) { - LogManager - .info( - "[HumanPoseManager] $timeSinceLastReset seconds since last reset. Tracker yaw drifts: $trackersDriftText", - ) - } - } - - @JvmOverloads - fun resetTrackersMounting(resetSourceName: String?, bodyParts: List? = null) { - val finalBodyParts = bodyParts - ?: if (server?.configManager?.vrConfig?.resetsConfig?.resetMountingFeet == true) { - TrackerUtils.allBodyPartsButFingers - } else { - TrackerUtils.allBodyPartsButFingersAndFeets - } - - skeleton.resetTrackersMounting(resetSourceName, finalBodyParts) - } - - fun clearTrackersMounting(resetSourceName: String?) { - skeleton.clearTrackersMounting(resetSourceName) - } - - @get:ThreadSafe - val legTweaksState: BooleanArray - get() = skeleton.legTweaksState - - @VRServerThread - fun setLegTweaksEnabled(value: Boolean) { - skeleton.setLegTweaksEnabled(value) - } - - @VRServerThread - fun setIKSolverEnabled(value: Boolean) { - skeleton.setIKSolverEnabled(value) - } - - @VRServerThread - fun setFloorClipEnabled(value: Boolean) { - skeleton.setFloorclipEnabled(value) - if (server != null) { - server.configManager - .vrConfig - .skeleton - .getToggles()[SkeletonConfigToggles.FLOOR_CLIP.configKey] = value - server.configManager.saveConfig() - } - } - - @VRServerThread - fun setSkatingCorrectionEnabled(value: Boolean) { - skeleton.setSkatingCorrectionEnabled(value) - if (server != null) { - server.configManager - .vrConfig - .skeleton - .getToggles()[SkeletonConfigToggles.SKATING_CORRECTION.configKey] = value - server.configManager.saveConfig() - } - } - - fun setLegTweaksStateTemp( - skatingCorrection: Boolean, - floorClip: Boolean, - toeSnap: Boolean, - footPlant: Boolean, - ) { - skeleton.setLegTweaksStateTemp(skatingCorrection, floorClip, toeSnap, footPlant) - } - - fun clearLegTweaksStateTemp( - skatingCorrection: Boolean, - floorClip: Boolean, - toeSnap: Boolean, - footPlant: Boolean, - ) { - skeleton.clearLegTweaksStateTemp(skatingCorrection, floorClip, toeSnap, footPlant) - } - - fun updateTapDetectionConfig() { - skeleton.updateTapDetectionConfig() - } - - fun updateLegTweaksConfig() { - skeleton.updateLegTweaksConfig() - } - - @get:ThreadSafe - val userHeightFromConfig: Float - get() = skeletonConfigManager.userHeightFromOffsets - - @get:ThreadSafe - val userNeckHeightFromConfig: Float - get() = skeletonConfigManager.userNeckHeightFromOffsets - - @get:ThreadSafe - val realUserHeight: Float - get() = skeletonConfigManager.userHeightFromOffsets / BodyProportionError.eyeHeightToHeightRatio - - // #endregion - fun getPauseTracking(): Boolean = skeleton.getPauseTracking() - - fun setPauseTracking(pauseTracking: Boolean, sourceName: String?) { - skeleton.setPauseTracking(pauseTracking, sourceName) - } - - fun togglePauseTracking(sourceName: String?): Boolean = skeleton.togglePauseTracking(sourceName) - - // This should be executed when the head tracker is changed - fun checkTrackersRequiringReset() { - // Checks if this is main human pose manager (having server) or - // skeleton doesn't have a head tracker or not an HMD one - if (server == null || - skeleton.headTracker?.isComputed != true - ) { - return - } - server.allTrackers - .filter { it.trackerPosition != null } - .forEach { - if (it.allowReset && !it.needReset) { - it.needReset = true - } - } - } - - // #endregion -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/TransformNode.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/TransformNode.kt deleted file mode 100644 index 626cf35cbe..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/TransformNode.kt +++ /dev/null @@ -1,73 +0,0 @@ -package dev.slimevr.tracking.processor - -import io.eiren.util.ann.ThreadSafe -import io.github.axisangles.ktmath.Transform -import java.util.concurrent.CopyOnWriteArrayList -import java.util.function.Consumer - -/** - * Represents a joint - */ -class TransformNode(val localRotation: Boolean) { - val localTransform = Transform() - val worldTransform = Transform() - var parent: TransformNode? = null - private set - val children: MutableList = CopyOnWriteArrayList() - - fun attachChild(node: TransformNode) { - require(node.parent == null) { "The child node must not already have a parent." } - children.add(node) - node.parent = this - } - - @ThreadSafe - fun update() { - // Update transform - updateWorldTransforms() - - // Update children - for (node in children) { - node.update() - } - } - - @ThreadSafe - fun updateThisNode() { - updateWorldTransforms() - } - - @Synchronized - private fun updateWorldTransforms() { - worldTransform.set(localTransform) - parent?.let { - if (localRotation) { - worldTransform.combineWithParent(it.worldTransform) - } else { - combineWithParentGlobalRotation(it.worldTransform) - } - } - } - - fun depthFirstTraversal(visitor: Consumer) { - for (node in children) { - node.depthFirstTraversal(visitor) - } - visitor.accept(this) - } - - private fun combineWithParentGlobalRotation(parent: Transform) { - worldTransform.scale = worldTransform.scale hadamard parent.scale - val scaledTranslation = worldTransform.translation hadamard parent.scale - worldTransform.translation = parent.rotation.sandwich(scaledTranslation) + parent.translation - } - - fun detachWithChildren() { - for (child in children) { - child.parent = null - } - children.clear() - parent?.children?.remove(this) - parent = null - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigManager.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigManager.kt deleted file mode 100644 index 45ea6a0548..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigManager.kt +++ /dev/null @@ -1,556 +0,0 @@ -package dev.slimevr.tracking.processor.config - -import dev.slimevr.VRServer.Companion.instance -import dev.slimevr.VRServer.Companion.instanceInitialized -import dev.slimevr.autobone.AutoBone -import dev.slimevr.autobone.errors.BodyProportionError.Companion.proportionLimitMap -import dev.slimevr.config.ConfigManager -import dev.slimevr.tracking.processor.BoneType -import dev.slimevr.tracking.processor.HumanPoseManager -import io.github.axisangles.ktmath.Vector3 -import java.util.* - -class SkeletonConfigManager( - private val autoUpdateOffsets: Boolean, - private val humanPoseManager: HumanPoseManager? = null, -) { - private val configOffsets: EnumMap = EnumMap( - SkeletonConfigOffsets::class.java, - ) - private val configToggles: EnumMap = EnumMap( - SkeletonConfigToggles::class.java, - ) - private val configValues: EnumMap = EnumMap( - SkeletonConfigValues::class.java, - ) - - private var changedToggles: BooleanArray = BooleanArray(SkeletonConfigToggles.values.size) - private var changedValues: BooleanArray = BooleanArray(SkeletonConfigValues.values.size) - - private val nodeOffsets: EnumMap = EnumMap( - BoneType::class.java, - ) - - var userHeightFromOffsets: Float = calculateUserHeight() - private set - - var userNeckHeightFromOffsets: Float = calculateUserHeight() - private set - - init { - if (humanPoseManager?.isSkeletonPresent != false) { - updateSettingsInSkeleton() - - if (autoUpdateOffsets) { - computeAllNodeOffsets() - } - } - } - - fun updateSettingsInSkeleton() { - if (humanPoseManager == null) return - - for (config in SkeletonConfigToggles.values) { - val configToggle = configToggles[config] - humanPoseManager.updateToggleState(config, configToggle ?: config.defaultValue) - } - - for (config in SkeletonConfigValues.values) { - val configValue = configValues[config] - humanPoseManager.updateValueState(config, configValue ?: config.defaultValue) - } - } - - fun updateNodeOffsetsInSkeleton() { - if (humanPoseManager == null) return - - for (config in BoneType.values) { - val nodeOffset = nodeOffsets[config] - if (nodeOffset != null) humanPoseManager.updateNodeOffset(config, nodeOffset) - } - } - - fun setOffset( - config: SkeletonConfigOffsets, - newValue: Float?, - computeOffsets: Boolean, - ) { - if (newValue != null) { - configOffsets[config] = newValue - } else { - configOffsets.remove(config) - } - - // Re-compute the affected offsets - if (computeOffsets && autoUpdateOffsets && config.affectedOffsets != null) { - for (offset in config.affectedOffsets) { - computeNodeOffset(offset) - } - } - - // Re-calculate user height - userHeightFromOffsets = calculateUserHeight() - userNeckHeightFromOffsets = userHeightFromOffsets - getOffset(SkeletonConfigOffsets.NECK) - - // Update vrc config checker if user height change - humanPoseManager?.server?.vrcConfigManager?.forceUpdate() - } - - fun setOffset(config: SkeletonConfigOffsets, newValue: Float?) { - setOffset(config, newValue, true) - } - - fun getOffset(config: SkeletonConfigOffsets?): Float { - if (config == null) { - return 0f - } - - val configOffset = configOffsets[config] - return configOffset ?: config.defaultValue - } - - private fun calculateUserHeight(): Float { - var height = 0f - for (offset in HEIGHT_OFFSETS) { - height += getOffset(offset) - } - return height - } - - fun setToggle(config: SkeletonConfigToggles, newValue: Boolean?) { - if (newValue != null) { - if (configToggles[config] != null && (newValue != configToggles[config])) { - changedToggles[config.ordinal] = true - } - configToggles[config] = newValue - } else { - configToggles.remove(config) - } - - // Updates in skeleton - humanPoseManager?.updateToggleState(config, newValue ?: config.defaultValue) - } - - fun getToggle(config: SkeletonConfigToggles?): Boolean { - if (config == null) { - return false - } - - val configToggle = configToggles[config] - return configToggle ?: config.defaultValue - } - - fun setValue(config: SkeletonConfigValues, newValue: Float?) { - if (newValue != null) { - if (configValues[config] != null && (newValue != configValues[config])) { - changedValues[config.ordinal] = true - } - configValues[config] = newValue - } else { - configValues.remove(config) - } - - // Updates in skeleton - humanPoseManager?.updateValueState(config, newValue ?: config.defaultValue) - } - - fun getValue(config: SkeletonConfigValues?): Float { - if (config == null) { - return 0f - } - - val configValue = configValues[config] - return configValue ?: config.defaultValue - } - - protected fun setNodeOffset(nodeOffset: BoneType, x: Float, y: Float, z: Float) { - val offset = Vector3(x, y, z) - nodeOffsets[nodeOffset] = offset - - // Updates in skeleton - humanPoseManager?.updateNodeOffset(nodeOffset, offset) - } - - fun computeNodeOffset(nodeOffset: BoneType) { - when (nodeOffset) { - BoneType.HEAD -> setNodeOffset(nodeOffset, 0f, 0f, getOffset(SkeletonConfigOffsets.HEAD)) - - BoneType.NECK -> setNodeOffset(nodeOffset, 0f, -getOffset(SkeletonConfigOffsets.NECK), 0f) - - BoneType.UPPER_CHEST -> setNodeOffset( - nodeOffset, - 0f, - -getOffset(SkeletonConfigOffsets.UPPER_CHEST), - 0f, - ) - - BoneType.CHEST_TRACKER -> setNodeOffset( - nodeOffset, - 0f, - -getOffset(SkeletonConfigOffsets.CHEST_OFFSET) - - getOffset(SkeletonConfigOffsets.CHEST), - -getOffset(SkeletonConfigOffsets.SKELETON_OFFSET), - ) - - BoneType.CHEST -> setNodeOffset(nodeOffset, 0f, -getOffset(SkeletonConfigOffsets.CHEST), 0f) - - BoneType.WAIST -> setNodeOffset(nodeOffset, 0f, -getOffset(SkeletonConfigOffsets.WAIST), 0f) - - BoneType.HIP -> setNodeOffset(nodeOffset, 0f, -getOffset(SkeletonConfigOffsets.HIP), 0f) - - BoneType.HIP_TRACKER -> setNodeOffset( - nodeOffset, - 0f, - -getOffset(SkeletonConfigOffsets.HIP_OFFSET), - -getOffset(SkeletonConfigOffsets.SKELETON_OFFSET), - ) - - BoneType.LEFT_HIP -> setNodeOffset( - nodeOffset, - -getOffset(SkeletonConfigOffsets.HIPS_WIDTH) / 2f, - 0f, - 0f, - ) - - BoneType.RIGHT_HIP -> setNodeOffset( - nodeOffset, - getOffset(SkeletonConfigOffsets.HIPS_WIDTH) / 2f, - 0f, - 0f, - ) - - BoneType.LEFT_UPPER_LEG, BoneType.RIGHT_UPPER_LEG -> setNodeOffset( - nodeOffset, - 0f, - -getOffset(SkeletonConfigOffsets.UPPER_LEG), - 0f, - ) - - BoneType.LEFT_KNEE_TRACKER, BoneType.RIGHT_KNEE_TRACKER, BoneType.LEFT_FOOT_TRACKER, BoneType.RIGHT_FOOT_TRACKER -> setNodeOffset( - nodeOffset, - 0f, - 0f, - -getOffset(SkeletonConfigOffsets.SKELETON_OFFSET), - ) - - BoneType.LEFT_LOWER_LEG, BoneType.RIGHT_LOWER_LEG -> setNodeOffset( - nodeOffset, - 0f, - -getOffset(SkeletonConfigOffsets.LOWER_LEG), - -getOffset(SkeletonConfigOffsets.FOOT_SHIFT), - ) - - BoneType.LEFT_FOOT, BoneType.RIGHT_FOOT -> setNodeOffset( - nodeOffset, - 0f, - 0f, - -getOffset(SkeletonConfigOffsets.FOOT_LENGTH), - ) - - BoneType.LEFT_UPPER_SHOULDER -> setNodeOffset( - nodeOffset, - 0f, - 0f, - 0f, - ) - - BoneType.RIGHT_UPPER_SHOULDER -> setNodeOffset( - nodeOffset, - 0f, - 0f, - 0f, - ) - - BoneType.LEFT_SHOULDER -> setNodeOffset( - nodeOffset, - -getOffset(SkeletonConfigOffsets.SHOULDERS_WIDTH) / 2f, - -getOffset(SkeletonConfigOffsets.SHOULDERS_DISTANCE), - 0f, - ) - - BoneType.RIGHT_SHOULDER -> setNodeOffset( - nodeOffset, - getOffset(SkeletonConfigOffsets.SHOULDERS_WIDTH) / 2f, - -getOffset(SkeletonConfigOffsets.SHOULDERS_DISTANCE), - 0f, - ) - - BoneType.LEFT_UPPER_ARM, BoneType.RIGHT_UPPER_ARM -> setNodeOffset( - nodeOffset, - 0f, - -getOffset(SkeletonConfigOffsets.UPPER_ARM), - 0f, - ) - - BoneType.LEFT_LOWER_ARM, BoneType.RIGHT_LOWER_ARM -> setNodeOffset( - nodeOffset, - 0f, - -getOffset(SkeletonConfigOffsets.LOWER_ARM), - 0f, - ) - - BoneType.LEFT_HAND, BoneType.RIGHT_HAND -> setNodeOffset( - nodeOffset, - 0f, - -getOffset(SkeletonConfigOffsets.HAND_Y), - -getOffset(SkeletonConfigOffsets.HAND_Z), - ) - - BoneType.LEFT_ELBOW_TRACKER, BoneType.RIGHT_ELBOW_TRACKER -> setNodeOffset( - nodeOffset, - 0f, - -getOffset(SkeletonConfigOffsets.ELBOW_OFFSET), - 0f, - ) - - BoneType.LEFT_THUMB_METACARPAL, BoneType.LEFT_THUMB_PROXIMAL, BoneType.LEFT_THUMB_DISTAL, - BoneType.RIGHT_THUMB_METACARPAL, BoneType.RIGHT_THUMB_PROXIMAL, BoneType.RIGHT_THUMB_DISTAL, - -> setNodeOffset( - nodeOffset, - 0f, - -getOffset(SkeletonConfigOffsets.HAND_Y) * 0.2f, - -getOffset(SkeletonConfigOffsets.HAND_Y) * 0.1f, - ) - - BoneType.LEFT_INDEX_PROXIMAL, BoneType.LEFT_INDEX_INTERMEDIATE, BoneType.LEFT_INDEX_DISTAL, - BoneType.RIGHT_INDEX_PROXIMAL, BoneType.RIGHT_INDEX_INTERMEDIATE, BoneType.RIGHT_INDEX_DISTAL, - -> setNodeOffset( - nodeOffset, - 0f, - -getOffset(SkeletonConfigOffsets.HAND_Y) * 0.25f, - 0f, - ) - - BoneType.LEFT_MIDDLE_PROXIMAL, BoneType.LEFT_MIDDLE_INTERMEDIATE, BoneType.LEFT_MIDDLE_DISTAL, - BoneType.RIGHT_MIDDLE_PROXIMAL, BoneType.RIGHT_MIDDLE_INTERMEDIATE, BoneType.RIGHT_MIDDLE_DISTAL, - -> setNodeOffset( - nodeOffset, - 0f, - -getOffset(SkeletonConfigOffsets.HAND_Y) * 0.3f, - 0f, - ) - - BoneType.LEFT_RING_PROXIMAL, BoneType.LEFT_RING_INTERMEDIATE, BoneType.LEFT_RING_DISTAL, - BoneType.RIGHT_RING_PROXIMAL, BoneType.RIGHT_RING_INTERMEDIATE, BoneType.RIGHT_RING_DISTAL, - -> setNodeOffset( - nodeOffset, - 0f, - -getOffset(SkeletonConfigOffsets.HAND_Y) * 0.28f, - 0f, - ) - - BoneType.LEFT_LITTLE_PROXIMAL, BoneType.LEFT_LITTLE_INTERMEDIATE, BoneType.LEFT_LITTLE_DISTAL, - BoneType.RIGHT_LITTLE_PROXIMAL, BoneType.RIGHT_LITTLE_INTERMEDIATE, BoneType.RIGHT_LITTLE_DISTAL, - -> setNodeOffset( - nodeOffset, - 0f, - -getOffset(SkeletonConfigOffsets.HAND_Y) * 0.2f, - 0f, - ) - - else -> {} - } - } - - fun computeAllNodeOffsets() { - for (offset in BoneType.values) { - computeNodeOffset(offset) - } - } - - fun setOffsets( - configOffsets: Map?, - computeOffsets: Boolean, - ) { - configOffsets?.forEach { (key: SkeletonConfigOffsets, value: Float?) -> - // Do not recalculate the offsets, these are done in bulk at the - // end - setOffset(key, value, false) - } - - if (computeOffsets && autoUpdateOffsets) { - computeAllNodeOffsets() - } - } - - fun setOffsets( - configOffsets: Map?, - ) { - setOffsets(configOffsets, true) - } - - fun setOffsets(skeletonConfigManager: SkeletonConfigManager) { - // Don't recalculate node offsets, just re-use them from skeletonConfig - setOffsets( - skeletonConfigManager.configOffsets, - false, - ) - - // Copy skeletonConfig's nodeOffsets as the configs are all the same - skeletonConfigManager.nodeOffsets.forEach { (key: BoneType, value: Vector3) -> - setNodeOffset(key, value.x, value.y, value.z) - } - } - - fun resetOffsets() { - if (humanPoseManager != null) { - for (config in SkeletonConfigOffsets.values) { - resetOffset(config) - } - } else { - configOffsets.clear() - if (autoUpdateOffsets) { - computeAllNodeOffsets() - } - } - } - - fun resetToggles() { - configToggles.clear() - - // Updates in skeleton - if (humanPoseManager != null) { - for (config in SkeletonConfigToggles.values) { - humanPoseManager.updateToggleState(config, config.defaultValue) - } - } - - // Remove from config to use default if they change in the future. - Arrays.fill(changedToggles, false) - if (instanceInitialized) { - for (value in SkeletonConfigToggles.values) { - instance.configManager - .vrConfig - .skeleton - .getToggles() - .remove(value.configKey) - } - } - } - - fun resetValues() { - configValues.clear() - - // Updates in skeleton - if (humanPoseManager != null) { - for (config in SkeletonConfigValues.values) { - humanPoseManager.updateValueState(config, config.defaultValue) - } - } - - // Remove from config to use default if they change in the future. - Arrays.fill(changedValues, false) - if (instanceInitialized) { - for (value in SkeletonConfigValues.values) { - instance.configManager - .vrConfig - .skeleton - .getValues() - .remove(value.configKey) - } - } - } - - fun resetAllConfigs() { - resetOffsets() - resetToggles() - resetValues() - } - - fun resetOffset(config: SkeletonConfigOffsets) { - val height = humanPoseManager?.server?.configManager?.vrConfig?.skeleton?.userHeight ?: -1f - // Only scale if the height is within range - if (height > AutoBone.MIN_HEIGHT) { - val proportionLimiter = proportionLimitMap[config] - if (proportionLimiter != null && proportionLimiter.scaleByHeight) { - setOffset( - config, - height * proportionLimiter.targetRatio, - ) - } else { - setOffset(config, null) - } - } else { - setOffset(config, null) - } - } - - fun loadFromConfig(configManager: ConfigManager) { - val skeletonConfig = configManager.vrConfig.skeleton - - // Load offsets - val offsets = skeletonConfig.getOffsets() - for (configValue in SkeletonConfigOffsets.values) { - val offset = offsets[configValue.configKey] - if (offset != null) { - // Do not recalculate the offsets, these are done in bulk at the - // end - setOffset(configValue, offset, false) - } - } - - // Load toggles - val toggles = skeletonConfig.getToggles() - for (configValue in SkeletonConfigToggles.values) { - val toggle = toggles[configValue.configKey] - if (toggle != null) { - setToggle(configValue, toggle) - } else { - humanPoseManager?.updateToggleState(configValue, configValue.defaultValue) - } - } - - // Load values - val values = skeletonConfig.getValues() - for (configValue in SkeletonConfigValues.values) { - val value = values[configValue.configKey] - if (value != null) { - setValue(configValue, value) - } else { - humanPoseManager?.updateValueState(configValue, configValue.defaultValue) - } - } - - // Updates all offsets - if (autoUpdateOffsets) { - computeAllNodeOffsets() - } - } - - fun save() { - require(instanceInitialized) { "VRServer instance is not initialized, config cannot be saved." } - - val skeletonConfig = instance.configManager - .vrConfig - .skeleton - - // Write all possible values to keep consistent even if defaults changed - for (value in SkeletonConfigOffsets.values) { - skeletonConfig.getOffsets()[value.configKey] = getOffset(value) - } - - // Only write changed values to keep using defaults if not changed - for (value in SkeletonConfigToggles.values) { - if (changedToggles[value.ordinal]) skeletonConfig.getToggles()[value.configKey] = getToggle(value) - } - - // Only write changed values to keep using defaults if not changed - for (value in SkeletonConfigValues.values) { - if (changedValues[value.ordinal]) skeletonConfig.getValues()[value.configKey] = getValue(value) - } - } - - companion object { - val HEIGHT_OFFSETS: Array = arrayOf( - SkeletonConfigOffsets.NECK, - SkeletonConfigOffsets.UPPER_CHEST, - SkeletonConfigOffsets.CHEST, - SkeletonConfigOffsets.WAIST, - SkeletonConfigOffsets.HIP, - SkeletonConfigOffsets.UPPER_LEG, - SkeletonConfigOffsets.LOWER_LEG, - ) - - const val FLOOR_OFFSET: Float = 0.05f - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigOffsets.java b/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigOffsets.java deleted file mode 100644 index f65e962467..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigOffsets.java +++ /dev/null @@ -1,172 +0,0 @@ -package dev.slimevr.tracking.processor.config; - -import dev.slimevr.tracking.processor.BoneType; - -import java.util.HashMap; -import java.util.Map; - - -public enum SkeletonConfigOffsets { - // Note: GUI uses this order to sort (not ID) - HEAD( - 1, - "headShift", - 0.1f, - new BoneType[] { BoneType.HEAD } - ), - NECK( - 2, - "neckLength", - 0.1f, - new BoneType[] { BoneType.NECK } - ), - UPPER_CHEST( - 21, - "upperChestLength", - 0.16f, - new BoneType[] { BoneType.UPPER_CHEST, BoneType.CHEST_TRACKER } - ), - CHEST( - 3, - "chestLength", - 0.16f, - new BoneType[] { BoneType.CHEST, BoneType.CHEST_TRACKER } - ), - CHEST_OFFSET( - 4, - "chestOffset", - 0.0f, - new BoneType[] { BoneType.CHEST_TRACKER } - ), - WAIST( - 5, - "waistLength", - 0.20f, - new BoneType[] { BoneType.WAIST } - ), - HIP( - 6, - "hipLength", - 0.04f, - new BoneType[] { BoneType.HIP } - ), - HIP_OFFSET( - 7, - "hipOffset", - 0.0f, - new BoneType[] { BoneType.HIP_TRACKER } - ), - HIPS_WIDTH( - 8, - "hipsWidth", - 0.26f, - new BoneType[] { BoneType.LEFT_HIP, BoneType.RIGHT_HIP } - ), - UPPER_LEG( - 9, - "upperLegLength", - 0.42f, - new BoneType[] { BoneType.LEFT_UPPER_LEG, BoneType.RIGHT_UPPER_LEG } - ), - LOWER_LEG( - 10, - "lowerLegLength", - 0.50f, - new BoneType[] { BoneType.LEFT_LOWER_LEG, BoneType.RIGHT_LOWER_LEG } - ), - FOOT_LENGTH( - 11, - "footLength", - 0.05f, - new BoneType[] { BoneType.LEFT_FOOT, BoneType.RIGHT_FOOT } - ), - FOOT_SHIFT( - 12, - "footShift", - -0.05f, - new BoneType[] { BoneType.LEFT_LOWER_LEG, BoneType.RIGHT_LOWER_LEG } - ), - SKELETON_OFFSET( - 13, - "skeletonOffset", - 0.0f, - new BoneType[] { BoneType.CHEST_TRACKER, BoneType.HIP_TRACKER, - BoneType.LEFT_KNEE_TRACKER, BoneType.RIGHT_KNEE_TRACKER, - BoneType.LEFT_FOOT_TRACKER, BoneType.RIGHT_FOOT_TRACKER } - ), - SHOULDERS_DISTANCE( - 14, - "shouldersDistance", - 0.08f, - new BoneType[] { BoneType.LEFT_SHOULDER, BoneType.RIGHT_SHOULDER } - ), - SHOULDERS_WIDTH( - 15, - "shouldersWidth", - 0.35f, - new BoneType[] { BoneType.LEFT_SHOULDER, BoneType.RIGHT_SHOULDER } - ), - UPPER_ARM( - 16, - "upperArmLength", - 0.26f, - new BoneType[] { BoneType.LEFT_UPPER_ARM, BoneType.RIGHT_UPPER_ARM } - ), - LOWER_ARM( - 17, - "lowerArmLength", - 0.26f, - new BoneType[] { BoneType.LEFT_LOWER_ARM, BoneType.RIGHT_LOWER_ARM } - ), - HAND_Y( - 18, - "handDistanceY", - 0.035f, - new BoneType[] { BoneType.LEFT_HAND, BoneType.RIGHT_HAND } - ), - HAND_Z( - 19, - "handDistanceZ", - 0.13f, - new BoneType[] { BoneType.LEFT_HAND, BoneType.RIGHT_HAND } - ), - ELBOW_OFFSET( - 20, - "elbowOffset", - 0.0f, - new BoneType[] { BoneType.LEFT_ELBOW_TRACKER, BoneType.RIGHT_ELBOW_TRACKER } - ),; - - public static final SkeletonConfigOffsets[] values = values(); - private static final Map byIdVal = new HashMap<>(); - - static { - for (SkeletonConfigOffsets configVal : values()) { - byIdVal.put(configVal.id, configVal); - } - } - - public final int id; - public final String configKey; - public final float defaultValue; - public final BoneType[] affectedOffsets; - - SkeletonConfigOffsets( - int id, - String configKey, - float defaultValue, - BoneType[] affectedOffsets - ) { - this.id = id; // id of SkeletonBone in solarxr - this.configKey = configKey; - - this.defaultValue = defaultValue; - - this.affectedOffsets = affectedOffsets - == null ? new BoneType[0] : affectedOffsets; - } - - public static SkeletonConfigOffsets getById(int id) { - return byIdVal.get(id); - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigToggles.java b/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigToggles.java deleted file mode 100644 index f6f8c967ff..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigToggles.java +++ /dev/null @@ -1,54 +0,0 @@ -package dev.slimevr.tracking.processor.config; - -import java.util.HashMap; -import java.util.Map; - - -public enum SkeletonConfigToggles { - - EXTENDED_SPINE_MODEL(1, "Extended spine model", "extendedSpine", true), - EXTENDED_PELVIS_MODEL(2, "Extended pelvis model", "extendedPelvis", true), - EXTENDED_KNEE_MODEL(3, "Extended knee model", "extendedKnee", true), - FORCE_ARMS_FROM_HMD(4, "Force arms from HMD", "forceArmsFromHMD", true), - FLOOR_CLIP(5, "Floor clip", "floorClip", true), - SKATING_CORRECTION(6, "Skating correction", "skatingCorrection", true), - TOE_SNAP(8, "Toe Snap", "toeSnap", false), - FOOT_PLANT(9, "Foot Plant", "footPlant", true), - SELF_LOCALIZATION(10, "Self Localization", "selfLocalization", false), - USE_POSITION(11, "Use Position", "usePosition", true), - ENFORCE_CONSTRAINTS(12, "Enforce Constraints", "enforceConstraints", true), - CORRECT_CONSTRAINTS(13, "Correct Constraints", "correctConstraints", true),; - - public static final SkeletonConfigToggles[] values = values(); - private static final Map byStringVal = new HashMap<>(); - - private static final Map byIdVal = new HashMap<>(); - - static { - for (SkeletonConfigToggles configVal : values()) { - byIdVal.put(configVal.id, configVal); - byStringVal.put(configVal.stringVal.toLowerCase(), configVal); - } - } - - public final int id; - public final String stringVal; - public final String configKey; - public final boolean defaultValue; - - SkeletonConfigToggles(int id, String stringVal, String configKey, boolean defaultValue) { - this.id = id; - this.stringVal = stringVal; - this.configKey = configKey; - - this.defaultValue = defaultValue; - } - - public static SkeletonConfigToggles getByStringValue(String stringVal) { - return stringVal == null ? null : byStringVal.get(stringVal.toLowerCase()); - } - - public static SkeletonConfigToggles getById(int id) { - return byIdVal.get(id); - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigValues.java b/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigValues.java deleted file mode 100644 index c0689dd6f3..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/config/SkeletonConfigValues.java +++ /dev/null @@ -1,45 +0,0 @@ -package dev.slimevr.tracking.processor.config; - -import java.util.HashMap; -import java.util.Map; - - -public enum SkeletonConfigValues { - // @formatter:off - WAIST_FROM_CHEST_HIP_AVERAGING(1, "waistFromChestHipAveraging", 0.30f), - WAIST_FROM_CHEST_LEGS_AVERAGING(2, "waistFromChestLegsAveraging", 0.30f), - HIP_FROM_CHEST_LEGS_AVERAGING(3, "hipFromChestLegsAveraging", 0.50f), - HIP_FROM_WAIST_LEGS_AVERAGING(4, "hipFromWaistLegsAveraging", 0.40f), - HIP_LEGS_AVERAGING(5, "hipLegsAveraging", 0.25f), - KNEE_TRACKER_ANKLE_AVERAGING(6, "kneeTrackerAnkleAveraging", 0.85f), - KNEE_ANKLE_AVERAGING(7, "kneeAnkleAveraging", 0.00f),; - // @formatter:on - - public static final SkeletonConfigValues[] values = values(); - private static final Map byIdVal = new HashMap<>(); - - static { - for (SkeletonConfigValues configVal : values()) { - byIdVal.put(configVal.id, configVal); - } - } - - public final int id; - public final String configKey; - public final float defaultValue; - - SkeletonConfigValues( - int id, - String configKey, - float defaultValue - ) { - this.id = id; - this.configKey = configKey; - - this.defaultValue = defaultValue; - } - - public static SkeletonConfigValues getById(int id) { - return byIdVal.get(id); - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/HumanSkeleton.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/HumanSkeleton.kt deleted file mode 100644 index 2baef0bcd5..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/HumanSkeleton.kt +++ /dev/null @@ -1,1791 +0,0 @@ -package dev.slimevr.tracking.processor.skeleton - -import dev.slimevr.VRServer -import dev.slimevr.config.MountingMethods -import dev.slimevr.config.StayAlignedConfig -import dev.slimevr.tracking.processor.Bone -import dev.slimevr.tracking.processor.BoneType -import dev.slimevr.tracking.processor.Constraint -import dev.slimevr.tracking.processor.Constraint.Companion.ConstraintType -import dev.slimevr.tracking.processor.HumanPoseManager -import dev.slimevr.tracking.processor.config.SkeletonConfigToggles -import dev.slimevr.tracking.processor.config.SkeletonConfigValues -import dev.slimevr.tracking.processor.stayaligned.StayAligned -import dev.slimevr.tracking.processor.stayaligned.trackers.TrackerSkeleton -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerPosition -import dev.slimevr.tracking.trackers.TrackerRole -import dev.slimevr.tracking.trackers.TrackerUtils -import dev.slimevr.tracking.trackers.TrackerUtils.getFirstAvailableTracker -import dev.slimevr.tracking.trackers.TrackerUtils.getTrackerForSkeleton -import dev.slimevr.tracking.trackers.udp.TrackerDataType -import dev.slimevr.util.ann.VRServerThread -import io.eiren.util.ann.ThreadSafe -import io.eiren.util.collections.FastList -import io.eiren.util.logging.LogManager -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Quaternion.Companion.I -import io.github.axisangles.ktmath.Quaternion.Companion.IDENTITY -import io.github.axisangles.ktmath.Quaternion.Companion.fromTo -import io.github.axisangles.ktmath.Vector3 -import io.github.axisangles.ktmath.Vector3.Companion.NEG_Y -import io.github.axisangles.ktmath.Vector3.Companion.NULL -import io.github.axisangles.ktmath.Vector3.Companion.POS_Y -import solarxr_protocol.datatypes.BodyPart -import java.lang.IllegalArgumentException -import kotlin.properties.Delegates - -class HumanSkeleton( - val humanPoseManager: HumanPoseManager, -) { - // Upper body bones - val headBone = Bone(BoneType.HEAD, Constraint(ConstraintType.COMPLETE)) - val neckBone = Bone(BoneType.NECK, Constraint(ConstraintType.COMPLETE)) - val upperChestBone = Bone(BoneType.UPPER_CHEST, Constraint(ConstraintType.TWIST_SWING, 90f, 120f)) - val chestBone = Bone(BoneType.CHEST, Constraint(ConstraintType.TWIST_SWING, 60f, 120f)) - val waistBone = Bone(BoneType.WAIST, Constraint(ConstraintType.TWIST_SWING, 60f, 120f)) - val hipBone = Bone(BoneType.HIP, Constraint(ConstraintType.TWIST_SWING, 60f, 120f)) - - // Lower body bones - val leftHipBone = Bone(BoneType.LEFT_HIP, Constraint(ConstraintType.TWIST_SWING, 0f, 15f)) - val rightHipBone = Bone(BoneType.RIGHT_HIP, Constraint(ConstraintType.TWIST_SWING, 0f, 15f)) - val leftUpperLegBone = Bone(BoneType.LEFT_UPPER_LEG, Constraint(ConstraintType.TWIST_SWING, 120f, 180f)) - val rightUpperLegBone = Bone(BoneType.RIGHT_UPPER_LEG, Constraint(ConstraintType.TWIST_SWING, 120f, 180f)) - val leftLowerLegBone = Bone(BoneType.LEFT_LOWER_LEG, Constraint(ConstraintType.LOOSE_HINGE, 180f, 0f, 50f)) - val rightLowerLegBone = Bone(BoneType.RIGHT_LOWER_LEG, Constraint(ConstraintType.LOOSE_HINGE, 180f, 0f, 50f)) - val leftFootBone = Bone(BoneType.LEFT_FOOT, Constraint(ConstraintType.TWIST_SWING, 60f, 60f)) - val rightFootBone = Bone(BoneType.RIGHT_FOOT, Constraint(ConstraintType.TWIST_SWING, 60f, 60f)) - - // Arm bones - val leftUpperShoulderBone = Bone(BoneType.LEFT_UPPER_SHOULDER, Constraint(ConstraintType.COMPLETE)) - val rightUpperShoulderBone = Bone(BoneType.RIGHT_UPPER_SHOULDER, Constraint(ConstraintType.COMPLETE)) - val leftShoulderBone = Bone(BoneType.LEFT_SHOULDER, Constraint(ConstraintType.TWIST_SWING, 0f, 30f)) - val rightShoulderBone = Bone(BoneType.RIGHT_SHOULDER, Constraint(ConstraintType.TWIST_SWING, 0f, 30f)) - val leftUpperArmBone = Bone(BoneType.LEFT_UPPER_ARM, Constraint(ConstraintType.TWIST_SWING, 120f, 180f)) - val rightUpperArmBone = Bone(BoneType.RIGHT_UPPER_ARM, Constraint(ConstraintType.TWIST_SWING, 120f, 180f)) - val leftLowerArmBone = Bone(BoneType.LEFT_LOWER_ARM, Constraint(ConstraintType.LOOSE_HINGE, 0f, -180f, 40f)) - val rightLowerArmBone = Bone(BoneType.RIGHT_LOWER_ARM, Constraint(ConstraintType.LOOSE_HINGE, 0f, -180f, 40f)) - val leftHandBone = Bone(BoneType.LEFT_HAND, Constraint(ConstraintType.TWIST_SWING, 120f, 120f)) - val rightHandBone = Bone(BoneType.RIGHT_HAND, Constraint(ConstraintType.TWIST_SWING, 120f, 120f)) - - // Finger bones - val leftThumbMetacarpalBone = Bone(BoneType.LEFT_THUMB_METACARPAL, Constraint(ConstraintType.COMPLETE)) - val leftThumbProximalBone = Bone(BoneType.LEFT_THUMB_PROXIMAL, Constraint(ConstraintType.COMPLETE)) - val leftThumbDistalBone = Bone(BoneType.LEFT_THUMB_DISTAL, Constraint(ConstraintType.COMPLETE)) - val leftIndexProximalBone = Bone(BoneType.LEFT_INDEX_PROXIMAL, Constraint(ConstraintType.COMPLETE)) - val leftIndexIntermediateBone = Bone(BoneType.LEFT_INDEX_INTERMEDIATE, Constraint(ConstraintType.COMPLETE)) - val leftIndexDistalBone = Bone(BoneType.LEFT_INDEX_DISTAL, Constraint(ConstraintType.COMPLETE)) - val leftMiddleProximalBone = Bone(BoneType.LEFT_MIDDLE_PROXIMAL, Constraint(ConstraintType.COMPLETE)) - val leftMiddleIntermediateBone = Bone(BoneType.LEFT_MIDDLE_INTERMEDIATE, Constraint(ConstraintType.COMPLETE)) - val leftMiddleDistalBone = Bone(BoneType.LEFT_MIDDLE_DISTAL, Constraint(ConstraintType.COMPLETE)) - val leftRingProximalBone = Bone(BoneType.LEFT_RING_PROXIMAL, Constraint(ConstraintType.COMPLETE)) - val leftRingIntermediateBone = Bone(BoneType.LEFT_RING_INTERMEDIATE, Constraint(ConstraintType.COMPLETE)) - val leftRingDistalBone = Bone(BoneType.LEFT_RING_DISTAL, Constraint(ConstraintType.COMPLETE)) - val leftLittleProximalBone = Bone(BoneType.LEFT_LITTLE_PROXIMAL, Constraint(ConstraintType.COMPLETE)) - val leftLittleIntermediateBone = Bone(BoneType.LEFT_LITTLE_INTERMEDIATE, Constraint(ConstraintType.COMPLETE)) - val leftLittleDistalBone = Bone(BoneType.LEFT_LITTLE_DISTAL, Constraint(ConstraintType.COMPLETE)) - val rightThumbMetacarpalBone = Bone(BoneType.RIGHT_THUMB_METACARPAL, Constraint(ConstraintType.COMPLETE)) - val rightThumbProximalBone = Bone(BoneType.RIGHT_THUMB_PROXIMAL, Constraint(ConstraintType.COMPLETE)) - val rightThumbDistalBone = Bone(BoneType.RIGHT_THUMB_DISTAL, Constraint(ConstraintType.COMPLETE)) - val rightIndexProximalBone = Bone(BoneType.RIGHT_INDEX_PROXIMAL, Constraint(ConstraintType.COMPLETE)) - val rightIndexIntermediateBone = Bone(BoneType.RIGHT_INDEX_INTERMEDIATE, Constraint(ConstraintType.COMPLETE)) - val rightIndexDistalBone = Bone(BoneType.RIGHT_INDEX_DISTAL, Constraint(ConstraintType.COMPLETE)) - val rightMiddleProximalBone = Bone(BoneType.RIGHT_MIDDLE_PROXIMAL, Constraint(ConstraintType.COMPLETE)) - val rightMiddleIntermediateBone = Bone(BoneType.RIGHT_MIDDLE_INTERMEDIATE, Constraint(ConstraintType.COMPLETE)) - val rightMiddleDistalBone = Bone(BoneType.RIGHT_MIDDLE_DISTAL, Constraint(ConstraintType.COMPLETE)) - val rightRingProximalBone = Bone(BoneType.RIGHT_RING_PROXIMAL, Constraint(ConstraintType.COMPLETE)) - val rightRingIntermediateBone = Bone(BoneType.RIGHT_RING_INTERMEDIATE, Constraint(ConstraintType.COMPLETE)) - val rightRingDistalBone = Bone(BoneType.RIGHT_RING_DISTAL, Constraint(ConstraintType.COMPLETE)) - val rightLittleProximalBone = Bone(BoneType.RIGHT_LITTLE_PROXIMAL, Constraint(ConstraintType.COMPLETE)) - val rightLittleIntermediateBone = Bone(BoneType.RIGHT_LITTLE_INTERMEDIATE, Constraint(ConstraintType.COMPLETE)) - val rightLittleDistalBone = Bone(BoneType.RIGHT_LITTLE_DISTAL, Constraint(ConstraintType.COMPLETE)) - - // Tracker bones - val headTrackerBone = Bone(BoneType.HEAD_TRACKER, Constraint(ConstraintType.COMPLETE)) - val chestTrackerBone = Bone(BoneType.CHEST_TRACKER, Constraint(ConstraintType.COMPLETE)) - val hipTrackerBone = Bone(BoneType.HIP_TRACKER, Constraint(ConstraintType.COMPLETE)) - val leftKneeTrackerBone = Bone(BoneType.LEFT_KNEE_TRACKER, Constraint(ConstraintType.COMPLETE)) - val rightKneeTrackerBone = Bone(BoneType.RIGHT_KNEE_TRACKER, Constraint(ConstraintType.COMPLETE)) - val leftFootTrackerBone = Bone(BoneType.LEFT_FOOT_TRACKER, Constraint(ConstraintType.COMPLETE)) - val rightFootTrackerBone = Bone(BoneType.RIGHT_FOOT_TRACKER, Constraint(ConstraintType.COMPLETE)) - val leftElbowTrackerBone = Bone(BoneType.LEFT_ELBOW_TRACKER, Constraint(ConstraintType.COMPLETE)) - val rightElbowTrackerBone = Bone(BoneType.RIGHT_ELBOW_TRACKER, Constraint(ConstraintType.COMPLETE)) - val leftHandTrackerBone = Bone(BoneType.LEFT_HAND_TRACKER, Constraint(ConstraintType.COMPLETE)) - val rightHandTrackerBone = Bone(BoneType.RIGHT_HAND_TRACKER, Constraint(ConstraintType.COMPLETE)) - - // Buffers - var hasSpineTracker = false - var hasKneeTrackers = false - var hasLeftArmTracker = false - var hasRightArmTracker = false - var hasLeftFingerTracker = false - var hasRightFingerTracker = false - - // Input trackers - var headTracker: Tracker? by Delegates.observable(null) { _, old, new -> - if (old == new) return@observable - - humanPoseManager.checkTrackersRequiringReset() - } - var neckTracker: Tracker? = null - var upperChestTracker: Tracker? = null - var chestTracker: Tracker? = null - var waistTracker: Tracker? = null - var hipTracker: Tracker? = null - var leftUpperLegTracker: Tracker? = null - var leftLowerLegTracker: Tracker? = null - var leftFootTracker: Tracker? = null - var rightUpperLegTracker: Tracker? = null - var rightLowerLegTracker: Tracker? = null - var rightFootTracker: Tracker? = null - var leftLowerArmTracker: Tracker? = null - var rightLowerArmTracker: Tracker? = null - var leftUpperArmTracker: Tracker? = null - var rightUpperArmTracker: Tracker? = null - var leftHandTracker: Tracker? = null - var rightHandTracker: Tracker? = null - var leftShoulderTracker: Tracker? = null - var rightShoulderTracker: Tracker? = null - var leftThumbMetacarpalTracker: Tracker? = null - var leftThumbProximalTracker: Tracker? = null - var leftThumbDistalTracker: Tracker? = null - var leftIndexProximalTracker: Tracker? = null - var leftIndexIntermediateTracker: Tracker? = null - var leftIndexDistalTracker: Tracker? = null - var leftMiddleProximalTracker: Tracker? = null - var leftMiddleIntermediateTracker: Tracker? = null - var leftMiddleDistalTracker: Tracker? = null - var leftRingProximalTracker: Tracker? = null - var leftRingIntermediateTracker: Tracker? = null - var leftRingDistalTracker: Tracker? = null - var leftLittleProximalTracker: Tracker? = null - var leftLittleIntermediateTracker: Tracker? = null - var leftLittleDistalTracker: Tracker? = null - var rightThumbMetacarpalTracker: Tracker? = null - var rightThumbProximalTracker: Tracker? = null - var rightThumbDistalTracker: Tracker? = null - var rightIndexProximalTracker: Tracker? = null - var rightIndexIntermediateTracker: Tracker? = null - var rightIndexDistalTracker: Tracker? = null - var rightMiddleProximalTracker: Tracker? = null - var rightMiddleIntermediateTracker: Tracker? = null - var rightMiddleDistalTracker: Tracker? = null - var rightRingProximalTracker: Tracker? = null - var rightRingIntermediateTracker: Tracker? = null - var rightRingDistalTracker: Tracker? = null - var rightLittleProximalTracker: Tracker? = null - var rightLittleIntermediateTracker: Tracker? = null - var rightLittleDistalTracker: Tracker? = null - - // Output trackers - var computedHeadTracker: Tracker? = null - var computedChestTracker: Tracker? = null - var computedHipTracker: Tracker? = null - var computedLeftKneeTracker: Tracker? = null - var computedLeftFootTracker: Tracker? = null - var computedRightKneeTracker: Tracker? = null - var computedRightFootTracker: Tracker? = null - var computedLeftElbowTracker: Tracker? = null - var computedRightElbowTracker: Tracker? = null - var computedLeftHandTracker: Tracker? = null - var computedRightHandTracker: Tracker? = null - - // Toggles - private var extendedSpineModel = false - private var extendedPelvisModel = false - private var extendedKneeModel = false - private var forceArmsFromHMD = true - private var enforceConstraints = true - private var correctConstraints = true - - // Ratios - private var waistFromChestHipAveraging = 0f - private var waistFromChestLegsAveraging = 0f - private var hipFromChestLegsAveraging = 0f - private var hipFromWaistLegsAveraging = 0f - private var hipLegsAveraging = 0f - private var kneeTrackerAnkleAveraging = 0f - private var kneeAnkleAveraging = 0f - - // Others - private var pauseTracking = false // Pauses skeleton tracking if true, resumes skeleton tracking if false - - // Modules - var legTweaks = LegTweaks(this) - var tapDetectionManager: TapDetectionManager? = null - var localizer = Localizer(this) - var ikSolver = IKSolver(headBone) - var userHeightCalibration: UserHeightCalibration? = null - - // Stay Aligned - var trackerSkeleton = TrackerSkeleton(this) - var stayAlignedConfig = StayAlignedConfig() - - // Constructors - init { - assembleSkeleton() - setComputedTrackers(humanPoseManager.computedTrackers) - } - - constructor( - humanPoseManager: HumanPoseManager, - server: VRServer, - ) : this(humanPoseManager) { - setTrackersFromList(server.allTrackers) - tapDetectionManager = TapDetectionManager( - server, - this, - humanPoseManager, - ) - userHeightCalibration = UserHeightCalibration(server, humanPoseManager) - legTweaks.setConfig(server.configManager.vrConfig.legTweaks) - localizer.setEnabled(humanPoseManager.getToggle(SkeletonConfigToggles.SELF_LOCALIZATION)) - stayAlignedConfig = server.configManager.vrConfig.stayAlignedConfig - } - - constructor( - humanPoseManager: HumanPoseManager, - trackers: List?, - ) : this(humanPoseManager) { - var trackersList = trackers - if (trackersList == null) { - trackersList = FastList(0) - } - setTrackersFromList(trackersList) - } - - /** - * Assembles the whole skeleton - */ - @ThreadSafe - fun assembleSkeleton() { - // Assemble upper skeleton (head to hip) - headBone.attachChild(neckBone) - neckBone.attachChild(upperChestBone) - upperChestBone.attachChild(chestBone) - chestBone.attachChild(waistBone) - waistBone.attachChild(hipBone) - - // Assemble lower skeleton (hip to feet) - hipBone.attachChild(leftHipBone) - hipBone.attachChild(rightHipBone) - leftHipBone.attachChild(leftUpperLegBone) - rightHipBone.attachChild(rightUpperLegBone) - leftUpperLegBone.attachChild(leftLowerLegBone) - rightUpperLegBone.attachChild(rightLowerLegBone) - leftLowerLegBone.attachChild(leftFootBone) - rightLowerLegBone.attachChild(rightFootBone) - - // Attach tracker bones for tracker offsets - neckBone.attachChild(headTrackerBone) - upperChestBone.attachChild(chestTrackerBone) - hipBone.attachChild(hipTrackerBone) - leftUpperLegBone.attachChild(leftKneeTrackerBone) - rightUpperLegBone.attachChild(rightKneeTrackerBone) - leftFootBone.attachChild(leftFootTrackerBone) - rightFootBone.attachChild(rightFootTrackerBone) - - // Attach arms - assembleSkeletonArms(false) - } - - /** - * Dynamically assembles the arms of the skeleton - * - * @param reset disassemble before reassembling - */ - @ThreadSafe - fun assembleSkeletonArms(reset: Boolean) { - if (reset) { - for (bone in allArmBones) { - bone.detachWithChildren() - } - } - - // Shoulders - neckBone.attachChild(leftUpperShoulderBone) - neckBone.attachChild(rightUpperShoulderBone) - leftUpperShoulderBone.attachChild(leftShoulderBone) - rightUpperShoulderBone.attachChild(rightShoulderBone) - - // Upper arm - leftShoulderBone.attachChild(leftUpperArmBone) - rightShoulderBone.attachChild(rightUpperArmBone) - - // Lower arm and hand - if (isTrackingLeftArmFromController) { - leftHandTrackerBone.attachChild(leftHandBone) - leftHandBone.attachChild(leftLowerArmBone) - leftLowerArmBone.attachChild(leftElbowTrackerBone) - } else { - leftUpperArmBone.attachChild(leftLowerArmBone) - leftUpperArmBone.attachChild(leftElbowTrackerBone) - leftLowerArmBone.attachChild(leftHandBone) - leftHandBone.attachChild(leftHandTrackerBone) - } - if (isTrackingRightArmFromController) { - rightHandTrackerBone.attachChild(rightHandBone) - rightHandBone.attachChild(rightLowerArmBone) - rightLowerArmBone.attachChild(rightElbowTrackerBone) - } else { - rightUpperArmBone.attachChild(rightLowerArmBone) - rightUpperArmBone.attachChild(rightElbowTrackerBone) - rightLowerArmBone.attachChild(rightHandBone) - rightHandBone.attachChild(rightHandTrackerBone) - } - - // Fingers - leftHandBone.attachChild(leftThumbMetacarpalBone) - leftThumbMetacarpalBone.attachChild(leftThumbProximalBone) - leftThumbProximalBone.attachChild(leftThumbDistalBone) - leftHandBone.attachChild(leftIndexProximalBone) - leftIndexProximalBone.attachChild(leftIndexIntermediateBone) - leftIndexIntermediateBone.attachChild(leftIndexDistalBone) - leftHandBone.attachChild(leftMiddleProximalBone) - leftMiddleProximalBone.attachChild(leftMiddleIntermediateBone) - leftMiddleIntermediateBone.attachChild(leftMiddleDistalBone) - leftHandBone.attachChild(leftRingProximalBone) - leftRingProximalBone.attachChild(leftRingIntermediateBone) - leftRingIntermediateBone.attachChild(leftRingDistalBone) - leftHandBone.attachChild(leftLittleProximalBone) - leftLittleProximalBone.attachChild(leftLittleIntermediateBone) - leftLittleIntermediateBone.attachChild(leftLittleDistalBone) - rightHandBone.attachChild(rightThumbMetacarpalBone) - rightThumbMetacarpalBone.attachChild(rightThumbProximalBone) - rightThumbProximalBone.attachChild(rightThumbDistalBone) - rightHandBone.attachChild(rightIndexProximalBone) - rightIndexProximalBone.attachChild(rightIndexIntermediateBone) - rightIndexIntermediateBone.attachChild(rightIndexDistalBone) - rightHandBone.attachChild(rightMiddleProximalBone) - rightMiddleProximalBone.attachChild(rightMiddleIntermediateBone) - rightMiddleIntermediateBone.attachChild(rightMiddleDistalBone) - rightHandBone.attachChild(rightRingProximalBone) - rightRingProximalBone.attachChild(rightRingIntermediateBone) - rightRingIntermediateBone.attachChild(rightRingDistalBone) - rightHandBone.attachChild(rightLittleProximalBone) - rightLittleProximalBone.attachChild(rightLittleIntermediateBone) - rightLittleIntermediateBone.attachChild(rightLittleDistalBone) - } - - /** - * Set input trackers from a list - */ - fun setTrackersFromList(trackers: List) { - // Head - headTracker = getTrackerForSkeleton(trackers, TrackerPosition.HEAD) - neckTracker = getTrackerForSkeleton(trackers, TrackerPosition.NECK) - - // Spine - upperChestTracker = getTrackerForSkeleton(trackers, TrackerPosition.UPPER_CHEST) - chestTracker = getTrackerForSkeleton(trackers, TrackerPosition.CHEST) - waistTracker = getTrackerForSkeleton(trackers, TrackerPosition.WAIST) - hipTracker = getTrackerForSkeleton(trackers, TrackerPosition.HIP) - - // Legs - leftUpperLegTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_UPPER_LEG) - leftLowerLegTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_LOWER_LEG) - leftFootTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_FOOT) - rightUpperLegTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_UPPER_LEG) - rightLowerLegTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_LOWER_LEG) - rightFootTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_FOOT) - - // Arms - leftLowerArmTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_LOWER_ARM) - rightLowerArmTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_LOWER_ARM) - leftUpperArmTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_UPPER_ARM) - rightUpperArmTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_UPPER_ARM) - leftHandTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_HAND) - rightHandTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_HAND) - leftShoulderTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_SHOULDER) - rightShoulderTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_SHOULDER) - - // Fingers - leftThumbMetacarpalTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_THUMB_METACARPAL) - leftThumbProximalTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_THUMB_PROXIMAL) - leftThumbDistalTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_THUMB_DISTAL) - leftIndexProximalTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_INDEX_PROXIMAL) - leftIndexIntermediateTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_INDEX_INTERMEDIATE) - leftIndexDistalTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_INDEX_DISTAL) - leftMiddleProximalTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_MIDDLE_PROXIMAL) - leftMiddleIntermediateTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_MIDDLE_INTERMEDIATE) - leftMiddleDistalTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_MIDDLE_DISTAL) - leftRingProximalTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_RING_PROXIMAL) - leftRingIntermediateTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_RING_INTERMEDIATE) - leftRingDistalTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_RING_DISTAL) - leftLittleProximalTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_LITTLE_PROXIMAL) - leftLittleIntermediateTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_LITTLE_INTERMEDIATE) - leftLittleDistalTracker = getTrackerForSkeleton(trackers, TrackerPosition.LEFT_LITTLE_DISTAL) - rightThumbMetacarpalTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_THUMB_METACARPAL) - rightThumbProximalTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_THUMB_PROXIMAL) - rightThumbDistalTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_THUMB_DISTAL) - rightIndexProximalTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_INDEX_PROXIMAL) - rightIndexIntermediateTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_INDEX_INTERMEDIATE) - rightIndexDistalTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_INDEX_DISTAL) - rightMiddleProximalTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_MIDDLE_PROXIMAL) - rightMiddleIntermediateTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_MIDDLE_INTERMEDIATE) - rightMiddleDistalTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_MIDDLE_DISTAL) - rightRingProximalTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_RING_PROXIMAL) - rightRingIntermediateTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_RING_INTERMEDIATE) - rightRingDistalTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_RING_DISTAL) - rightLittleProximalTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_LITTLE_PROXIMAL) - rightLittleIntermediateTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_LITTLE_INTERMEDIATE) - rightLittleDistalTracker = getTrackerForSkeleton(trackers, TrackerPosition.RIGHT_LITTLE_DISTAL) - - // Check for specific conditions and cache them - hasSpineTracker = upperChestTracker != null || chestTracker != null || waistTracker != null || hipTracker != null - hasKneeTrackers = leftUpperLegTracker != null && rightUpperLegTracker != null - hasLeftArmTracker = leftLowerArmTracker != null || leftUpperArmTracker != null - hasRightArmTracker = rightLowerArmTracker != null || rightUpperArmTracker != null - hasLeftFingerTracker = leftThumbMetacarpalTracker != null || - leftThumbProximalTracker != null || - leftThumbDistalTracker != null || - leftIndexProximalTracker != null || - leftIndexIntermediateTracker != null || - leftIndexDistalTracker != null || - leftMiddleProximalTracker != null || - leftMiddleIntermediateTracker != null || - leftMiddleDistalTracker != null || - leftRingProximalTracker != null || - leftRingIntermediateTracker != null || - leftRingDistalTracker != null || - leftLittleProximalTracker != null || - leftLittleIntermediateTracker != null || - leftLittleDistalTracker != null - hasRightFingerTracker = rightThumbMetacarpalTracker != null || - rightThumbProximalTracker != null || - rightThumbDistalTracker != null || - rightIndexProximalTracker != null || - rightIndexIntermediateTracker != null || - rightIndexDistalTracker != null || - rightMiddleProximalTracker != null || - rightMiddleIntermediateTracker != null || - rightMiddleDistalTracker != null || - rightRingProximalTracker != null || - rightRingIntermediateTracker != null || - rightRingDistalTracker != null || - rightLittleProximalTracker != null || - rightLittleIntermediateTracker != null || - rightLittleDistalTracker != null - - // Rebuilds the arm skeleton nodes attachments - assembleSkeletonArms(true) - - // Refresh all skeleton node offsets based on new trackers - humanPoseManager.updateNodeOffsetsInSkeleton() - - // Update tap detection's trackers - tapDetectionManager?.refresh() - - userHeightCalibration?.checkTrackers() - - // Rebuild Ik Solver - ikSolver.buildChains(trackers) - - // Update bones tracker field - refreshBoneTracker() - - // Update tracker skeleton - trackerSkeleton = TrackerSkeleton(this) - } - - /** - * Set computed trackers from list - */ - private fun setComputedTrackers(trackers: List) { - for (t in trackers) { - setComputedTracker(t) - } - } - - /** - * Set computed tracker - */ - private fun setComputedTracker(tracker: Tracker) { - when (tracker.trackerPosition) { - TrackerPosition.HEAD -> computedHeadTracker = tracker - TrackerPosition.UPPER_CHEST -> computedChestTracker = tracker - TrackerPosition.HIP -> computedHipTracker = tracker - TrackerPosition.LEFT_UPPER_LEG -> computedLeftKneeTracker = tracker - TrackerPosition.LEFT_FOOT -> computedLeftFootTracker = tracker - TrackerPosition.RIGHT_UPPER_LEG -> computedRightKneeTracker = tracker - TrackerPosition.RIGHT_FOOT -> computedRightFootTracker = tracker - TrackerPosition.LEFT_UPPER_ARM -> computedLeftElbowTracker = tracker - TrackerPosition.RIGHT_UPPER_ARM -> computedRightElbowTracker = tracker - TrackerPosition.LEFT_HAND -> computedLeftHandTracker = tracker - TrackerPosition.RIGHT_HAND -> computedRightHandTracker = tracker - else -> {} - } - } - - /** - * Get output tracker from TrackerRole - */ - fun getComputedTracker(trackerRole: TrackerRole): Tracker = when (trackerRole) { - TrackerRole.HEAD -> computedHeadTracker!! - TrackerRole.CHEST -> computedChestTracker!! - TrackerRole.WAIST -> computedHipTracker!! - TrackerRole.LEFT_KNEE -> computedLeftKneeTracker!! - TrackerRole.LEFT_FOOT -> computedLeftFootTracker!! - TrackerRole.RIGHT_KNEE -> computedRightKneeTracker!! - TrackerRole.RIGHT_FOOT -> computedRightFootTracker!! - TrackerRole.LEFT_ELBOW -> computedLeftElbowTracker!! - TrackerRole.RIGHT_ELBOW -> computedRightElbowTracker!! - TrackerRole.LEFT_HAND -> computedLeftHandTracker!! - TrackerRole.RIGHT_HAND -> computedRightHandTracker!! - else -> throw IllegalArgumentException("Unsupported computed tracker's TrackerRole in HumanSkeleton") - } - - /** - * Updates the pose from tracker positions - */ - @VRServerThread - fun updatePose() { - tapDetectionManager?.update() - userHeightCalibration?.tick() - - StayAligned.adjustNextTracker(trackerSkeleton, stayAlignedConfig) - - updateTransforms() - updateBones() - if (enforceConstraints) { - // TODO re-enable toggling correctConstraints once - // https://github.com/SlimeVR/SlimeVR-Server/issues/1297 is solved - headBone.updateWithConstraints(false) - } - updateComputedTrackers() - - // Don't run post-processing if the tracking is paused - if (pauseTracking) return - - legTweaks.tweakLegs() - localizer.update() - } - - /** - * Refresh the attachedTracker field in each bone - */ - private fun refreshBoneTracker() { - for (bone in allHumanBones) { - bone.attachedTracker = getTrackerForBone(bone.boneType) - } - } - - /** - * Update all the bones by updating the roots - */ - @ThreadSafe - fun updateBones() { - headBone.update() - if (isTrackingLeftArmFromController) leftHandTrackerBone.update() - if (isTrackingRightArmFromController) rightHandTrackerBone.update() - } - - /** - * Update all the bones' transforms from trackers - */ - private fun updateTransforms() { - // Head - updateHeadTransforms() - - // Stop at head (for the body to follow) if tracking is paused - if (pauseTracking) return - - // Spine - updateSpineTransforms() - - // Left leg - updateLegTransforms( - leftUpperLegBone, - leftKneeTrackerBone, - leftLowerLegBone, - leftFootBone, - leftFootTrackerBone, - leftUpperLegTracker, - leftLowerLegTracker, - leftFootTracker, - ) - - // Right leg - updateLegTransforms( - rightUpperLegBone, - rightKneeTrackerBone, - rightLowerLegBone, - rightFootBone, - rightFootTrackerBone, - rightUpperLegTracker, - rightLowerLegTracker, - rightFootTracker, - ) - - // Left arm - updateArmTransforms( - isTrackingLeftArmFromController, - leftUpperShoulderBone, - leftShoulderBone, - leftUpperArmBone, - leftElbowTrackerBone, - leftLowerArmBone, - leftHandBone, - leftHandTrackerBone, - leftShoulderTracker, - leftUpperArmTracker, - leftLowerArmTracker, - leftHandTracker, - ) - - // Right arm - updateArmTransforms( - isTrackingRightArmFromController, - rightUpperShoulderBone, - rightShoulderBone, - rightUpperArmBone, - rightElbowTrackerBone, - rightLowerArmBone, - rightHandBone, - rightHandTrackerBone, - rightShoulderTracker, - rightUpperArmTracker, - rightLowerArmTracker, - rightHandTracker, - ) - - // Left thumb - updateFingerTransforms( - leftHandTrackerBone.getGlobalRotation(), - leftThumbMetacarpalBone, - leftThumbProximalBone, - leftThumbDistalBone, - leftThumbMetacarpalTracker, - leftThumbProximalTracker, - leftThumbDistalTracker, - ) - - // Left index - updateFingerTransforms( - leftHandTrackerBone.getGlobalRotation(), - leftIndexProximalBone, - leftIndexIntermediateBone, - leftIndexDistalBone, - leftIndexProximalTracker, - leftIndexIntermediateTracker, - leftIndexDistalTracker, - ) - - // Left middle - updateFingerTransforms( - leftHandTrackerBone.getGlobalRotation(), - leftMiddleProximalBone, - leftMiddleIntermediateBone, - leftMiddleDistalBone, - leftMiddleProximalTracker, - leftMiddleIntermediateTracker, - leftMiddleDistalTracker, - ) - - // Left ring - updateFingerTransforms( - leftHandTrackerBone.getGlobalRotation(), - leftRingProximalBone, - leftRingIntermediateBone, - leftRingDistalBone, - leftRingProximalTracker, - leftRingIntermediateTracker, - leftRingDistalTracker, - ) - - // Left little - updateFingerTransforms( - leftHandTrackerBone.getGlobalRotation(), - leftLittleProximalBone, - leftLittleIntermediateBone, - leftLittleDistalBone, - leftLittleProximalTracker, - leftLittleIntermediateTracker, - leftLittleDistalTracker, - ) - - // Right thumb - updateFingerTransforms( - rightHandTrackerBone.getGlobalRotation(), - rightThumbMetacarpalBone, - rightThumbProximalBone, - rightThumbDistalBone, - rightThumbMetacarpalTracker, - rightThumbProximalTracker, - rightThumbDistalTracker, - ) - - // Right index - updateFingerTransforms( - rightHandTrackerBone.getGlobalRotation(), - rightIndexProximalBone, - rightIndexIntermediateBone, - rightIndexDistalBone, - rightIndexProximalTracker, - rightIndexIntermediateTracker, - rightIndexDistalTracker, - ) - - // Right middle - updateFingerTransforms( - rightHandTrackerBone.getGlobalRotation(), - rightMiddleProximalBone, - rightMiddleIntermediateBone, - rightMiddleDistalBone, - rightMiddleProximalTracker, - rightMiddleIntermediateTracker, - rightMiddleDistalTracker, - ) - - // Right ring - updateFingerTransforms( - rightHandTrackerBone.getGlobalRotation(), - rightRingProximalBone, - rightRingIntermediateBone, - rightRingDistalBone, - rightRingProximalTracker, - rightRingIntermediateTracker, - rightRingDistalTracker, - ) - - // Right little - updateFingerTransforms( - rightHandTrackerBone.getGlobalRotation(), - rightLittleProximalBone, - rightLittleIntermediateBone, - rightLittleDistalBone, - rightLittleProximalTracker, - rightLittleIntermediateTracker, - rightLittleDistalTracker, - ) - } - - /** - * Update the head and neck bone transforms - */ - private fun updateHeadTransforms() { - var headRot = IDENTITY - headTracker?.let { head -> - // Set head position - if (head.hasPosition) headBone.setPosition(head.position) - - // Get head rotation - headRot = head.getRotation() - - // Set head rotation - headBone.setRotation(headRot) - headTrackerBone.setRotation(headRot) - - // Get neck rotation - neckTracker?.let { headRot = it.getRotation() } - - // Set neck rotation - neckBone.setRotation(headRot) - } ?: run { - // Set head position - if (!localizer.getEnabled()) headBone.setPosition(NULL) - - // Get neck or spine rotation (else is identity) - getFirstAvailableTracker( - neckTracker, - upperChestTracker, - chestTracker, - waistTracker, - hipTracker, - )?.let { headRot = it.getRotation() } - - headBone.setRotation(headRot) - headTrackerBone.setRotation(headRot) - neckBone.setRotation(headRot) - } - } - - /** - * Update the spine transforms, from the upper chest to the hip - */ - private fun updateSpineTransforms() { - if (hasSpineTracker) { - // Upper chest and chest tracker - getFirstAvailableTracker(upperChestTracker, chestTracker, waistTracker, hipTracker)?.let { - upperChestBone.setRotation(it.getRotation()) - chestTrackerBone.setRotation(it.getRotation()) - } - - // Chest - getFirstAvailableTracker(chestTracker, upperChestTracker, waistTracker, hipTracker)?.let { - chestBone.setRotation(it.getRotation()) - } - - // Waist - getFirstAvailableTracker(waistTracker, chestTracker, hipTracker, upperChestTracker)?.let { - waistBone.setRotation(it.getRotation()) - } - - // Hip and hip tracker - getFirstAvailableTracker(hipTracker, waistTracker, chestTracker, upperChestTracker)?.let { - hipBone.setRotation(it.getRotation()) - hipTrackerBone.setRotation(it.getRotation()) - } - } else if (headTracker != null) { - // Align with neck's yaw - val yawRot = neckBone.getGlobalRotation().project(POS_Y).unit() - upperChestBone.setRotation(yawRot) - chestTrackerBone.setRotation(yawRot) - chestBone.setRotation(yawRot) - waistBone.setRotation(yawRot) - hipBone.setRotation(yawRot) - hipTrackerBone.setRotation(yawRot) - } - - // Extended spine model - if (extendedSpineModel && hasSpineTracker) { - // Tries to guess missing lower spine trackers by interpolating rotations - if (waistTracker == null) { - getFirstAvailableTracker(chestTracker, upperChestTracker)?.let { chest -> - hipTracker?.let { - // Calculates waist from chest + hip - var hipRot = it.getRotation() - var chestRot = chest.getRotation() - - // Interpolate between the chest and the hip - chestRot = chestRot.interpQ(hipRot, waistFromChestHipAveraging) - - // Set waist's rotation - waistBone.setRotation(chestRot) - } ?: run { - if (hasKneeTrackers) { - // Calculates waist from chest + legs - var leftLegRot = leftUpperLegTracker?.getRotation() ?: IDENTITY - var rightLegRot = rightUpperLegTracker?.getRotation() ?: IDENTITY - var chestRot = chest.getRotation() - - // Interpolate between the pelvis, averaged from the legs, and the chest - chestRot = chestRot.interpQ(leftLegRot.lerpQ(rightLegRot, 0.5f), waistFromChestLegsAveraging).unit() - - // Set waist's rotation - waistBone.setRotation(chestRot) - } - } - } - } - if (hipTracker == null && hasKneeTrackers) { - waistTracker?.let { - // Calculates hip from waist + legs - var leftLegRot = leftUpperLegTracker?.getRotation() ?: IDENTITY - var rightLegRot = rightUpperLegTracker?.getRotation() ?: IDENTITY - var waistRot = it.getRotation() - - // Interpolate between the pelvis, averaged from the legs, and the chest - waistRot = waistRot.interpQ(leftLegRot.lerpQ(rightLegRot, 0.5f), hipFromWaistLegsAveraging).unit() - - // Set hip rotation - hipBone.setRotation(waistRot) - hipTrackerBone.setRotation(waistRot) - } ?: run { - getFirstAvailableTracker(chestTracker, upperChestTracker)?.let { - // Calculates hip from chest + legs - var leftLegRot = leftUpperLegTracker?.getRotation() ?: IDENTITY - var rightLegRot = rightUpperLegTracker?.getRotation() ?: IDENTITY - var chestRot = it.getRotation() - - // Interpolate between the pelvis, averaged from the legs, and the chest - chestRot = chestRot.interpQ(leftLegRot.lerpQ(rightLegRot, 0.5f), hipFromChestLegsAveraging).unit() - - // Set hip rotation - hipBone.setRotation(chestRot) - hipTrackerBone.setRotation(chestRot) - } - } - } - } - - // Extended pelvis model - if (extendedPelvisModel && hasKneeTrackers && hipTracker == null) { - val leftLegRot = leftUpperLegTracker?.getRotation() ?: IDENTITY - val rightLegRot = rightUpperLegTracker?.getRotation() ?: IDENTITY - val hipRot = hipBone.getLocalRotation() - - val extendedPelvisRot = extendedPelvisYawRoll(leftLegRot, rightLegRot, hipRot) - - // Interpolate between the hipRot and extendedPelvisRot - val newHipRot = hipRot.interpR( - if (extendedPelvisRot.lenSq() != 0.0f) extendedPelvisRot else IDENTITY, - hipLegsAveraging, - ) - - // Set new hip rotation - hipBone.setRotation(newHipRot) - hipTrackerBone.setRotation(newHipRot) - } - - // Set left and right hip rotations to the hip's - leftHipBone.setRotation(hipBone.getLocalRotation()) - rightHipBone.setRotation(hipBone.getLocalRotation()) - } - - /** - * Update a leg's transforms, from its hip to its foot - */ - private fun updateLegTransforms( - upperLegBone: Bone, - kneeTrackerBone: Bone, - lowerLegBone: Bone, - footBone: Bone, - footTrackerBone: Bone, - upperLegTracker: Tracker?, - lowerLegTracker: Tracker?, - footTracker: Tracker?, - ) { - var legRot = IDENTITY - - upperLegTracker?.let { - // Get upper leg rotation - legRot = it.getRotation() - } ?: run { - // Use hip's yaw - legRot = hipBone.getLocalRotation().project(POS_Y).unit() - } - // Set upper leg rotation - upperLegBone.setRotation(legRot) - kneeTrackerBone.setRotation(legRot) - - lowerLegTracker?.let { - // Get lower leg rotation - legRot = it.getRotation() - } ?: run { - // Use lower leg or hip's yaw - legRot = legRot.project(POS_Y).unit() - } - // Set lower leg rotation - lowerLegBone.setRotation(legRot) - - // Get foot rotation - footTracker?.let { legRot = it.getRotation() } - // Set foot rotation - footBone.setRotation(legRot) - footTrackerBone.setRotation(legRot) - - // Extended knee model - if (extendedKneeModel) { - upperLegTracker?.let { upper -> - lowerLegTracker?.let { lower -> - // Averages the upper leg's rotation with the local lower leg's - // pitch and roll and apply to the tracker node. - val upperRot = upper.getRotation() - val lowerRot = lower.getRotation() - val extendedRot = extendedKneeYawRoll(upperRot, lowerRot) - - upperLegBone.setRotation(upperRot.interpR(extendedRot, kneeAnkleAveraging)) - kneeTrackerBone.setRotation(upperRot.interpR(extendedRot, kneeTrackerAnkleAveraging)) - } - } - } - } - - /** - * Update an arm's transforms, from its shoulder to its hand - */ - private fun updateArmTransforms( - isTrackingFromController: Boolean, - upperShoulderBone: Bone, - shoulderBone: Bone, - upperArmBone: Bone, - elbowTrackerBone: Bone, - lowerArmBone: Bone, - handBone: Bone, - handTrackerBone: Bone, - shoulderTracker: Tracker?, - upperArmTracker: Tracker?, - lowerArmTracker: Tracker?, - handTracker: Tracker?, - ) { - if (isTrackingFromController) { // From controller - // Set hand rotation and position from tracker - handTracker?.let { - handTrackerBone.setPosition(it.position) - handTrackerBone.setRotation(it.getRotation()) - handBone.setRotation(it.getRotation()) - } - - // Get lower arm rotation - var armRot = getFirstAvailableTracker(lowerArmTracker, upperArmTracker)?.getRotation() ?: IDENTITY - // Set lower arm rotation - lowerArmBone.setRotation(armRot) - - // Get upper arm rotation - armRot = getFirstAvailableTracker(upperArmTracker, lowerArmTracker)?.getRotation() ?: IDENTITY - // Set elbow tracker rotation - elbowTrackerBone.setRotation(armRot) - } else { // From HMD - // Get shoulder rotation - var armRot = shoulderTracker?.getRotation() ?: upperChestBone.getLocalRotation() - // Set shoulder rotation - upperShoulderBone.setRotation(upperChestBone.getLocalRotation()) - shoulderBone.setRotation(armRot) - - if (upperArmTracker != null || lowerArmTracker != null) { - // Get upper arm rotation - getFirstAvailableTracker(upperArmTracker, lowerArmTracker)?.let { armRot = it.getRotation() } - // Set upper arm and elbow tracker rotation - upperArmBone.setRotation(armRot) - elbowTrackerBone.setRotation(armRot) - - // Get lower arm rotation - getFirstAvailableTracker(lowerArmTracker, upperArmTracker)?.let { armRot = it.getRotation() } - // Set lower arm rotation - lowerArmBone.setRotation(armRot) - } else { - // Fallback arm rotation as upper chest - armRot = upperChestBone.getLocalRotation() - upperArmBone.setRotation(armRot) - elbowTrackerBone.setRotation(armRot) - lowerArmBone.setRotation(armRot) - } - - // Get hand rotation - handTracker?.let { armRot = it.getRotation() } - // Set hand, and hand tracker rotation - handBone.setRotation(armRot) - handTrackerBone.setRotation(armRot) - } - } - - /** - * Update a finger's 3 bones' transforms - */ - private fun updateFingerTransforms( - handRotation: Quaternion, - proximalBone: Bone, - intermediateBone: Bone, - distalBone: Bone, - proximalTracker: Tracker?, - intermediateTracker: Tracker?, - distalTracker: Tracker?, - ) { - if (distalTracker == null && intermediateTracker == null && proximalTracker == null) { - // Set fingers' rotations to the hand's if no finger tracker - proximalBone.setRotation(handRotation) - intermediateBone.setRotation(handRotation) - distalBone.setRotation(handRotation) - } - - // Note: we use interpQ instead of interpR in order to slerp over 180 degrees. - // Start of finger - proximalTracker?.let { - val fingerRot = if (it.trackerDataType == TrackerDataType.FLEX_RESISTANCE || - it.trackerDataType == TrackerDataType.FLEX_ANGLE - ) { - handRotation * it.getRotation() - } else { - it.getRotation() - } - - proximalBone.setRotation(fingerRot) - if (intermediateTracker == null) intermediateBone.setRotation(handRotation.interpQ(fingerRot, 2.12f)) - if (distalTracker == null) distalBone.setRotation(handRotation.interpQ(fingerRot, 3.03f)) - } - // Middle of finger - intermediateTracker?.let { - val fingerRot = if (it.trackerDataType == TrackerDataType.FLEX_RESISTANCE || - it.trackerDataType == TrackerDataType.FLEX_ANGLE - ) { - handRotation * it.getRotation() - } else { - it.getRotation() - } - - if (proximalTracker == null) proximalBone.setRotation(handRotation.interpQ(fingerRot, 0.47f)) - intermediateBone.setRotation(fingerRot) - if (distalTracker == null) distalBone.setRotation(handRotation.interpQ(fingerRot, 1.43f)) - } - // Tip of finger - distalTracker?.let { - val fingerRot = if (it.trackerDataType == TrackerDataType.FLEX_RESISTANCE || - it.trackerDataType == TrackerDataType.FLEX_ANGLE - ) { - handRotation * it.getRotation() - } else { - it.getRotation() - } - - if (proximalTracker == null && intermediateTracker == null) proximalBone.setRotation(handRotation.interpQ(fingerRot, 0.33f)) - if (intermediateTracker == null) intermediateBone.setRotation(handRotation.interpQ(fingerRot, 0.7f)) - distalBone.setRotation(fingerRot) - } - } - - /** - * Rotates the first Quaternion to match its yaw and roll to the rotation of - * the second Quaternion - * - * @param knee the first Quaternion - * @param ankle the second Quaternion - * @return the rotated Quaternion - */ - private fun extendedKneeYawRoll(knee: Quaternion, ankle: Quaternion): Quaternion { - val r = knee.inv() * ankle - val c = Quaternion(r.w, -r.x, 0f, 0f) - return (knee * r * c).unit() - } - - /** - * Rotates the third Quaternion to match its yaw and roll to the rotation of - * the average of the first and second quaternions. - * - * @param leftKnee the first Quaternion - * @param rightKnee the second Quaternion - * @param hip the third Quaternion - * @return the rotated Quaternion - */ - private fun extendedPelvisYawRoll( - leftKnee: Quaternion, - rightKnee: Quaternion, - hip: Quaternion, - ): Quaternion { - // R = InverseHip * (LeftLeft + RightLeg) - // C = Quaternion(R.w, -R.x, 0, 0) - // Pelvis = Hip * R * C - // normalize(Pelvis) - val r = hip.inv() * (leftKnee + rightKnee) - val c = Quaternion(r.w, -r.x, 0f, 0f) - return (hip * r * c).unit() - } - - // Update the output trackers - private fun updateComputedTrackers() { - updateComputedTracker(computedHeadTracker, headTrackerBone) - updateComputedTracker(computedChestTracker, chestTrackerBone) - updateComputedTracker(computedHipTracker, hipTrackerBone) - updateComputedTracker(computedLeftKneeTracker, leftKneeTrackerBone) - updateComputedTracker(computedRightKneeTracker, rightKneeTrackerBone) - updateComputedTracker(computedLeftFootTracker, leftFootTrackerBone) - updateComputedTracker(computedRightFootTracker, rightFootTrackerBone) - updateComputedTracker(computedLeftElbowTracker, leftElbowTrackerBone) - updateComputedTracker(computedRightElbowTracker, rightElbowTrackerBone) - updateComputedTracker(computedLeftHandTracker, leftHandTrackerBone) - updateComputedTracker(computedRightHandTracker, rightHandTrackerBone) - } - - private fun updateComputedTracker(computedTracker: Tracker?, trackerBone: Bone) { - computedTracker?.let { - it.position = trackerBone.getTailPosition() - it.setRotation(trackerBone.getGlobalRotation() * trackerBone.rotationOffset.inv()) - it.dataTick() - } - } - - // Skeleton Config toggles - fun updateToggleState(configToggle: SkeletonConfigToggles, newValue: Boolean) { - when (configToggle) { - SkeletonConfigToggles.EXTENDED_SPINE_MODEL -> extendedSpineModel = newValue - - SkeletonConfigToggles.EXTENDED_PELVIS_MODEL -> extendedPelvisModel = newValue - - SkeletonConfigToggles.EXTENDED_KNEE_MODEL -> extendedKneeModel = newValue - - SkeletonConfigToggles.FORCE_ARMS_FROM_HMD -> { - forceArmsFromHMD = newValue - assembleSkeletonArms(true) // Rebuilds the arm skeleton nodes attachments - computeDependentArmOffsets() // Refresh node offsets for arms - } - - SkeletonConfigToggles.SKATING_CORRECTION -> legTweaks.setSkatingCorrectionEnabled(newValue) - - SkeletonConfigToggles.FLOOR_CLIP -> legTweaks.setFloorClipEnabled(newValue) - - SkeletonConfigToggles.TOE_SNAP -> legTweaks.toeSnapEnabled = newValue - - SkeletonConfigToggles.FOOT_PLANT -> legTweaks.footPlantEnabled = newValue - - SkeletonConfigToggles.SELF_LOCALIZATION -> localizer.setEnabled(newValue) - - SkeletonConfigToggles.USE_POSITION -> ikSolver.enabled = newValue - - SkeletonConfigToggles.ENFORCE_CONSTRAINTS -> enforceConstraints = newValue - - SkeletonConfigToggles.CORRECT_CONSTRAINTS -> correctConstraints = newValue - } - } - - // Skeleton Config ratios - fun updateValueState(configValue: SkeletonConfigValues, newValue: Float) { - when (configValue) { - SkeletonConfigValues.WAIST_FROM_CHEST_HIP_AVERAGING -> waistFromChestHipAveraging = newValue - SkeletonConfigValues.WAIST_FROM_CHEST_LEGS_AVERAGING -> waistFromChestLegsAveraging = newValue - SkeletonConfigValues.HIP_FROM_CHEST_LEGS_AVERAGING -> hipFromChestLegsAveraging = newValue - SkeletonConfigValues.HIP_FROM_WAIST_LEGS_AVERAGING -> hipFromWaistLegsAveraging = newValue - SkeletonConfigValues.HIP_LEGS_AVERAGING -> hipLegsAveraging = newValue - SkeletonConfigValues.KNEE_TRACKER_ANKLE_AVERAGING -> kneeTrackerAnkleAveraging = newValue - SkeletonConfigValues.KNEE_ANKLE_AVERAGING -> kneeAnkleAveraging = newValue - } - } - - // Skeleton Config bone lengths - fun updateNodeOffset(boneType: BoneType, offset: Vector3) { - var transOffset = offset - - // If no head position, headShift and neckLength = 0 - if ((boneType == BoneType.HEAD || boneType == BoneType.NECK) && (headTracker == null || !(headTracker!!.hasPosition && headTracker!!.hasRotation))) { - transOffset = NULL - } - // If trackingArmFromController, reverse - if (((boneType == BoneType.LEFT_LOWER_ARM || boneType == BoneType.LEFT_HAND) && isTrackingLeftArmFromController) || - ( - (boneType == BoneType.RIGHT_LOWER_ARM || boneType == BoneType.RIGHT_HAND) && - isTrackingRightArmFromController - ) - ) { - transOffset = -transOffset - } - - // Compute bone rotation - val rotOffset = if (transOffset.len() > 0f) { - if (transOffset.unit().y == 1f) { - I - } else { - fromTo(NEG_Y, transOffset) - } - } else { - IDENTITY - } - - // Get the bone - val bone = getBone(boneType) - - // Update bone length - bone.length = transOffset.len() - - // Set bone rotation offset - bone.rotationOffset = rotOffset - } - - private fun computeDependentArmOffsets() { - humanPoseManager.computeNodeOffset(BoneType.LEFT_LOWER_ARM) - humanPoseManager.computeNodeOffset(BoneType.RIGHT_LOWER_ARM) - humanPoseManager.computeNodeOffset(BoneType.LEFT_HAND) - humanPoseManager.computeNodeOffset(BoneType.RIGHT_HAND) - } - - fun getBone(bone: BoneType): Bone = when (bone) { - BoneType.HEAD -> headBone - BoneType.HEAD_TRACKER -> headTrackerBone - BoneType.NECK -> neckBone - BoneType.UPPER_CHEST -> upperChestBone - BoneType.CHEST_TRACKER -> chestTrackerBone - BoneType.CHEST -> chestBone - BoneType.WAIST -> waistBone - BoneType.HIP -> hipBone - BoneType.HIP_TRACKER -> hipTrackerBone - BoneType.LEFT_HIP -> leftHipBone - BoneType.RIGHT_HIP -> rightHipBone - BoneType.LEFT_UPPER_LEG -> leftUpperLegBone - BoneType.RIGHT_UPPER_LEG -> rightUpperLegBone - BoneType.LEFT_KNEE_TRACKER -> leftKneeTrackerBone - BoneType.RIGHT_KNEE_TRACKER -> rightKneeTrackerBone - BoneType.LEFT_LOWER_LEG -> leftLowerLegBone - BoneType.RIGHT_LOWER_LEG -> rightLowerLegBone - BoneType.LEFT_FOOT -> leftFootBone - BoneType.RIGHT_FOOT -> rightFootBone - BoneType.LEFT_FOOT_TRACKER -> leftFootTrackerBone - BoneType.RIGHT_FOOT_TRACKER -> rightFootTrackerBone - BoneType.LEFT_UPPER_SHOULDER -> leftUpperShoulderBone - BoneType.RIGHT_UPPER_SHOULDER -> rightUpperShoulderBone - BoneType.LEFT_SHOULDER -> leftShoulderBone - BoneType.RIGHT_SHOULDER -> rightShoulderBone - BoneType.LEFT_UPPER_ARM -> leftUpperArmBone - BoneType.RIGHT_UPPER_ARM -> rightUpperArmBone - BoneType.LEFT_ELBOW_TRACKER -> leftElbowTrackerBone - BoneType.RIGHT_ELBOW_TRACKER -> rightElbowTrackerBone - BoneType.LEFT_LOWER_ARM -> leftLowerArmBone - BoneType.RIGHT_LOWER_ARM -> rightLowerArmBone - BoneType.LEFT_HAND -> leftHandBone - BoneType.RIGHT_HAND -> rightHandBone - BoneType.LEFT_HAND_TRACKER -> leftHandTrackerBone - BoneType.RIGHT_HAND_TRACKER -> rightHandTrackerBone - BoneType.LEFT_THUMB_METACARPAL -> leftThumbMetacarpalBone - BoneType.LEFT_THUMB_PROXIMAL -> leftThumbProximalBone - BoneType.LEFT_THUMB_DISTAL -> leftThumbDistalBone - BoneType.LEFT_INDEX_PROXIMAL -> leftIndexProximalBone - BoneType.LEFT_INDEX_INTERMEDIATE -> leftIndexIntermediateBone - BoneType.LEFT_INDEX_DISTAL -> leftIndexDistalBone - BoneType.LEFT_MIDDLE_PROXIMAL -> leftMiddleProximalBone - BoneType.LEFT_MIDDLE_INTERMEDIATE -> leftMiddleIntermediateBone - BoneType.LEFT_MIDDLE_DISTAL -> leftMiddleDistalBone - BoneType.LEFT_RING_PROXIMAL -> leftRingProximalBone - BoneType.LEFT_RING_INTERMEDIATE -> leftRingIntermediateBone - BoneType.LEFT_RING_DISTAL -> leftRingDistalBone - BoneType.LEFT_LITTLE_PROXIMAL -> leftLittleProximalBone - BoneType.LEFT_LITTLE_INTERMEDIATE -> leftLittleIntermediateBone - BoneType.LEFT_LITTLE_DISTAL -> leftLittleDistalBone - BoneType.RIGHT_THUMB_METACARPAL -> rightThumbMetacarpalBone - BoneType.RIGHT_THUMB_PROXIMAL -> rightThumbProximalBone - BoneType.RIGHT_THUMB_DISTAL -> rightThumbDistalBone - BoneType.RIGHT_INDEX_PROXIMAL -> rightIndexProximalBone - BoneType.RIGHT_INDEX_INTERMEDIATE -> rightIndexIntermediateBone - BoneType.RIGHT_INDEX_DISTAL -> rightIndexDistalBone - BoneType.RIGHT_MIDDLE_PROXIMAL -> rightMiddleProximalBone - BoneType.RIGHT_MIDDLE_INTERMEDIATE -> rightMiddleIntermediateBone - BoneType.RIGHT_MIDDLE_DISTAL -> rightMiddleDistalBone - BoneType.RIGHT_RING_PROXIMAL -> rightRingProximalBone - BoneType.RIGHT_RING_INTERMEDIATE -> rightRingIntermediateBone - BoneType.RIGHT_RING_DISTAL -> rightRingDistalBone - BoneType.RIGHT_LITTLE_PROXIMAL -> rightLittleProximalBone - BoneType.RIGHT_LITTLE_INTERMEDIATE -> rightLittleIntermediateBone - BoneType.RIGHT_LITTLE_DISTAL -> rightLittleDistalBone - } - - private fun getTrackerForBone(bone: BoneType?): Tracker? = when (bone) { - BoneType.HEAD -> headTracker - BoneType.NECK -> neckTracker - BoneType.UPPER_CHEST -> upperChestTracker - BoneType.CHEST -> chestTracker - BoneType.WAIST -> waistTracker - BoneType.HIP -> hipTracker - BoneType.LEFT_UPPER_LEG -> leftUpperLegTracker - BoneType.RIGHT_UPPER_LEG -> rightUpperLegTracker - BoneType.LEFT_LOWER_LEG -> leftLowerLegTracker - BoneType.RIGHT_LOWER_LEG -> rightLowerLegTracker - BoneType.LEFT_FOOT -> leftFootTracker - BoneType.RIGHT_FOOT -> rightFootTracker - BoneType.LEFT_SHOULDER -> leftShoulderTracker - BoneType.RIGHT_SHOULDER -> rightShoulderTracker - BoneType.LEFT_UPPER_ARM -> leftUpperArmTracker - BoneType.RIGHT_UPPER_ARM -> rightUpperArmTracker - BoneType.LEFT_LOWER_ARM -> leftLowerArmTracker - BoneType.RIGHT_LOWER_ARM -> rightLowerArmTracker - BoneType.LEFT_HAND -> leftHandTracker - BoneType.RIGHT_HAND -> rightHandTracker - else -> null - } - - /** - * Returns an array of all the non-tracker bones. - */ - val allHumanBones: Array - get() = arrayOf( - headBone, - neckBone, - upperChestBone, - chestBone, - waistBone, - hipBone, - leftHipBone, - rightHipBone, - leftUpperLegBone, - rightUpperLegBone, - leftLowerLegBone, - rightLowerLegBone, - leftFootBone, - rightFootBone, - leftUpperShoulderBone, - rightUpperShoulderBone, - leftShoulderBone, - rightShoulderBone, - leftUpperArmBone, - rightUpperArmBone, - leftLowerArmBone, - rightLowerArmBone, - leftHandBone, - rightHandBone, - leftThumbMetacarpalBone, - leftThumbProximalBone, - leftThumbDistalBone, - leftIndexProximalBone, - leftIndexIntermediateBone, - leftIndexDistalBone, - leftMiddleProximalBone, - leftMiddleIntermediateBone, - leftMiddleDistalBone, - leftRingProximalBone, - leftRingIntermediateBone, - leftRingDistalBone, - leftLittleProximalBone, - leftLittleIntermediateBone, - leftLittleDistalBone, - rightThumbMetacarpalBone, - rightThumbProximalBone, - rightThumbDistalBone, - rightIndexProximalBone, - rightIndexIntermediateBone, - rightIndexDistalBone, - rightMiddleProximalBone, - rightMiddleIntermediateBone, - rightMiddleDistalBone, - rightRingProximalBone, - rightRingIntermediateBone, - rightRingDistalBone, - rightLittleProximalBone, - rightLittleIntermediateBone, - rightLittleDistalBone, - ) - - /** - * Returns all the arm bones, tracker or not. - */ - private val allArmBones: Array - get() = arrayOf( - leftUpperShoulderBone, - rightUpperShoulderBone, - leftShoulderBone, - rightShoulderBone, - leftUpperArmBone, - rightUpperArmBone, - leftElbowTrackerBone, - rightElbowTrackerBone, - leftLowerArmBone, - rightLowerArmBone, - leftHandBone, - rightHandBone, - leftHandTrackerBone, - rightHandTrackerBone, - leftThumbMetacarpalBone, - leftThumbProximalBone, - leftThumbDistalBone, - leftIndexProximalBone, - leftIndexIntermediateBone, - leftIndexDistalBone, - leftMiddleProximalBone, - leftMiddleIntermediateBone, - leftMiddleDistalBone, - leftRingProximalBone, - leftRingIntermediateBone, - leftRingDistalBone, - leftLittleProximalBone, - leftLittleIntermediateBone, - leftLittleDistalBone, - rightThumbMetacarpalBone, - rightThumbProximalBone, - rightThumbDistalBone, - rightIndexProximalBone, - rightIndexIntermediateBone, - rightIndexDistalBone, - rightMiddleProximalBone, - rightMiddleIntermediateBone, - rightMiddleDistalBone, - rightRingProximalBone, - rightRingIntermediateBone, - rightRingDistalBone, - rightLittleProximalBone, - rightLittleIntermediateBone, - rightLittleDistalBone, - ) - - val hmdHeight: Float - get() = headTracker?.position?.y ?: 0f - - /** - * Runs checks to know if we should (and are) performing the tracking of the - * left arm from the controller. - * - * @return a bool telling us if we are tracking the left arm from the - * controller or not. - */ - val isTrackingLeftArmFromController: Boolean - get() = leftHandTracker != null && leftHandTracker!!.hasPosition && !forceArmsFromHMD - - /** - * Runs checks to know if we should (and are) performing the tracking of the - * right arm from the controller. - * - * @return a bool telling us if we are tracking the right arm from the - * controller or not. - */ - val isTrackingRightArmFromController: Boolean - get() = rightHandTracker != null && rightHandTracker!!.hasPosition && !forceArmsFromHMD - - val trackersToReset: List - get() = listOf( - neckTracker, - upperChestTracker, - chestTracker, - waistTracker, - hipTracker, - leftUpperLegTracker, - leftLowerLegTracker, - leftFootTracker, - rightUpperLegTracker, - rightLowerLegTracker, - rightFootTracker, - leftLowerArmTracker, - rightLowerArmTracker, - leftUpperArmTracker, - rightUpperArmTracker, - leftHandTracker, - rightHandTracker, - leftShoulderTracker, - rightShoulderTracker, - leftThumbMetacarpalTracker, - leftThumbProximalTracker, - leftThumbDistalTracker, - leftIndexProximalTracker, - leftIndexIntermediateTracker, - leftIndexDistalTracker, - leftMiddleProximalTracker, - leftMiddleIntermediateTracker, - leftMiddleDistalTracker, - leftRingProximalTracker, - leftRingIntermediateTracker, - leftRingDistalTracker, - leftLittleProximalTracker, - leftLittleIntermediateTracker, - leftLittleDistalTracker, - rightThumbMetacarpalTracker, - rightThumbProximalTracker, - rightThumbDistalTracker, - rightIndexProximalTracker, - rightIndexIntermediateTracker, - rightIndexDistalTracker, - rightMiddleProximalTracker, - rightMiddleIntermediateTracker, - rightMiddleDistalTracker, - rightRingProximalTracker, - rightRingIntermediateTracker, - rightRingDistalTracker, - rightLittleProximalTracker, - rightLittleIntermediateTracker, - rightLittleDistalTracker, - ) - - @JvmOverloads - fun resetTrackersFull(resetSourceName: String?, bodyParts: List = ArrayList()) { - humanPoseManager.server?.serverGuards?.onFullReset() - - var referenceRotation = IDENTITY - headTracker?.let { - if (bodyParts.isEmpty() || bodyParts.contains(BodyPart.HEAD)) { - // Always reset the head (ifs in resetsHandler) - it.resetsHandler.resetFull(referenceRotation) - } - referenceRotation = it.getRotation() - } - - // Resets all axes of the trackers with the HMD as reference. - for (tracker in trackersToReset) { - // Only reset if tracker needsReset - if (tracker != null && (tracker.allowReset || tracker.isHmd) && (bodyParts.isEmpty() || bodyParts.contains(tracker.trackerPosition?.bodyPart))) { - tracker.resetsHandler.resetFull(referenceRotation) - } - } - - // Tell floorclip to reset its floor level on the next update - // of the computed trackers - if (!localizer.getEnabled()) { - legTweaks.resetFloorLevel() - } - legTweaks.resetBuffer() - localizer.reset() - ikSolver.resetOffsets() - LogManager.info("[HumanSkeleton] Reset: full ($resetSourceName)") - } - - @VRServerThread - @JvmOverloads - fun resetTrackersYaw(resetSourceName: String?, bodyParts: List = TrackerUtils.allBodyPartsButFingers) { - // Resets the yaw of the trackers with the head as reference. - var referenceRotation = IDENTITY - headTracker?.let { - if (bodyParts.isEmpty() || bodyParts.contains(BodyPart.HEAD)) { - // Only reset if head allowReset and isn't computed - if (it.allowReset && !it.isComputed) { - it.resetsHandler.resetYaw(referenceRotation) - } - } - referenceRotation = it.getRotation() - } - for (tracker in trackersToReset) { - // Only reset if tracker allowReset - if (tracker != null && tracker.allowReset && (bodyParts.isEmpty() || bodyParts.contains(tracker.trackerPosition?.bodyPart))) { - tracker.resetsHandler.resetYaw(referenceRotation) - } - } - legTweaks.resetBuffer() - LogManager.info("[HumanSkeleton] Reset: yaw ($resetSourceName)") - } - - /** - * if bodyParts is empty, this resets mounting for all trackers. - * Keep in mind TrackerResetsHandler.kt has some logic as well for which trackers get reset (feet) - */ - @VRServerThread - @JvmOverloads - fun resetTrackersMounting(resetSourceName: String?, bodyParts: List) { - val trackersToReset = trackersToReset - - // TODO: PLEASE rewrite this handling at some point in the future... This is so - // hacky!! Surely there's a better way to check reset status - Butterscotch - // If there's a server present (required for status) and any tracker reports a - // non-zero reset status (indicates reset required), then block mounting reset, - // as it requires a full reset first - if (humanPoseManager.server != null && trackersToReset.any { it != null && it.needReset }) { - LogManager.info("[HumanSkeleton] Reset: mounting ($resetSourceName) failed, reset required") - return - } - - // Resets the mounting orientation of the trackers with the HMD as reference. - var referenceRotation = IDENTITY - headTracker?.let { - if (bodyParts.isEmpty() || bodyParts.contains(BodyPart.HEAD)) { - // Only reset if head allowMounting or is computed but not HMD - if (it.allowMounting || (it.isComputed && !it.isHmd)) { - it.resetsHandler.resetMounting(referenceRotation) - } - } - referenceRotation = it.getRotation() - } - - for (tracker in trackersToReset) { - // Only reset if tracker needsMounting - if (tracker != null && tracker.allowMounting && (bodyParts.isEmpty() || bodyParts.contains(tracker.trackerPosition?.bodyPart))) { - tracker.resetsHandler.resetMounting(referenceRotation) - } - } - legTweaks.resetBuffer() - localizer.reset() - - if (humanPoseManager.server != null) { - humanPoseManager.server.configManager.vrConfig.resetsConfig.lastMountingMethod = - MountingMethods.AUTOMATIC - if (!humanPoseManager.server.trackingChecklistManager.resetMountingCompleted) { - humanPoseManager.server.trackingChecklistManager.resetMountingCompleted = bodyParts.any { it -> - val defaultParts = if (humanPoseManager.server.configManager.vrConfig.resetsConfig.resetMountingFeet) { - TrackerUtils.allBodyPartsButFingers - } else { - TrackerUtils.allBodyPartsButFingersAndFeets - } - - return@any defaultParts.contains(it) - } - } - if (!humanPoseManager.server.trackingChecklistManager.feetResetMountingCompleted) { - humanPoseManager.server.trackingChecklistManager.feetResetMountingCompleted = bodyParts.any { TrackerUtils.feetsBodyParts.contains(it) } - } - humanPoseManager.server.configManager.saveConfig() - } - - LogManager.info("[HumanSkeleton] Reset: mounting ($resetSourceName)") - } - - @VRServerThread - fun clearTrackersMounting(resetSourceName: String?) { - headTracker?.let { - if (it.allowMounting) it.resetsHandler.clearMounting() - } - for (tracker in trackersToReset) { - if (tracker != null && - tracker.allowMounting - ) { - tracker.resetsHandler.clearMounting() - } - } - legTweaks.resetBuffer() - LogManager.info("[HumanSkeleton] Clear: mounting ($resetSourceName)") - - if (humanPoseManager.server != null) { - humanPoseManager.server.trackingChecklistManager.resetMountingCompleted = false - humanPoseManager.server.trackingChecklistManager.feetResetMountingCompleted = false - humanPoseManager.server.configManager.saveConfig() - } - } - - fun updateTapDetectionConfig() { - tapDetectionManager?.refresh() - } - - fun updateLegTweaksConfig() { - legTweaks.updateConfig() - } - - /** - * Does not save to config - */ - fun setLegTweaksStateTemp( - skatingCorrection: Boolean, - floorClip: Boolean, - toeSnap: Boolean, - footPlant: Boolean, - ) { - legTweaks.setSkatingCorrectionEnabled(skatingCorrection) - legTweaks.setFloorClipEnabled(floorClip) - legTweaks.toeSnapEnabled = toeSnap - legTweaks.footPlantEnabled = footPlant - } - - /** - * Resets to config values - */ - fun clearLegTweaksStateTemp( - skatingCorrection: Boolean, - floorClip: Boolean, - toeSnap: Boolean, - footPlant: Boolean, - ) { - // only reset the true values as they are a mask for what to reset - if (skatingCorrection) { - legTweaks - .setSkatingCorrectionEnabled( - humanPoseManager.getToggle(SkeletonConfigToggles.SKATING_CORRECTION), - ) - } - if (floorClip) { - legTweaks - .setFloorClipEnabled(humanPoseManager.getToggle(SkeletonConfigToggles.FLOOR_CLIP)) - } - if (toeSnap) legTweaks.toeSnapEnabled = humanPoseManager.getToggle(SkeletonConfigToggles.TOE_SNAP) - if (footPlant) legTweaks.footPlantEnabled = humanPoseManager.getToggle(SkeletonConfigToggles.FOOT_PLANT) - } - - val legTweaksState: BooleanArray - get() { - val state = BooleanArray(4) - state[0] = legTweaks.floorClipEnabled - state[1] = legTweaks.skatingCorrectionEnabled - state[2] = legTweaks.toeSnapEnabled - state[3] = legTweaks.footPlantEnabled - return state - } - - /** - * Master enable/disable of all leg tweaks (for Autobone) - */ - @VRServerThread - fun setLegTweaksEnabled(value: Boolean) { - legTweaks.enabled = value - } - - /** - * enable/disable IK solver (for Autobone) - */ - @VRServerThread - fun setIKSolverEnabled(value: Boolean) { - ikSolver.enabled = value - } - - @VRServerThread - fun setFloorclipEnabled(value: Boolean) { - humanPoseManager.setToggle(SkeletonConfigToggles.FLOOR_CLIP, value) - } - - @VRServerThread - fun setSkatingCorrectionEnabled(value: Boolean) { - humanPoseManager.setToggle(SkeletonConfigToggles.SKATING_CORRECTION, value) - } - - fun getPauseTracking(): Boolean = pauseTracking - - fun setPauseTracking(pauseTracking: Boolean, sourceName: String?) { - if (!pauseTracking && this.pauseTracking) { - // If unpausing tracking, clear the legtweaks buffer - legTweaks.resetBuffer() - } - this.pauseTracking = pauseTracking - LogManager.info("[HumanSkeleton] ${if (pauseTracking) "Pause" else "Unpause"} tracking ($sourceName)") - // Report the new state of tracking pause - humanPoseManager.trackingPauseHandler.sendTrackingPauseState(pauseTracking) - } - - fun togglePauseTracking(sourceName: String?): Boolean { - val newState = !pauseTracking - setPauseTracking(newState, sourceName) - return newState - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/IKChain.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/IKChain.kt deleted file mode 100644 index b34c930323..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/IKChain.kt +++ /dev/null @@ -1,159 +0,0 @@ -package dev.slimevr.tracking.processor.skeleton - -import dev.slimevr.tracking.processor.Bone -import dev.slimevr.tracking.processor.Constraint.Companion.ConstraintType -import dev.slimevr.tracking.trackers.Tracker -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import kotlin.math.* - -/* - * This class implements a chain of Bones - */ - -class IKChain( - val bones: MutableList, - var parent: IKChain?, - val level: Int, - val baseConstraint: Tracker?, - val tailConstraint: Tracker?, -) { - // State variables - private val computedBasePosition = baseConstraint?.let { IKConstraint(it) } - private val computedTailPosition = tailConstraint?.let { IKConstraint(it) } - var children = mutableListOf() - var target = Vector3.NULL - var distToTargetSqr = Float.POSITIVE_INFINITY - private var rotations = getRotationsList() - - private fun getRotationsList(): MutableList { - val rotList = mutableListOf() - for (b in bones) { - rotList.add(b.getGlobalRotation()) - } - - return rotList - } - - /* - * Populate the non-static rotations with the solve angle from the last iteration - */ - private fun prepBones() { - for (i in 0..() - private var rootChain: IKChain? = null - private var needsReset = false - - /** - * Any time the skeleton is rebuilt or trackers are assigned / unassigned the chains - * should be rebuilt. - */ - fun buildChains(trackers: List) { - chainList.clear() - - val positionalConstraints = extractPositionalConstraints(trackers) - val rotationalConstraints = extractRotationalConstraints(trackers) - - rootChain = chainBuilder(root, null, 0, positionalConstraints, rotationalConstraints) - populateChainList(rootChain!!) - addConstraints() - - // Check if there is any constraints (other than the head) in the model - rootChain = if (neededChain(rootChain!!)) rootChain else null - chainList.sortBy { -it.level } - } - - /** - * Reset the offsets of positional trackers. - */ - fun resetOffsets() { - needsReset = true - } - - /** - * Convert the skeleton in to a system of chains. - * A break in a chain is created at any point that has either - * multiple children or is positionally constrained, useless chains are discarded - * (useless chains are chains with no positional constraint at their tail). - */ - private fun chainBuilder( - root: Bone, - parent: IKChain?, - level: Int, - positionalConstraints: MutableList, - rotationalConstraints: MutableList, - ): IKChain { - val boneList = mutableListOf() - var currentBone = root - var constraint = getConstraint(currentBone, rotationalConstraints) - currentBone.rotationConstraint.allowModifications = constraint == null - currentBone.rotationConstraint.hasTrackerRotation = constraint != null - boneList.add(currentBone) - - // Get constraints - val baseConstraint = if (parent == null) { - getConstraint(boneList.first(), positionalConstraints) - } else { - parent.tailConstraint - } - var tailConstraint = getConstraint(currentBone, positionalConstraints) - - // Add bones until there is a reason to make a new chain - while (currentBone.children.size == 1 && tailConstraint == null) { - currentBone = currentBone.children.first() - constraint = getConstraint(currentBone, rotationalConstraints) - currentBone.rotationConstraint.allowModifications = constraint == null - currentBone.rotationConstraint.hasTrackerRotation = constraint != null - boneList.add(currentBone) - tailConstraint = getConstraint(currentBone, positionalConstraints) - } - - var chain = IKChain(boneList, parent, level, baseConstraint, tailConstraint) - - if (currentBone.children.isNotEmpty()) { - // Build child chains - val childrenList = mutableListOf() - for (child in currentBone.children) { - val childChain = chainBuilder(child, chain, level + 1, positionalConstraints, rotationalConstraints) - if (neededChain(childChain)) { - childrenList.add(childChain) - } - } - chain.children = childrenList - } - - // If the chain has only one child and no tail constraint combine the chains - if (chain.children.size == 1 && chain.tailConstraint == null) { - chain = combineChains(chain, chain.children.first()) - } - - return chain - } - - private fun populateChainList(chain: IKChain) { - chainList.add(chain) - for (c in chain.children) { - populateChainList(c) - } - } - - private fun combineChains(chain: IKChain, childChain: IKChain): IKChain { - val boneList = mutableListOf() - boneList.addAll(chain.bones) - boneList.addAll(childChain.bones) - - val newChain = IKChain( - boneList, - chain.parent, - chain.level, - chain.baseConstraint, - childChain.tailConstraint, - ) - - newChain.children = childChain.children - - for (c in newChain.children) { - c.parent = newChain - } - - return newChain - } - - private fun addConstraints() { - fun constrainChain(chain: IKChain) { - chain.bones.forEach { it.rotationConstraint.allowModifications = false } - } - chainList.forEach { if (it.tailConstraint == null) constrainChain(it) } - } - - private fun neededChain(chain: IKChain): Boolean { - if (chain.tailConstraint != null) { - return true - } - - for (c in chain.children) { - if (c.tailConstraint != null) { - return true - } - - if (neededChain(c)) { - return true - } - } - - return false - } - - private fun extractPositionalConstraints(trackers: List): MutableList { - val constraintList = mutableListOf() - for (t in trackers) { - if (t.hasPosition && - !t.isInternal && - !t.status.reset - ) { - constraintList.add(t) - } - } - return constraintList - } - - private fun extractRotationalConstraints(trackers: List): MutableList { - val constrainList = mutableListOf() - for (t in trackers) { - if (t.hasRotation && - !t.status.reset && - !t.isInternal - ) { - constrainList.add(t) - } - } - - return constrainList - } - - private fun getConstraint(bone: Bone, constraints: MutableList): Tracker? { - for (c in constraints) { - if (c.trackerPosition != null && bone.boneType.bodyPart == (c.trackerPosition?.bodyPart ?: 0)) { - constraints.remove(c) - return c - } - } - return null - } - - private fun solve(iterations: Int): Boolean { - var solved = false - for (i in 0..iterations) { - for (chain in chainList) { - chain.backwardsCCDIK() - } - - rootChain?.computeTargetDistance() - - // If all chains have reached their target the chain is solved - solved = true - for (chain in chainList) { - if (chain.distToTargetSqr > TOLERANCE_SQR) { - solved = false - break - } - } - - if (solved) break - } - - return solved - } - - fun solve() { - if (rootChain == null || !enabled) return - - if (needsReset) { - for (c in chainList) { - c.resetTrackerOffsets() - } - needsReset = false - } - - rootChain?.resetChain() - root.update() - - solve(MAX_ITERATIONS) - - root.update() - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/LegTweaks.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/LegTweaks.kt deleted file mode 100644 index 2ae9824dbf..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/LegTweaks.kt +++ /dev/null @@ -1,1060 +0,0 @@ -package dev.slimevr.tracking.processor.skeleton - -import com.jme3.math.FastMath -import dev.slimevr.config.LegTweaksConfig -import dev.slimevr.tracking.processor.Bone -import dev.slimevr.tracking.processor.config.SkeletonConfigToggles -import io.github.axisangles.ktmath.EulerAngles -import io.github.axisangles.ktmath.EulerOrder -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import kotlin.math.* - -class LegTweaks(private val skeleton: HumanSkeleton) { - /** - * here is an explanation of each parameter that may need explaining - * STANDING_CUTOFF_VERTICAL is the percentage the hip has to be below its - * position at calibration to register as the user not standing - * MAX_DISENGAGEMENT_OFFSET is how much the floor will be shifted to allow - * an offset to happen smoothly DYNAMIC_DISPLACEMENT_CUTOFF is the percent - * of downwards rotation that can contribute to dynamic displacement - * MAX_DYNAMIC_DISPLACEMENT is the max amount the floor will be moved up to - * account for the foot rotating downward and needing to be put higher to - * avoid clipping in the game world MIN_ACCEPTABLE_ERROR and - * MAX_ACCEPTABLE_ERROR Defines the distance where CORRECTION_WEIGHT_MIN and - * CORRECTION_WEIGHT_MAX are calculating a percent of velocity to correct - * rather than using the min or max FLOOR_CALIBRATION_OFFSET is the amount - * the floor plane is shifted up. This can help the feet from floating - * slightly above the ground - */ - - companion object { - // clip correction - const val DYNAMIC_DISPLACEMENT_CUTOFF = 1.0f - const val FLOOR_CALIBRATION_OFFSET = 0.0025f - - // skating correction - private const val MIN_ACCEPTABLE_ERROR = 0.01f - private const val MAX_ACCEPTABLE_ERROR = 0.05f - private const val CORRECTION_WEIGHT_MIN = 0.55f - private const val CORRECTION_WEIGHT_MAX = 0.70f - private const val CONTINUOUS_CORRECTION_DIST = 0.5f - private const val CONTINUOUS_CORRECTION_WARMUP = 175 - - // knee / hip correction - private const val KNEE_CORRECTION_WEIGHT = 0.00f - private const val KNEE_LATERAL_WEIGHT = 0.8f - private const val WAIST_PUSH_WEIGHT = 0.2f - - // COM calculation - private const val HEAD_MASS = 0.0827f - private const val THORAX_MASS = 0.1870f - private const val ABDOMEN_MASS = 0.1320f - private const val PELVIS_MASS = 0.1530f - private const val THIGH_MASS = 0.1122f - private const val LEG_AND_FOOT_MASS = 0.0620f - private const val UPPER_ARM_MASS = 0.0263f - private const val FOREARM_AND_HAND_MASS = 0.0224f - - // rotation correction - private const val ROTATION_CORRECTION_VERTICAL = 0.1f - private const val MAXIMUM_CORRECTION_ANGLE = 0.4f - private const val MAXIMUM_CORRECTION_ANGLE_DELTA = 0.7f - private const val MAXIMUM_TOE_DOWN_ANGLE = 0.8f - private const val TOE_SNAP_COOLDOWN = 3.0f - private const val MIN_DISTANCE_FOR_PLANT = 0.025f - private const val MAX_DISTANCE_FOR_PLANT = 0.075f - - // misc - const val NEARLY_ZERO = 0.001f - private const val STANDING_CUTOFF_VERTICAL = 0.65f - private const val MAX_DISENGAGEMENT_OFFSET = 0.30f - private const val DEFAULT_ARM_DISTANCE = 0.15f - private const val MAX_CORRECTION_STRENGTH_DELTA = 1.0f - } - - // state variables - var floorLevel = 0f - private set - private var hipToFloorDist = 0f - private var currentDisengagementOffset = 0.0f - private var footLength = 0.0f - private var currentCorrectionStrength = 0.3f // default value - - private var initialized = true - var enabled = true // master switch - - private var alwaysUseFloorclip = false - var floorClipEnabled = false - private set - var skatingCorrectionEnabled = false - private set - var toeSnapEnabled = false - var footPlantEnabled = false - private var active = false - private var rightLegActive = false - private var leftLegActive = false - private var leftFramesLocked = 0 - private var rightFramesLocked = 0 - private var leftFramesUnlocked = 0 - private var rightFramesUnlocked = 0 - private var leftToeAngle = 0.0f - private var leftToeTouched = false - private var rightToeAngle = 0.0f - private var rightToeTouched = false - private var localizerMode = false - - // config - private var config: LegTweaksConfig? = null - - // leg data - private var leftFootPosition = Vector3.NULL - private var rightFootPosition = Vector3.NULL - private var leftKneePosition = Vector3.NULL - private var rightKneePosition = Vector3.NULL - private var hipPosition = Vector3.NULL - private var leftFootRotation = Quaternion.IDENTITY - private var rightFootRotation = Quaternion.IDENTITY - - private var leftFootAcceleration = Vector3.NULL - private var rightFootAcceleration = Vector3.NULL - private var leftLowerLegAcceleration = Vector3.NULL - private var rightLowerLegAcceleration = Vector3.NULL - - // buffer for holding previous frames of data - var bufferHead = LegTweaksBuffer() - private set - private var bufferInvalid = true - - constructor(skeleton: HumanSkeleton, config: LegTweaksConfig) : this(skeleton) { - this.config = config - updateConfig() - } - - fun resetFloorLevel() { - initialized = false - } - - fun setFloorClipEnabled(floorClipEnabled: Boolean) { - this.floorClipEnabled = floorClipEnabled - - // reset the buffer - bufferHead = LegTweaksBuffer() - bufferInvalid = true - } - - fun setSkatingCorrectionEnabled(skatingCorrectionEnabled: Boolean) { - this.skatingCorrectionEnabled = skatingCorrectionEnabled - - // reset the buffer - bufferHead = LegTweaksBuffer() - bufferInvalid = true - } - - fun setLocalizerMode(value: Boolean) { - localizerMode = value - if (value) setFloorLevel(0.0f) - } - - fun resetBuffer() { - bufferInvalid = true - } - - fun setConfig(config: LegTweaksConfig) { - this.config = config - updateConfig() - } - - fun updateConfig() { - if (config == null) return - - updateHyperParameters(config!!.correctionStrength) - floorClipEnabled = - skeleton.humanPoseManager.getToggle(SkeletonConfigToggles.FLOOR_CLIP) - alwaysUseFloorclip = config!!.alwaysUseFloorclip - skatingCorrectionEnabled = skeleton.humanPoseManager - .getToggle(SkeletonConfigToggles.SKATING_CORRECTION) - toeSnapEnabled = - skeleton.humanPoseManager.getToggle(SkeletonConfigToggles.TOE_SNAP) - footPlantEnabled = - skeleton.humanPoseManager.getToggle(SkeletonConfigToggles.FOOT_PLANT) - } - - // tweak the position of the legs based on data from the last frames - fun tweakLegs() { - // If user doesn't have knees or legtweaks is disabled, - // don't spend time doing calculations! - if ((!skeleton.hasKneeTrackers && !alwaysUseFloorclip) || !enabled) return - - // update the class with the latest data from the skeleton - // if false is returned something indicated that the legs should not - // be tweaked - preUpdate() - - // correct foot rotation's (Foot plant & Toe snap) - if (footPlantEnabled || toeSnapEnabled) correctFootRotations() - - // push the feet up if needed (Floor clip) - if (floorClipEnabled && !localizerMode) correctClipping() - - // correct for skating if needed (Skating correction) - if (skatingCorrectionEnabled) correctSkating() - - // determine if either leg is in a position to activate or - // deactivate - // (use the buffer to get the positions before corrections) - val leftFootDif = abs( - (bufferHead.leftFootPosition - leftFootPosition).y, - ) - val rightFootDif = abs( - (bufferHead.rightFootPosition - rightFootPosition).y, - ) - - if (!active && leftFootDif < NEARLY_ZERO) { - leftLegActive = false - } else if (active && leftFootDif < NEARLY_ZERO) { - leftLegActive = true - } - if (!active && rightFootDif < NEARLY_ZERO) { - rightLegActive = false - } else if (active && rightFootDif < NEARLY_ZERO) { - rightLegActive = true - } - - // restore the y positions of inactive legs - if (!leftLegActive) { - leftFootPosition = Vector3( - leftFootPosition.x, - bufferHead.leftFootPosition.y, - leftFootPosition.z, - ) - leftKneePosition = Vector3( - leftKneePosition.x, - bufferHead.leftKneePosition.y, - leftKneePosition.z, - ) - } - if (!rightLegActive) { - rightFootPosition = Vector3( - rightFootPosition.x, - bufferHead.rightFootPosition.y, - rightFootPosition.z, - ) - rightKneePosition = Vector3( - rightKneePosition.x, - bufferHead.rightKneePosition.y, - rightKneePosition.z, - ) - } - - // calculate the correction for the knees - if (initialized) solveLowerBody() - - // populate the corrected data into the current frame - bufferHead - .setCorrectedPositions( - leftFootPosition, - rightFootPosition, - leftKneePosition, - rightKneePosition, - hipPosition, - ) - - // Set the corrected positions in the skeleton - skeleton.computedHipTracker?.position = hipPosition - skeleton.computedLeftKneeTracker?.position = leftKneePosition - skeleton.computedRightKneeTracker?.position = rightKneePosition - skeleton.computedLeftFootTracker?.position = leftFootPosition - skeleton.computedRightFootTracker?.position = rightFootPosition - } - - // update the hyperparameters with the config - private fun updateHyperParameters(newStrength: Float) { - LegTweaksBuffer.setSkatingVelocityThreshold( - getScaledHyperParameter( - newStrength, - LegTweaksBuffer.getSkatingVelocityThreshold(), - ), - ) - LegTweaksBuffer.setSkatingAccelerationThreshold( - getScaledHyperParameter( - newStrength, - LegTweaksBuffer.getSkatingAccelerationThreshold(), - ), - ) - currentCorrectionStrength = newStrength - } - - private fun getScaledHyperParameter(newStrength: Float, currentValue: Float): Float = currentValue - currentCorrectionStrength * MAX_CORRECTION_STRENGTH_DELTA + newStrength * MAX_CORRECTION_STRENGTH_DELTA - - private fun setFloorLevel(floorLevel: Float) { - this.floorLevel = floorLevel - hipToFloorDist = hipPosition.y - floorLevel - } - - // set the vectors in this object to the vectors in the skeleton - private fun setVectors() { - // set the positions of the feet and knees to the skeleton's - // current positions - hipPosition = skeleton.computedHipTracker?.position ?: Vector3.NULL - leftKneePosition = skeleton.computedLeftKneeTracker?.position ?: Vector3.NULL - rightKneePosition = skeleton.computedRightKneeTracker?.position ?: Vector3.NULL - leftFootPosition = skeleton.computedLeftFootTracker?.position ?: Vector3.NULL - rightFootPosition = skeleton.computedRightFootTracker?.position ?: Vector3.NULL - leftFootRotation = skeleton.computedLeftFootTracker?.getRotation() ?: Quaternion.NULL - rightFootRotation = skeleton.computedRightFootTracker?.getRotation() ?: Quaternion.NULL - - // get the vector for acceleration of the feet and lower legs - leftFootAcceleration = - if (skeleton.leftFootTracker != null) skeleton.leftFootTracker!!.getAcceleration() else Vector3.NULL - rightFootAcceleration = - if (skeleton.rightFootTracker != null) skeleton.rightFootTracker!!.getAcceleration() else Vector3.NULL - leftLowerLegAcceleration = - if (skeleton.leftLowerLegTracker != null) skeleton.leftLowerLegTracker!!.getAcceleration() else Vector3.NULL - rightLowerLegAcceleration = - if (skeleton.rightLowerLegTracker != null) skeleton.rightLowerLegTracker!!.getAcceleration() else Vector3.NULL - } - - // updates the object with the latest data from the skeleton - private fun preUpdate() { - // populate the vectors with the latest data - setVectors() - - // if not initialized, we need to calculate some values from this frame - // to be used later (must happen immediately after reset) - if (!initialized) { - setFloorLevel(((leftFootPosition.y + rightFootPosition.y) / 2f + FLOOR_CALIBRATION_OFFSET)) - - // invalidate the buffer since the non-initialized output may be - // very wrong - bufferInvalid = true - initialized = true - } - - // update the foot length - footLength = skeleton.leftFootBone.length - - // if the user is standing start checking for a good time to enable leg - // tweaks - active = isStanding() - - // if the buffer is invalid add all the extra info - if (bufferInvalid && !localizerMode) { - bufferHead - .setPositions( - leftFootPosition, - rightFootPosition, - leftKneePosition, - rightKneePosition, - hipPosition, - ) - - // if active correct clipping before populating corrected positions - if (active) { - correctClipping() - } - - bufferHead - .setCorrectedPositions( - leftFootPosition, - rightFootPosition, - leftKneePosition, - rightKneePosition, - hipPosition, - ) - bufferHead.leftLegState = LegTweaksBuffer.UNLOCKED - bufferHead.rightLegState = LegTweaksBuffer.UNLOCKED - bufferInvalid = false - } - - // update the buffer - val leftFloorLevel: Float = ( - floorLevel + - footLength * - getFootOffset(leftFootRotation) - - currentDisengagementOffset - ) - val rightFloorLevel: Float = ( - floorLevel + - footLength * - getFootOffset(rightFootRotation) - - currentDisengagementOffset - ) - val leftFootAccel = - if (skeleton.leftFootTracker != null) leftFootAcceleration else leftLowerLegAcceleration - val rightFootAccel = - if (skeleton.rightFootTracker != null) rightFootAcceleration else rightLowerLegAcceleration - val detectionMode = - if (skeleton.leftFootTracker != null && skeleton.rightFootTracker != null) LegTweaksBuffer.FOOT_ACCEL else LegTweaksBuffer.ANKLE_ACCEL - val centerOfMass: Vector3 = computeCenterOfMass() - - // update the buffer head - bufferHead = LegTweaksBuffer( - leftFootPosition, - rightFootPosition, - leftKneePosition, - rightKneePosition, - leftFootRotation, - rightFootRotation, - leftFloorLevel, - rightFloorLevel, - leftFootAccel, - rightFootAccel, - detectionMode, - hipPosition, - centerOfMass, - bufferHead, - active, - ) - - // update the lock duration counters - updateLockStateCounters() - } - - // returns true if the foot is clipped and false if it is not - private fun isClipped(leftOffset: Float, rightOffset: Float): Boolean = ( - leftFootPosition.y < floorLevel + leftOffset * footLength || - rightFootPosition.y < floorLevel + rightOffset * footLength - ) - - // corrects the foot position to be above the floor level that is calculated - // on calibration - private fun correctClipping() { - // calculate how angled down the feet are as a scalar value between 0 - // and 1 (0 = flat, 1 = max angle) - val leftOffset: Float = getFootOffset(leftFootRotation) - val rightOffset: Float = getFootOffset(rightFootRotation) - var avgOffset = 0f - - // if there is no clipping, return - if (!isClipped(leftOffset, rightOffset)) return - - // move the feet to their new positions - if (leftFootPosition.y - < floorLevel + - footLength * - leftOffset - - currentDisengagementOffset - ) { - val displacement = abs( - ( - floorLevel + - footLength * - leftOffset - - leftFootPosition.y - - currentDisengagementOffset - ), - ) - leftFootPosition = Vector3( - leftFootPosition.x, - leftFootPosition.y + displacement, - leftFootPosition.z, - ) - leftKneePosition = Vector3( - leftKneePosition.x, - leftKneePosition.y + displacement, - leftKneePosition.z, - ) - avgOffset += displacement - } - - if (rightFootPosition.y - < floorLevel + - footLength * - rightOffset - - currentDisengagementOffset - ) { - val displacement = abs( - ( - floorLevel + - footLength * - rightOffset - - rightFootPosition.y - - currentDisengagementOffset - ), - ) - rightFootPosition = Vector3( - rightFootPosition.x, - rightFootPosition.y + displacement, - rightFootPosition.z, - ) - rightKneePosition = Vector3( - rightKneePosition.x, - rightKneePosition.y + displacement, - rightKneePosition.z, - ) - avgOffset += displacement - } - - hipPosition = Vector3( - hipPosition.x, - hipPosition.y + avgOffset / 2 * WAIST_PUSH_WEIGHT, - hipPosition.z, - ) - } - - // based on the data from the last frame compute a new position that reduces - // ice skating - private fun correctSkating() { - // for either foot that is locked get its position (x and z only we let - // y move freely) and set it to be there - val bufPrev = bufferHead.parent ?: return - - if (bufferHead.leftLegState == LegTweaksBuffer.LOCKED) { - leftFootPosition = Vector3( - bufPrev - .leftFootPositionCorrected - .x, - leftFootPosition.y, - bufPrev - .leftFootPositionCorrected - .z, - ) - } - if (bufferHead.rightLegState == LegTweaksBuffer.LOCKED) { - rightFootPosition = Vector3( - bufPrev - .rightFootPositionCorrected - .x, - rightFootPosition.y, - bufPrev - .rightFootPositionCorrected - .z, - ) - } - - // for either foot that is unlocked get its last position and calculate - // its position for this frame. the amount of displacement is based on - // the distance between the last position, the current position, and - // the hyperparameters - if (bufferHead.leftLegState == LegTweaksBuffer.UNLOCKED) { - leftFootPosition = correctUnlockedFootTracker(leftFootPosition, bufPrev.leftFootPosition, bufPrev.leftFootPositionCorrected, bufferHead.leftFootVelocity, leftFramesUnlocked) - } - if (bufferHead.rightLegState == LegTweaksBuffer.UNLOCKED) { - rightFootPosition = correctUnlockedFootTracker(rightFootPosition, bufPrev.rightFootPosition, bufPrev.rightFootPositionCorrected, bufferHead.rightFootVelocity, rightFramesUnlocked) - } - } - - private fun correctUnlockedFootTracker(footPosition: Vector3, previousFootPosition: Vector3, previousFootPositionCorrected: Vector3, footVelocity: Vector3, framesUnlocked: Int): Vector3 { - var newFootPosition = footPosition - var footDif = footPosition - previousFootPositionCorrected - footDif = Vector3(footDif.x, 0f, footDif.z) - - if (footDif.len() > NEARLY_ZERO) { - val leftY = footPosition.y - var temp = previousFootPositionCorrected - val (x, _, z) = footVelocity - - // first add the difference from the last frame to this frame - temp -= (previousFootPosition - footPosition) - newFootPosition = Vector3(temp.x, leftY, temp.z) - - // if velocity and dif are pointing in the same direction, - // add a small amount of velocity to the dif - // else subtract a small amount of velocity from the dif - // calculate the correction weight. - // it is also right here where the constant correction is - // applied - val weight: Float = calculateCorrectionWeight( - newFootPosition, - previousFootPositionCorrected, - ) - val constantCorrection = getConstantCorrectionQuantity(framesUnlocked) - var newX = newFootPosition.x - var newZ = newFootPosition.z - - if (x * footDif.x > 0) { - newX += ( - x * - weight + - ( - constantCorrection - * - (if (x > 0) 1 else -1) / - bufferHead.getTimeDelta() - ) - ) - } else if (x * footDif.x < 0) { - newX -= ( - x * - weight + - ( - constantCorrection - * - (if (x > 0) 1 else -1) / - bufferHead.getTimeDelta() - ) - ) - } - if (z * footDif.z > 0) { - newZ += ( - z * - weight + - ( - constantCorrection - * - (if (z > 0) 1 else -1) / - bufferHead.getTimeDelta() - ) - ) - } else if (z * footDif.z < 0) { - newZ -= ( - z * - weight + - ( - constantCorrection - * - (if (z > 0) 1 else -1) / - bufferHead.getTimeDelta() - ) - ) - } - - // if the foot overshot the target, move it back to the target - if (checkOverShoot( - footPosition.x, - previousFootPositionCorrected.x, - newX, - ) - ) { - newX = footPosition.x - } - if (checkOverShoot( - footPosition.z, - previousFootPositionCorrected.z, - newZ, - ) - ) { - newZ = footPosition.z - } - newFootPosition = Vector3(newX, newFootPosition.y, newZ) - } - - return newFootPosition - } - - // get the amount of the constant correction to apply. - private fun getConstantCorrectionQuantity(framesUnlocked: Int): Float = if (framesUnlocked >= CONTINUOUS_CORRECTION_WARMUP) { - CONTINUOUS_CORRECTION_DIST - } else { - ( - CONTINUOUS_CORRECTION_DIST - * (leftFramesUnlocked.toFloat() / CONTINUOUS_CORRECTION_WARMUP) - ) - } - - // correct the rotations of the feet - // this is done by planting the foot better and by snapping the toes to the - // ground - private fun correctFootRotations() { - if (bufferHead.parent == null) return - - // boolean for if there is a foot tracker - val leftFootTracker = skeleton.leftFootTracker != null - val rightFootTracker = skeleton.rightFootTracker != null - - // get the foot positions - var leftFootRotation = bufferHead.leftFootRotation - var rightFootRotation = bufferHead.rightFootRotation - - // between maximum correction angle and maximum correction angle delta - // the values are interpolated - val kneeAngleL = getXZAmount(leftFootPosition, leftKneePosition) - val kneeAngleR = getXZAmount(rightFootPosition, rightKneePosition) - val masterWeightL = getMasterWeight(kneeAngleL) - val masterWeightR = getMasterWeight(kneeAngleR) - - // corrects rotations when planted firmly on the ground - if (footPlantEnabled) { - // the further from the ground the foot is, the less weight it - // should have - var weightL = getFootPlantWeight(leftFootPosition) - var weightR = getFootPlantWeight(rightFootPosition) - - // if foot trackers exist add to the weights - val leftFootYaw = isolateYaw(leftFootRotation) - if (leftFootTracker) { - weightL *= getRotationalDistanceToPlant( - leftFootRotation, - leftFootYaw, - ) - } - val rightFootYaw = isolateYaw(rightFootRotation) - if (rightFootTracker) { - weightR *= getRotationalDistanceToPlant( - rightFootRotation, - rightFootYaw, - ) - } - - // perform the correction - leftFootRotation = leftFootRotation - .interpR( - leftFootYaw, - weightL * masterWeightL, - ) - rightFootRotation = rightFootRotation - .interpR( - rightFootYaw, - weightR * masterWeightR, - ) - } - - // corrects rotations when the foot is in the air by rotating the foot - // down so that the toes are touching - if (toeSnapEnabled && !(leftFootTracker && rightFootTracker)) { - // this correction step has its own weight vars - var weightL: Float - var weightR: Float - - // first compute the angle of the foot - val angleL = getToeSnapAngle(leftFootPosition) - val angleR = getToeSnapAngle(rightFootPosition) - - // then compute the weight of the correction - weightL = getToeSnapWeight(leftFootPosition) - weightR = getToeSnapWeight(rightFootPosition) - - // depending on the state variables, the correction weights should - // be clamped - if (!leftToeTouched) { - weightL = min(weightL, leftToeAngle) - } - if (!rightToeTouched) { - weightR = min(weightR, rightToeAngle) - } - - // then slerp the rotation to the new rotation based on the weight - if (!leftFootTracker) { - leftFootRotation = leftFootRotation - .interpR( - replacePitch(leftFootRotation, -angleL), - weightL * masterWeightL, - ) - } - if (!rightFootTracker) { - rightFootRotation = rightFootRotation - .interpR( - replacePitch(rightFootRotation, -angleR), - weightR * masterWeightR, - ) - } - - // update state variables regarding toe snap - if (leftFootPosition.y - floorLevel > footLength * MAXIMUM_TOE_DOWN_ANGLE) { - leftToeTouched = false - leftToeAngle = weightL - } else if (leftFootPosition.y - floorLevel <= 0.0f) { - leftToeTouched = true - leftToeAngle = 1.0f - } - if (rightFootPosition.y - floorLevel > footLength * MAXIMUM_TOE_DOWN_ANGLE) { - rightToeTouched = false - rightToeAngle = weightR - } else if (rightFootPosition.y - floorLevel <= 0.0f) { - rightToeTouched = true - rightToeAngle = 1.0f - } - } - - // update the foot rotations in the buffer - bufferHead.setCorrectedRotations(leftFootRotation, rightFootRotation) - - // update the skeleton - skeleton.computedLeftFootTracker?.setRotation(leftFootRotation) - skeleton.computedRightFootTracker?.setRotation(rightFootRotation) - } - - // returns the length of the xz components of the normalized difference - // between two vectors - private fun getXZAmount(vec1: Vector3, vec2: Vector3): Float { - val (x, _, z) = (vec1 - vec2).unit() - return Vector3(x, 0f, z).len() - } - - // returns a float between 0 and 1 that represents the master weight for - // foot rotation correciton - private fun getMasterWeight(kneeAngle: Float): Float { - val masterWeight = if (kneeAngle > MAXIMUM_CORRECTION_ANGLE && - kneeAngle < MAXIMUM_CORRECTION_ANGLE_DELTA - ) { - ( - 1.0f - - ( - (kneeAngle - MAXIMUM_CORRECTION_ANGLE) / - (MAXIMUM_CORRECTION_ANGLE_DELTA - MAXIMUM_CORRECTION_ANGLE) - ) - ) - } else { - 0.0f - } - return if (kneeAngle < MAXIMUM_CORRECTION_ANGLE) 1.0f else masterWeight - } - - // return the weight of the correction for toe snap - private fun getToeSnapWeight(footPos: Vector3): Float { - // then compute the weight of the correction - val weight = - if (footPos.y - floorLevel > footLength * TOE_SNAP_COOLDOWN) { - 0.0f - } else { - ( - 1.0f - - ( - (footPos.y - floorLevel - footLength) / - (footLength * (TOE_SNAP_COOLDOWN - 1.0f)) - ) - ) - } - return FastMath.clamp(weight, 0.0f, 1.0f) - } - - // returns the angle of the foot for toe snap - private fun getToeSnapAngle(footPos: Vector3): Float { - val angle = FastMath.clamp(footPos.y - floorLevel, 0.0f, footLength) - return if (angle > footLength * MAXIMUM_TOE_DOWN_ANGLE) { - asin( - MAXIMUM_TOE_DOWN_ANGLE, - ) - } else { - asin( - angle / footLength, - ) - } - } - - // returns the weight for floor plant - private fun getFootPlantWeight(footPos: Vector3): Float { - val weight = - if (footPos.y - floorLevel > ROTATION_CORRECTION_VERTICAL) 0.0f else 1.0f - (footPos.y - floorLevel) / ROTATION_CORRECTION_VERTICAL - return FastMath.clamp(weight, 0.0f, 1.0f) - } - - // returns the amount to slerp for foot plant when foot trackers are active - private fun getRotationalDistanceToPlant(footRot: Quaternion, footRotYaw: Quaternion): Float { - var angle = footRot.angleToR(footRotYaw) - angle = (angle / (2 * Math.PI)).toFloat() - angle = FastMath.clamp( - angle, - MIN_DISTANCE_FOR_PLANT, - MAX_DISTANCE_FOR_PLANT, - ) - return ( - 1 - - ( - (angle - MIN_DISTANCE_FOR_PLANT) / - (MAX_DISTANCE_FOR_PLANT - MIN_DISTANCE_FOR_PLANT) - ) - ) - } - - // returns true if it is likely the user is standing - private fun isStanding(): Boolean { - // if the hip is below the vertical cutoff, user is not standing - val cutoff = ( - floorLevel + - hipToFloorDist - - (hipToFloorDist * STANDING_CUTOFF_VERTICAL) - ) - if (hipPosition.y < cutoff) { - currentDisengagementOffset = ( - ( - 1 - - ( - (floorLevel - hipPosition.y) / - (floorLevel - cutoff) - ) - ) * - MAX_DISENGAGEMENT_OFFSET - ) - return false - } - currentDisengagementOffset = 0f - return true - } - - // move the knees in to a position that is closer to the truth - private fun solveLowerBody() { - // calculate the left and right hip nodes in standing space - val leftHip = hipPosition - val rightHip = hipPosition - - // before moving the knees back closer to the hip nodes, offset them - // the same amount the foot trackers where offset - val leftXDif = leftFootPosition.x - bufferHead.leftFootPosition.x - val rightXDif = rightFootPosition.x - bufferHead.rightFootPosition.x - val leftZDif = leftFootPosition.z - bufferHead.leftFootPosition.z - val rightZDif = rightFootPosition.z - bufferHead.rightFootPosition.z - val leftX = leftKneePosition.x + leftXDif * KNEE_LATERAL_WEIGHT - val leftZ = leftKneePosition.z + leftZDif * KNEE_LATERAL_WEIGHT - val rightX = rightKneePosition.x + rightXDif * KNEE_LATERAL_WEIGHT - val rightZ = rightKneePosition.z + rightZDif * KNEE_LATERAL_WEIGHT - leftKneePosition = Vector3(leftX, leftKneePosition.y, leftZ) - rightKneePosition = Vector3(rightX, rightKneePosition.y, rightZ) - - // calculate the bone distances - val leftKneeHip = (bufferHead.leftKneePosition - leftHip).len() - val rightKneeHip = (bufferHead.rightKneePosition - rightHip).len() - val leftKneeHipNew = (leftKneePosition - leftHip).len() - val rightKneeHipNew = (rightKneePosition - rightHip).len() - val leftKneeOffset = leftKneeHipNew - leftKneeHip - val rightKneeOffset = rightKneeHipNew - rightKneeHip - - // get the vector from the hip to the knee - val leftKneeVector = (leftKneePosition - leftHip).unit() * - (leftKneeOffset * KNEE_CORRECTION_WEIGHT) - val rightKneeVector = (rightKneePosition - rightHip).unit() * - (rightKneeOffset * KNEE_CORRECTION_WEIGHT) - - // correct the knees - leftKneePosition -= leftKneeVector - rightKneePosition -= rightKneeVector - } - - private fun getFootOffset(footRotation: Quaternion): Float { - val offset: Float = computeUnitVector(footRotation).y - return FastMath.clamp(offset, 0f, DYNAMIC_DISPLACEMENT_CUTOFF) - } - - // calculate the weight of foot correction - private fun calculateCorrectionWeight( - foot: Vector3, - footCorrected: Vector3, - ): Float { - var footDif = foot - footCorrected - footDif = Vector3(footDif.x, 0f, footDif.z) - if (footDif.len() < MIN_ACCEPTABLE_ERROR) { - return CORRECTION_WEIGHT_MIN - } else if (footDif.len() > MAX_ACCEPTABLE_ERROR) { - return CORRECTION_WEIGHT_MAX - } - return ( - CORRECTION_WEIGHT_MIN + - (footDif.len() - MIN_ACCEPTABLE_ERROR) / - (MAX_ACCEPTABLE_ERROR - MIN_ACCEPTABLE_ERROR) * - (CORRECTION_WEIGHT_MAX - CORRECTION_WEIGHT_MIN) - ) - } - - // calculate the center of mass of the user for the current frame - // returns a vector representing the center of mass position - private fun computeCenterOfMass(): Vector3 { - // check if arm data is available - val armsAvailable = ( - skeleton.hasLeftArmTracker && - skeleton.hasRightArmTracker - ) - var centerOfMass = Vector3(0f, 0f, 0f) - - // compute the center of mass of smaller body parts and then sum them up - // with their respective weights - val head = skeleton.headBone.getPosition() - val thorax: Vector3 = - getCenterOfJoint(skeleton.chestBone) - val abdomen = skeleton.waistBone.getPosition() - val pelvis = skeleton.hipBone.getPosition() - val leftCalf: Vector3 = - getCenterOfJoint(skeleton.leftLowerLegBone) - val rightCalf: Vector3 = - getCenterOfJoint(skeleton.rightLowerLegBone) - val leftThigh: Vector3 = - getCenterOfJoint(skeleton.leftUpperLegBone) - val rightThigh: Vector3 = - getCenterOfJoint(skeleton.rightUpperLegBone) - centerOfMass += head * HEAD_MASS - centerOfMass += thorax * THORAX_MASS - centerOfMass += abdomen * ABDOMEN_MASS - centerOfMass += pelvis * PELVIS_MASS - centerOfMass += leftCalf * LEG_AND_FOOT_MASS - centerOfMass += rightCalf * LEG_AND_FOOT_MASS - centerOfMass += leftThigh * THIGH_MASS - centerOfMass += rightThigh * THIGH_MASS - - if (armsAvailable) { - val leftUpperArm: Vector3 = getCenterOfJoint( - skeleton.leftUpperArmBone, - ) - val rightUpperArm: Vector3 = getCenterOfJoint( - skeleton.rightUpperArmBone, - ) - val leftForearm: Vector3 = getCenterOfJoint( - skeleton.leftLowerArmBone, - ) - val rightForearm: Vector3 = getCenterOfJoint( - skeleton.rightLowerArmBone, - ) - centerOfMass += leftUpperArm * UPPER_ARM_MASS - centerOfMass += rightUpperArm * UPPER_ARM_MASS - centerOfMass += leftForearm * FOREARM_AND_HAND_MASS - centerOfMass += rightForearm * FOREARM_AND_HAND_MASS - } else { - // if the arms are not available put them slightly in front - // of the upper chest. - val chestUnitVector: Vector3 = computeUnitVector( - skeleton.upperChestBone.getGlobalRotation(), - ) - val armLocation = - abdomen + (chestUnitVector * DEFAULT_ARM_DISTANCE) - centerOfMass += armLocation * (UPPER_ARM_MASS * 2.0f) - centerOfMass += armLocation * (FOREARM_AND_HAND_MASS * 2.0f) - } - - // finally translate in to tracker space - centerOfMass = hipPosition + - (centerOfMass - skeleton.hipTrackerBone.getPosition()) - return centerOfMass - } - - // get the center of two joints - private fun getCenterOfJoint(bone: Bone): Vector3 = ( - bone.getPosition() + - bone.getTailPosition() - ) * - 0.5f - - // update counters for the lock state of the feet - private fun updateLockStateCounters() { - if (bufferHead.leftLegState == LegTweaksBuffer.LOCKED) { - leftFramesUnlocked = 0 - leftFramesLocked++ - } else { - leftFramesLocked = 0 - leftFramesUnlocked++ - } - if (bufferHead.rightLegState == LegTweaksBuffer.LOCKED) { - rightFramesUnlocked = 0 - rightFramesLocked++ - } else { - rightFramesLocked = 0 - rightFramesUnlocked++ - } - } - - // isolate the euler yaw component of a given quaternion - private fun isolateYaw(quaternion: Quaternion): Quaternion = Quaternion.rotationAroundYAxis( - quaternion.toEulerAngles(EulerOrder.YZX).y, - ) - - // return a quaternion that has been rotated by the new pitch amount - private fun replacePitch(quaternion: Quaternion, newPitch: Float): Quaternion { - val (_, _, y, z) = quaternion.toEulerAngles(EulerOrder.YZX) - val newAngs = EulerAngles( - EulerOrder.YZX, - newPitch, - y, - z, - ) - return newAngs.toQuaternion() - } - - // check if correction overshot the true value - // returns true if overshot - private fun checkOverShoot( - trueVal: Float, - valBefore: Float, - valAfter: Float, - ): Boolean = (trueVal - valBefore) * (trueVal - valAfter) < 0 - - // get the unit vector of the given rotation - private fun computeUnitVector(quaternion: Quaternion): Vector3 = quaternion.toMatrix().z.unit() -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/LegTweaksBuffer.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/LegTweaksBuffer.kt deleted file mode 100644 index 1bd5efa1f5..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/LegTweaksBuffer.kt +++ /dev/null @@ -1,728 +0,0 @@ -package dev.slimevr.tracking.processor.skeleton - -import com.jme3.math.FastMath -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import kotlin.math.* - -/** - * class that holds data related to the state and other various attributes of - * the legs such as the position of the foot, knee, and hip, after and before - * correction, the velocity of the foot and the computed state of the feet at - * that frame. mainly calculates the state of the legs per frame using these - * rules: The conditions for an unlock are as follows: 1. the foot is too far - * from its correct position 2. a velocity higher than a threshold is achieved - * 3. a large acceleration is applied to the foot 4. angular velocity of the - * foot goes higher than a threshold. The conditions for a lock are the opposite - * of the above but require a lower value for all of the above conditions. The - * aforementioned thresholds are computed by applying scalars to a base - * threshold value. This allows one set of initial values to be applicable to a - * large range of actions and body types. - */ - -class LegTweaksBuffer @Suppress("ktlint") constructor() { - // hyperparameters / constants - companion object { - const val STATE_UNKNOWN = 0 - const val LOCKED = 1 - const val UNLOCKED = 2 - const val FOOT_ACCEL = 3 - const val ANKLE_ACCEL = 4 - const val NS_CONVERT = 1.0e9f - const val BUFFER_LEN = 10 - val GRAVITY: Vector3 = Vector3(0f, -9.81f, 0f) - val GRAVITY_MAGNITUDE: Float = GRAVITY.len() - - private const val SKATING_DISTANCE_CUTOFF = 0.5f - private const val SKATING_ROTVELOCITY_THRESHOLD = 4.5f - private const val SKATING_LOCK_ENGAGE_PERCENT = 1.1f - private const val FLOOR_DISTANCE_CUTOFF = 0.065f - private const val SIX_TRACKER_TOLERANCE = -0.10f - private val FORCE_VECTOR_TO_PRESSURE: Vector3 = Vector3(0.25f, 1.0f, 0.25f) - private val FORCE_ERROR_TOLERANCE_SQR: Float = 4.0f.pow(2) - private val FORCE_VECTOR_FALLBACK = floatArrayOf(0.1f, 0.1f) - - var SKATING_VELOCITY_THRESHOLD = 2.4f - var SKATING_ACCELERATION_THRESHOLD = 0.8f - - private var PARAM_SCALAR_MAX = 3.2f - private var PARAM_SCALAR_MIN = 0.25f - private const val PARAM_SCALAR_MID = 1.0f - - // the point at which the scalar is at the max or min depending on accel - private const val MAX_SCALAR_ACCEL = 0.2f - private const val MIN_SCALAR_ACCEL = 0.9f - - // the point at which the scalar is at it max or min in a double locked foot - // situation - private const val MAX_SCALAR_DORMANT = 0.2f - private const val MIN_SCALAR_DORMANT = 1.50f - - // the point at which the scalar is at it max or min in a single locked foot - // situation - private const val MIN_SCALAR_ACTIVE = 1.75f - private const val MAX_SCALAR_ACTIVE = 0.1f - - // maximum scalars for the pressure on each foot - private const val PRESSURE_SCALAR_MIN = 0.1f - private const val PRESSURE_SCALAR_MAX = 1.9f - - private const val SKATING_CUTOFF_ENGAGE = ( - SKATING_DISTANCE_CUTOFF - * SKATING_LOCK_ENGAGE_PERCENT - ) - private var SKATING_VELOCITY_CUTOFF_ENGAGE = ( - SKATING_VELOCITY_THRESHOLD - * SKATING_LOCK_ENGAGE_PERCENT - ) - private var SKATING_ACCELERATION_CUTOFF_ENGAGE = ( - SKATING_ACCELERATION_THRESHOLD - * SKATING_LOCK_ENGAGE_PERCENT - ) - private const val SKATING_ROTATIONAL_VELOCITY_CUTOFF_ENGAGE = - ( - SKATING_ROTVELOCITY_THRESHOLD - * SKATING_LOCK_ENGAGE_PERCENT - ) - - fun setSkatingVelocityThreshold(value: Float) { - SKATING_VELOCITY_THRESHOLD = value - SKATING_VELOCITY_CUTOFF_ENGAGE = ( - SKATING_VELOCITY_THRESHOLD - * SKATING_LOCK_ENGAGE_PERCENT - ) - } - - fun getSkatingVelocityThreshold(): Float = SKATING_VELOCITY_THRESHOLD - - fun setSkatingAccelerationThreshold(value: Float) { - SKATING_ACCELERATION_THRESHOLD = value - SKATING_ACCELERATION_CUTOFF_ENGAGE = ( - SKATING_ACCELERATION_THRESHOLD - * SKATING_LOCK_ENGAGE_PERCENT - ) - } - - fun getSkatingAccelerationThreshold(): Float = SKATING_ACCELERATION_THRESHOLD - } - - // states for the legs - var leftLegState = STATE_UNKNOWN - var rightLegState = STATE_UNKNOWN - var leftLegNumericalState = 0f - private set - var rightLegNumericalState = 0f - private set - - // positions and rotations - var leftFootPosition: Vector3 = Vector3.NULL - private set - var rightFootPosition: Vector3 = Vector3.NULL - private set - var leftKneePosition: Vector3 = Vector3.NULL - private set - var rightKneePosition: Vector3 = Vector3.NULL - private set - var hipPosition: Vector3 = Vector3.NULL - private set - var leftFootRotation: Quaternion = Quaternion.IDENTITY - private set - var rightFootRotation: Quaternion = Quaternion.IDENTITY - private set - var leftFootPositionCorrected: Vector3 = Vector3.NULL - private set - var rightFootPositionCorrected: Vector3 = Vector3.NULL - private set - var leftKneePositionCorrected: Vector3 = Vector3.NULL - private set - var rightKneePositionCorrected: Vector3 = Vector3.NULL - private set - var hipPositionCorrected: Vector3 = Vector3.NULL - private set - var leftFootRotationCorrected: Quaternion = - Quaternion.IDENTITY - private set - var rightFootRotationCorrected: Quaternion = - Quaternion.IDENTITY - private set - - // velocities - var leftFootVelocity: Vector3 = Vector3.NULL - private set - var leftFootVelocityMagnitude = 0f - private set - var rightFootVelocity: Vector3 = Vector3.NULL - private set - var rightFootVelocityMagnitude = 0f - private set - private var leftFootAngleDiff = 0f - private var rightFootAngleDiff = 0f - - // acceleration - var leftFootAcceleration: Vector3 = Vector3.NULL - private set - var leftFootAccelerationMagnitude = 0f - private set - var rightFootAcceleration: Vector3 = Vector3.NULL - private set - var rightFootAccelerationMagnitude = 0f - private set - - // center of mass - var centerOfMass: Vector3 = Vector3.NULL - private set - var centerOfMassVelocity: Vector3 = Vector3.NULL - private set - var centerOfMassAcceleration: Vector3 = Vector3.NULL - private set - - // other data - val timeOfFrame: Long = System.nanoTime() - var parent: LegTweaksBuffer? = null - private set - - private var frameNumber = 0 // higher number is older frame - private var detectionMode = ANKLE_ACCEL - private var accelerationAboveThresholdLeft = true - private var accelerationAboveThresholdRight = true - private var leftFloorLevel = 0f - private var rightFloorLevel = 0f - var isStanding = false - private set - - private var leftFootSensitivityVel = 1.0f - private var rightFootSensitivityVel = 1.0f - private var leftFootSensitivityAccel = 1.0f - private var rightFootSensitivityAccel = 1.0f - - constructor( - leftFootPosition: Vector3, - rightFootPosition: Vector3, - leftKneePosition: Vector3, - rightKneePosition: Vector3, - leftFootRotation: Quaternion, - rightFootRotation: Quaternion, - leftFloorLevel: Float, - rightFloorLevel: Float, - leftFootAcceleration: Vector3, - rightFootAcceleration: Vector3, - accelerationMode: Int, - hipPosition: Vector3, - centerOfMass: Vector3, - parent: LegTweaksBuffer, - active: Boolean, - ) : this() { - this.leftFootPosition = leftFootPosition - this.rightFootPosition = rightFootPosition - this.leftKneePosition = leftKneePosition - this.rightKneePosition = rightKneePosition - this.leftFootRotation = leftFootRotation - this.rightFootRotation = rightFootRotation - this.leftFloorLevel = leftFloorLevel - this.rightFloorLevel = rightFloorLevel - this.leftFootAcceleration = leftFootAcceleration - this.rightFootAcceleration = rightFootAcceleration - this.detectionMode = accelerationMode - this.hipPosition = hipPosition - this.centerOfMass = centerOfMass - this.parent = parent - - this.calculateFootAttributes(active) - } - - fun setPositions( - leftFootPosition: Vector3, - rightFootPosition: Vector3, - leftKneePosition: Vector3, - rightKneePosition: Vector3, - hipPosition: Vector3, - ) { - this.leftFootPosition = leftFootPosition - this.rightFootPosition = rightFootPosition - this.leftKneePosition = leftKneePosition - this.rightKneePosition = rightKneePosition - this.hipPosition = hipPosition - } - - fun setCorrectedPositions( - leftFootPosition: Vector3, - rightFootPosition: Vector3, - leftKneePosition: Vector3, - rightKneePosition: Vector3, - hipPosition: Vector3, - ) { - this.leftFootPositionCorrected = leftFootPosition - this.rightFootPositionCorrected = rightFootPosition - this.leftKneePositionCorrected = leftKneePosition - this.rightKneePositionCorrected = rightKneePosition - this.hipPositionCorrected = hipPosition - } - - fun setCorrectedRotations(leftFootRotation: Quaternion, rightFootRotation: Quaternion) { - this.leftFootRotationCorrected = leftFootRotation - this.rightFootRotationCorrected = rightFootRotation - } - - // returns 1 / delta time - fun getTimeDelta(): Float = if (parent == null) 0.0f else 1.0f / ((timeOfFrame - parent!!.timeOfFrame) / NS_CONVERT) - - // calculate movement attributes - private fun calculateFootAttributes(active: Boolean) { - updateFrameNumber(0) - - // compute attributes of the legs - computeVelocity() - computeAccelerationMagnitude() - computeComAttributes() - - // check if the acceleration triggers forced unlock - if (detectionMode == FOOT_ACCEL) { - computeAccelerationAboveThresholdFootTrackers() - } else { - computeAccelerationAboveThresholdAnkleTrackers() - } - - // calculate the scalar for other parameters - computeScalar() - - // if correction is inactive state is unknown (default to unlocked) - if (!active || parent == null) { - leftLegState = UNLOCKED - rightLegState = UNLOCKED - } else { - computeState() - } - } - - // update the frame number and discard frames older than BUFFER_LEN - private fun updateFrameNumber(frameNumber: Int) { - this.frameNumber = frameNumber - if (this.frameNumber >= BUFFER_LEN) parent = null - parent?.updateFrameNumber(frameNumber + 1) - } - - // compute the state of the legs - private fun computeState() { - // get the difference of the corrected and current positions - val leftDiff = getFootHorizontalDifference(parent!!.leftFootPositionCorrected, leftFootPosition) - val rightDiff = getFootHorizontalDifference(parent!!.rightFootPositionCorrected, rightFootPosition) - - // based on the last state of the legs compute their state for this - // individual frame - leftLegState = checkState( - parent!!.leftLegState, - leftDiff, - leftFootVelocityMagnitude, - leftFootSensitivityVel, - leftFootAngleDiff, - leftFloorLevel, - accelerationAboveThresholdLeft, - leftFootPosition, - ) - rightLegState = checkState( - parent!!.rightLegState, - rightDiff, - rightFootVelocityMagnitude, - rightFootSensitivityVel, - rightFootAngleDiff, - rightFloorLevel, - accelerationAboveThresholdRight, - rightFootPosition, - ) - - computeNumericalState() - } - - // check if a locked foot should stay locked or be released and vice versa - private fun checkState( - legState: Int, - horizontalDifference: Float, - velocityMagnitude: Float, - velocitySensitivity: Float, - angleDiff: Float, - floorLevel: Float, - accelerationAboveThreshold: Boolean, - footPosition: Vector3, - ): Int { - val timeStep = getTimeDelta() - if (legState == UNLOCKED) { - return if (horizontalDifference > SKATING_CUTOFF_ENGAGE || - (velocityMagnitude * timeStep > SKATING_VELOCITY_CUTOFF_ENGAGE * velocitySensitivity) || - (angleDiff * timeStep > SKATING_ROTATIONAL_VELOCITY_CUTOFF_ENGAGE * velocitySensitivity) || - footPosition.y > floorLevel + FLOOR_DISTANCE_CUTOFF || - accelerationAboveThreshold - ) { - UNLOCKED - } else { - LOCKED - } - } - return if (horizontalDifference > SKATING_DISTANCE_CUTOFF || - (velocityMagnitude * timeStep > SKATING_VELOCITY_THRESHOLD * velocitySensitivity) || - (angleDiff * timeStep > SKATING_ROTVELOCITY_THRESHOLD * velocitySensitivity) || - footPosition.y > floorLevel + FLOOR_DISTANCE_CUTOFF || - accelerationAboveThreshold - ) { - UNLOCKED - } else { - LOCKED - } - } - - // compute a numerical value representing how locked a foot is (bigger - // number == less locked) - private fun computeNumericalState() { - leftLegNumericalState = computeNumericalState( - leftFootVelocityMagnitude, - leftFootAccelerationMagnitude, - leftFootSensitivityAccel, - leftFootSensitivityVel, - ) - rightLegNumericalState = computeNumericalState( - rightFootVelocityMagnitude, - rightFootAccelerationMagnitude, - rightFootSensitivityAccel, - rightFootSensitivityVel, - ) - } - - // returns the average percentage the real velocity and acceleration are of - // the scaled thresholds for velocity and acceleration - private fun computeNumericalState( - velocityMagnitude: Float, - accelerationMagnitude: Float, - accelSensitivity: Float, - velSensitivity: Float, - ): Float { - val timeStep = getTimeDelta() - val velocity = velocityMagnitude * timeStep - val velocityPercentage: Float = - velocity / (SKATING_VELOCITY_THRESHOLD * velSensitivity) - val accelerationPercentage: Float = ( - accelerationMagnitude / - (SKATING_ACCELERATION_THRESHOLD * accelSensitivity) - ) - return (velocityPercentage + accelerationPercentage) * 0.5f - } - - // get the difference in feet position between the kinematic and corrected - // positions of the feet disregarding vertical displacement - private fun getFootHorizontalDifference(correctedPosition: Vector3, position: Vector3): Float { - val diff: Vector3 = correctedPosition - position - return Vector3( - diff.x, - 0f, - diff.z, - ).len() - } - - // get the angular velocity of the left foot (kinda we just want a scalar) - private fun getFootAngularVelocity(oldRot: Quaternion, rot: Quaternion): Float = (rot.toMatrix().z.unit() - oldRot.toMatrix().z.unit()).len() - - // compute the velocity of the feet from the position in the last frames - private fun computeVelocity() { - if (parent == null) return - leftFootVelocity = leftFootPosition - parent!!.leftFootPosition - leftFootVelocityMagnitude = leftFootVelocity.len() - rightFootVelocity = rightFootPosition - parent!!.rightFootPosition - rightFootVelocityMagnitude = rightFootVelocity.len() - leftFootAngleDiff = getFootAngularVelocity(parent!!.leftFootRotation, leftFootRotation) - rightFootAngleDiff = getFootAngularVelocity(parent!!.rightFootRotation, rightFootRotation) - } - - // get the nth parent of this frame - private fun getNParent(n: Int): LegTweaksBuffer? = if (n == 0 || parent == null) this else parent!!.getNParent(n - 1) - - // compute the acceleration magnitude of the feet from the acceleration - // given by the imus - private fun computeAccelerationMagnitude() { - leftFootAccelerationMagnitude = leftFootAcceleration.len() - rightFootAccelerationMagnitude = rightFootAcceleration.len() - } - - // compute the velocity and acceleration of the center of mass - private fun computeComAttributes() { - centerOfMassVelocity = centerOfMass - parent!!.centerOfMass - centerOfMassAcceleration = centerOfMassVelocity - parent!!.centerOfMassVelocity - } - - // for a setup with foot trackers the data from the imus is enough to determine lock/unlock - private fun computeAccelerationAboveThresholdFootTrackers() { - accelerationAboveThresholdLeft = ( - leftFootAccelerationMagnitude - > SKATING_ACCELERATION_CUTOFF_ENGAGE * leftFootSensitivityAccel - ) - accelerationAboveThresholdRight = ( - rightFootAccelerationMagnitude - > SKATING_ACCELERATION_CUTOFF_ENGAGE * rightFootSensitivityAccel - ) - } - - // for any setup without foot trackers the data from the imus is enough to - // determine lock/unlock but we add some tolerance - private fun computeAccelerationAboveThresholdAnkleTrackers() { - accelerationAboveThresholdLeft = ( - leftFootAccelerationMagnitude - > (SKATING_ACCELERATION_THRESHOLD + SIX_TRACKER_TOLERANCE) * leftFootSensitivityAccel - ) - accelerationAboveThresholdRight = ( - rightFootAccelerationMagnitude - > (SKATING_ACCELERATION_THRESHOLD + SIX_TRACKER_TOLERANCE) * rightFootSensitivityAccel - ) - } - - private fun computeScalar() { - // get the first set of scalars that are based on acceleration from the - // imus - val leftFootScalarAccel: Float = getFootScalarAccel(leftFootAccelerationMagnitude) - val rightFootScalarAccel: Float = getFootScalarAccel(rightFootAccelerationMagnitude) - - // get the second set of scalars that is based off of how close each - // foot is to a lock and dynamically adjusting the scalars - // (based off the assumption that if you are standing one foot is likely - // planted on the ground unless you are moving fast) - val leftFootScalarVel: Float = getFootLockLikelihood( - leftFootVelocity, - rightFootVelocity, - leftFootVelocityMagnitude, - rightFootVelocityMagnitude, - ) - val rightFootScalarVel: Float = getFootLockLikelihood( - rightFootVelocity, - leftFootVelocity, - rightFootVelocityMagnitude, - leftFootVelocityMagnitude, - ) - - // get the third set of scalars that is based on where the COM is - val pressureScalars: FloatArray = getPressurePrediction() - - // combine the scalars to get the final scalars - leftFootSensitivityVel = ( - ( - leftFootScalarAccel + - leftFootScalarVel / - 2.0f - ) * - FastMath.clamp( - pressureScalars[0] * 2.0f, - PRESSURE_SCALAR_MIN, - PRESSURE_SCALAR_MAX, - ) - ) - rightFootSensitivityVel = ( - ( - rightFootScalarAccel + - rightFootScalarVel / - 2.0f - ) * - FastMath.clamp( - pressureScalars[1] * 2.0f, - PRESSURE_SCALAR_MIN, - PRESSURE_SCALAR_MAX, - ) - ) - - leftFootSensitivityAccel = leftFootScalarVel - rightFootSensitivityAccel = rightFootScalarVel - } - - // calculate a scalar using acceleration to apply to the non acceleration - // based hyperparameters when calculating - // lock states - private fun getFootScalarAccel(accelMagnitude: Float): Float { - if (leftLegState == LOCKED) { - if (accelMagnitude < MAX_SCALAR_ACCEL) { - return PARAM_SCALAR_MAX - } else if (accelMagnitude > MIN_SCALAR_ACCEL) { - return ( - PARAM_SCALAR_MAX - * - (accelMagnitude - MIN_SCALAR_ACCEL) / - (MAX_SCALAR_ACCEL - MIN_SCALAR_ACCEL) - ) - } - } - return PARAM_SCALAR_MID - } - - // calculate a scalar using the velocity of the foot trackers and the lock - // states to calculate a scalar to apply to the non acceleration based - // hyperparameters when calculating - // lock states - private fun getFootLockLikelihood( - primaryFootVel: Vector3, - otherFootVel: Vector3, - primaryFootVelMagnitude: Float, - otherFootVelMagnitude: Float, - ): Float { - if (leftLegState == LOCKED && rightLegState == LOCKED) { - var velocityDiff: Vector3 = primaryFootVel - otherFootVel - velocityDiff = Vector3(velocityDiff.x, 0f, velocityDiff.z) - val velocityDiffMagnitude: Float = velocityDiff.len() - if (velocityDiffMagnitude < MAX_SCALAR_DORMANT) { - return PARAM_SCALAR_MAX - } else if (velocityDiffMagnitude > MIN_SCALAR_DORMANT) { - return ( - PARAM_SCALAR_MAX - * - (velocityDiffMagnitude - MIN_SCALAR_DORMANT) / - (MAX_SCALAR_DORMANT - MIN_SCALAR_DORMANT) - ) - } - } - - // calculate the 'unlockedness factor' and use that to - // determine the scalar (go as low as 0.5 and as high as - // param_scalar_max) - val velocityDiffAbs: Float = abs(primaryFootVelMagnitude - otherFootVelMagnitude) - if (velocityDiffAbs > MIN_SCALAR_ACTIVE) { - return PARAM_SCALAR_MIN - } else if (velocityDiffAbs < MAX_SCALAR_ACTIVE) { - return PARAM_SCALAR_MAX - } - return ( - PARAM_SCALAR_MAX - * - (velocityDiffAbs - MIN_SCALAR_ACTIVE) / - (MAX_SCALAR_ACTIVE - MIN_SCALAR_ACTIVE) - - PARAM_SCALAR_MID - ) - } - - // get the pressure prediction for the feet based of the center of mass - // (assume mass is 1) - // for understanding in the future this is assuming that the mass is one and - // the force of gravity - // is 9.8 m/s^2 this allows for the force sum to map directly to the - // acceleration of the center of mass - // since F = ma and if m is 1 then F = a - private fun getPressurePrediction(): FloatArray { - // get the vectors from the com to each foot - val leftFootVector: Vector3 = (leftFootPosition - centerOfMass).unit() - val rightFootVector: Vector3 = (rightFootPosition - centerOfMass).unit() - - // get the magnitude of the force on each foot - val leftFootMagnitude: Float = - GRAVITY_MAGNITUDE * leftFootVector.y / leftFootVector.len() - val rightFootMagnitude: Float = ( - GRAVITY_MAGNITUDE - * - rightFootVector.y / - rightFootVector.len() - ) - - // get the force vector each foot could apply to the com - val leftFootForce: Vector3 = leftFootVector * (leftFootMagnitude / 2.0f) - val rightFootForce: Vector3 = rightFootVector * (rightFootMagnitude / 2.0f) - - // based off the acceleration of the com, get the force each foot is - // likely applying (the expected force sum should be equal to - // centerOfMassAcceleration since the mass is 1) - val (modifiedLeftFootForce, modifiedRightFootForce) = - findForceVectors(leftFootForce, rightFootForce) - - // see if the force vectors found a reasonable solution - // if they did not we assume there is another force acting on the com - // and fall back to a low pressure prediction - if (detectOutsideForces(modifiedLeftFootForce, modifiedRightFootForce)) { - isStanding = false - return FORCE_VECTOR_FALLBACK - } - isStanding = true - - // set the pressure to the force on each foot times the force to - // pressure scalar - var leftFootPressure = modifiedLeftFootForce.hadamard(FORCE_VECTOR_TO_PRESSURE).len() - var rightFootPressure = modifiedRightFootForce.hadamard(FORCE_VECTOR_TO_PRESSURE).len() - - // distance from the ground is a factor in the pressure - // using the inverse of the distance to the ground scale the - // pressure - val leftDistance: Float = - if (leftFootPosition.y > (leftFloorLevel + FLOOR_DISTANCE_CUTOFF)) { - leftFootPosition.y - (leftFloorLevel + FLOOR_DISTANCE_CUTOFF) - } else { - LegTweaks.NEARLY_ZERO - } - leftFootPressure *= 1.0f / leftDistance - val rightDistance: Float = - if (rightFootPosition.y > (rightFloorLevel + FLOOR_DISTANCE_CUTOFF)) { - rightFootPosition.y - (rightFloorLevel + FLOOR_DISTANCE_CUTOFF) - } else { - LegTweaks.NEARLY_ZERO - } - rightFootPressure *= 1.0f / rightDistance - - // normalize the pressure values - val pressureSum = leftFootPressure + rightFootPressure - leftFootPressure /= pressureSum - rightFootPressure /= pressureSum - return floatArrayOf(leftFootPressure, rightFootPressure) - } - - // perform a gradient descent to find the force vectors that best match the - // acceleration of the com - private fun findForceVectors(leftFootForceInit: Vector3, rightFootForceInit: Vector3): List { - var leftFootForce: Vector3 = leftFootForceInit - var rightFootForce: Vector3 = rightFootForceInit - val iterations = 100 - val stepSize = 0.01f - // set up the temporary variables - var tempLeftFootForce1: Vector3 - var tempLeftFootForce2: Vector3 - var tempRightFootForce1: Vector3 - var tempRightFootForce2: Vector3 - var error: Vector3 - var error1: Vector3 - var error2: Vector3 - var error3: Vector3 - var error4: Vector3 - for (i in 0 until iterations) { - tempLeftFootForce1 = leftFootForce - tempLeftFootForce2 = leftFootForce - tempRightFootForce1 = rightFootForce - tempRightFootForce2 = rightFootForce - - // get the error at the current position - error = centerOfMassAcceleration - (leftFootForce + rightFootForce + GRAVITY) - - // add and subtract the error to the force vectors - tempLeftFootForce1 *= (1.0f + stepSize) - tempLeftFootForce2 *= (1.0f - stepSize) - tempRightFootForce1 *= (1.0f + stepSize) - tempRightFootForce2 *= (1.0f - stepSize) - - // get the error at the new position - error1 = getForceVectorError(tempLeftFootForce1, rightFootForce) - error2 = getForceVectorError(tempLeftFootForce2, rightFootForce) - error3 = getForceVectorError(tempRightFootForce1, leftFootForce) - error4 = getForceVectorError(tempRightFootForce2, leftFootForce) - - // set the new force vectors - if (error1.len() < error.len()) { - leftFootForce = tempLeftFootForce1 - } else if (error2.len() < error.len()) { - leftFootForce = tempLeftFootForce2 - } - if (error3.len() < error.len()) { - rightFootForce = tempRightFootForce1 - } else if (error4.len() < error.len()) { - rightFootForce = tempRightFootForce2 - } - } - - return listOf(leftFootForce, rightFootForce) - } - - // detect any outside forces on the body such - // as a wall or a chair. returns true if there is an outside force - private fun detectOutsideForces(f1: Vector3, f2: Vector3): Boolean { - val force: Vector3 = GRAVITY + f1 + f2 - val error: Vector3 = centerOfMassAcceleration - force - return error.lenSq() > FORCE_ERROR_TOLERANCE_SQR - } - - // simple error function for the force vector gradient descent - private fun getForceVectorError(testForce: Vector3, otherForce: Vector3): Vector3 = centerOfMassAcceleration - (testForce + otherForce + GRAVITY) -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/Localizer.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/Localizer.kt deleted file mode 100644 index 225b402f2d..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/Localizer.kt +++ /dev/null @@ -1,417 +0,0 @@ -package dev.slimevr.tracking.processor.skeleton - -import com.jme3.math.FastMath -import dev.slimevr.tracking.trackers.Tracker -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 - -/* Handles localizing the skeleton in 3d space when no 6dof device is present. - * This is accomplished by using the foot state calculated by legtweaks. This of course - * has the problem of the true position and predicted location drifting apart over time. - * Jumping is quite unreliable - */ - -enum class MovementStates { - LEFT_LOCKED, - RIGHT_LOCKED, - NONE_LOCKED, - FOLLOW_FOOT, - FOLLOW_COM, - FOLLOW_SITTING, -} - -class Localizer(humanSkeleton: HumanSkeleton) { - // hyper parameters - companion object { - private const val WARMUP_FRAMES = 100 // ~0.1 seconds - private const val MAX_FOOT_PERCENTAGE = 50.0f - private const val MAX_ACCEL_UP = 2.0f - private const val SITTING_KNEE_THRESHOLD = 1.1f - private const val SITTING_EARLY = 1000f - private const val VELOCITY_SAMPLE_RATE: Long = 100000000 // 10ms - private const val CONSTANT_ACCELERATION: Float = 2.0f - } - - private val skeleton: HumanSkeleton = humanSkeleton - private val legTweaks: LegTweaks = skeleton.legTweaks - private var bufCur: LegTweaksBuffer = legTweaks.bufferHead - private var bufPrev: LegTweaksBuffer = LegTweaksBuffer() - - // state variables - private var enabled: Boolean = false - private var targetFoot: Vector3 = Vector3.NULL - private var currentCOM: Vector3 = Vector3.NULL - private var targetCOM: Vector3 = Vector3.NULL - private var targetHip: Vector3 = Vector3.NULL - private var comVelocity: Vector3 = Vector3.NULL - private var comAccel: Vector3 = Vector3.NULL - private var plantedFoot = MovementStates.LEFT_LOCKED - private var worldReference = MovementStates.FOLLOW_FOOT - private var uncorrectedFloor = 0.0f - LegTweaks.FLOOR_CALIBRATION_OFFSET - private var floor = 0.0f - private var warmupFrames = 0 - private var comFrames = 0 - private var footFrames = 0 - private var sittingFrames = 0 - - // travel from different sources - private var footTravel: Vector3 = Vector3.NULL - private var comTravel: Vector3 = Vector3.NULL - private var sittingTravel: Vector3 = Vector3.NULL - - fun getEnabled(): Boolean = enabled - - fun setEnabled(enabled: Boolean) { - this.enabled = enabled - legTweaks.setLocalizerMode(enabled) - } - - fun update() { - if (!enabled) { - return - } - - // if there is a 6dof device just use it - if (skeleton.headTracker != null && skeleton.headTracker!!.hasPosition) { - return - } - - // set the acceleration of the com for this frame - comAccel = getTorsoAccel() - - if (warmupFrames < WARMUP_FRAMES) { - comVelocity = Vector3.NULL - targetFoot = Vector3.NULL - } - warmupFrames++ - - // set the buffers for easy access - bufCur = legTweaks.bufferHead - if (bufCur.parent == null) { - return - } - bufPrev = bufCur.parent!! - - var finalTravel: Vector3 - - // get the movement of the skeleton by foot travel - footTravel = getPlantedFootTravel() - - // get the movement of the skeleton by the previous COM velocity - comTravel = getCOMTravel() - - sittingTravel = computeSittingTravel() - - // get the metric that this frame should rely on - worldReference = getWorldReference() - - // update the final travel vector - if (worldReference == MovementStates.FOLLOW_FOOT || warmupFrames < WARMUP_FRAMES) { - finalTravel = footTravel - } else if (worldReference == MovementStates.FOLLOW_COM) { - finalTravel = comTravel - } else if (worldReference == MovementStates.FOLLOW_SITTING) { - finalTravel = sittingTravel - if (sittingFrames < SITTING_EARLY) { - finalTravel = footTravel - } - } else { - finalTravel = Vector3.NULL - } - - // update the y value - if (worldReference != MovementStates.FOLLOW_SITTING || sittingFrames < SITTING_EARLY) { - finalTravel = Vector3( - finalTravel.x, - comTravel.y, - finalTravel.z, - ) - } - - updateSkeletonPos(finalTravel) - } - - // resets to the starting position - fun reset() { - if (!enabled) return - - skeleton.headBone.setPosition(Vector3.NULL) - comVelocity = Vector3.NULL - - // when localizing without a 6 dof device we choose the floor level - // 0 happens to be an easy number to use - legTweaks.setLocalizerMode(enabled) - floor = 0.0f - uncorrectedFloor = 0.0f - LegTweaks.FLOOR_CALIBRATION_OFFSET - warmupFrames = 0 - } - - private fun getPlantedFoot(): MovementStates { - // if locked in legtweaks it's the locked foot - if (bufCur.leftLegState == LegTweaksBuffer.LOCKED) return MovementStates.LEFT_LOCKED - if (bufCur.rightLegState == LegTweaksBuffer.LOCKED) return MovementStates.RIGHT_LOCKED - - // if the state is not locked, use the numerical state to determine a - // foot to follow - val leftNumericalState = bufCur.leftLegNumericalState - val rightNumericalState = bufCur.rightLegNumericalState - - return if (leftNumericalState < rightNumericalState && - leftNumericalState < MAX_FOOT_PERCENTAGE && - bufCur.leftFootAcceleration.y < MAX_ACCEL_UP - ) { - return MovementStates.LEFT_LOCKED - } else if (rightNumericalState < leftNumericalState && - rightNumericalState < MAX_FOOT_PERCENTAGE && - bufCur.rightFootAcceleration.y < MAX_ACCEL_UP - ) { - MovementStates.RIGHT_LOCKED - } else { - MovementStates.NONE_LOCKED - } - } - - // check if the foot that is planted is actually planted - private fun getWorldReference(): MovementStates { - // check for sitting position - if (isUserSitting()) { - return MovementStates.FOLLOW_SITTING - } - - // if the foot is not on the ground, use the COM - return if (!isFootOnGround()) { - MovementStates.FOLLOW_COM - } else { - MovementStates.FOLLOW_FOOT - } - } - - // get the foot or feet that are planted - // also sets the planted foot, foot init, and target pos variables - private fun getPlantedFootTravel(): Vector3 { - // get the foot that is planted - val foot: MovementStates = getPlantedFoot() - - if (foot == MovementStates.LEFT_LOCKED) { - val footLoc: Vector3 = bufCur.leftFootPosition - updateTargetPos(footLoc, foot) - return getFootTravel(footLoc) - } else if (foot == MovementStates.RIGHT_LOCKED) { - val footLoc: Vector3 = bufCur.rightFootPosition - updateTargetPos(footLoc, foot) - return getFootTravel(footLoc) - } - return Vector3.NULL - } - - // get the travel of a foot over a frame - private fun getFootTravel(loc: Vector3): Vector3 = loc - targetFoot - - // update the target position of the foot - private fun updateTargetPos(loc: Vector3, foot: MovementStates) { - if (foot == plantedFoot) { - if (worldReference == MovementStates.FOLLOW_COM) { - targetFoot = loc - } - } else { - targetFoot = loc - plantedFoot = foot - } - } - - // get the sitting travel (emulates hip lock) - private fun computeSittingTravel(): Vector3 { - val hip = skeleton.computedHipTracker?.position ?: Vector3.NULL - - // get the distance to move the waist to the target waist - val dist: Vector3 = hip - targetHip - - val lowTracker = getLowestTracker() - - if (lowTracker != null) { - if (lowTracker.position.y < uncorrectedFloor) { - targetHip = Vector3(targetHip.x, targetHip.y + (uncorrectedFloor - lowTracker.position.y), targetHip.z) - } - } - - // if the world reference is not sitting update the target waist - if (worldReference != MovementStates.FOLLOW_SITTING || sittingFrames < SITTING_EARLY) { - targetHip = hip - } - return dist - } - - // returns the travel of the COM from its last position - private fun getCOMTravel(): Vector3 { - // update COM attributes - updateCOMAttributes() - return bufCur.centerOfMass - targetCOM - } - - // get the movement of the COM based on the last velocity - private fun updateCOMAttributes() { - getCOMVelocity() - updateTargetCOM() - - // update how long the COM has been the reference and how long the foot - // has been - comFrames = if (worldReference == MovementStates.FOLLOW_COM) comFrames + 1 else 0 - footFrames = if (worldReference == MovementStates.FOLLOW_FOOT) footFrames + 1 else 0 - sittingFrames = if (worldReference == MovementStates.FOLLOW_SITTING) sittingFrames + 1 else 0 - } - - // gets the position the COM should be at based on the velocity of the com and - // the location of the floor - private fun updateTargetCOM() { - // if not in COM tracking mode, just use the current COM - if (worldReference == MovementStates.FOLLOW_FOOT || worldReference == MovementStates.FOLLOW_SITTING) { - targetCOM = bufCur.centerOfMass - } else { - currentCOM = targetCOM - } - - targetCOM += (comVelocity / bufCur.getTimeDelta()) - - val lowTracker = getLowestTracker() - - // update the target COM and velocity to reflect this new distance - if (lowTracker != null) { - if (lowTracker.position.y < uncorrectedFloor) { - targetCOM = Vector3(targetCOM.x, targetCOM.y + (uncorrectedFloor - lowTracker.position.y), targetCOM.z) - comVelocity = Vector3(comVelocity.x, 0.0f, comVelocity.z) - } - } - } - - // get the velocity of the COM - private fun getCOMVelocity(): Vector3 { - val comY = comVelocity.y - - var buf = bufCur - val timeStart: Long = buf.timeOfFrame - var timeEnd = timeStart - VELOCITY_SAMPLE_RATE - val comPosStart: Vector3 = buf.centerOfMass - - // get the buffer that occurred VELOCITY_SAMPLE_RATE ago in time - while (buf.timeOfFrame > timeEnd && buf.parent != null) { - buf = buf.parent!! - } - - val comPosEnd: Vector3 = buf.centerOfMass - timeEnd = buf.timeOfFrame - - // calculate the velocity - comVelocity = (comPosEnd - comPosStart) / ((timeEnd - timeStart) / LegTweaksBuffer.NS_CONVERT) - - // if the feet have been the reference for a short amount of time nullify any upwards acceleration to prevent flying away - if (footFrames < WARMUP_FRAMES) { - comAccel = Vector3( - comAccel.x, - FastMath.clamp(comAccel.y, -9999.0f, 0.0f), - comAccel.z, - ) - } - - // constantly pull the skeleton down a little to account for acceleration - // inaccuracy - val gravity = comAccel.y - CONSTANT_ACCELERATION - - // add the acceleration of gravity - comVelocity = Vector3( - comVelocity.x, - comY + (gravity / bufCur.getTimeDelta()), - comVelocity.z, - ) - - return comVelocity - } - - // returns true if either foot is below 0.0 - private fun isFootOnGround(): Boolean = ( - bufCur.leftFootPosition.y <= floor || - bufCur.rightFootPosition.y <= floor - ) - - // returns the tracker closest to or the furthest in the ground - private fun getLowestTracker(): Tracker? { - val trackerList = arrayOf( - skeleton.computedHeadTracker, - skeleton.computedChestTracker, - skeleton.computedHipTracker, - skeleton.computedLeftElbowTracker, - skeleton.computedRightElbowTracker, - skeleton.computedLeftHandTracker, - skeleton.computedRightHandTracker, - skeleton.computedLeftKneeTracker, - skeleton.computedRightKneeTracker, - skeleton.computedLeftFootTracker, - skeleton.computedRightFootTracker, - ) - - var minVal = trackerList[0]?.position?.y - var retVal: Tracker? = trackerList[0] - for (tracker in trackerList) { - if (tracker == null) { - continue - } - - if (tracker.position.y < minVal!!) { - minVal = tracker.position.y - retVal = tracker - } - } - - return retVal - } - - // returns true if the user is likely sitting - // (assumes the floor is flat at 0.0) - private fun isUserSitting(): Boolean { - // based on the waist to knee vector decide if the user is sitting or - // standing (ie, if the user is sitting the vector will be pointing off - // to the side for both feet) - var leftKnee: Vector3 = bufCur.leftKneePosition - var rightKnee: Vector3 = bufCur.rightKneePosition - val hip: Vector3 = skeleton.computedHipTracker?.position ?: Vector3.NULL - leftKnee = hip - leftKnee - rightKnee = hip - rightKnee - - // if the y component of the vectors is small then the user is probably - // sitting - var left = false - var right = false - if (leftKnee.y * SITTING_KNEE_THRESHOLD < leftKnee.x + leftKnee.z) { - left = true - } - if (rightKnee.y * SITTING_KNEE_THRESHOLD < rightKnee.x + rightKnee.z) { - right = true - } - return !bufCur.isStanding || (left && right) - } - - // get the combined accel of the Torso trackers - private fun getTorsoAccel(): Vector3 { - var num = 0.0f - var accel = Vector3.NULL - if (skeleton.waistTracker != null) { - accel += skeleton.waistTracker!!.getAcceleration() - num++ - } else if (skeleton.hipTracker != null) { - accel += skeleton.hipTracker!!.getAcceleration() - num++ - } else if (skeleton.chestTracker != null) { - accel += skeleton.chestTracker!!.getAcceleration() - num++ - } - return if (num == 0f) accel else accel / num - } - - // update the head position and rotation - private fun updateSkeletonPos(travel: Vector3) { - val rot = skeleton.headTracker?.getRotation() ?: Quaternion.IDENTITY - val temp = skeleton.headBone.getPosition() - travel - - skeleton.headBone.setPosition(temp) - skeleton.headBone.setRotation(rot) - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/TapDetection.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/TapDetection.kt deleted file mode 100644 index 073709eabd..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/TapDetection.kt +++ /dev/null @@ -1,163 +0,0 @@ -package dev.slimevr.tracking.processor.skeleton - -import dev.slimevr.tracking.trackers.Tracker -import java.util.* - -// class that monitors the acceleration of the waist, hip, chest or upper chest trackers to detect taps -// and use this to trigger a variety of resets (if your wondering why no single tap class exists, it's because -// to many false positives) -class TapDetection(val skeleton: HumanSkeleton, val trackerToWatch: Tracker, val numberTrackersOverThreshold: Int, val tapToComplete: Int, val onTapCompleted: () -> Unit) { - - private val accelList = LinkedList() - private val tapTimestamps = LinkedList() - private var timeWindowNS = 0.3f * tapToComplete * NS_CONVERTER - private var waitForLowAccel = false - - // reset the lists for detecting taps - fun reset() { - tapTimestamps.clear() - accelList.clear() - waitForLowAccel = false - } - - // main function for tap detection - fun update() { - // get the acceleration of the tracker and add it to the list - val time = System.nanoTime().toFloat() - accelList.add(floatArrayOf(trackerToWatch.getAcceleration().len(), time)) - - // remove old values from the list (if they are too old) - while (time - accelList.first()[1] > CLUMP_TIME_NS) { - accelList.removeFirst() - } - - // check for a tap - val accelDelta = accelDelta - if (accelDelta > NEEDED_ACCEL_DELTA && !waitForLowAccel) { - // after a tap is added to the list, a lower acceleration - // is needed before another tap can be added - tapTimestamps.add(time) - waitForLowAccel = true - } - - // if waiting for low accel - if (maxAccel < ALLOWED_BODY_ACCEL) { - waitForLowAccel = false - } - - // remove old taps from the list (if they are too old) - if (!tapTimestamps.isEmpty()) { - while (time - tapTimestamps.first() > timeWindowNS) { - tapTimestamps.removeFirst() - if (tapTimestamps.isEmpty()) return - } - } - - // if we have no taps within the timeframe or - // if the user is moving their body too much, reset the tap detector - if (!isUserStatic(trackerToWatch)) { - reset() - return - } - - if (tapTimestamps.size >= tapToComplete) { - onTapCompleted() - reset() - } - } - - private val accelDelta: Float - get() { - var max = -999.9f - var min = 999.9f - for (`val` in accelList) { - if (`val`[0] > max) max = `val`[0] - if (`val`[0] < min) min = `val`[0] - } - return max - min - } - - private val maxAccel: Float - get() { - var max = 0.0f - for (`val` in accelList) { - if (`val`[0] > max) { - max = `val`[0] - } - } - return max - } - - // returns true if the user is not imparting more than allowedBodyAccel of - // force on any of the torso or upper leg trackers (this sadly implies that - // you need two or more trackers for this feature to be reliable) - private fun isUserStatic(trackerToExclude: Tracker): Boolean { - var num = 0 - if (skeleton.upperChestTracker != null && - skeleton.upperChestTracker != trackerToExclude - ) { - if (skeleton.upperChestTracker!!.getAcceleration().lenSq() - > ALLOWED_BODY_ACCEL_SQUARED - ) { - num++ - } - } - if (skeleton.chestTracker != null && - skeleton.chestTracker != trackerToExclude - ) { - if (skeleton.chestTracker!!.getAcceleration().lenSq() > ALLOWED_BODY_ACCEL_SQUARED) num++ - } - if (skeleton.hipTracker != null && skeleton.hipTracker != trackerToExclude) { - if (skeleton.hipTracker!!.getAcceleration().lenSq() > ALLOWED_BODY_ACCEL_SQUARED) num++ - } - if (skeleton.waistTracker != null && - skeleton.waistTracker != trackerToExclude - ) { - if (skeleton.waistTracker!!.getAcceleration().lenSq() > ALLOWED_BODY_ACCEL_SQUARED) num++ - } - if (skeleton.leftUpperLegTracker != null && - skeleton.leftUpperLegTracker != trackerToExclude - ) { - if (skeleton.leftUpperLegTracker!!.getAcceleration().lenSq() - > ALLOWED_BODY_ACCEL_SQUARED - ) { - num++ - } - } - if (skeleton.rightUpperLegTracker != null && - skeleton.rightUpperLegTracker != trackerToExclude - ) { - if (skeleton.rightUpperLegTracker!!.getAcceleration().lenSq() - > ALLOWED_BODY_ACCEL_SQUARED - ) { - num++ - } - } - if (skeleton.leftFootTracker != null && - skeleton.leftFootTracker != trackerToExclude - ) { - if (skeleton.leftFootTracker!!.getAcceleration().lenSq() > ALLOWED_BODY_ACCEL_SQUARED) { - num++ - } - } - if (skeleton.rightFootTracker != null && - skeleton.rightFootTracker != trackerToExclude - ) { - if (skeleton.rightFootTracker!!.getAcceleration().lenSq() - > ALLOWED_BODY_ACCEL_SQUARED - ) { - num++ - } - } - return num < numberTrackersOverThreshold - } - - companion object { - // hyperparameters - private const val NS_CONVERTER = 1.0e9f - private const val NEEDED_ACCEL_DELTA = 6.0f - private const val ALLOWED_BODY_ACCEL = 2.5f - private const val ALLOWED_BODY_ACCEL_SQUARED = ALLOWED_BODY_ACCEL * ALLOWED_BODY_ACCEL - private const val CLUMP_TIME_NS = 0.06f * NS_CONVERTER - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/TapDetectionManager.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/TapDetectionManager.kt deleted file mode 100644 index db942cea71..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/TapDetectionManager.kt +++ /dev/null @@ -1,117 +0,0 @@ -package dev.slimevr.tracking.processor.skeleton - -import dev.slimevr.VRServer -import dev.slimevr.tracking.processor.HumanPoseManager -import dev.slimevr.tracking.trackers.Tracker -import java.util.concurrent.CopyOnWriteArrayList - -class TapDetectionManager( - val server: VRServer, - val skeleton: HumanSkeleton, - val humanPoseManager: HumanPoseManager, -) { - private var tapDetectors: MutableList = CopyOnWriteArrayList() - var yawResetDetector: TapDetection? = null - var fullResetDetector: TapDetection? = null - var mountingResetDetector: TapDetection? = null - - var config = server.configManager.vrConfig.tapDetection - - init { - refresh() - } - - fun registerSingleTapDetectors() { - for (tracker in server.allTrackers) { - tapDetectors.add( - TapDetection(skeleton, tracker, config.numberTrackersOverThreshold, 2) { - server.tapSetupHandler.sendTap(tracker) - }, - ) - } - } - - fun registerResetsDetectors() { - val yawTracker = yawResetTracker - yawResetDetector = if (yawTracker != null && config.yawResetEnabled) { - TapDetection(skeleton, yawTracker, config.numberTrackersOverThreshold, config.yawResetTaps) { - server.scheduleResetTrackersYaw(RESET_SOURCE_NAME, (config.yawResetDelay * 1000).toLong()) - } - } else { - null - } - - val fullTracker = fullResetTracker - fullResetDetector = if (fullTracker != null && config.fullResetEnabled) { - TapDetection(skeleton, fullTracker, config.numberTrackersOverThreshold, config.fullResetTaps) { - server.scheduleResetTrackersFull(RESET_SOURCE_NAME, (config.fullResetDelay * 1000).toLong()) - } - } else { - null - } - - val mountingTracker = mountingResetTracker - mountingResetDetector = if (mountingTracker != null && config.mountingResetEnabled) { - TapDetection(skeleton, mountingTracker, config.numberTrackersOverThreshold, config.mountingResetTaps) { - server.scheduleResetTrackersMounting(RESET_SOURCE_NAME, (config.mountingResetDelay * 1000).toLong()) - } - } else { - null - } - } - - /** - * Called when the list of available trackers gets updated - * or when the tap settings get changed - * it re-create the tap detectors according to the configs and available trackers - */ - fun refresh() { - tapDetectors.clear() - registerSingleTapDetectors() - registerResetsDetectors() - } - - fun update() { - // We disable the resets detectors during the assignment phase so you cant - // trigger a reset while assigning - if (config.setupMode) { - for (detector in tapDetectors) { - detector.update() - } - } else { - yawResetDetector?.update() - fullResetDetector?.update() - mountingResetDetector?.update() - } - } - - private val mountingResetTracker: Tracker? - get() { - return arrayOf( - skeleton.rightUpperLegTracker, - skeleton.rightLowerLegTracker, - ).firstNotNullOfOrNull { it } - } - - private val fullResetTracker: Tracker? - get() { - return arrayOf( - skeleton.leftUpperLegTracker, - skeleton.leftLowerLegTracker, - ).firstNotNullOfOrNull { it } - } - - private val yawResetTracker: Tracker? - get() { - return arrayOf( - skeleton.upperChestTracker, - skeleton.chestTracker, - skeleton.hipTracker, - skeleton.waistTracker, - ).firstNotNullOfOrNull { it } - } - - companion object { - const val RESET_SOURCE_NAME: String = "TapDetection" - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/UserHeightCalibration.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/UserHeightCalibration.kt deleted file mode 100644 index b87d64cf02..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/skeleton/UserHeightCalibration.kt +++ /dev/null @@ -1,317 +0,0 @@ -package dev.slimevr.tracking.processor.skeleton - -import dev.slimevr.VRServer -import dev.slimevr.tracking.processor.HumanPoseManager -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerPosition -import dev.slimevr.tracking.trackers.TrackerStatus -import io.github.axisangles.ktmath.Vector3 -import org.apache.commons.collections4.queue.CircularFifoQueue -import solarxr_protocol.rpc.UserHeightCalibrationStatus -import solarxr_protocol.rpc.UserHeightRecordingStatusResponseT -import java.util.concurrent.CopyOnWriteArrayList -import kotlin.math.PI -import kotlin.math.cos -import kotlin.math.max -import kotlin.math.sqrt - -/** - * @param positionSamples The list of Vector3 samples. - * @return The standard deviation as a Float. Returns Float.MAX_VALUE if the list is empty. - */ -fun calculatePositionStdDev(positionSamples: Collection): Float { - val sampleCount = positionSamples.size - if (sampleCount == 0) return Float.MAX_VALUE - - var sumX = 0f - var sumY = 0f - var sumZ = 0f - - for (pos in positionSamples) { - sumX += pos.x - sumY += pos.y - sumZ += pos.z - } - - val meanX = sumX / sampleCount - val meanY = sumY / sampleCount - val meanZ = sumZ / sampleCount - - var varianceSum = 0f - for (pos in positionSamples) { - val dx = pos.x - meanX - val dy = pos.y - meanY - val dz = pos.z - meanZ - // Square of the Euclidean distance from the mean - varianceSum += dx * dx + dy * dy + dz * dz - } - - val variance = varianceSum / sampleCount - return sqrt(variance) -} - -fun isHmdLeveled(hmd: Tracker, threshold: Double): Boolean { - val q = hmd.getRotation() - - val worldHmdUp = q.sandwich(Vector3.POS_Y) - val dotProduct = worldHmdUp.dot(Vector3.POS_Y) - return dotProduct >= threshold -} - -fun isControllerPointingDown(controller: Tracker, threshold: Double): Boolean { - val q = controller.getRawRotation() - val controllerForwardWorld = q.sandwich(Vector3.NEG_Z) - val worldDown = Vector3.NEG_Y - val dotProduct = controllerForwardWorld.dot(worldDown) - - return dotProduct >= threshold -} - -interface UserHeightCalibrationListener { - fun onStatusChange(status: UserHeightRecordingStatusResponseT) -} - -class UserHeightCalibration(val server: VRServer, val humanPoseManager: HumanPoseManager) { - var status = UserHeightCalibrationStatus.NONE - - var currentHeight = 0f - var currentFloorLevel = 0f - - var startTime = 0L - - private val hmdPositionSamples: CircularFifoQueue = CircularFifoQueue(MAX_SAMPLES) - private var heightStableStartTime: Long? = null - - private val floorPositionSamples: CircularFifoQueue = CircularFifoQueue(MAX_SAMPLES) - private var floorStableStartTime: Long? = null - - private val listeners: MutableList = CopyOnWriteArrayList() - private var hmd: Tracker? = null - - private val handTrackers: MutableList = mutableListOf() - - fun start() { - clear() - checkTrackers() - - if (!server.serverGuards.canDoUserHeightCalibration) { - return - } - - startTime = System.nanoTime() - - status = UserHeightCalibrationStatus.RECORDING_FLOOR - currentFloorLevel = Float.MAX_VALUE - - sendStatusUpdate() - } - - fun clear() { - status = UserHeightCalibrationStatus.NONE - hmdPositionSamples.clear() - floorPositionSamples.clear() - heightStableStartTime = null - floorStableStartTime = null - currentHeight = 0f - startTime = 0L - } - - init { - clear() - checkTrackers() - } - - fun checkTrackers() { - handTrackers.clear() - handTrackers.addAll( - server.allTrackers.filter { - ( - it.trackerPosition == TrackerPosition.LEFT_HAND || - it.trackerPosition == TrackerPosition.RIGHT_HAND - ) && - !it.isInternal && - it.hasPosition && - it.status == TrackerStatus.OK - }, - ) - - hmd = server.allTrackers.find { - it.trackerPosition == TrackerPosition.HEAD && - it.hasPosition && - !it.isInternal && - it.status == TrackerStatus.OK - } - server.serverGuards.canDoUserHeightCalibration = hmd != null && handTrackers.isNotEmpty() - - currentHeight = 0f - currentFloorLevel = 0f - } - - fun applyCalibration() { - server.configManager.vrConfig.skeleton.hmdHeight = currentHeight - server.configManager.vrConfig.skeleton.floorHeight = 0f - - server.humanPoseManager.resetOffsets() - server.humanPoseManager.saveConfig() - server.configManager.saveConfig() - - server.trackingChecklistManager.resetMountingCompleted = false - server.trackingChecklistManager.feetResetMountingCompleted = false - } - - fun tick() { - if (startTime == 0L) return - - val currentTime = System.nanoTime() - if (active && currentTime - startTime > TIMEOUT_TIME) { - status = UserHeightCalibrationStatus.ERROR_TIMEOUT - sendStatusUpdate() - return - } - - when (status) { - UserHeightCalibrationStatus.RECORDING_FLOOR, UserHeightCalibrationStatus.WAITING_FOR_CONTROLLER_PITCH -> recordFloor(currentTime) - UserHeightCalibrationStatus.WAITING_FOR_RISE, UserHeightCalibrationStatus.RECORDING_HEIGHT, UserHeightCalibrationStatus.WAITING_FOR_FW_LOOK -> recordHeight(currentTime) - } - } - - private fun recordFloor(currentTime: Long) { - val lowestTracker = handTrackers.minByOrNull { it.position.y } - val currentLowestPos = lowestTracker?.position ?: return - - if (currentLowestPos.y > MAX_FLOOR_Y) { - floorStableStartTime = null - floorPositionSamples.clear() - return - } - - if (!isControllerPointingDown(lowestTracker, CONTROLLER_ANGLE_THRESHOLD)) { - status = UserHeightCalibrationStatus.WAITING_FOR_CONTROLLER_PITCH - floorStableStartTime = null - floorPositionSamples.clear() - sendStatusUpdate() - return - } - - floorPositionSamples.add(currentLowestPos) - currentFloorLevel = minOf(currentFloorLevel, currentLowestPos.y) - - if (floorPositionSamples.isAtFullCapacity) { - val isStable = calculatePositionStdDev(floorPositionSamples) <= CONTROLLER_POSITION_STD_DEV_THRESHOLD - - if (isStable) { - if (floorStableStartTime == null) { - floorStableStartTime = currentTime - } - - val stableDuration = currentTime - floorStableStartTime!! - if (stableDuration >= CONTROLLER_STABILITY_DURATION) { - status = UserHeightCalibrationStatus.WAITING_FOR_RISE - sendStatusUpdate() - } - } else { - floorStableStartTime = null - } - } - } - - private fun recordHeight(currentTime: Long) { - val localHmd = hmd ?: return - - val currentPos = localHmd.position - val relativeY = currentPos.y - currentFloorLevel - - if (relativeY <= HMD_RISE_THRESHOLD) { - status = UserHeightCalibrationStatus.WAITING_FOR_RISE - sendStatusUpdate() - hmdPositionSamples.clear() - heightStableStartTime = null - return - } - - if (currentHeight != relativeY) { - currentHeight = relativeY - sendStatusUpdate() - } - - if (!isHmdLeveled(localHmd, HEAD_ANGLE_THRESHOLD)) { - status = UserHeightCalibrationStatus.WAITING_FOR_FW_LOOK - heightStableStartTime = null - hmdPositionSamples.clear() - sendStatusUpdate() - return - } - - status = UserHeightCalibrationStatus.RECORDING_HEIGHT - hmdPositionSamples.add(currentPos) - - if (hmdPositionSamples.isAtFullCapacity) { - val std = calculatePositionStdDev(hmdPositionSamples) - val isStable = std <= POSITION_STD_DEV_THRESHOLD - - if (isStable) { - if (heightStableStartTime == null) { - heightStableStartTime = currentTime - } - - val stableDuration = currentTime - heightStableStartTime!! - if (stableDuration >= HEAD_STABILITY_DURATION) { - status = if (currentHeight < 1.2f) { - UserHeightCalibrationStatus.ERROR_TOO_SMALL - } else if (currentHeight > 1.936f) { - UserHeightCalibrationStatus.ERROR_TOO_HIGH - } else { - UserHeightCalibrationStatus.DONE - } - - if (status == UserHeightCalibrationStatus.DONE) { - applyCalibration() - } - - sendStatusUpdate() - } - } else { - heightStableStartTime = null - } - } - } - - fun addListener(listener: UserHeightCalibrationListener) { - listeners.add(listener) - } - - fun removeListener(listener: UserHeightCalibrationListener) { - listeners.remove(listener) - } - - fun sendStatusUpdate() { - val res = UserHeightRecordingStatusResponseT().apply { - this.status = this@UserHeightCalibration.status - this.hmdHeight = this@UserHeightCalibration.currentHeight - } - listeners.forEach { it.onStatusChange(res) } - } - - val active: Boolean - get() { - return status == UserHeightCalibrationStatus.RECORDING_HEIGHT || status == UserHeightCalibrationStatus.RECORDING_FLOOR || status == UserHeightCalibrationStatus.WAITING_FOR_RISE || status == UserHeightCalibrationStatus.WAITING_FOR_FW_LOOK - } - - companion object { - private const val MAX_SAMPLES = 100 - - private const val POSITION_STD_DEV_THRESHOLD = 0.003f - private const val HEAD_STABILITY_DURATION = 600_000_000f - - private const val CONTROLLER_POSITION_STD_DEV_THRESHOLD = 0.005f - private const val CONTROLLER_STABILITY_DURATION = 300_000_000f - - private const val MAX_FLOOR_Y = 0.10f - private const val HMD_RISE_THRESHOLD = 1.2f - - val HEAD_ANGLE_THRESHOLD = cos((PI / 180f) * 15f) - val CONTROLLER_ANGLE_THRESHOLD = cos((PI / 180f) * 45f) - - private const val TIMEOUT_TIME = 30_000_000_000f - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/StayAligned.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/StayAligned.kt deleted file mode 100644 index e31d0e7fd0..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/StayAligned.kt +++ /dev/null @@ -1,60 +0,0 @@ -package dev.slimevr.tracking.processor.stayaligned - -import dev.slimevr.VRServer -import dev.slimevr.config.StayAlignedConfig -import dev.slimevr.math.Angle -import dev.slimevr.tracking.processor.stayaligned.StayAlignedDefaults.IMU_TO_YAW_CORRECTION -import dev.slimevr.tracking.processor.stayaligned.StayAlignedDefaults.YAW_CORRECTION_DEFAULT -import dev.slimevr.tracking.processor.stayaligned.adjust.AdjustTrackerYaw -import dev.slimevr.tracking.processor.stayaligned.trackers.TrackerSkeleton - -/** - * Manager to keep the trackers aligned. - */ -object StayAligned { - - private var nextTrackerIndex = 0 - - /** - * Adjusts the yaw of the next tracker. - * - * We only adjust one tracker per tick to minimize CPU usage. When the server is - * running at 1000 Hz and there are 20 trackers, each tracker is still updated 50 - * times a second. - */ - fun adjustNextTracker(trackers: TrackerSkeleton, config: StayAlignedConfig) { - if (!config.enabled) { - return - } - - val numTrackers = trackers.allTrackers.size - if (numTrackers == 0) { - return - } - - val trackerToAdjust = trackers.allTrackers[nextTrackerIndex % numTrackers] - ++nextTrackerIndex - - // Update hide correction since the config could have changed - trackerToAdjust.stayAligned.hideCorrection = config.hideYawCorrection - - val yawCorrectionPerSec = - IMU_TO_YAW_CORRECTION.getOrDefault(trackerToAdjust.imuType, YAW_CORRECTION_DEFAULT) - if (yawCorrectionPerSec == Angle.ZERO) { - return - } - - // Scale yaw correction since we're only updating one tracker per tick - val yawCorrection = - yawCorrectionPerSec * - VRServer.instance.fpsTimer.timePerFrame * - numTrackers.toFloat() - - AdjustTrackerYaw.adjust( - trackerToAdjust, - trackers, - yawCorrection, - config, - ) - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/StayAlignedDefaults.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/StayAlignedDefaults.kt deleted file mode 100644 index 330475f82e..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/StayAlignedDefaults.kt +++ /dev/null @@ -1,72 +0,0 @@ -package dev.slimevr.tracking.processor.stayaligned - -import dev.slimevr.math.Angle -import dev.slimevr.tracking.processor.stayaligned.poses.RelaxedPose -import dev.slimevr.tracking.processor.stayaligned.trackers.RestDetector -import dev.slimevr.tracking.trackers.udp.IMUType -import kotlin.time.Duration.Companion.seconds - -/** - * All non-user-configurable defaults used by Stay Aligned, so that we can tune the - * algorithm from a single place. - */ -object StayAlignedDefaults { - - // Rest detector for detecting when trackers are at rest - fun makeRestDetector() = RestDetector( - maxRotation = Angle.ofDeg(2.0f), - enterRestTime = 1.seconds, - enterMovingTime = 3.seconds, - ) - - // Relaxed pose for kneeling. This isn't that common, so we don't want to ask - // players to provide this relaxed pose during setup. - val RELAXED_POSE_KNEELING = - RelaxedPose( - upperLeg = Angle.ofDeg(0.0f), - lowerLeg = Angle.ofDeg(0.0f), - foot = Angle.ofDeg(0.0f), - ) - - // Weights to calculate the average yaw of the skeleton - const val CENTER_ERROR_HEAD_WEIGHT = 0.5f - const val CENTER_ERROR_UPPER_BODY_WEIGHT = 1.0f - const val CENTER_ERROR_UPPER_LEG_WEIGHT = 0.4f - const val CENTER_ERROR_LOWER_LEG_WEIGHT = 0.3f - - // Weight of each force - const val YAW_ERRORS_LOCKED_ERROR_WEIGHT = 10.0f - const val YAW_ERRORS_CENTER_ERROR_WEIGHT = 2.0f - const val YAW_ERRORS_NEIGHBOR_ERROR_WEIGHT = 1.0f - - // Yaw correction for each type of IMU - val YAW_CORRECTION_IMU_GOOD = Angle.ofDeg(0.15f) - val YAW_CORRECTION_IMU_OK = Angle.ofDeg(0.20f) - val YAW_CORRECTION_IMU_BAD = Angle.ofDeg(0.40f) - val YAW_CORRECTION_IMU_DISABLED = Angle.ZERO - - val IMU_TO_YAW_CORRECTION = buildMap { - // Mag is enabled on MPU9250 but server doesn't know about it - set(IMUType.MPU9250, YAW_CORRECTION_IMU_DISABLED) - set(IMUType.MPU6500, YAW_CORRECTION_IMU_BAD) - set(IMUType.BNO080, YAW_CORRECTION_IMU_GOOD) - set(IMUType.BNO085, YAW_CORRECTION_IMU_GOOD) - set(IMUType.BNO055, YAW_CORRECTION_IMU_BAD) - set(IMUType.MPU6050, YAW_CORRECTION_IMU_BAD) - set(IMUType.BNO086, YAW_CORRECTION_IMU_GOOD) - set(IMUType.BMI160, YAW_CORRECTION_IMU_BAD) - set(IMUType.ICM20948, YAW_CORRECTION_IMU_BAD) - set(IMUType.ICM42688, YAW_CORRECTION_IMU_OK) - set(IMUType.BMI270, YAW_CORRECTION_IMU_OK) - set(IMUType.LSM6DS3TRC, YAW_CORRECTION_IMU_BAD) - set(IMUType.LSM6DSV, YAW_CORRECTION_IMU_GOOD) - set(IMUType.LSM6DSO, YAW_CORRECTION_IMU_OK) - set(IMUType.LSM6DSR, YAW_CORRECTION_IMU_GOOD) - set(IMUType.ICM45686, YAW_CORRECTION_IMU_GOOD) - set(IMUType.ICM45605, YAW_CORRECTION_IMU_GOOD) - } - - // Assume any new IMUs are at least OK, or else we wouldn't be writing firmware to - // support it. Please classify and add new IMUs to the map above! - val YAW_CORRECTION_DEFAULT = YAW_CORRECTION_IMU_OK -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/AdjustTrackerYaw.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/AdjustTrackerYaw.kt deleted file mode 100644 index 73bc8268f8..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/AdjustTrackerYaw.kt +++ /dev/null @@ -1,185 +0,0 @@ -package dev.slimevr.tracking.processor.stayaligned.adjust - -import dev.slimevr.config.StayAlignedConfig -import dev.slimevr.math.Angle -import dev.slimevr.tracking.processor.stayaligned.StayAlignedDefaults.YAW_ERRORS_CENTER_ERROR_WEIGHT -import dev.slimevr.tracking.processor.stayaligned.StayAlignedDefaults.YAW_ERRORS_LOCKED_ERROR_WEIGHT -import dev.slimevr.tracking.processor.stayaligned.StayAlignedDefaults.YAW_ERRORS_NEIGHBOR_ERROR_WEIGHT -import dev.slimevr.tracking.processor.stayaligned.poses.PlayerPose -import dev.slimevr.tracking.processor.stayaligned.poses.RelaxedPose -import dev.slimevr.tracking.processor.stayaligned.trackers.RestDetector -import dev.slimevr.tracking.processor.stayaligned.trackers.TrackerSkeleton -import dev.slimevr.tracking.processor.stayaligned.trackers.YawErrors -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.udp.MagnetometerStatus - -object AdjustTrackerYaw { - - /** - * Adjusts the yaw of a tracker. - * - * Locked Trackers - * --------------- - * After a tracker is at rest for a short time, we lock it and save its initial - * rotation. We assume that locked trackers really are at rest, and that any - * rotation is due to drift. We adjust the tracker's yaw towards its initial - * rotation. If the tracker rotates beyonds a certain angle, we unlock the - * tracker. - * - * This works very well when the player is still and the tracker is supported by - * some surface, e.g. sitting in a chair or lying in a bed. However, it does not - * work well when the player is standing or moving around because the trackers - * will never lock. - * - * Centering Force - * --------------- - * When the player is moving around, we assume that the player will often be in - * a relaxed pose, or will eventually return to a relaxed pose. During setup, we - * collect the player's relaxed posed when standing, sitting and lying on their - * back. - * - * The centering force adjusts the tracker's yaw towards the relaxed pose. - * Upper body trackers are adjusted towards the average yaw of the body. Leg - * trackers are also adjusted towards the average yaw of the body, but with a - * yaw offset corresponding to their relaxed pose. - * - * This works well when the player is moving a lot. However, it doesn't work - * well when some of the trackers are locked, and others are moving. The locked - * trackers will stay in place, while the moving trackers will pull towards the - * relaxed pose, which can result in imbalanced poses. - * - * Neighbor Trackers - * ----------------- - * The neighboring force adjusts the tracker's yaw so that it is balanced - * between its neighboring trackers. For example, if the player is standing in a - * very wide stance, the neighboring force will push the upper leg tracker to a - * position that is proportional to their relaxed pose. This keeps the poses - * balanced. - * - * We use gradient descent to find the direction to apply a yaw correction. By - * applying this 50 times a second, the whole body is nudged into a reasonable - * alignment. - */ - fun adjust( - tracker: Tracker, - trackers: TrackerSkeleton, - yawCorrection: Angle, - config: StayAlignedConfig, - ) { - // Only IMUs can drift - if (!tracker.isImu()) { - return - } - - // Skip trackers that use magnetometer, because the magnetometer should know the - // absolute yaw of the tracker - if (tracker.magStatus == MagnetometerStatus.ENABLED) { - return - } - - // Clear errors, in case we don't adjust the tracker - val state = tracker.stayAligned - state.yawErrors = YawErrors() - - val restDetector = state.restDetector - when (restDetector.state) { - RestDetector.State.MOVING -> - adjustMovingTracker(tracker, trackers, yawCorrection, config) - - RestDetector.State.AT_REST -> - adjustLockedTracker(tracker, trackers, yawCorrection) - - RestDetector.State.RECENTLY_AT_REST -> { - // Do not adjust trackers that were recently at rest, to support play - // styles that are primarily at rest - } - } - } - - /** - * Adjusts a locked tracker. - */ - private fun adjustLockedTracker( - tracker: Tracker, - trackers: TrackerSkeleton, - yawCorrection: Angle, - ) { - val lockedRotation = tracker.stayAligned.lockedRotation ?: return - - adjustByError(tracker, yawCorrection) { - YawErrors().also { - trackers.visit(tracker, LockedErrorVisitor(lockedRotation, it.lockedError)) - } - } - } - - /** - * Adjusts a tracker that is moving. - */ - private fun adjustMovingTracker( - tracker: Tracker, - trackers: TrackerSkeleton, - yawCorrection: Angle, - config: StayAlignedConfig, - ) { - val centerYaw = CenterYaw.ofSkeleton(trackers) ?: return - - val pose = PlayerPose.ofTrackers(trackers) - val relaxedPose = RelaxedPose.forPose(pose, config) ?: return - - adjustByError(tracker, yawCorrection) { - YawErrors().also { - trackers.visit(tracker, CenterErrorVisitor(centerYaw, relaxedPose, it.centerError)) - trackers.visit(tracker, NeighborErrorVisitor(relaxedPose, it.neighborError)) - } - } - } - - /** - * Adjusts the yaw by applying gradient descent. - */ - private fun adjustByError( - tracker: Tracker, - yawCorrection: Angle, - errorFn: (tracker: Tracker) -> YawErrors, - ) { - val state = tracker.stayAligned - - val curYaw = state.yawCorrection - val curError = errorFn(tracker) - - val posYaw = curYaw + yawCorrection - state.yawCorrection = posYaw - val posError = errorFn(tracker) - - val negYaw = curYaw - yawCorrection - state.yawCorrection = negYaw - val negError = errorFn(tracker) - - val posYawDelta = gradient(posError, curError) - val negYawDelta = gradient(negError, curError) - - // Pick the yaw correction that minimizes the error - if ((posYawDelta < Angle.ZERO) && (posYawDelta < negYawDelta)) { - state.yawCorrection = posYaw - state.yawErrors = posError - } else if (negYawDelta < Angle.ZERO) { - state.yawCorrection = negYaw - state.yawErrors = negError - } else { - state.yawCorrection = curYaw - state.yawErrors = curError - } - } - - /** - * Calculates the gradient between two errors. A negative gradient means that there - * is less error in that direction. - */ - private fun gradient(errors: YawErrors, base: YawErrors) = (errors.lockedError.toL2Norm() - base.lockedError.toL2Norm()) * - YAW_ERRORS_LOCKED_ERROR_WEIGHT + - (errors.centerError.toL2Norm() - base.centerError.toL2Norm()) * - YAW_ERRORS_CENTER_ERROR_WEIGHT + - (errors.neighborError.toL2Norm() - base.neighborError.toL2Norm()) * - YAW_ERRORS_NEIGHBOR_ERROR_WEIGHT -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/CenterErrorVisitor.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/CenterErrorVisitor.kt deleted file mode 100644 index 52fcf229df..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/CenterErrorVisitor.kt +++ /dev/null @@ -1,92 +0,0 @@ -package dev.slimevr.tracking.processor.stayaligned.adjust - -import dev.slimevr.math.Angle -import dev.slimevr.math.AngleErrors -import dev.slimevr.tracking.processor.stayaligned.adjust.TrackerYaw.extraYaw -import dev.slimevr.tracking.processor.stayaligned.adjust.TrackerYaw.trackerYaw -import dev.slimevr.tracking.processor.stayaligned.poses.RelaxedPose -import dev.slimevr.tracking.processor.stayaligned.trackers.Side -import dev.slimevr.tracking.processor.stayaligned.trackers.TrackerSkeleton -import dev.slimevr.tracking.trackers.Tracker - -/** - * Assumes that the body is centered around an average yaw, and returns the error of the - * tracker with respect to that average yaw. - */ -class CenterErrorVisitor( - val centerYaw: Angle, - val relaxedPose: RelaxedPose, - val errors: AngleErrors, -) : TrackerSkeleton.TrackerVisitor { - - override fun visitHeadTracker( - tracker: Tracker, - belowUpperBody: Tracker?, - ) { - errors.add(centerYaw - trackerYaw(tracker)) - } - - override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowUpperBody: Tracker?, - ) { - errors.add(centerYaw - trackerYaw(tracker)) - } - - override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowLeftUpperLeg: Tracker?, - belowRightUpperLeg: Tracker?, - ) { - errors.add(centerYaw - trackerYaw(tracker)) - } - - override fun visitArmTracker( - side: Side, - tracker: Tracker, - aboveUpperBodyOrArm: Tracker?, - belowHandOrArm: Tracker?, - ) { - // No error because arms can go anywhere - } - - override fun visitHandTracker( - side: Side, - tracker: Tracker, - aboveArm: Tracker?, - oppositeHand: Tracker?, - ) { - // No error because hands can go anywhere - } - - override fun visitUpperLegTracker( - side: Side, - tracker: Tracker, - aboveUpperBody: Tracker?, - belowLowerLeg: Tracker?, - oppositeUpperLeg: Tracker?, - ) { - errors.add(centerYaw + extraYaw(side, relaxedPose.upperLeg) - trackerYaw(tracker)) - } - - override fun visitLowerLegTracker( - side: Side, - tracker: Tracker, - aboveUpperLeg: Tracker?, - belowFoot: Tracker?, - oppositeLowerLeg: Tracker?, - ) { - errors.add(centerYaw + extraYaw(side, relaxedPose.lowerLeg) - trackerYaw(tracker)) - } - - override fun visitFootTracker( - side: Side, - tracker: Tracker, - aboveLowerLeg: Tracker?, - oppositeFoot: Tracker?, - ) { - errors.add(centerYaw + extraYaw(side, relaxedPose.foot) - trackerYaw(tracker)) - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/CenterYaw.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/CenterYaw.kt deleted file mode 100644 index 9c2d6c26eb..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/CenterYaw.kt +++ /dev/null @@ -1,67 +0,0 @@ -package dev.slimevr.tracking.processor.stayaligned.adjust - -import dev.slimevr.math.Angle -import dev.slimevr.math.AngleAverage -import dev.slimevr.tracking.processor.stayaligned.StayAlignedDefaults.CENTER_ERROR_HEAD_WEIGHT -import dev.slimevr.tracking.processor.stayaligned.StayAlignedDefaults.CENTER_ERROR_LOWER_LEG_WEIGHT -import dev.slimevr.tracking.processor.stayaligned.StayAlignedDefaults.CENTER_ERROR_UPPER_BODY_WEIGHT -import dev.slimevr.tracking.processor.stayaligned.StayAlignedDefaults.CENTER_ERROR_UPPER_LEG_WEIGHT -import dev.slimevr.tracking.processor.stayaligned.adjust.TrackerYaw.hasTrackerYaw -import dev.slimevr.tracking.processor.stayaligned.adjust.TrackerYaw.trackerYaw -import dev.slimevr.tracking.processor.stayaligned.trackers.TrackerSkeleton - -object CenterYaw { - - fun ofSkeleton( - trackers: TrackerSkeleton, - ): Angle? { - val head = trackers.head - val upperBody = trackers.upperBody - val leftUpperLeg = trackers.leftUpperLeg - val rightUpperLeg = trackers.rightUpperLeg - val leftLowerLeg = trackers.leftLowerLeg - val rightLowerLeg = trackers.rightLowerLeg - - if ( - // Head optional, because some mocap scenarios don't use one - upperBody.isEmpty() || - leftUpperLeg == null || - rightUpperLeg == null || - leftLowerLeg == null || - rightLowerLeg == null - ) { - return null - } - - // Need a minimum set of trackers, and the trackers need to be oriented in a - // way where we can actually calculate its yaw. - val hasCenterYaw = - upperBody.all(::hasTrackerYaw) && - hasTrackerYaw(leftUpperLeg) && - hasTrackerYaw(rightUpperLeg) && - hasTrackerYaw(leftLowerLeg) && - hasTrackerYaw(rightLowerLeg) - if (!hasCenterYaw) { - return null - } - - // Calculate average yaw of the body - val averageYaw = AngleAverage() - - if (head != null && hasTrackerYaw(head)) { - averageYaw.add(trackerYaw(head), CENTER_ERROR_HEAD_WEIGHT) - } - - upperBody.forEach { - averageYaw.add(trackerYaw(it), CENTER_ERROR_UPPER_BODY_WEIGHT) - } - - averageYaw.add(trackerYaw(leftUpperLeg), CENTER_ERROR_UPPER_LEG_WEIGHT) - averageYaw.add(trackerYaw(rightUpperLeg), CENTER_ERROR_UPPER_LEG_WEIGHT) - - averageYaw.add(trackerYaw(leftLowerLeg), CENTER_ERROR_LOWER_LEG_WEIGHT) - averageYaw.add(trackerYaw(rightLowerLeg), CENTER_ERROR_LOWER_LEG_WEIGHT) - - return averageYaw.toAngle() - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/LockedErrorVisitor.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/LockedErrorVisitor.kt deleted file mode 100644 index 0d8f04050a..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/LockedErrorVisitor.kt +++ /dev/null @@ -1,143 +0,0 @@ -package dev.slimevr.tracking.processor.stayaligned.adjust - -import dev.slimevr.math.Angle -import dev.slimevr.math.AngleErrors -import dev.slimevr.tracking.processor.stayaligned.trackers.Side -import dev.slimevr.tracking.processor.stayaligned.trackers.TrackerSkeleton -import dev.slimevr.tracking.trackers.Tracker -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import kotlin.math.* - -/** - * Error between a locked tracker's yaw and its yaw when it was initially locked. - */ -class LockedErrorVisitor( - val lockedRotation: Quaternion, - val errors: AngleErrors, -) : TrackerSkeleton.TrackerVisitor { - - override fun visitHeadTracker( - tracker: Tracker, - belowUpperBody: Tracker?, - ) { - errors.add(error(tracker)) - } - - override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowUpperBody: Tracker?, - ) { - errors.add(error(tracker)) - } - - override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowLeftUpperLeg: Tracker?, - belowRightUpperLeg: Tracker?, - ) { - errors.add(error(tracker)) - } - - override fun visitArmTracker( - side: Side, - tracker: Tracker, - aboveUpperBodyOrArm: Tracker?, - belowHandOrArm: Tracker?, - ) { - errors.add(error(tracker)) - } - - override fun visitHandTracker( - side: Side, - tracker: Tracker, - aboveArm: Tracker?, - oppositeHand: Tracker?, - ) { - errors.add(error(tracker)) - } - - override fun visitUpperLegTracker( - side: Side, - tracker: Tracker, - aboveUpperBody: Tracker?, - belowLowerLeg: Tracker?, - oppositeUpperLeg: Tracker?, - ) { - errors.add(error(tracker)) - } - - override fun visitLowerLegTracker( - side: Side, - tracker: Tracker, - aboveUpperLeg: Tracker?, - belowFoot: Tracker?, - oppositeLowerLeg: Tracker?, - ) { - errors.add(error(tracker)) - } - - override fun visitFootTracker( - side: Side, - tracker: Tracker, - aboveLowerLeg: Tracker?, - oppositeFoot: Tracker?, - ) { - errors.add(error(tracker)) - } - - private fun error(tracker: Tracker): Angle = yawDifference(tracker.getAdjustedRotationForceStayAligned(), lockedRotation) - - companion object { - - /** - * Gets the yaw between two rotations, for small rotations. - * - * A locked tracker can be in any rotation, so we cannot use - * TrackerYaw::trackerYaw, which doesn't work for a tracker that is on its - * side. - * - * WARNING: DO NOT USE for large rotations because the chosen axis might have - * a very small projection on the yaw plane, which yields a low confidence yaw. - * - * TODO: It might be possible to pick a different EulerOrder when we encounter - * singularities, but I wasn't able to get this working correctly. - */ - private fun yawDifference( - rotation: Quaternion, - targetRotation: Quaternion, - ): Angle { - val targetX = targetRotation.sandwichUnitX() - val targetY = targetRotation.sandwichUnitY() - val targetZ = targetRotation.sandwichUnitZ() - - // Find the axis that is closest to the yaw plane - val axis: Vector3 - val targetAxis: Vector3 - - val targetXScore = abs(targetX.dot(Vector3.POS_Y)) - val targetYScore = abs(targetY.dot(Vector3.POS_Y)) - val targetZScore = abs(targetZ.dot(Vector3.POS_Y)) - - // The axis that is closest to the yaw plane has the smallest absolute dot - // product with the Y axis - if ((targetXScore <= targetYScore) && (targetXScore <= targetZScore)) { - axis = rotation.sandwichUnitX() - targetAxis = targetX - } else if ((targetYScore <= targetXScore) && (targetYScore <= targetZScore)) { - axis = rotation.sandwichUnitY() - targetAxis = targetY - } else { - axis = rotation.sandwichUnitZ() - targetAxis = targetZ - } - - val yaw = Angle.ofRad(atan2(axis.z, axis.x)) - val targetYaw = Angle.ofRad(atan2(targetAxis.z, targetAxis.x)) - - return targetYaw - yaw - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/NeighborErrorVisitor.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/NeighborErrorVisitor.kt deleted file mode 100644 index e9bb0f07a0..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/NeighborErrorVisitor.kt +++ /dev/null @@ -1,164 +0,0 @@ -package dev.slimevr.tracking.processor.stayaligned.adjust - -import dev.slimevr.math.AngleErrors -import dev.slimevr.tracking.processor.stayaligned.adjust.TrackerYaw.extraYaw -import dev.slimevr.tracking.processor.stayaligned.adjust.TrackerYaw.trackerYaw -import dev.slimevr.tracking.processor.stayaligned.poses.RelaxedPose -import dev.slimevr.tracking.processor.stayaligned.trackers.Side -import dev.slimevr.tracking.processor.stayaligned.trackers.TrackerSkeleton -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerPosition - -/** - * Error between a tracker's yaw and its neighbors' yaws. - */ -class NeighborErrorVisitor( - val relaxedPose: RelaxedPose, - val errors: AngleErrors, -) : TrackerSkeleton.TrackerVisitor { - - override fun visitHeadTracker( - tracker: Tracker, - belowUpperBody: Tracker?, - ) { - if (belowUpperBody != null) { - errors.add(trackerYaw(belowUpperBody) - trackerYaw(tracker)) - } - } - - override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowUpperBody: Tracker?, - ) { - if ( - aboveHeadOrUpperBody != null && - // Head often drags the upper body trackers off to the side, so ignore it - aboveHeadOrUpperBody.trackerPosition != TrackerPosition.HEAD - ) { - errors.add(trackerYaw(aboveHeadOrUpperBody) - trackerYaw(tracker)) - } - - if (belowUpperBody != null) { - errors.add(trackerYaw(belowUpperBody) - trackerYaw(tracker)) - } - } - - override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowLeftUpperLeg: Tracker?, - belowRightUpperLeg: Tracker?, - ) { - if ( - aboveHeadOrUpperBody != null && - // Head often drags the upper body trackers off to the side, so ignore it - aboveHeadOrUpperBody.trackerPosition != TrackerPosition.HEAD - ) { - errors.add(trackerYaw(aboveHeadOrUpperBody) - trackerYaw(tracker)) - } - - // Only consider upper leg trackers if both are available, so that the upper - // body tracker can be balanced between both - if ( - belowLeftUpperLeg != null && - belowRightUpperLeg != null - ) { - errors.add( - trackerYaw(belowLeftUpperLeg) - - extraYaw(Side.LEFT, relaxedPose.upperLeg) - - trackerYaw(tracker), - ) - errors.add( - trackerYaw(belowRightUpperLeg) - - extraYaw(Side.RIGHT, relaxedPose.upperLeg) - - trackerYaw(tracker), - ) - } - } - - override fun visitArmTracker( - side: Side, - tracker: Tracker, - aboveUpperBodyOrArm: Tracker?, - belowHandOrArm: Tracker?, - ) { - // No error because arms can go anywhere - } - - override fun visitHandTracker( - side: Side, - tracker: Tracker, - aboveArm: Tracker?, - oppositeHand: Tracker?, - ) { - // No error because hands can go anywhere - } - - override fun visitUpperLegTracker( - side: Side, - tracker: Tracker, - aboveUpperBody: Tracker?, - belowLowerLeg: Tracker?, - oppositeUpperLeg: Tracker?, - ) { - if (aboveUpperBody != null) { - errors.add( - trackerYaw(aboveUpperBody) + - extraYaw(side, relaxedPose.upperLeg) - - trackerYaw(tracker), - ) - } - - if (belowLowerLeg != null) { - errors.add( - trackerYaw(belowLowerLeg) - - extraYaw(side, relaxedPose.lowerLeg) + - extraYaw(side, relaxedPose.upperLeg) - - trackerYaw(tracker), - ) - } - } - - override fun visitLowerLegTracker( - side: Side, - tracker: Tracker, - aboveUpperLeg: Tracker?, - belowFoot: Tracker?, - oppositeLowerLeg: Tracker?, - ) { - if (aboveUpperLeg != null) { - errors.add( - trackerYaw(aboveUpperLeg) - - extraYaw(side, relaxedPose.upperLeg) + - extraYaw(side, relaxedPose.lowerLeg) - - trackerYaw(tracker), - ) - } - - if (belowFoot != null) { - errors.add( - trackerYaw(belowFoot) - - extraYaw(side, relaxedPose.foot) + - extraYaw(side, relaxedPose.lowerLeg) - - trackerYaw(tracker), - ) - } - } - - override fun visitFootTracker( - side: Side, - tracker: Tracker, - aboveLowerLeg: Tracker?, - oppositeFoot: Tracker?, - ) { - if (aboveLowerLeg != null) { - errors.add( - trackerYaw(aboveLowerLeg) - - extraYaw(side, relaxedPose.lowerLeg) + - extraYaw(side, relaxedPose.foot) - - trackerYaw(tracker), - ) - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/TrackerYaw.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/TrackerYaw.kt deleted file mode 100644 index fb903538c7..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/adjust/TrackerYaw.kt +++ /dev/null @@ -1,56 +0,0 @@ -package dev.slimevr.tracking.processor.stayaligned.adjust - -import dev.slimevr.math.Angle -import dev.slimevr.tracking.processor.stayaligned.trackers.Side -import dev.slimevr.tracking.trackers.Tracker -import io.github.axisangles.ktmath.EulerOrder -import io.github.axisangles.ktmath.Vector3 - -/** - * Utilities for tracker yaw. - * - * The SlimeVR coordinate system is x-right, y-up, z-back, which is a right-handed - * coordinate system. - * - * Rotations follow the right-hand rule, for example, a positive rotation around the - * y-axis is a counter-clockwise rotation from z to x. From the perspective of a player, - * left is positive yaw, right is negative yaw. - */ -object TrackerYaw { - - /** - * Whether we can get the yaw of a tracker. - */ - fun hasTrackerYaw(tracker: Tracker) = Angle.absBetween( - tracker.getAdjustedRotationForceStayAligned().sandwichUnitX(), - Vector3.POS_Y, - ) > MIN_ON_SIDE_ANGLE - - /** - * Gets the yaw of the tracker, for trackers that are not on its side. - * - * WARNING: DO NOT USE for a tracker that is on its side. Euler YZX angles have a - * singularity for a tracker that is on its side, and can yield arbitrary yaws. - * For example, the Euler YZX angles (Y=0°, Z=90°, X=30°) and (Y=30°, Z=90°, X=0°) - * are equivalent but yield completely different yaws. - * - * WARNING: It is possible to use another EulerOrder which does not have a - * singularity for this rotation to get "some" yaw, but this yaw will be very - * different from the from YZX. DO NOT ATTEMPT! - */ - fun trackerYaw(tracker: Tracker) = Angle.ofRad( - tracker.getAdjustedRotationForceStayAligned() - .toEulerAngles(EulerOrder.YZX) - .y, - ) - - /** - * Applies an extra yaw in the specified direction. - */ - fun extraYaw(direction: Side, angle: Angle) = when (direction) { - Side.LEFT -> angle - Side.RIGHT -> -angle - } - - private val MIN_ON_SIDE_ANGLE = Angle.ofDeg(30.0f) -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/poses/PlayerPose.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/poses/PlayerPose.kt deleted file mode 100644 index 349c5dea36..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/poses/PlayerPose.kt +++ /dev/null @@ -1,89 +0,0 @@ -package dev.slimevr.tracking.processor.stayaligned.poses - -import dev.slimevr.tracking.processor.stayaligned.trackers.TrackerSkeleton - -/** - * The pose of the player. - */ -enum class PlayerPose { - UNKNOWN, - STANDING, - SITTING_IN_CHAIR, - SITTING_ON_GROUND, - LYING_ON_BACK, - KNEELING, - ; - - companion object { - - fun ofTrackers(trackers: TrackerSkeleton): PlayerPose { - val poses = - TrackerPoses( - trackers.upperBody.map(TrackerPose.Companion::ofTracker), - TrackerPose.ofTracker(trackers.leftUpperLeg), - TrackerPose.ofTracker(trackers.rightUpperLeg), - TrackerPose.ofTracker(trackers.leftLowerLeg), - TrackerPose.ofTracker(trackers.rightLowerLeg), - ) - - return ( - if (isStanding(poses)) { - STANDING - } else if (isSittingInChair(poses)) { - SITTING_IN_CHAIR - } else if (isSittingOnGround(poses)) { - SITTING_ON_GROUND - } else if (isLyingOnBack(poses)) { - LYING_ON_BACK - } else if (isKneeling(poses)) { - KNEELING - } else { - UNKNOWN - } - ) - } - - private class TrackerPoses( - val upperBody: List, - val leftUpperLeg: TrackerPose, - val rightUpperLeg: TrackerPose, - val leftLowerLeg: TrackerPose, - val rightLowerLeg: TrackerPose, - ) - - private fun isStanding(pose: TrackerPoses) = pose.upperBody.all { it == TrackerPose.TOP_FACING_UP } && - pose.leftUpperLeg == TrackerPose.TOP_FACING_UP && - pose.rightUpperLeg == TrackerPose.TOP_FACING_UP && - pose.leftLowerLeg == TrackerPose.TOP_FACING_UP && - pose.rightLowerLeg == TrackerPose.TOP_FACING_UP - - private fun isSittingInChair(pose: TrackerPoses) = pose.upperBody.isNotEmpty() && - pose.upperBody[0] == TrackerPose.TOP_FACING_UP && - pose.upperBody.all { it == TrackerPose.TOP_FACING_UP || it == TrackerPose.FRONT_FACING_UP } && - pose.leftUpperLeg == TrackerPose.FRONT_FACING_UP && - pose.rightUpperLeg == TrackerPose.FRONT_FACING_UP && - pose.leftLowerLeg == TrackerPose.TOP_FACING_UP && - pose.rightLowerLeg == TrackerPose.TOP_FACING_UP - - private fun isSittingOnGround(pose: TrackerPoses) = pose.upperBody.isNotEmpty() && - pose.upperBody[0] == TrackerPose.TOP_FACING_UP && - pose.upperBody.all { it == TrackerPose.TOP_FACING_UP || it == TrackerPose.FRONT_FACING_UP } && - // Allow legs to be flat on ground, or knees-up - pose.leftUpperLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_DOWN } && - pose.rightUpperLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_DOWN } && - pose.leftLowerLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_UP } && - pose.rightLowerLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_UP } - - private fun isLyingOnBack(pose: TrackerPoses) = pose.upperBody.all { it == TrackerPose.FRONT_FACING_UP } && - // Allow legs to be flat on ground, or knees-up - pose.leftUpperLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_DOWN } && - pose.rightUpperLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_DOWN } && - pose.leftLowerLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_UP } && - pose.rightLowerLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_UP } - - private fun isKneeling(pose: TrackerPoses) = pose.leftUpperLeg.let { it == TrackerPose.TOP_FACING_UP || it == TrackerPose.FRONT_FACING_UP } && - pose.rightUpperLeg.let { it == TrackerPose.TOP_FACING_UP || it == TrackerPose.FRONT_FACING_UP } && - pose.leftLowerLeg == TrackerPose.FRONT_FACING_DOWN && - pose.rightLowerLeg == TrackerPose.FRONT_FACING_DOWN - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/poses/RelaxedPose.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/poses/RelaxedPose.kt deleted file mode 100644 index cebc3bc526..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/poses/RelaxedPose.kt +++ /dev/null @@ -1,109 +0,0 @@ -package dev.slimevr.tracking.processor.stayaligned.poses - -import dev.slimevr.config.StayAlignedConfig -import dev.slimevr.math.Angle -import dev.slimevr.tracking.processor.skeleton.HumanSkeleton -import dev.slimevr.tracking.processor.stayaligned.StayAlignedDefaults -import dev.slimevr.tracking.processor.stayaligned.adjust.TrackerYaw.trackerYaw -import dev.slimevr.tracking.trackers.Tracker - -class RelaxedPose( - val upperLeg: Angle, - val lowerLeg: Angle, - val foot: Angle, -) { - override fun toString(): String = "upperLeg=$upperLeg lowerLeg=$lowerLeg foot=$foot" - - companion object { - - val ZERO = RelaxedPose(Angle.ZERO, Angle.ZERO, Angle.ZERO) - - /** - * Gets the relaxed angles for a particular pose. May provide defaults if the - * angles aren't configured for the pose. - */ - fun forPose( - playerPose: PlayerPose, - config: StayAlignedConfig, - ) = when (playerPose) { - PlayerPose.STANDING -> { - val poseConfig = config.standingRelaxedPose - if (poseConfig.enabled) { - RelaxedPose( - Angle.ofDeg(poseConfig.upperLegAngleInDeg), - Angle.ofDeg(poseConfig.lowerLegAngleInDeg), - Angle.ofDeg(poseConfig.footAngleInDeg), - ) - } else { - null - } - } - - PlayerPose.SITTING_IN_CHAIR -> { - val poseConfig = config.sittingRelaxedPose - if (poseConfig.enabled) { - RelaxedPose( - Angle.ofDeg(poseConfig.upperLegAngleInDeg), - Angle.ofDeg(poseConfig.lowerLegAngleInDeg), - Angle.ofDeg(poseConfig.footAngleInDeg), - ) - } else { - null - } - } - - PlayerPose.SITTING_ON_GROUND, - PlayerPose.LYING_ON_BACK, - -> { - val poseConfig = config.flatRelaxedPose - if (poseConfig.enabled) { - RelaxedPose( - Angle.ofDeg(poseConfig.upperLegAngleInDeg), - Angle.ofDeg(poseConfig.lowerLegAngleInDeg), - Angle.ofDeg(poseConfig.footAngleInDeg), - ) - } else { - null - } - } - - PlayerPose.KNEELING -> - StayAlignedDefaults.RELAXED_POSE_KNEELING - - else -> - null - } - - /** - * Gets the relaxed angles from the trackers. - */ - fun fromTrackers(humanSkeleton: HumanSkeleton): RelaxedPose { - val halfAngleBetween = { left: Tracker, right: Tracker -> - (trackerYaw(left) - trackerYaw(right)) * 0.5f - } - - var upperLegAngle = Angle.ZERO - humanSkeleton.leftUpperLegTracker?.let { left -> - humanSkeleton.rightUpperLegTracker?.let { right -> - upperLegAngle = halfAngleBetween(left, right) - } - } - - var lowerLegAngle = Angle.ZERO - humanSkeleton.leftLowerLegTracker?.let { left -> - humanSkeleton.rightLowerLegTracker?.let { right -> - lowerLegAngle = halfAngleBetween(left, right) - } - } - - var footAngle = Angle.ZERO - humanSkeleton.leftFootTracker?.let { left -> - humanSkeleton.rightFootTracker?.let { right -> - footAngle = halfAngleBetween(left, right) - } - } - - return RelaxedPose(upperLegAngle, lowerLegAngle, footAngle) - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/poses/TrackerPose.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/poses/TrackerPose.kt deleted file mode 100644 index 2f3cd35ee5..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/poses/TrackerPose.kt +++ /dev/null @@ -1,61 +0,0 @@ -package dev.slimevr.tracking.processor.stayaligned.poses - -import dev.slimevr.tracking.trackers.Tracker -import io.github.axisangles.ktmath.Vector3 -import kotlin.math.* - -/** - * The orientation of a tracker. - */ -enum class TrackerPose { - NONE, - TOP_FACING_UP, - TOP_FACING_DOWN, - FRONT_FACING_UP, - FRONT_FACING_DOWN, - ON_SIDE, - ; - - companion object { - - fun ofTracker(tracker: Tracker?): TrackerPose { - if (tracker == null) { - return NONE - } - - val rotation = tracker.getAdjustedRotationForceStayAligned() - - val x = rotation.sandwichUnitX() - val y = rotation.sandwichUnitY() - val z = rotation.sandwichUnitZ() - - val xDot = x.dot(Vector3.POS_Y) - val yDot = y.dot(Vector3.POS_Y) - val zDot = z.dot(Vector3.POS_Y) - - val xAbsDot = abs(xDot) - val yAbsDot = abs(yDot) - val zAbsDot = abs(zDot) - - val pose = - if ((xAbsDot >= yAbsDot) && (xAbsDot >= zAbsDot)) { - ON_SIDE - } else if ((yAbsDot >= xAbsDot) && (yAbsDot >= zAbsDot)) { - if (yDot >= 0) { - TOP_FACING_UP - } else { - TOP_FACING_DOWN - } - } else { - // Tracker local POS_Z is behind - if (zDot >= 0) { - FRONT_FACING_DOWN - } else { - FRONT_FACING_UP - } - } - - return pose - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/RestDetector.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/RestDetector.kt deleted file mode 100644 index cf385cd979..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/RestDetector.kt +++ /dev/null @@ -1,95 +0,0 @@ -package dev.slimevr.tracking.processor.stayaligned.trackers - -import dev.slimevr.math.Angle -import io.github.axisangles.ktmath.Quaternion -import kotlin.time.Duration -import kotlin.time.TimeSource - -/** - * Detects whether a tracker is at rest. - * - * A tracker is at rest when it stays within a certain rotational range for a given - * amount of time. If it rotates past that range, it is no longer at rest. - * - * TODO: In practice this is good enough for Stay Aligned, but we could also consider - * acceleration if we want to make this a general purpose rest detector. - */ -class RestDetector( - private val maxRotation: Angle, - private val enterRestTime: Duration, - private val enterMovingTime: Duration, -) { - enum class State { - MOVING, - AT_REST, - RECENTLY_AT_REST, - } - - var state = State.MOVING - private set - - // Instant that we entered the current state - private var startTime = TimeSource.Monotonic.markNow() - - // Rotation which could - private var lastRotation = Quaternion.IDENTITY - private var lastRotationTime = TimeSource.Monotonic.markNow() - - /** - * Resets the detector - */ - fun reset() { - val now = TimeSource.Monotonic.markNow() - - state = State.MOVING - startTime = now - lastRotation = Quaternion.IDENTITY - lastRotationTime = now - } - - /** - * Provides a new rotation sample to the detector. - * - * @return whether the tracker is at rest - */ - fun update(rotation: Quaternion) { - val now = TimeSource.Monotonic.markNow() - - if ( - state == State.RECENTLY_AT_REST && - now > startTime.plus(enterMovingTime) - ) { - state = State.MOVING - startTime = now - lastRotation = rotation - lastRotationTime = now - } - - when (state) { - State.MOVING, - State.RECENTLY_AT_REST, - -> - if (Angle.absBetween(lastRotation, rotation) > maxRotation) { - lastRotation = rotation - lastRotationTime = now - } else { - // When we detect the tracker is at rest, use the current rotation as the - // new start rotation for continuing to detect the tracker is at rest - if (now > lastRotationTime.plus(enterRestTime)) { - state = State.AT_REST - startTime = now - lastRotation = rotation - lastRotationTime = now - } - } - - State.AT_REST -> - if (Angle.absBetween(lastRotation, rotation) > maxRotation) { - state = State.RECENTLY_AT_REST - startTime = now - lastRotation = rotation - lastRotationTime = now - } - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/Side.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/Side.kt deleted file mode 100644 index f2da3ba6b7..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/Side.kt +++ /dev/null @@ -1,6 +0,0 @@ -package dev.slimevr.tracking.processor.stayaligned.trackers - -enum class Side { - LEFT, - RIGHT, -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/StayAlignedTrackerState.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/StayAlignedTrackerState.kt deleted file mode 100644 index c49bc7af72..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/StayAlignedTrackerState.kt +++ /dev/null @@ -1,43 +0,0 @@ -package dev.slimevr.tracking.processor.stayaligned.trackers - -import dev.slimevr.math.Angle -import dev.slimevr.tracking.processor.stayaligned.StayAlignedDefaults -import dev.slimevr.tracking.trackers.Tracker -import io.github.axisangles.ktmath.Quaternion - -class StayAlignedTrackerState( - val tracker: Tracker, -) { - // Whether to hide the yaw correction - var hideCorrection = false - - // Detects whether the tracker is at rest - val restDetector = StayAlignedDefaults.makeRestDetector() - - // Rotation of the tracker when it was locked - var lockedRotation: Quaternion? = null - - // Yaw correction to apply to tracker rotation - var yawCorrection = Angle.ZERO - - // Alignment error that yaw correction attempts to minimize - var yawErrors = YawErrors() - - fun update() { - restDetector.update(tracker.getRawRotation()) - if (restDetector.state == RestDetector.State.AT_REST) { - if (lockedRotation == null) { - lockedRotation = tracker.getAdjustedRotationForceStayAligned() - } - } else { - lockedRotation = null - } - } - - fun reset() { - restDetector.reset() - lockedRotation = null - yawCorrection = Angle.ZERO - yawErrors = YawErrors() - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/TrackerSkeleton.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/TrackerSkeleton.kt deleted file mode 100644 index dbf5962b7e..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/TrackerSkeleton.kt +++ /dev/null @@ -1,415 +0,0 @@ -package dev.slimevr.tracking.processor.stayaligned.trackers - -import dev.slimevr.tracking.processor.skeleton.HumanSkeleton -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerPosition - -/** - * Represents a skeleton of trackers. - * - * The skeleton consists of: - * - An upper body group - * - A head tracker, connected to the top of the upper body group - * - Two arm groups, connected to the top of the upper body group - * - Two hands connected to the bottom of the corresponding arm group - * - Two upper legs, connected to the bottom of the upper body group - * - Two lower legs, connected to the bottom of each corresponding upper leg - * - Two feet, connected to the bottom of each corresponding lower leg - */ -class TrackerSkeleton(skeleton: HumanSkeleton) { - - val allTrackers = with(skeleton) { - listOfNotNull( - headTracker, - // Upper body - neckTracker, - upperChestTracker, - chestTracker, - waistTracker, - hipTracker, - // Left arm - leftShoulderTracker, - leftUpperArmTracker, - leftLowerArmTracker, - leftHandTracker, - // Right arm - rightShoulderTracker, - rightUpperArmTracker, - rightLowerArmTracker, - rightHandTracker, - // Left leg - leftUpperLegTracker, - leftLowerLegTracker, - leftFootTracker, - // Right leg - rightUpperLegTracker, - rightLowerLegTracker, - rightFootTracker, - ) - } - - // Tracker groups - val upperBody = with(skeleton) { - listOfNotNull( - neckTracker, - upperChestTracker, - chestTracker, - waistTracker, - hipTracker, - ) - } - - val leftArm = with(skeleton) { - listOfNotNull( - leftShoulderTracker, - leftUpperArmTracker, - leftLowerArmTracker, - ) - } - - val rightArm = with(skeleton) { - listOfNotNull( - rightShoulderTracker, - rightUpperArmTracker, - rightLowerArmTracker, - ) - } - - // Individual trackers - val head = skeleton.headTracker - val leftHand = skeleton.leftHandTracker - val rightHand = skeleton.rightHandTracker - val leftUpperLeg = skeleton.leftUpperLegTracker - val leftLowerLeg = skeleton.leftLowerLegTracker - val leftFoot = skeleton.leftFootTracker - val rightUpperLeg = skeleton.rightUpperLegTracker - val rightLowerLeg = skeleton.rightLowerLegTracker - val rightFoot = skeleton.rightFootTracker - - /** - * Visits a tracker within the skeleton. - */ - fun visit( - tracker: Tracker, - visitor: TrackerVisitor, - ) { - when (tracker.trackerPosition) { - TrackerPosition.HEAD -> - if (tracker == head) { - visitor.visitHeadTracker(tracker, upperBody.firstOrNull()) - } - - // Upper body - TrackerPosition.NECK, - TrackerPosition.UPPER_CHEST, - TrackerPosition.CHEST, - TrackerPosition.WAIST, - TrackerPosition.HIP, - -> - visitUpperBodyTrackers( - tracker, - visitor, - head, - upperBody, - leftUpperLeg, - rightUpperLeg, - ) - - // Left arm - TrackerPosition.LEFT_SHOULDER, - TrackerPosition.LEFT_UPPER_ARM, - TrackerPosition.LEFT_LOWER_ARM, - -> - visitArmTrackers( - tracker, - visitor, - Side.LEFT, - upperBody.firstOrNull(), - leftArm, - leftHand, - ) - - // Right arm - TrackerPosition.RIGHT_SHOULDER, - TrackerPosition.RIGHT_UPPER_ARM, - TrackerPosition.RIGHT_LOWER_ARM, - -> - visitArmTrackers( - tracker, - visitor, - Side.RIGHT, - upperBody.firstOrNull(), - rightArm, - rightHand, - ) - - TrackerPosition.LEFT_HAND -> - if (tracker == leftHand) { - visitor.visitHandTracker( - Side.LEFT, - tracker, - leftArm.lastOrNull(), - rightHand, - ) - } - - TrackerPosition.RIGHT_HAND -> - if (tracker == rightHand) { - visitor.visitHandTracker( - Side.RIGHT, - tracker, - rightArm.lastOrNull(), - leftHand, - ) - } - - TrackerPosition.LEFT_UPPER_LEG -> - if (tracker == leftUpperLeg) { - visitor.visitUpperLegTracker( - Side.LEFT, - tracker, - upperBody.lastOrNull(), - leftLowerLeg, - rightUpperLeg, - ) - } - - TrackerPosition.RIGHT_UPPER_LEG -> - if (tracker == rightUpperLeg) { - visitor.visitUpperLegTracker( - Side.RIGHT, - tracker, - upperBody.lastOrNull(), - rightLowerLeg, - leftUpperLeg, - ) - } - - TrackerPosition.LEFT_LOWER_LEG -> - if (tracker == leftLowerLeg) { - visitor.visitLowerLegTracker( - Side.LEFT, - tracker, - leftUpperLeg, - leftFoot, - rightLowerLeg, - ) - } - - TrackerPosition.RIGHT_LOWER_LEG -> - if (tracker == rightLowerLeg) { - visitor.visitLowerLegTracker( - Side.RIGHT, - tracker, - rightUpperLeg, - rightFoot, - leftLowerLeg, - ) - } - - TrackerPosition.LEFT_FOOT -> - if (tracker == leftFoot) { - visitor.visitFootTracker( - Side.LEFT, - tracker, - leftLowerLeg, - rightFoot, - ) - } - - TrackerPosition.RIGHT_FOOT -> - if (tracker == rightFoot) { - visitor.visitFootTracker( - Side.RIGHT, - tracker, - rightLowerLeg, - leftFoot, - ) - } - - else -> { - // No tracker to visit - } - } - } - - private fun visitUpperBodyTrackers( - tracker: Tracker, - visitor: TrackerVisitor, - head: Tracker?, - upperBody: List, - leftUpperLeg: Tracker?, - rightUpperLeg: Tracker?, - ) { - val index = upperBody.indexOf(tracker) - if (index < 0) { - return - } - - if (index == 0) { - if (upperBody.size == 1) { - // Only upper body tracker - visitor.visitUpperBodyTracker( - tracker, - head, - leftUpperLeg, - rightUpperLeg, - ) - } else { - // First upper body tracker - visitor.visitUpperBodyTracker( - tracker, - head, - upperBody[1], - ) - } - } else if (index < upperBody.size - 1) { - // Middle upper body tracker - visitor.visitUpperBodyTracker( - tracker, - upperBody[index - 1], - upperBody[index + 1], - ) - } else { - // Last upper body tracker - visitor.visitUpperBodyTracker( - tracker, - upperBody[index - 1], - leftUpperLeg, - rightUpperLeg, - ) - } - } - - private fun visitArmTrackers( - tracker: Tracker, - visitor: TrackerVisitor, - side: Side, - upperBody: Tracker?, - arm: List, - hand: Tracker?, - ) { - val index = arm.indexOf(tracker) - if (index < 0) { - return - } - - if (index == 0) { - if (arm.size == 1) { - // Only arm tracker - visitor.visitArmTracker( - side, - tracker, - upperBody, - hand, - ) - } else { - // First arm tracker - visitor.visitArmTracker( - side, - tracker, - upperBody, - arm[1], - ) - } - } else if (index < arm.size - 1) { - // Middle arm tracker - visitor.visitArmTracker( - side, - tracker, - arm[index - 1], - arm[index + 1], - ) - } else { - // Last arm tracker - visitor.visitArmTracker( - side, - tracker, - arm[index - 1], - hand, - ) - } - } - - interface TrackerVisitor { - - /** - * Visits the head tracker. - */ - fun visitHeadTracker( - tracker: Tracker, - belowUpperBody: Tracker?, - ) - - /** - * Visits an upper body tracker (except for the bottom-most tracker). - */ - fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowUpperBody: Tracker?, - ) - - /** - * Visits the bottom-most upper body tracker. - */ - fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowLeftUpperLeg: Tracker?, - belowRightUpperLeg: Tracker?, - ) - - /** - * Visits an arm tracker. - */ - fun visitArmTracker( - side: Side, - tracker: Tracker, - aboveUpperBodyOrArm: Tracker?, - belowHandOrArm: Tracker?, - ) - - /** - * Visits a hand tracker. - */ - fun visitHandTracker( - side: Side, - tracker: Tracker, - aboveArm: Tracker?, - oppositeHand: Tracker?, - ) - - /** - * Visits an upper leg tracker. - */ - fun visitUpperLegTracker( - side: Side, - tracker: Tracker, - aboveUpperBody: Tracker?, - belowLowerLeg: Tracker?, - oppositeUpperLeg: Tracker?, - ) - - /** - * Visits a lower leg tracker. - */ - fun visitLowerLegTracker( - side: Side, - tracker: Tracker, - aboveUpperLeg: Tracker?, - belowFoot: Tracker?, - oppositeLowerLeg: Tracker?, - ) - - /** - * Visits a foot tracker. - */ - fun visitFootTracker( - side: Side, - tracker: Tracker, - aboveLowerLeg: Tracker?, - oppositeFoot: Tracker?, - ) - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/YawErrors.kt b/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/YawErrors.kt deleted file mode 100644 index 4658669b4a..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/processor/stayaligned/trackers/YawErrors.kt +++ /dev/null @@ -1,12 +0,0 @@ -package dev.slimevr.tracking.processor.stayaligned.trackers - -import dev.slimevr.math.AngleErrors - -/** - * Aggregates the yaw errors from multiple forces. - */ -class YawErrors { - var lockedError = AngleErrors() - var centerError = AngleErrors() - var neighborError = AngleErrors() -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/Device.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/Device.kt deleted file mode 100644 index d92f60d04a..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/Device.kt +++ /dev/null @@ -1,52 +0,0 @@ -package dev.slimevr.tracking.trackers - -import dev.slimevr.tracking.trackers.udp.BoardType -import dev.slimevr.tracking.trackers.udp.MCUType -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicInteger - -open class Device(val magSupport: Boolean = false) { - open val id: Int = nextLocalDeviceId.incrementAndGet() - open var name: String? = null - open var firmwareVersion: String? = null - open var firmwareDate: String? = null - open var manufacturer: String? = null - open val trackers: MutableMap = ConcurrentHashMap() - - /** - * Implement toString() to return a string that uniquely identifies the board type - * SHOULDN'T RETURN NULL WHEN toString() IS CALLED - */ - open val boardType: BoardType = BoardType.UNKNOWN - open val mcuType: MCUType = MCUType.UNKNOWN - - open val hardwareIdentifier: String = "Unknown" - - val isOpenVrDevice: Boolean - get() = manufacturer == "OpenVR" - - init { - CoroutineScope(Job()).launch { - // Wait a little for device to get configured - delay(1000) - } - } - - /** - * Enables or disables magnetometers in all the trackers of the device - * if `sensorId` null or in the specified tracker - * @param sensorId If null, every sensor will be modified - */ - open suspend fun setMag(state: Boolean, sensorId: Int = 255) { - TODO("Not implemented because no mag support: $magSupport") - } - - companion object { - @JvmStatic - protected val nextLocalDeviceId = AtomicInteger() - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/DeviceManager.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/DeviceManager.kt deleted file mode 100644 index dd409623a6..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/DeviceManager.kt +++ /dev/null @@ -1,19 +0,0 @@ -package dev.slimevr.tracking.trackers - -import dev.slimevr.VRServer -import io.eiren.util.collections.FastList - -class DeviceManager(private val server: VRServer) { - val devices = FastList() - fun createDevice(name: String?, version: String?, manufacturer: String?): Device { - val device = Device() - device.name = name - device.firmwareVersion = version - device.manufacturer = manufacturer - return device - } - - fun addDevice(device: Device) { - server.queueTask { devices.add(device) } - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/Tracker.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/Tracker.kt deleted file mode 100644 index bb70fbc830..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/Tracker.kt +++ /dev/null @@ -1,453 +0,0 @@ -package dev.slimevr.tracking.trackers - -import dev.slimevr.VRServer -import dev.slimevr.config.TrackerConfig -import dev.slimevr.tracking.processor.stayaligned.trackers.StayAlignedTrackerState -import dev.slimevr.tracking.trackers.TrackerPosition.Companion.getByDesignation -import dev.slimevr.tracking.trackers.udp.IMUType -import dev.slimevr.tracking.trackers.udp.MagnetometerStatus -import dev.slimevr.tracking.trackers.udp.TrackerDataType -import dev.slimevr.util.InterpolationHandler -import io.eiren.util.BufferedTimer -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import kotlin.properties.Delegates - -const val TIMEOUT_MS = 2_000L -const val DISCONNECT_MS = 3_000L + TIMEOUT_MS - -/** - * Generic tracker class for input and output tracker, - * with flags on instantiation. - */ -class Tracker @JvmOverloads constructor( - val device: Device?, - /** - * VRServer.nextLocalTrackerId - */ - val id: Int, - /** - * unique, for config - */ - val name: String, - /** - * default display GUI name - */ - val displayName: String = "Tracker #$id", - trackerPosition: TrackerPosition?, - /** - * It's like the ID, but it should be local to the device if it has one - */ - trackerNum: Int? = null, - val hasPosition: Boolean = false, - val hasRotation: Boolean = false, - val hasAcceleration: Boolean = false, - /** - * User can change TrackerPosition, mounting... - */ - val userEditable: Boolean = false, - /** - * Is used within SlimeVR (shareable trackers) - */ - val isInternal: Boolean = false, - /** - * Has solved position + rotation (Vive trackers) - */ - val isComputed: Boolean = false, - val imuType: IMUType? = null, - /** - * Automatically set the status to DISCONNECTED - */ - val usesTimeout: Boolean = false, - /** - * If true, smoothing and prediction may be enabled. If either are enabled, then - * rotations will be updated with [tick]. This will not have any effect if - * [trackRotDirection] is set to false. - */ - val allowFiltering: Boolean = false, - - /** - * If true, the tracker can be reset - */ - val allowReset: Boolean = false, - /** - * If true, the tracker can do mounting calibration - */ - val allowMounting: Boolean = false, - - val isHmd: Boolean = false, - - /** - * If true, the tracker need the user to perform a reset - */ - var needReset: Boolean = false, - - /** - * Whether to track the direction of the tracker's rotation - * (positive vs negative rotation). This needs to be disabled for AutoBone and - * unit tests, where the rotation is absolute and not temporal. - * - * If true, the output rotation will only be updated after [dataTick]. If false, the - * output rotation will be updated immediately with the raw rotation. - */ - val trackRotDirection: Boolean = true, - magStatus: MagnetometerStatus = MagnetometerStatus.NOT_SUPPORTED, - /** - * Rotation by default. - * NOT the same as hasRotation (other data types emulate rotation) - */ - val trackerDataType: TrackerDataType = TrackerDataType.ROTATION, -) { - private val timer = BufferedTimer(1f) - private var timeAtLastUpdate: Long = System.currentTimeMillis() - private var _rotation = Quaternion.IDENTITY - - // IMU: +z forward, +x left, +y up - // SlimeVR: +z backward, +x right, +y up - private var _acceleration = Vector3.NULL - private var _magVector = Vector3.NULL - var position = Vector3.NULL - val resetsHandler: TrackerResetsHandler = TrackerResetsHandler(this) - val filteringHandler: TrackerFilteringHandler = TrackerFilteringHandler() - val trackerFlexHandler: TrackerFlexHandler = TrackerFlexHandler(this) - var batteryVoltage: Float? = null - var batteryLevel: Float? = null - var batteryRemainingRuntime: Long? = null - var ping: Int? = null - var signalStrength: Int? = null - var temperature: Float? = null - var button: Int? = null - var packetsReceived: Int? = null - var packetsLost: Int? = null - var packetLoss: Float? = null - var customName: String? = null - var magStatus: MagnetometerStatus = magStatus - private set - - /** - * Watch the rest calibration status - */ - var hasCompletedRestCalibration: Boolean? = null - - /** - * If the tracker has gotten disconnected after it was initialized first time - */ - var status: TrackerStatus by Delegates.observable(TrackerStatus.DISCONNECTED) { _, old, new -> - if (old == new) return@observable - - if (allowReset && !old.reset && new.reset && !needReset) { - needReset = true - } - - if (!isInternal && VRServer.instanceInitialized) { - // If the status of a non-internal tracker has changed, inform - // the VRServer to recreate the skeleton, as it may need to - // assign or un-assign the tracker to a body part - VRServer.instance.updateSkeletonModel() - VRServer.instance.refreshTrackersDriftCompensationEnabled() - VRServer.instance.trackerStatusChanged(this, old, new) - } - } - - var trackerPosition: TrackerPosition? by Delegates.observable(trackerPosition) { _, old, new -> - if (old == new) return@observable - - if (allowReset && !needReset) { - needReset = true - } - - if (!isInternal) { - // Set default mounting orientation for that body part - new?.let { resetsHandler.mountingOrientation = it.defaultMounting() } - } - } - - // Computed value to simplify availability checks - val hasAdjustedRotation = hasRotation && (allowFiltering || allowReset) - - /** - * It's like the ID, but it should be local to the device if it has one - */ - val trackerNum: Int = trackerNum ?: id - - val stayAligned = StayAlignedTrackerState(this) - val yawResetSmoothing = InterpolationHandler() - - init { - // IMPORTANT: Look here for the required states of inputs - require(!allowReset || (hasRotation && allowReset)) { - "If ${::allowReset.name} is true, then ${::hasRotation.name} must also be true" - } - require(!allowMounting || (allowReset && allowMounting)) { - "If ${::allowMounting.name} is true, then ${::allowReset.name} must also be true" - } - require(!isHmd || (hasPosition && isHmd)) { - "If ${::isHmd.name} is true, then ${::hasPosition.name} must also be true" - } -// require(device != null && _trackerNum == null) { -// "If ${::device.name} exists, then ${::trackerNum.name} must not be null" -// } - } - - /** - * Reads/loads from the given config - */ - fun readConfig(config: TrackerConfig) { - config.customName?.let { - customName = it - } - config.designation?.let { designation -> - getByDesignation(designation)?.let { trackerPosition = it } - } ?: run { trackerPosition = null } - if (allowMounting) { - // Load manual mounting - config.mountingOrientation?.let { resetsHandler.mountingOrientation = it.toValue() } - } - if (this.isImu() && config.allowDriftCompensation == null) { - // If value didn't exist, default to true and save - resetsHandler.allowDriftCompensation = true - VRServer.instance.configManager.vrConfig.getTracker(this).allowDriftCompensation = true - VRServer.instance.configManager.saveConfig() - } else { - config.allowDriftCompensation?.let { - resetsHandler.allowDriftCompensation = it - } - } - } - - /** - * Writes/saves to the given config - */ - fun writeConfig(config: TrackerConfig) { - trackerPosition?.let { config.designation = it.designation } ?: run { config.designation = null } - customName?.let { config.customName = it } - if (allowMounting) { - // Save manual mounting - config.mountingOrientation = resetsHandler.mountingOrientation.toObject() - } - if (this.isImu()) { - config.allowDriftCompensation = resetsHandler.allowDriftCompensation - } - } - - /** - * Loads the mounting reset quaternion from disk - */ - fun saveMountingResetOrientation(config: TrackerConfig) { - // Load automatic mounting - config.mountingResetOrientation?.let { - resetsHandler.trySetMountingReset(it.toValue()) - } - } - - /** - * Saves the mounting reset quaternion to disk - */ - fun saveMountingResetOrientation(quat: Quaternion?) { - val configManager = VRServer.instance.configManager - configManager.vrConfig.getTracker(this).mountingResetOrientation = quat?.toObject() - configManager.saveConfig() - } - - /** - * Synchronized with the VRServer's 1000hz while loop - */ - fun tick(deltaTime: Float) { - if (usesTimeout) { - if (System.currentTimeMillis() - timeAtLastUpdate > DISCONNECT_MS) { - status = TrackerStatus.DISCONNECTED - } else if (System.currentTimeMillis() - timeAtLastUpdate > TIMEOUT_MS) { - status = TrackerStatus.TIMED_OUT - } - } - - filteringHandler.update() - yawResetSmoothing.tick(deltaTime) - stayAligned.update() - } - - /** - * Tells the tracker that it received new data - * NOTE: Use only when rotation is received - */ - fun dataTick() { - timer.update() - timeAtLastUpdate = System.currentTimeMillis() - if (trackRotDirection) { - filteringHandler.dataTick(getAdjustedRotation()) - } - } - - /** - * A way to delay the timeout of the tracker - */ - fun heartbeat() { - timeAtLastUpdate = System.currentTimeMillis() - } - - /** - * Gets the adjusted tracker rotation after the resetsHandler's corrections - * (reset, mounting and drift compensation). - * This is the rotation that is applied on the SlimeVR skeleton bones. - * Warning: This performs several Quaternion multiplications, so calling - * it too much should be avoided for performance reasons. - */ - private fun getAdjustedRotation(): Quaternion { - var rot = _rotation - - if (!stayAligned.hideCorrection) { - // Yaw drift happens in the raw rotation space - rot = Quaternion.rotationAroundYAxis(stayAligned.yawCorrection.toRad()) * rot - } - - // Reset if needed and is not computed and internal - return if (allowReset && !(isComputed && isInternal) && trackerDataType == TrackerDataType.ROTATION) { - // Adjust to reset, mounting and drift compensation - resetsHandler.getReferenceAdjustedDriftRotationFrom(rot) - } else { - rot - } - } - - /** - * Same as getAdjustedRotation except that Stay Aligned correction is always - * applied. This allows Stay Aligned to do gradient descent with the tracker's - * rotation. - */ - fun getAdjustedRotationForceStayAligned(): Quaternion { - var rot = _rotation - - // Yaw drift happens in the raw rotation space - rot = Quaternion.rotationAroundYAxis(stayAligned.yawCorrection.toRad()) * rot - - // Reset if needed and is not computed and internal - return if (allowReset && !(isComputed && isInternal) && trackerDataType == TrackerDataType.ROTATION) { - // Adjust to reset, mounting and drift compensation - resetsHandler.getReferenceAdjustedDriftRotationFrom(rot) - } else { - rot - } - } - - /** - * Gets the identity-adjusted tracker rotation after the resetsHandler's corrections - * (identity reset, drift and identity mounting). - * This is used for debugging/visualizing tracker data - */ - fun getIdentityAdjustedRotation(): Quaternion { - var rot = _rotation - - if (!stayAligned.hideCorrection) { - // Yaw drift happens in the raw rotation space - rot = Quaternion.rotationAroundYAxis(stayAligned.yawCorrection.toRad()) * rot - } - - // Reset if needed or is a computed tracker besides head - return if (allowReset && !(isComputed && trackerPosition != TrackerPosition.HEAD) && trackerDataType == TrackerDataType.ROTATION) { - // Adjust to reset and mounting - resetsHandler.getIdentityAdjustedDriftRotationFrom(rot) - } else { - rot - } - } - - /** - * Get the rotation of the tracker after the resetsHandler's corrections, filtering, - * and reset smoothing if applicable - */ - fun getRotation(): Quaternion { - var rot = getRotationNoResetSmooth() - - if (yawResetSmoothing.remainingTime > 0f) { - rot = yawResetSmoothing.curRotation * rot - } - - return rot - } - - /** - * Get the rotation of the tracker after the resetsHandler's corrections and - * filtering if applicable - */ - fun getRotationNoResetSmooth(): Quaternion = if (trackRotDirection) { - filteringHandler.getFilteredRotation() - } else { - // Get non-filtered rotation - getAdjustedRotation() - } - - /** - * Gets the world-adjusted acceleration - */ - fun getAcceleration(): Vector3 = if (allowReset) { - resetsHandler.getReferenceAdjustedAccel(_rotation, _acceleration) - } else { - _acceleration - } - - /** - * Gets the raw (unadjusted) rotation of the tracker. - * If this is an IMU, this will be the raw sensor rotation. - */ - fun getRawRotation() = _rotation - - /** - * Sets the raw (unadjusted) rotation of the tracker. - */ - fun setRotation(rotation: Quaternion) { - this._rotation = rotation - } - - /** - * Sets the raw (unadjusted) acceleration of the tracker. - */ - fun setAcceleration(vec: Vector3) { - this._acceleration = vec - } - - /** - * True if the raw rotation is coming directly from an IMU (no cameras or lighthouses) - * For example, flex sensor trackers are not considered as IMU trackers (see TrackerDataType) - */ - fun isImu(): Boolean = imuType != null && trackerDataType == TrackerDataType.ROTATION - - /** - * Please don't use this and instead set it via [Device.setMag] - */ - internal fun setMagPrivate(mag: Boolean) { - magStatus = if (mag) { - MagnetometerStatus.ENABLED - } else { - MagnetometerStatus.DISABLED - } - } - - /** - * Gets the magnetic field vector, in mGauss. - */ - fun getMagVector() = if (allowReset) { - resetsHandler.getReferenceAdjustedAccel(_rotation, _magVector) - } else { - _magVector - } - - /** - * Sets the magnetic field vector. - */ - fun setMagVector(vec: Vector3) { - this._magVector = vec - } - - /** - * Gets the current TPS of the tracker - */ - val tps: Float - get() = timer.averageFPS - - /** - * Call when doing a full reset to reset the tracking of rotations >180 degrees - */ - fun resetFilteringQuats(reference: Quaternion) { - filteringHandler.resetMovingAverage(getAdjustedRotation(), reference) - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerFilteringHandler.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerFilteringHandler.kt deleted file mode 100644 index d660c50cb5..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerFilteringHandler.kt +++ /dev/null @@ -1,69 +0,0 @@ -package dev.slimevr.tracking.trackers - -import dev.slimevr.config.FiltersConfig -import dev.slimevr.filtering.QuaternionMovingAverage -import dev.slimevr.filtering.TrackerFilters -import io.github.axisangles.ktmath.Quaternion - -/** - * Class taking care of filtering logic - * (smoothing and prediction) - * See QuaternionMovingAverage.kt for the quaternion math. - */ -class TrackerFilteringHandler { - // Instantiated by default in case config doesn't get read (if tracker doesn't support filtering) - private var movingAverage = QuaternionMovingAverage(TrackerFilters.NONE) - var filteringEnabled = false - - /** - * Reads/loads filtering settings from given config - */ - fun readFilteringConfig(config: FiltersConfig, currentRotation: Quaternion) { - val type = TrackerFilters.getByConfigkey(config.type) - if (type == TrackerFilters.SMOOTHING || type == TrackerFilters.PREDICTION) { - movingAverage = QuaternionMovingAverage( - type, - config.amount, - currentRotation, - ) - filteringEnabled = true - } else { - movingAverage = QuaternionMovingAverage( - TrackerFilters.NONE, - initialRotation = currentRotation, - ) - filteringEnabled = false - } - } - - /** - * Update the moving average to make it smooth - */ - fun update() { - movingAverage.update() - } - - /** - * Updates the latest rotation - */ - fun dataTick(currentRawRotation: Quaternion) { - movingAverage.addQuaternion(currentRawRotation) - } - - /** - * Call when doing a full reset to reset the tracking of rotations >180 degrees - */ - fun resetMovingAverage(currentRotation: Quaternion, reference: Quaternion) { - movingAverage.resetQuats(currentRotation, reference) - } - - /** - * Get the filtered rotation from the moving average (either prediction/smoothing or just >180 degs) - */ - fun getFilteredRotation() = movingAverage.filteredQuaternion - - /** - * Get the impact filtering has on the rotation - */ - fun getFilteringImpact(): Float = movingAverage.filteringImpact -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerFlexHandler.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerFlexHandler.kt deleted file mode 100644 index 653292cfb0..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerFlexHandler.kt +++ /dev/null @@ -1,176 +0,0 @@ -package dev.slimevr.tracking.trackers - -import com.jme3.math.FastMath -import io.github.axisangles.ktmath.EulerAngles -import io.github.axisangles.ktmath.EulerOrder -import kotlin.math.* - -/** - * Class handling flex sensor data (angle and resistance) - * Resistance is expected to go up with bend by default, but a mounting reset allows the contrary - */ -class TrackerFlexHandler(val tracker: Tracker) { - private var minResistance = Float.MIN_VALUE - private var maxResistance = Float.MAX_VALUE - - // Used to support resistance going both ways. - // Default is higher = more bend, but can change after a full and mounting reset. - private var lastMinResetResistance = Float.MIN_VALUE - private var resistanceReversed = false - private var lastResistance = 0f - private val thumbInitialOffset = FastMath.PI / 8 // 22.5 deg - - /** - * Resets the min resistance from the last resistance value received. - * Triggered from full reset - */ - fun resetMin() { - minResistance = lastResistance - lastMinResetResistance = lastResistance - - setFlexResistance(lastResistance) - tracker.dataTick() - } - - /** - * Resets the max resistance from the last resistance value received. - * Triggered from mounting reset - */ - fun resetMax() { - // Account for the resistance being able to be reversed - if (resistanceReversed != lastResistance < lastMinResetResistance) { - // Switching - resistanceReversed = lastResistance < lastMinResetResistance - minResistance = maxResistance - maxResistance = lastMinResetResistance - } else { - // Not switching - maxResistance = lastResistance - } - - setFlexResistance(lastResistance) - tracker.dataTick() - } - - /** - * Sets the flex resistance which is then calculated into an angle - */ - fun setFlexResistance(resistance: Float) { - // Dynamically calibrate the minimum resistance - minResistance = if (minResistance == Float.MIN_VALUE) { - resistance - } else if (!resistanceReversed) { - min(minResistance, resistance) - } else { - max(minResistance, resistance) - } - - // Dynamically calibrate the maximum resistance - maxResistance = if (maxResistance == Float.MAX_VALUE) { - resistance - } else if (!resistanceReversed) { - max(maxResistance, resistance) - } else { - min(maxResistance, resistance) - } - - // Get max angle - val maxBend = getMaxAngleForTrackerPosition(tracker.trackerPosition) - - // Get angle and set it - val angle = if (minResistance == maxResistance) { - // Avoid division by 0 - 0f - } else { - maxBend * (resistance - minResistance) / (maxResistance - minResistance) - } - setFlexAngle(angle) - - lastResistance = resistance - } - - /** - * Sets an angle (rad) about the X axis - */ - fun setFlexAngle(angle: Float) { - // Sets the rotation of the tracker by the angle about a given axis depending on - // the tracker's TrackerPosition - when (tracker.trackerPosition) { - TrackerPosition.LEFT_INDEX_PROXIMAL, TrackerPosition.LEFT_INDEX_INTERMEDIATE, - TrackerPosition.LEFT_INDEX_DISTAL, TrackerPosition.LEFT_MIDDLE_PROXIMAL, - TrackerPosition.LEFT_MIDDLE_INTERMEDIATE, TrackerPosition.LEFT_MIDDLE_DISTAL, - TrackerPosition.LEFT_RING_PROXIMAL, TrackerPosition.LEFT_RING_INTERMEDIATE, - TrackerPosition.LEFT_RING_DISTAL, TrackerPosition.LEFT_LITTLE_PROXIMAL, - TrackerPosition.LEFT_LITTLE_INTERMEDIATE, TrackerPosition.LEFT_LITTLE_DISTAL, - TrackerPosition.RIGHT_SHOULDER, - -> tracker.setRotation(EulerAngles(EulerOrder.YZX, 0f, 0f, angle).toQuaternion()) - - TrackerPosition.RIGHT_INDEX_PROXIMAL, TrackerPosition.RIGHT_INDEX_INTERMEDIATE, - TrackerPosition.RIGHT_INDEX_DISTAL, TrackerPosition.RIGHT_MIDDLE_PROXIMAL, - TrackerPosition.RIGHT_MIDDLE_INTERMEDIATE, TrackerPosition.RIGHT_MIDDLE_DISTAL, - TrackerPosition.RIGHT_RING_PROXIMAL, TrackerPosition.RIGHT_RING_INTERMEDIATE, - TrackerPosition.RIGHT_RING_DISTAL, TrackerPosition.RIGHT_LITTLE_PROXIMAL, - TrackerPosition.RIGHT_LITTLE_INTERMEDIATE, TrackerPosition.RIGHT_LITTLE_DISTAL, - TrackerPosition.LEFT_SHOULDER, - -> tracker.setRotation(EulerAngles(EulerOrder.YZX, 0f, 0f, -angle).toQuaternion()) - - TrackerPosition.LEFT_THUMB_METACARPAL, TrackerPosition.LEFT_THUMB_PROXIMAL, TrackerPosition.LEFT_THUMB_DISTAL, - -> tracker.setRotation(EulerAngles(EulerOrder.YZX, thumbInitialOffset - angle, -angle * 0.05f, angle * 0.1f).toQuaternion()) - - TrackerPosition.RIGHT_THUMB_METACARPAL, TrackerPosition.RIGHT_THUMB_PROXIMAL, TrackerPosition.RIGHT_THUMB_DISTAL, - -> tracker.setRotation(EulerAngles(EulerOrder.YZX, thumbInitialOffset - angle, angle * 0.05f, -angle * 0.1f).toQuaternion()) - - // Default to X axis (pitch) - else -> tracker.setRotation(EulerAngles(EulerOrder.YZX, angle, 0f, 0f).toQuaternion()) - } - } - - /** - * Gets the max angle for a TrackerPosition - */ - private fun getMaxAngleForTrackerPosition(trackerPosition: TrackerPosition?): Float { - if (trackerPosition == null) return FastMath.PI // 180 degrees - - return when (trackerPosition) { - // 270 degrees - TrackerPosition.LEFT_INDEX_DISTAL, TrackerPosition.LEFT_MIDDLE_DISTAL, - TrackerPosition.LEFT_RING_DISTAL, TrackerPosition.LEFT_LITTLE_DISTAL, - TrackerPosition.RIGHT_INDEX_DISTAL, TrackerPosition.RIGHT_MIDDLE_DISTAL, - TrackerPosition.RIGHT_RING_DISTAL, TrackerPosition.RIGHT_LITTLE_DISTAL, - -> FastMath.PI + FastMath.HALF_PI - - // 202.5 degrees - TrackerPosition.LEFT_THUMB_DISTAL, TrackerPosition.RIGHT_THUMB_DISTAL, - -> FastMath.PI + thumbInitialOffset - - // 180 degrees - TrackerPosition.LEFT_INDEX_INTERMEDIATE, TrackerPosition.LEFT_MIDDLE_INTERMEDIATE, - TrackerPosition.LEFT_RING_INTERMEDIATE, TrackerPosition.LEFT_LITTLE_INTERMEDIATE, - TrackerPosition.RIGHT_INDEX_INTERMEDIATE, TrackerPosition.RIGHT_MIDDLE_INTERMEDIATE, - TrackerPosition.RIGHT_RING_INTERMEDIATE, TrackerPosition.RIGHT_LITTLE_INTERMEDIATE, - -> FastMath.PI - - // 112.5 degrees - TrackerPosition.LEFT_THUMB_PROXIMAL, TrackerPosition.RIGHT_THUMB_PROXIMAL, - -> FastMath.HALF_PI + thumbInitialOffset - - // 90 degrees - TrackerPosition.LEFT_INDEX_PROXIMAL, TrackerPosition.LEFT_MIDDLE_PROXIMAL, - TrackerPosition.LEFT_RING_PROXIMAL, TrackerPosition.LEFT_LITTLE_PROXIMAL, - TrackerPosition.RIGHT_INDEX_PROXIMAL, TrackerPosition.RIGHT_MIDDLE_PROXIMAL, - TrackerPosition.RIGHT_RING_PROXIMAL, TrackerPosition.RIGHT_LITTLE_PROXIMAL, - -> FastMath.HALF_PI - - // 67.5 degrees - TrackerPosition.LEFT_THUMB_METACARPAL, TrackerPosition.RIGHT_THUMB_METACARPAL, - -> FastMath.QUARTER_PI + thumbInitialOffset - - // 45 degrees - TrackerPosition.LEFT_SHOULDER, TrackerPosition.RIGHT_SHOULDER, - -> FastMath.QUARTER_PI - - // 135 degrees - else -> FastMath.HALF_PI + FastMath.QUARTER_PI - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerPosition.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerPosition.kt deleted file mode 100644 index 65a6c79afa..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerPosition.kt +++ /dev/null @@ -1,236 +0,0 @@ -@file:Suppress("ktlint:standard:no-wildcard-imports") - -package dev.slimevr.tracking.trackers - -import dev.slimevr.tracking.trackers.TrackerPosition.* -import io.github.axisangles.ktmath.Quaternion -import solarxr_protocol.datatypes.BodyPart - -fun TrackerPosition?.isThigh(): Boolean { - this?.let { - return it == LEFT_UPPER_LEG || - it == RIGHT_UPPER_LEG - } - return false -} - -fun TrackerPosition?.isLeftArm(): Boolean { - this?.let { - return it == LEFT_SHOULDER || - it == LEFT_UPPER_ARM || - it == LEFT_LOWER_ARM || - it == LEFT_HAND - } - return false -} - -fun TrackerPosition?.isRightArm(): Boolean { - this?.let { - return it == RIGHT_SHOULDER || - it == RIGHT_UPPER_ARM || - it == RIGHT_LOWER_ARM || - it == RIGHT_HAND - } - return false -} - -fun TrackerPosition?.isLeftLowerArm(): Boolean { - this?.let { - return it == LEFT_LOWER_ARM || - it == LEFT_HAND - } - return false -} - -fun TrackerPosition?.isRightLowerArm(): Boolean { - this?.let { - return it == RIGHT_LOWER_ARM || - it == RIGHT_HAND - } - return false -} - -fun TrackerPosition?.isFoot(): Boolean { - this?.let { - return it == LEFT_FOOT || - it == RIGHT_FOOT - } - return false -} - -fun TrackerPosition?.isLeftFinger(): Boolean { - this?.let { - return it == LEFT_THUMB_METACARPAL || - it == LEFT_THUMB_PROXIMAL || - it == LEFT_THUMB_DISTAL || - it == LEFT_INDEX_PROXIMAL || - it == LEFT_INDEX_INTERMEDIATE || - it == LEFT_INDEX_DISTAL || - it == LEFT_MIDDLE_PROXIMAL || - it == LEFT_MIDDLE_INTERMEDIATE || - it == LEFT_MIDDLE_DISTAL || - it == LEFT_RING_PROXIMAL || - it == LEFT_RING_INTERMEDIATE || - it == LEFT_RING_DISTAL || - it == LEFT_LITTLE_PROXIMAL || - it == LEFT_LITTLE_INTERMEDIATE || - it == LEFT_LITTLE_DISTAL - } - return false -} - -fun TrackerPosition?.isRightFinger(): Boolean { - this?.let { - return it == RIGHT_THUMB_METACARPAL || - it == RIGHT_THUMB_PROXIMAL || - it == RIGHT_THUMB_DISTAL || - it == RIGHT_INDEX_PROXIMAL || - it == RIGHT_INDEX_INTERMEDIATE || - it == RIGHT_INDEX_DISTAL || - it == RIGHT_MIDDLE_PROXIMAL || - it == RIGHT_MIDDLE_INTERMEDIATE || - it == RIGHT_MIDDLE_DISTAL || - it == RIGHT_RING_PROXIMAL || - it == RIGHT_RING_INTERMEDIATE || - it == RIGHT_RING_DISTAL || - it == RIGHT_LITTLE_PROXIMAL || - it == RIGHT_LITTLE_INTERMEDIATE || - it == RIGHT_LITTLE_DISTAL - } - return false -} - -/** - * Represents a position on the body that a tracker could be placed. Any bone is - * a valid position. - * - * TrackerPosition intentionally lacks a numerical id to avoid breakage. - */ -enum class TrackerPosition( - val designation: String, - val trackerRole: TrackerRole?, - val bodyPart: Int, - val id: Int, -) { - // If updating BodyPart of a TrackerRole, - // please also update SteamVRBridge#updateShareSettingsAutomatically() - HEAD("body:head", TrackerRole.HMD, BodyPart.HEAD, 1), - NECK("body:neck", TrackerRole.NECK, BodyPart.NECK, 2), - UPPER_CHEST("body:upper_chest", TrackerRole.CHEST, BodyPart.UPPER_CHEST, 3), - CHEST("body:chest", null, BodyPart.CHEST, 4), - WAIST("body:waist", null, BodyPart.WAIST, 5), - HIP("body:hip", TrackerRole.WAIST, BodyPart.HIP, 6), - LEFT_UPPER_LEG("body:left_upper_leg", TrackerRole.LEFT_KNEE, BodyPart.LEFT_UPPER_LEG, 7), - RIGHT_UPPER_LEG("body:right_upper_leg", TrackerRole.RIGHT_KNEE, BodyPart.RIGHT_UPPER_LEG, 8), - LEFT_LOWER_LEG("body:left_lower_leg", null, BodyPart.LEFT_LOWER_LEG, 9), - RIGHT_LOWER_LEG("body:right_lower_leg", null, BodyPart.RIGHT_LOWER_LEG, 10), - LEFT_FOOT("body:left_foot", TrackerRole.LEFT_FOOT, BodyPart.LEFT_FOOT, 11), - RIGHT_FOOT("body:right_foot", TrackerRole.RIGHT_FOOT, BodyPart.RIGHT_FOOT, 12), - LEFT_LOWER_ARM("body:left_lower_arm", null, BodyPart.LEFT_LOWER_ARM, 13), - RIGHT_LOWER_ARM("body:right_lower_arm", null, BodyPart.RIGHT_LOWER_ARM, 14), - LEFT_UPPER_ARM("body:left_upper_arm", TrackerRole.LEFT_ELBOW, BodyPart.LEFT_UPPER_ARM, 15), - RIGHT_UPPER_ARM("body:right_upper_arm", TrackerRole.RIGHT_ELBOW, BodyPart.RIGHT_UPPER_ARM, 16), - LEFT_HAND("body:left_hand", TrackerRole.LEFT_HAND, BodyPart.LEFT_HAND, 17), - RIGHT_HAND("body:right_hand", TrackerRole.RIGHT_HAND, BodyPart.RIGHT_HAND, 18), - LEFT_SHOULDER("body:left_shoulder", TrackerRole.LEFT_SHOULDER, BodyPart.LEFT_SHOULDER, 19), - RIGHT_SHOULDER("body:right_shoulder", TrackerRole.RIGHT_SHOULDER, BodyPart.RIGHT_SHOULDER, 20), - LEFT_THUMB_METACARPAL("body:left_thumb_metacarpal", null, BodyPart.LEFT_THUMB_METACARPAL, 21), - LEFT_THUMB_PROXIMAL("body:left_thumb_proximal", null, BodyPart.LEFT_THUMB_PROXIMAL, 22), - LEFT_THUMB_DISTAL("body:left_thumb_distal", null, BodyPart.LEFT_THUMB_DISTAL, 23), - LEFT_INDEX_PROXIMAL("body:left_index_proximal", null, BodyPart.LEFT_INDEX_PROXIMAL, 24), - LEFT_INDEX_INTERMEDIATE("body:left_index_intermediate", null, BodyPart.LEFT_INDEX_INTERMEDIATE, 25), - LEFT_INDEX_DISTAL("body:left_index_distal", null, BodyPart.LEFT_INDEX_DISTAL, 26), - LEFT_MIDDLE_PROXIMAL("body:left_middle_proximal", null, BodyPart.LEFT_MIDDLE_PROXIMAL, 27), - LEFT_MIDDLE_INTERMEDIATE("body:left_middle_intermediate", null, BodyPart.LEFT_MIDDLE_INTERMEDIATE, 28), - LEFT_MIDDLE_DISTAL("body:left_middle_distal", null, BodyPart.LEFT_MIDDLE_DISTAL, 29), - LEFT_RING_PROXIMAL("body:left_ring_proximal", null, BodyPart.LEFT_RING_PROXIMAL, 30), - LEFT_RING_INTERMEDIATE("body:left_ring_intermediate", null, BodyPart.LEFT_RING_INTERMEDIATE, 31), - LEFT_RING_DISTAL("body:left_ring_distal", null, BodyPart.LEFT_RING_DISTAL, 32), - LEFT_LITTLE_PROXIMAL("body:left_little_proximal", null, BodyPart.LEFT_LITTLE_PROXIMAL, 33), - LEFT_LITTLE_INTERMEDIATE("body:left_little_intermediate", null, BodyPart.LEFT_LITTLE_INTERMEDIATE, 34), - LEFT_LITTLE_DISTAL("body:left_little_distal", null, BodyPart.LEFT_LITTLE_DISTAL, 35), - RIGHT_THUMB_METACARPAL("body:right_thumb_metacarpal", null, BodyPart.RIGHT_THUMB_METACARPAL, 36), - RIGHT_THUMB_PROXIMAL("body:right_thumb_proximal", null, BodyPart.RIGHT_THUMB_PROXIMAL, 37), - RIGHT_THUMB_DISTAL("body:right_thumb_distal", null, BodyPart.RIGHT_THUMB_DISTAL, 38), - RIGHT_INDEX_PROXIMAL("body:right_index_proximal", null, BodyPart.RIGHT_INDEX_PROXIMAL, 39), - RIGHT_INDEX_INTERMEDIATE("body:right_index_intermediate", null, BodyPart.RIGHT_INDEX_INTERMEDIATE, 40), - RIGHT_INDEX_DISTAL("body:right_index_distal", null, BodyPart.RIGHT_INDEX_DISTAL, 41), - RIGHT_MIDDLE_PROXIMAL("body:right_middle_proximal", null, BodyPart.RIGHT_MIDDLE_PROXIMAL, 42), - RIGHT_MIDDLE_INTERMEDIATE("body:right_middle_intermediate", null, BodyPart.RIGHT_MIDDLE_INTERMEDIATE, 43), - RIGHT_MIDDLE_DISTAL("body:right_middle_distal", null, BodyPart.RIGHT_MIDDLE_DISTAL, 44), - RIGHT_RING_PROXIMAL("body:right_ring_proximal", null, BodyPart.RIGHT_RING_PROXIMAL, 45), - RIGHT_RING_INTERMEDIATE("body:right_ring_intermediate", null, BodyPart.RIGHT_RING_INTERMEDIATE, 46), - RIGHT_RING_DISTAL("body:right_ring_distal", null, BodyPart.RIGHT_RING_DISTAL, 47), - RIGHT_LITTLE_PROXIMAL("body:right_little_proximal", null, BodyPart.RIGHT_LITTLE_PROXIMAL, 48), - RIGHT_LITTLE_INTERMEDIATE("body:right_little_intermediate", null, BodyPart.RIGHT_LITTLE_INTERMEDIATE, 49), - RIGHT_LITTLE_DISTAL("body:right_little_distal", null, BodyPart.RIGHT_LITTLE_DISTAL, 50), - ; - - /** - * Returns the default mounting orientation for the body part - */ - fun defaultMounting(): Quaternion = when (this) { - LEFT_LOWER_ARM, LEFT_HAND, - LEFT_INDEX_PROXIMAL, LEFT_INDEX_INTERMEDIATE, - LEFT_INDEX_DISTAL, LEFT_MIDDLE_PROXIMAL, - LEFT_MIDDLE_INTERMEDIATE, LEFT_MIDDLE_DISTAL, - LEFT_RING_PROXIMAL, LEFT_RING_INTERMEDIATE, - LEFT_RING_DISTAL, LEFT_LITTLE_PROXIMAL, - LEFT_LITTLE_INTERMEDIATE, LEFT_LITTLE_DISTAL, - -> Quaternion.SLIMEVR.LEFT - - RIGHT_LOWER_ARM, RIGHT_HAND, - RIGHT_INDEX_PROXIMAL, RIGHT_INDEX_INTERMEDIATE, - RIGHT_INDEX_DISTAL, RIGHT_MIDDLE_PROXIMAL, - RIGHT_MIDDLE_INTERMEDIATE, RIGHT_MIDDLE_DISTAL, - RIGHT_RING_PROXIMAL, RIGHT_RING_INTERMEDIATE, - RIGHT_RING_DISTAL, RIGHT_LITTLE_PROXIMAL, - RIGHT_LITTLE_INTERMEDIATE, RIGHT_LITTLE_DISTAL, - -> Quaternion.SLIMEVR.RIGHT - - LEFT_UPPER_ARM, LEFT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_LEFT - - RIGHT_UPPER_ARM, RIGHT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_RIGHT - - else -> Quaternion.SLIMEVR.FRONT - } - - companion object { - /** Indexed by `BodyPart` int value. EFFICIENCY FTW */ - private val byBodyPart: Array = arrayOfNulls(BodyPart.names.size).apply { - for (position in entries) { - this[position.bodyPart] = position - } - } - private val byId = entries.associateBy { it.id } - private val byDesignation = entries.associateBy { it.designation.lowercase() } - private val byTrackerRole = entries.filter { it.trackerRole != null }.associateBy { it.trackerRole!! } - - /** - * Gets the `TrackerPosition` by its string designation. - * - * @return Returns an optional as not all strings are valid designators. - */ - @JvmStatic - fun getByDesignation(designation: String): TrackerPosition? = byDesignation[designation.lowercase()] - - @JvmStatic - fun getByTrackerRole(role: TrackerRole): TrackerPosition? { - // Hands TrackerPositions are bound to the hands TrackerRoles, - // so we hardcode getting those. - if (role == TrackerRole.LEFT_CONTROLLER) { - return LEFT_HAND - } - if (role == TrackerRole.RIGHT_CONTROLLER) { - return RIGHT_HAND - } - return byTrackerRole[role] - } - - @JvmStatic - fun getByBodyPart(bodyPart: Int): TrackerPosition? = byBodyPart[bodyPart] - - @JvmStatic - fun getById(id: Int): TrackerPosition? = byId[id] - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerResetsHandler.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerResetsHandler.kt deleted file mode 100644 index 25a5fd3426..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerResetsHandler.kt +++ /dev/null @@ -1,599 +0,0 @@ -package dev.slimevr.tracking.trackers - -import com.jme3.math.FastMath -import dev.slimevr.VRServer -import dev.slimevr.config.ArmsResetModes -import dev.slimevr.config.DriftCompensationConfig -import dev.slimevr.config.ResetsConfig -import dev.slimevr.filtering.CircularArrayList -import dev.slimevr.tracking.trackers.udp.TrackerDataType -import io.github.axisangles.ktmath.EulerAngles -import io.github.axisangles.ktmath.EulerOrder -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import kotlin.math.* - -private const val DRIFT_COOLDOWN_MS = 50000L - -/** Class taking care of full reset, yaw reset, mounting reset, and drift compensation logic. */ -class TrackerResetsHandler(val tracker: Tracker) { - - private val HalfHorizontal = EulerAngles( - EulerOrder.YZX, - 0f, - Math.PI.toFloat(), - 0f, - ).toQuaternion() - private var driftAmount = 0f - private var averagedDriftQuat = Quaternion.IDENTITY - private var rotationSinceReset = Quaternion.IDENTITY - private var driftQuats = CircularArrayList(0) - private var driftTimes = CircularArrayList(0) - private var totalDriftTime: Long = 0 - private var driftSince: Long = 0 - private var timeAtLastReset: Long = 0 - private var compensateDrift = false - private var driftPrediction = false - private var driftCompensationEnabled = false - private var armsResetMode = ArmsResetModes.BACK - private var yawResetSmoothTime = 0.0f - var saveMountingReset = false - var resetHmdPitch = false - var allowDriftCompensation = false - var lastResetQuaternion: Quaternion? = null - - // Manual mounting orientation - var mountingOrientation = HalfHorizontal - set(value) { - field = value - // Clear the mounting reset now that it's been set manually - clearMounting() - } - - // Reference adjustment quats - - /** - * Gyro fix is set by full reset. This sets the current y rotation to 0, correcting - * for initial yaw rotation and the rotation incurred by mounting orientation. This - * is a local offset in rotation and does not affect the axes of rotation. - * - * This rotation is only used to compute [attachmentFix], otherwise [yawFix] would - * correct for the same rotation. - */ - private var gyroFix = Quaternion.IDENTITY - - /** - * Attachment fix is set by full reset. This sets the current x and z rotations to - * 0, correcting for initial pitch and roll rotation. This is a global offset in - * rotation and affects the axes of rotation. - * - * This effectively sets the rotation at the moment of a full reset to be - * zero-reference in the x and z axes. - */ - private var attachmentFix = Quaternion.IDENTITY - - /** - * Mounting rotation fix is set by mounting reset. This corrects for the mounting - * orientation, then the inverse is used to correct for the rotation incurred. This - * value is computed after [yawFix], but takes effect before [yawFix]. This affects - * the axes of rotation, but does not incur an offset in rotation. - * - * This rotation is done in addition to [mountingOrientation] as to not interfere - * with the functionality of manual mounting orientation. This effectively sets the - * rotation at the moment of a mounting reset to be zero-reference in the y-axis. If - * no mounting reset is done, then this rotation will not be used and only - * [mountingOrientation] will apply. - */ - var mountRotFix = Quaternion.IDENTITY - private set - - /** - * Yaw fix is set by yaw reset. This sets the current y rotation to match the - * provided reference, correlating the tracker to the provided frame of reference. - * This is a local offset in rotation and does not affect the axes of rotation. - * - * This effectively aligns the current yaw rotation to the head tracker's yaw - * rotation. - */ - private var yawFix = Quaternion.IDENTITY - - /** - * Constraint fix is set by skeleton constraints. This corrects for any yaw rotation - * that violates the skeleton constraints. This is a local offset in rotation and - * does not affect the axes of rotation. - */ - private var constraintFix = Quaternion.IDENTITY - - // Zero-reference/identity adjustment quats for IMU debugging - private var gyroFixNoMounting = Quaternion.IDENTITY - private var attachmentFixNoMounting = Quaternion.IDENTITY - private var yawFixZeroReference = Quaternion.IDENTITY - - /** - * T-Pose down fix is set by full reset. This corrects for the pitch of the rotation - * assuming a t-pose reference, adjusting to match our expected i-pose reference. - * This is a global offset in rotation and affects the axes of rotation. - */ - private var tposeDownFix = Quaternion.IDENTITY - - /** - * Reads/loads drift compensation settings from given config - */ - fun readDriftCompensationConfig(config: DriftCompensationConfig) { - compensateDrift = false - driftPrediction = config.prediction - driftAmount = config.amount - val maxResets = config.maxResets - - if (compensateDrift && maxResets != driftQuats.capacity()) { - driftQuats = CircularArrayList(maxResets) - driftTimes = CircularArrayList(maxResets) - } - - refreshDriftCompensationEnabled() - } - - /** - * Clears drift compensation data - */ - fun clearDriftCompensation() { - driftSince = 0L - timeAtLastReset = 0L - totalDriftTime = 0L - driftQuats.clear() - driftTimes.clear() - } - - /** - * Checks for compensateDrift, allowDriftCompensation, and if - * a computed head tracker exists. - */ - fun refreshDriftCompensationEnabled() { - driftCompensationEnabled = compensateDrift && - allowDriftCompensation && - TrackerUtils.getNonInternalNonImuTrackerForBodyPosition( - VRServer.instance.allTrackers, - TrackerPosition.HEAD, - ) != null - } - - /** - * Reads/loads reset settings from the given config - */ - fun readResetConfig(config: ResetsConfig) { - armsResetMode = config.mode - yawResetSmoothTime = config.yawResetSmoothTime - saveMountingReset = config.saveMountingReset - resetHmdPitch = config.resetHmdPitch - } - - fun trySetMountingReset(quat: Quaternion) { - if (saveMountingReset) { - mountRotFix = quat - } - } - - /** - * Takes a rotation and adjusts it to resets, mounting, - * and drift compensation, with the HMD as the reference. - */ - fun getReferenceAdjustedDriftRotationFrom(rotation: Quaternion): Quaternion = adjustToDrift(adjustToReference(rotation)) - - /** - * Takes a rotation and adjusts it to resets and mounting, - * with the identity Quaternion as the reference. - */ - fun getIdentityAdjustedDriftRotationFrom(rotation: Quaternion): Quaternion = adjustToDrift(adjustToIdentity(rotation)) - - /** - * Get the reference adjusted accel. - */ - // TODO: Make this actually adjusted to the corrected IMU heading. The current - // implementation for heading correction doesn't appear to be correct and may simply - // make acceleration worse, so I'm just leaving this until we work that out. The - // output of this will be world space, but with an unknown offset to heading (yaw). - // - Butterscotch - fun getReferenceAdjustedAccel(rawRot: Quaternion, accel: Vector3): Vector3 = rawRot.sandwich(accel) - - /** - * Converts raw or filtered rotation into reference- and - * mounting-reset-adjusted by applying quaternions produced after - * full reset, yaw rest and mounting reset - */ - private fun adjustToReference(rotation: Quaternion): Quaternion { - var rot = rotation - // Align heading axis with bone space - if (!tracker.isHmd || tracker.trackerPosition != TrackerPosition.HEAD) { - rot *= mountingOrientation - } - // Heading correction assuming manual orientation is correct - rot = gyroFix * rot - // Align attitude axes with bone space - rot *= attachmentFix - // Secondary heading axis alignment with bone space for automatic mounting - // Note: Applying an inverse amount of heading correction corresponding to the - // axis alignment quaternion will leave the correction to another variable - rot = mountRotFix.inv() * (rot * mountRotFix) - // More attitude axes alignment specifically for the t-pose configuration, this - // probably shouldn't be a separate variable from attachmentFix? - rot *= tposeDownFix - // More heading correction - rot = yawFix * rot - rot = constraintFix * rot - return rot - } - - /** - * Converts raw or filtered rotation into zero-reference-adjusted by - * applying quaternions produced after full reset and yaw reset only - */ - // This is essentially just adjustToReference but aligning to quaternion identity - // rather than to the bone. - private fun adjustToIdentity(rotation: Quaternion): Quaternion { - var rot = rotation - rot = gyroFixNoMounting * rot - rot *= attachmentFixNoMounting - rot = yawFixZeroReference * rot - rot = constraintFix * rot - return rot - } - - /** - * Adjust the given rotation for drift compensation if enabled, - * and returns it - */ - private fun adjustToDrift(rotation: Quaternion): Quaternion { - if (driftCompensationEnabled && totalDriftTime > 0) { - var driftTimeRatio = ((System.currentTimeMillis() - driftSince).toFloat() / totalDriftTime) - if (!driftPrediction) { - driftTimeRatio = min(1.0f, driftTimeRatio) - } - return averagedDriftQuat.pow(driftAmount * driftTimeRatio) * rotation - } - return rotation - } - - /** - * Reset the tracker so that its current rotation is counted as (0, HMD Yaw, - * 0). This allows the tracker to be strapped to body at any pitch and roll. - */ - fun resetFull(reference: Quaternion) { - constraintFix = Quaternion.IDENTITY - - if (tracker.trackerDataType == TrackerDataType.FLEX_RESISTANCE) { - tracker.trackerFlexHandler.resetMin() - postProcessResetFull(reference) - return - } else if (tracker.trackerDataType == TrackerDataType.FLEX_ANGLE) { - postProcessResetFull(reference) - return - } - - // Adjust for T-Pose (down) - tposeDownFix = if (((tracker.trackerPosition.isLeftArm() || tracker.trackerPosition.isLeftFinger()) && armsResetMode == ArmsResetModes.TPOSE_DOWN)) { - EulerAngles(EulerOrder.YZX, 0f, 0f, -FastMath.HALF_PI).toQuaternion() - } else if (((tracker.trackerPosition.isRightArm() || tracker.trackerPosition.isRightFinger()) && armsResetMode == ArmsResetModes.TPOSE_DOWN)) { - EulerAngles(EulerOrder.YZX, 0f, 0f, FastMath.HALF_PI).toQuaternion() - } else { - Quaternion.IDENTITY - } - - // Old rot for drift compensation - val oldRot = adjustToReference(tracker.getRawRotation()) - lastResetQuaternion = oldRot - - // Adjust raw rotation to mountingOrientation - val mountingAdjustedRotation = tracker.getRawRotation() * mountingOrientation - - // Gyrofix - if (tracker.allowMounting || (tracker.trackerPosition == TrackerPosition.HEAD && !tracker.isHmd)) { - gyroFix = if (tracker.isComputed) { - fixGyroscope(tracker.getRawRotation()) - } else { - fixGyroscope(mountingAdjustedRotation * tposeDownFix) - } - } - - // Mounting for computed trackers - if (tracker.isComputed && tracker.trackerPosition != TrackerPosition.HEAD) { - // Set mounting to the reference's yaw so that a computed - // tracker goes forward according to the head tracker. - mountRotFix = getYawQuaternion(reference) - } - - // Attachment fix - attachmentFix = if (tracker.trackerPosition == TrackerPosition.HEAD && tracker.isHmd) { - if (resetHmdPitch) { - // Reset the HMD's pitch if it's assigned to head and resetHmdPitch is true - // Get rotation without yaw (make sure to use the raw rotation directly!) - val rotBuf = getYawQuaternion(tracker.getRawRotation()).inv() * tracker.getRawRotation() - // Isolate pitch - Quaternion(rotBuf.w, -rotBuf.x, 0f, 0f).unit() - } else { - // Don't reset the HMD at all - Quaternion.IDENTITY - } - } else { - fixAttachment(mountingAdjustedRotation) - } - - // Rotate attachmentFix by 180 degrees as a workaround for t-pose (down) - if (tposeDownFix != Quaternion.IDENTITY && tracker.allowMounting) { - attachmentFix *= HalfHorizontal - } - - makeIdentityAdjustmentQuatsFull() - - // Don't adjust yaw if head and computed - if (tracker.trackerPosition != TrackerPosition.HEAD || !tracker.isComputed) { - yawFix = fixYaw(mountingAdjustedRotation, reference) - tracker.yawResetSmoothing.reset() - } - - calculateDrift(oldRot) - - // Reset Stay Aligned (before resetting filtering, which depends on the - // tracker's rotation) - tracker.stayAligned.reset() - - postProcessResetFull(reference) - } - - private fun postProcessResetFull(reference: Quaternion) { - if (this.tracker.needReset) { - this.tracker.needReset = false - } - - tracker.resetFilteringQuats(reference) - } - - /** - * Reset the tracker so that its current yaw rotation is aligned with the HMD's - * Yaw. This allows the tracker to have yaw independent of the HMD. Tracker - * should still report yaw as if it was mounted facing HMD, mounting - * position should be corrected in the source. - */ - fun resetYaw(reference: Quaternion) { - // TODO HMD doesn't get yaw reset, which makes it so tracker.resetFilteringQuats() doesn't get called - - constraintFix = Quaternion.IDENTITY - - if (tracker.trackerDataType == TrackerDataType.FLEX_RESISTANCE || - tracker.trackerDataType == TrackerDataType.FLEX_ANGLE - ) { - // Don't do anything as these don't have yaw anyways - return - } - - // Old rot for drift compensation - val oldRot = adjustToReference(tracker.getRawRotation()) - lastResetQuaternion = oldRot - - val yawFixOld = yawFix - yawFix = fixYaw(tracker.getRawRotation() * mountingOrientation, reference) - tracker.yawResetSmoothing.reset() - - makeIdentityAdjustmentQuatsYaw() - - calculateDrift(oldRot) - - // Start at yaw before reset if smoothing enabled - if (yawResetSmoothTime > 0.0f) { - tracker.yawResetSmoothing.interpolate( - yawFixOld / yawFix, - Quaternion.IDENTITY, - yawResetSmoothTime, - ) - } - - // Reset Stay Aligned (before resetting filtering, which depends on the - // tracker's rotation) - tracker.stayAligned.reset() - - tracker.resetFilteringQuats(reference) - } - - /** - * Perform the math to align the tracker to go forward - * and stores it in mountRotFix, and adjusts yawFix - */ - fun resetMounting(reference: Quaternion) { - if (tracker.trackerDataType == TrackerDataType.FLEX_RESISTANCE) { - tracker.trackerFlexHandler.resetMax() - tracker.resetFilteringQuats(reference) - return - } else if (tracker.trackerDataType == TrackerDataType.FLEX_ANGLE) { - return - } - - constraintFix = Quaternion.IDENTITY - - // Get the current calibrated rotation - var rotBuf = adjustToDrift(tracker.getRawRotation() * mountingOrientation) - rotBuf = gyroFix * rotBuf - rotBuf *= attachmentFix - rotBuf = yawFix * rotBuf - - // Adjust buffer to reference - rotBuf = reference.project(Vector3.POS_Y).inv().unit() * rotBuf - - // Rotate a vector pointing up by the quat - val rotVector = rotBuf.sandwich(Vector3.POS_Y) - - // Calculate the yaw angle using tan - var yawAngle = atan2(rotVector.x, rotVector.z) - - // Adjust for T-Pose and fingers - if ((tracker.trackerPosition.isLeftArm() && armsResetMode == ArmsResetModes.TPOSE_DOWN) || - (tracker.trackerPosition.isRightArm() && armsResetMode == ArmsResetModes.TPOSE_UP) || - tracker.trackerPosition.isLeftFinger() - ) { - // Tracker goes right - yawAngle -= FastMath.HALF_PI - } - if ((tracker.trackerPosition.isLeftArm() && armsResetMode == ArmsResetModes.TPOSE_UP) || - (tracker.trackerPosition.isRightArm() && armsResetMode == ArmsResetModes.TPOSE_DOWN) || - tracker.trackerPosition.isRightFinger() - ) { - // Tracker goes left - yawAngle += FastMath.HALF_PI - } - - // Adjust for forward/back arms and thighs - val isLowerArmBack = armsResetMode == ArmsResetModes.BACK && (tracker.trackerPosition.isLeftLowerArm() || tracker.trackerPosition.isRightLowerArm()) - val isArmForward = armsResetMode == ArmsResetModes.FORWARD && (tracker.trackerPosition.isLeftArm() || tracker.trackerPosition.isRightArm()) - if (!tracker.trackerPosition.isThigh() && !isArmForward && !isLowerArmBack) { - // Tracker goes back - yawAngle -= FastMath.PI - } - - // Make an adjustment quaternion from the angle - mountRotFix = EulerAngles(EulerOrder.YZX, 0f, yawAngle, 0f).toQuaternion() - - // save mounting reset - if (saveMountingReset) tracker.saveMountingResetOrientation(mountRotFix) - - tracker.resetFilteringQuats(reference) - } - - /** - * Apply a corrective rotation to the gyroFix - */ - fun updateConstraintFix(correctedRotation: Quaternion) { - constraintFix *= correctedRotation - } - - fun clearMounting() { - mountRotFix = Quaternion.IDENTITY - } - - private fun fixGyroscope(sensorRotation: Quaternion): Quaternion = getYawQuaternion(sensorRotation).inv() - - private fun fixAttachment(sensorRotation: Quaternion): Quaternion = (gyroFix * sensorRotation).inv() - - private fun fixYaw(sensorRotation: Quaternion, reference: Quaternion): Quaternion { - var rot = gyroFix * sensorRotation - rot *= attachmentFix - rot = mountRotFix.inv() * (rot * mountRotFix) - rot = getYawQuaternion(rot) - return rot.inv() * reference.project(Vector3.POS_Y).unit() - } - - // TODO : isolating yaw for yaw reset bad. - // The way we isolate the tracker's yaw for yaw reset is - // incorrect. Projection around the Y-axis is worse. - // In both cases, the isolated yaw value changes - // with the tracker's roll when pointing forward. - // calling twinNearest() makes sure this rotation has the wanted polarity (+-). - private fun getYawQuaternion(rot: Quaternion): Quaternion = EulerAngles(EulerOrder.YZX, 0f, rot.toEulerAngles(EulerOrder.YZX).y, 0f).toQuaternion().twinNearest(rot) - - private fun makeIdentityAdjustmentQuatsFull() { - val sensorRotation = tracker.getRawRotation() - gyroFixNoMounting = fixGyroscope(sensorRotation) - attachmentFixNoMounting = (gyroFixNoMounting * sensorRotation).inv() - yawFixZeroReference = Quaternion.IDENTITY - } - - private fun makeIdentityAdjustmentQuatsYaw() { - var sensorRotation = tracker.getRawRotation() - sensorRotation = gyroFixNoMounting * sensorRotation - sensorRotation *= attachmentFixNoMounting - yawFixZeroReference = fixGyroscope(sensorRotation) - } - - /** - * Calculates drift since last reset and store the data related to it in - * driftQuat and timeAtLastReset - */ - private fun calculateDrift(beforeQuat: Quaternion) { - if (driftCompensationEnabled) { - val rotQuat = adjustToReference(tracker.getRawRotation()) - - if (driftSince > 0 && System.currentTimeMillis() - timeAtLastReset > DRIFT_COOLDOWN_MS) { - // Check and remove from lists to keep them under the reset limit - if (driftQuats.size == driftQuats.capacity()) { - driftQuats.removeLast() - driftTimes.removeLast() - } - - // Add new drift quaternion - driftQuats.add(getYawQuaternion(rotQuat) / getYawQuaternion(beforeQuat)) - - // Add drift time to total - driftTimes.add(System.currentTimeMillis() - driftSince) - totalDriftTime = 0 - for (time in driftTimes) { - totalDriftTime += time - } - - // Calculate drift Quaternions' weights - val driftWeights = ArrayList(driftTimes.size) - for (time in driftTimes) { - driftWeights.add(time.toFloat() / totalDriftTime.toFloat()) - } - - // Make it so recent Quaternions weigh more - for (i in driftWeights.size - 1 downTo 1) { - // Add some of i-1's value to i - driftWeights[i] = driftWeights[i] + driftWeights[i - 1] / driftWeights.size - // Remove the value that was added to i from i-1 - driftWeights[i - 1] = driftWeights[i - 1] - driftWeights[i - 1] / driftWeights.size - } - - // Set final averaged drift Quaternion - averagedDriftQuat = fromAveragedQuaternions(driftQuats, driftWeights) - - // Save tracker rotation and current time - rotationSinceReset = driftQuats.latest - timeAtLastReset = System.currentTimeMillis() - } else if (System.currentTimeMillis() - timeAtLastReset < DRIFT_COOLDOWN_MS && driftQuats.size > 0) { - // Replace latest drift quaternion - rotationSinceReset *= (getYawQuaternion(rotQuat) / getYawQuaternion(beforeQuat)) - driftQuats[driftQuats.size - 1] = rotationSinceReset - - // Add drift time to total - driftTimes[driftTimes.size - 1] = driftTimes.latest + System.currentTimeMillis() - driftSince - totalDriftTime = 0 - for (time in driftTimes) { - totalDriftTime += time - } - - // Calculate drift Quaternions' weights - val driftWeights = ArrayList(driftTimes.size) - for (time in driftTimes) { - driftWeights.add(time.toFloat() / totalDriftTime.toFloat()) - } - - // Make it so recent Quaternions weigh more - for (i in driftWeights.size - 1 downTo 1) { - // Add some of i-1's value to i - driftWeights[i] = driftWeights[i] + driftWeights[i - 1] / driftWeights.size - // Remove the value that was added to i from i-1 - driftWeights[i - 1] = driftWeights[i - 1] - driftWeights[i - 1] / driftWeights.size - } - - // Set final averaged drift Quaternion - averagedDriftQuat = fromAveragedQuaternions(driftQuats, driftWeights) - } else { - timeAtLastReset = System.currentTimeMillis() - } - - driftSince = System.currentTimeMillis() - } - } - - /** - * Calculates and returns the averaged Quaternion - * from the given Quaternions and weights. - */ - private fun fromAveragedQuaternions( - qn: CircularArrayList, - tn: ArrayList, - ): Quaternion { - var totalMatrix = qn[0].toMatrix() * tn[0] - for (i in 1 until qn.size) { - totalMatrix += (qn[i].toMatrix() * tn[i]) - } - return totalMatrix.toQuaternion() - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerRole.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerRole.kt deleted file mode 100644 index 081bb5ed86..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerRole.kt +++ /dev/null @@ -1,42 +0,0 @@ -package dev.slimevr.tracking.trackers - -/** - * The tracker role classifies the position and the role of a tracker on user's - * body or playspace (like CAMERA or BEACON). Tracker roles are hints for - * interacting programs what the tracker means, and they do not correspond to - * body's bones on purpose. Example: virtual vive trackers for SteamVR vs actual - * SlimeVR trackers. - */ -enum class TrackerRole(val id: Int, val roleHint: String, val viveRole: String) { - // @formatter:off - NONE(0, "", ""), - WAIST(1, "vive_tracker_waist", "TrackerRole_Waist"), - LEFT_FOOT(2, "vive_tracker_left_foot", "TrackerRole_LeftFoot"), - RIGHT_FOOT(3, "vive_tracker_right_foot", "TrackerRole_RightFoot"), - CHEST(4, "vive_tracker_chest", "TrackerRole_Chest"), - LEFT_KNEE(5, "vive_tracker_left_knee", "TrackerRole_LeftKnee"), - RIGHT_KNEE(6, "vive_tracker_right_knee", "TrackerRole_RightKnee"), - LEFT_ELBOW(7, "vive_tracker_left_elbow", "TrackerRole_LeftElbow"), - RIGHT_ELBOW(8, "vive_tracker_right_elbow", "TrackerRole_RightElbow"), - LEFT_SHOULDER(9, "vive_tracker_left_shoulder", "TrackerRole_LeftShoulder"), - RIGHT_SHOULDER(10, "vive_tracker_right_shoulder", "TrackerRole_RightShoulder"), - LEFT_HAND(11, "vive_tracker_handed", "TrackerRole_Handed"), - RIGHT_HAND(12, "vive_tracker_handed", "TrackerRole_Handed"), - LEFT_CONTROLLER(13, "vive_tracker_handed", "TrackerRole_Handed"), - RIGHT_CONTROLLER(14, "vive_tracker_handed", "TrackerRole_Handed"), - HEAD(15, "", ""), - NECK(16, "", ""), - CAMERA(17, "vive_tracker_camera", "TrackerRole_Camera"), - KEYBOARD(18, "vive_tracker_keyboard", "TrackerRole_Keyboard"), - HMD(19, "", ""), - BEACON(20, "", ""), - GENERIC_CONTROLLER(21, "vive_tracker_handed", "TrackerRole_Handed"), - ; - - companion object { - private val byId = values().associateBy { it.id } - - @JvmStatic - fun getById(id: Int): TrackerRole? = byId[id] - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerStatus.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerStatus.kt deleted file mode 100644 index 3ba3973d9a..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerStatus.kt +++ /dev/null @@ -1,20 +0,0 @@ -package dev.slimevr.tracking.trackers - -enum class TrackerStatus(val id: Int, val sendData: Boolean, val reset: Boolean) { - - DISCONNECTED(0, false, true), - OK(1, true, false), - BUSY(2, true, false), - ERROR(3, false, true), - OCCLUDED(4, false, false), - TIMED_OUT(5, false, false), - ; - - companion object { - - private val byId = entries.associateBy { it.id } - - @JvmStatic - fun getById(id: Int): TrackerStatus? = byId[id] - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerStatusListener.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerStatusListener.kt deleted file mode 100644 index 4f4069be67..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerStatusListener.kt +++ /dev/null @@ -1,6 +0,0 @@ -package dev.slimevr.tracking.trackers - -interface TrackerStatusListener { - - fun onTrackerStatusChanged(tracker: Tracker, oldStatus: TrackerStatus, newStatus: TrackerStatus) -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerUtils.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerUtils.kt deleted file mode 100644 index f3e73b3eee..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/TrackerUtils.kt +++ /dev/null @@ -1,119 +0,0 @@ -package dev.slimevr.tracking.trackers - -import solarxr_protocol.datatypes.BodyPart - -object TrackerUtils { - - /** - * Finds a suitable tracker for use in the SlimeVR skeleton - * in allTrackers matching the position. - * - * This won't return disconnected, errored or internal trackers, - * but it will return timed out trackers, and they have a lower - * priority than normal trackers. - * - * @return A tracker for use in the SlimeVR skeleton - */ - @JvmStatic - fun getTrackerForSkeleton( - allTrackers: List, - position: TrackerPosition, - ): Tracker? = getNonInternalTrackerForBodyPosition(allTrackers, position) - - /** - * Finds the first non-internal tracker from allTrackers - * matching the position, that is not `TrackerStatus.reset`. - * It will also choose timed out trackers, but they have a lower - * priority than the rest of the trackers - * - * @return A non-internal tracker - */ - private fun getNonInternalTrackerForBodyPosition( - allTrackers: List, - position: TrackerPosition, - ): Tracker? { - val resetTrackers = allTrackers.filter { - it.trackerPosition == position && - !it.isInternal && - !it.status.reset - } - return resetTrackers.firstOrNull { it.status != TrackerStatus.TIMED_OUT } ?: resetTrackers.firstOrNull() - } - - /** - * Finds the first non-internal non-computed tracker from allTrackers - * matching the position, that is not `TrackerStatus.reset`. - * It will also choose timed out trackers, but they have a lower - * priority than the rest of the trackers - * - * @return A non-internal non-computed tracker - */ - private fun getNonInternalNonComputedTrackerForBodyPosition( - allTrackers: List, - position: TrackerPosition, - ): Tracker? { - val resetTrackers = allTrackers.filter { - it.trackerPosition == position && - !it.isComputed && - !it.isInternal && - !it.status.reset - } - return resetTrackers.firstOrNull { it.status != TrackerStatus.TIMED_OUT } ?: resetTrackers.firstOrNull() - } - - /** - * Finds the first non-internal and non-imu tracker from allTrackers - * matching the position, that is not `TrackerStatus.reset`. - * It will also choose timed out trackers, but they have a lower - * priority than the rest of the trackers - * @return A non-internal non-imu tracker - */ - @JvmStatic - fun getNonInternalNonImuTrackerForBodyPosition( - allTrackers: List, - position: TrackerPosition, - ): Tracker? { - val resetTrackers = allTrackers.filter { - it.trackerPosition == position && - !it.isImu() && - !it.isInternal && - !it.status.reset - } - return resetTrackers.firstOrNull { it.status != TrackerStatus.TIMED_OUT } ?: resetTrackers.firstOrNull() - } - - /** - * Returns the first tracker that isn't null out of the n trackers passed as - * arguments. - * - * @return The first non-null tracker or null - */ - @JvmStatic - fun getFirstAvailableTracker( - vararg trackers: Tracker?, - ): Tracker? = trackers.firstOrNull { it != null } - - val allBodyPartsButFingers = listOf( - BodyPart.HEAD, BodyPart.NECK, BodyPart.UPPER_CHEST, - BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP, - BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG, BodyPart.LEFT_LOWER_LEG, - BodyPart.RIGHT_LOWER_LEG, BodyPart.LEFT_LOWER_ARM, BodyPart.RIGHT_LOWER_ARM, - BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM, BodyPart.LEFT_HAND, - BodyPart.RIGHT_HAND, BodyPart.LEFT_SHOULDER, BodyPart.RIGHT_SHOULDER, - BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT, - ) - - val allBodyPartsButFingersAndFeets = listOf( - BodyPart.HEAD, BodyPart.NECK, BodyPart.UPPER_CHEST, - BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP, - BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG, BodyPart.LEFT_LOWER_LEG, - BodyPart.RIGHT_LOWER_LEG, BodyPart.LEFT_LOWER_ARM, BodyPart.RIGHT_LOWER_ARM, - BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM, BodyPart.LEFT_HAND, - BodyPart.RIGHT_HAND, BodyPart.LEFT_SHOULDER, BodyPart.RIGHT_SHOULDER, - ) - - val feetsBodyParts = listOf( - BodyPart.LEFT_FOOT, - BodyPart.RIGHT_FOOT, - ) -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/hid/HIDCommon.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/hid/HIDCommon.kt deleted file mode 100644 index b1be8d1330..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/hid/HIDCommon.kt +++ /dev/null @@ -1,383 +0,0 @@ -package dev.slimevr.tracking.trackers.hid - -import com.jme3.math.FastMath -import dev.slimevr.VRServer -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerStatus -import dev.slimevr.tracking.trackers.udp.BoardType -import dev.slimevr.tracking.trackers.udp.IMUType -import dev.slimevr.tracking.trackers.udp.MCUType -import dev.slimevr.tracking.trackers.udp.MagnetometerStatus -import io.eiren.util.logging.LogManager -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Quaternion.Companion.fromRotationVector -import io.github.axisangles.ktmath.Vector3 -import java.nio.ByteBuffer -import java.util.function.Consumer -import kotlin.collections.set -import kotlin.math.PI -import kotlin.math.cos -import kotlin.math.sin -import kotlin.math.sqrt - -/** - * A collection of shared HID functions between OS specific HID implementations. - */ -class HIDCommon { - companion object { - const val HID_TRACKER_RECEIVER_VID = 0x1209 - const val HID_TRACKER_RECEIVER_PID = 0x7690 - const val HID_TRACKER_PID = 0x7692 - - const val PACKET_SIZE = 16 - - private val AXES_OFFSET = fromRotationVector(-FastMath.HALF_PI, 0f, 0f) - - fun deviceIdLookup( - hidDevices: MutableList, - hidSerialNumber: String?, - deviceId: Int, - deviceName: String? = null, - deviceList: MutableList, - ): HIDDevice? { - synchronized(hidDevices) { - deviceList.map { hidDevices[it] }.find { it.hidId == deviceId }?.let { return it } - if (deviceName == null) { // not registered yet - return null - } - val device = HIDDevice(deviceId) - // server wants tracker to be unique, so use combination of hid serial and full id // TODO: use the tracker "address" instead - // TODO: the server should not setup any device, only when the receiver associates the id with the tracker "address" and sends this packet (0xff?) which it will do occasionally - // device.name = hidDevice.serialNumber ?: "Unknown HID Device" - // device.name += "-$deviceId" - device.name = deviceName - device.manufacturer = "HID Device" // TODO: - // device.manufacturer = hidDevice.manufacturer ?: "HID Device" -// device.hardwareIdentifier = hidDevice.serialNumber // hardwareIdentifier is not used to identify the tracker, so also display the receiver serial -// device.hardwareIdentifier += "-$deviceId/$deviceName" // receiver serial + assigned id in receiver + device address - device.hardwareIdentifier = deviceName // the rest of identifier wont fit in gui - hidDevices.add(device) - deviceList.add(hidDevices.size - 1) - VRServer.instance.deviceManager.addDevice(device) // actually add device to the server - LogManager - .info( - "[TrackerServer] Added device $deviceName for ${hidSerialNumber ?: "Unknown HID Device"}, id $deviceId", - ) - return device - } - } - - private fun setUpSensor( - device: HIDDevice, - trackerId: Int, - sensorType: IMUType, - sensorStatus: TrackerStatus, - magStatus: MagnetometerStatus, - trackersConsumer: Consumer, - ) { - // LogManager.info("[TrackerServer] Sensor $trackerId for ${device.name}, status $sensorStatus") - var imuTracker = device.getTracker(trackerId) - if (imuTracker == null) { - var formattedHWID = device.hardwareIdentifier.replace(":", "").takeLast(5) - imuTracker = Tracker( - device, - VRServer.getNextLocalTrackerId(), - device.name + "/" + trackerId, - "Tracker $formattedHWID", - null, - trackerNum = trackerId, - hasRotation = true, - hasAcceleration = true, - userEditable = true, - imuType = sensorType, - allowFiltering = true, - allowReset = true, - allowMounting = true, - usesTimeout = false, - magStatus = magStatus, - ) - // usesTimeout false because HID trackers aren't "Disconnected" unless receiver is physically removed probably - // TODO: Could tracker maybe use "Timed out" status without marking as disconnecting? - // TODO: can be marked as "Disconnected" by timeout if the tracker has enabled activity timeouts - device.trackers[trackerId] = imuTracker - trackersConsumer.accept(imuTracker) - imuTracker.status = sensorStatus - LogManager - .info( - "[TrackerServer] Added sensor $trackerId for ${device.name}, type $sensorType", - ) - } - } - - fun processPacket( - dataReceived: ByteArray, - i: Int, - packetType: Int, - device: HIDDevice, - q: IntArray, - a: IntArray, - m: IntArray, - trackersConsumer: Consumer, - ) { - val trackerId = 0 // no concept of extensions - - // Register tracker - if (packetType == 0) { // Tracker register packet (device info) - val imu_id = dataReceived[i + 8].toUByte().toInt() - val mag_id = dataReceived[i + 9].toUByte().toInt() - val sensorType = IMUType.getById(imu_id.toUInt()) - // only able to register magnetometer status, not magnetometer type - val magStatus = MagnetometerStatus.getById(mag_id.toUByte()) - if (sensorType != null && magStatus != null) { - setUpSensor(device, trackerId, sensorType, TrackerStatus.OK, magStatus, trackersConsumer) - } - } - - val tracker: Tracker? = device.getTracker(trackerId) - if (tracker == null) { // not registered yet - return - } - - // Packet data - var runtime: Long? = null - var batt: Int? = null - var batt_v: Int? = null - var temp: Int? = null - var brd_id: Int? = null - var mcu_id: Int? = null - var button: Int? = null - // var imu_id: Int? = null - // var mag_id: Int? = null - var fw_date: Int? = null - var fw_major: Int? = null - var fw_minor: Int? = null - var fw_patch: Int? = null - var svr_status: Int? = null - // var status: Int? = null // raw status from tracker - var rssi: Int? = null - var packets_received: Int? = null - var packets_lost: Int? = null - var windows_hit: Int? = null - var windows_missed: Int? = null - - // Tracker packets - when (packetType) { - 0 -> { // device info - batt = dataReceived[i + 2].toUByte().toInt() - batt_v = dataReceived[i + 3].toUByte().toInt() - temp = dataReceived[i + 4].toUByte().toInt() - brd_id = dataReceived[i + 5].toUByte().toInt() - mcu_id = dataReceived[i + 6].toUByte().toInt() - // imu_id = dataReceived[i + 8].toUByte().toInt() - // mag_id = dataReceived[i + 9].toUByte().toInt() - // ushort little endian - fw_date = dataReceived[i + 11].toUByte().toInt() shl 8 or dataReceived[i + 10].toUByte().toInt() - fw_major = dataReceived[i + 12].toUByte().toInt() - fw_minor = dataReceived[i + 13].toUByte().toInt() - fw_patch = dataReceived[i + 14].toUByte().toInt() - rssi = dataReceived[i + 15].toUByte().toInt() - } - - 1 -> { // full precision quat and accel, no extra data - // Q15: 1 is represented as 0x7FFF, -1 as 0x8000 - // The sender can use integer saturation to avoid overflow - for (j in 0..3) { // quat received as fixed Q15 - // Q15 as short little endian - q[j] = dataReceived[i + 2 + j * 2 + 1].toInt() shl 8 or dataReceived[i + 2 + j * 2].toUByte().toInt() - } - for (j in 0..2) { // accel received as fixed 7, in m/s^2 - // Q7 as short little endian - a[j] = dataReceived[i + 10 + j * 2 + 1].toInt() shl 8 or dataReceived[i + 10 + j * 2].toUByte().toInt() - } - } - - 2 -> { // reduced precision quat and accel with data - batt = dataReceived[i + 2].toUByte().toInt() - batt_v = dataReceived[i + 3].toUByte().toInt() - temp = dataReceived[i + 4].toUByte().toInt() - // quaternion is quantized as exponential map - // X = 10 bits, Y/Z = 11 bits - val buffer = ByteBuffer.wrap(dataReceived, i + 5, 4) - buffer.order(java.nio.ByteOrder.LITTLE_ENDIAN) - val q_buf = buffer.getInt().toUInt() - q[0] = (q_buf and 1023u).toInt() - q[1] = (q_buf shr 10 and 2047u).toInt() - q[2] = (q_buf shr 21 and 2047u).toInt() - for (j in 0..2) { // accel received as fixed 7, in m/s^2 - // Q7 as short little endian - a[j] = dataReceived[i + 9 + j * 2 + 1].toInt() shl 8 or dataReceived[i + 9 + j * 2].toUByte().toInt() - } - rssi = dataReceived[i + 15].toUByte().toInt() - } - - 3 -> { // status - svr_status = dataReceived[i + 2].toUByte().toInt() - // status = dataReceived[i + 3].toUByte().toInt() - packets_received = dataReceived[i + 4].toUByte().toInt() - packets_lost = dataReceived[i + 5].toUByte().toInt() - windows_hit = dataReceived[i + 6].toUByte().toInt() - windows_missed = dataReceived[i + 7].toUByte().toInt() - rssi = dataReceived[i + 15].toUByte().toInt() - } - - 4 -> { // full precision quat and mag, no extra data - for (j in 0..3) { // quat received as fixed Q15 - // Q15 as short little endian - q[j] = dataReceived[i + 2 + j * 2 + 1].toInt() shl 8 or dataReceived[i + 2 + j * 2].toUByte().toInt() - } - for (j in 0..2) { // mag received as fixed 10, in gauss - // Q10 as short little endian - m[j] = dataReceived[i + 10 + j * 2 + 1].toInt() shl 8 or dataReceived[i + 10 + j * 2].toUByte().toInt() - } - } - - 5 -> { // runtime - // ulong as little endian - runtime = (dataReceived[i + 9].toUByte().toLong() shl 56) or (dataReceived[i + 8].toUByte().toLong() shl 48) or (dataReceived[i + 7].toUByte().toLong() shl 40) or (dataReceived[i + 6].toUByte().toLong() shl 32) or (dataReceived[i + 5].toUByte().toLong() shl 24) or (dataReceived[i + 4].toUByte().toLong() shl 16) or (dataReceived[i + 3].toUByte().toLong() shl 8) or dataReceived[i + 2].toUByte().toLong() - } - - 6 -> { // data - button = dataReceived[i + 2].toUByte().toInt() - rssi = dataReceived[i + 15].toUByte().toInt() - } - - 7 -> { // reduced precision quat and accel with data - button = dataReceived[i + 2].toUByte().toInt() - // quaternion is quantized as exponential map - // X = 10 bits, Y/Z = 11 bits - val buffer = ByteBuffer.wrap(dataReceived, i + 5, 4) - buffer.order(java.nio.ByteOrder.LITTLE_ENDIAN) - val q_buf = buffer.getInt().toUInt() - q[0] = (q_buf and 1023u).toInt() - q[1] = (q_buf shr 10 and 2047u).toInt() - q[2] = (q_buf shr 21 and 2047u).toInt() - for (j in 0..2) { // accel received as fixed 7, in m/s^2 - // Q7 as short little endian - a[j] = dataReceived[i + 9 + j * 2 + 1].toInt() shl 8 or dataReceived[i + 9 + j * 2].toUByte().toInt() - } - rssi = dataReceived[i + 15].toUByte().toInt() - } - - else -> { - } - } - - // Assign data - if (runtime != null && runtime >= 0) { - tracker.batteryRemainingRuntime = runtime - } - // -1: Not known (e.g. not yet calculated after wake up, reusing known value is okay), 0: N/A (e.g. charging) - if (batt != null) { - tracker.batteryLevel = if (batt == 128) 1f else (batt and 127).toFloat() - } - // Server still won't display battery at 0% at all - if (batt_v != null) { - tracker.batteryVoltage = (batt_v.toFloat() + 245f) / 100f - } - if (temp != null) { - tracker.temperature = if (temp > 0) temp.toFloat() / 2f - 39f else null - } - // Range 1 - 255 -> -38.5 - +88.5 C - if (brd_id != null) { - val boardType = BoardType.getById(brd_id.toUInt()) - if (boardType != null) { - device.boardType = boardType!! - } - } - if (mcu_id != null) { - val mcuType = MCUType.getById(mcu_id.toUInt()) - if (mcuType != null) { - device.mcuType = mcuType!! - } - } - if (button != null) { - if (tracker.button == null) { - tracker.button = 0 - } - if (button != tracker.button) { - button = button and tracker.button!!.inv() - // Nothing to do now.. - } - } - if (fw_date != null) { - val firmwareYear = 2020 + (fw_date shr 9 and 127) - val firmwareMonth = fw_date shr 5 and 15 - val firmwareDay = fw_date and 31 - device.firmwareDate = String.format("%04d-%02d-%02d", firmwareYear, firmwareMonth, firmwareDay) - } - if (fw_major != null && fw_minor != null && fw_patch != null) { - device.firmwareVersion = "$fw_major.$fw_minor.$fw_patch" - } - if (svr_status != null) { - val status = TrackerStatus.getById(svr_status) - if (status != null) { - tracker.status = status!! - } - } - if (rssi != null) { - tracker.signalStrength = -rssi - } - if (packets_received != null && packets_lost != null) { - tracker.packetsReceived = packets_received - tracker.packetsLost = packets_lost - tracker.packetLoss = if (packets_lost == 0) 0.0f else packets_lost.toFloat() / (packets_received + packets_lost).toFloat() - } - - // Assign rotation and acceleration - if (packetType == 1 || packetType == 4) { - // The data comes in the same order as in the UDP protocol - // x y z w -> w x y z - var rot = Quaternion(q[3].toFloat(), q[0].toFloat(), q[1].toFloat(), q[2].toFloat()) - val scaleRot = 1 / (1 shl 15).toFloat() // compile time evaluation - rot = AXES_OFFSET.times(scaleRot).times(rot) // no division - tracker.setRotation(rot) - } - if (packetType == 2 || packetType == 7) { - val v = floatArrayOf(q[0].toFloat(), q[1].toFloat(), q[2].toFloat()) // used q array for quantized data - v[0] /= (1 shl 10).toFloat() - v[1] /= (1 shl 11).toFloat() - v[2] /= (1 shl 11).toFloat() - for (i in 0..2) { - v[i] = v[i] * 2 - 1 - } - // http://marc-b-reynolds.github.io/quaternions/2017/05/02/QuatQuantPart1.html#fnref:pos:3 - // https://github.com/Marc-B-Reynolds/Stand-alone-junk/blob/559bd78893a3a95cdee1845834c632141b945a45/src/Posts/quatquant0.c#L898 - val d = v[0] * v[0] + v[1] * v[1] + v[2] * v[2] - val invSqrtD = 1 / sqrt(d + 1e-6f) - val a = (PI.toFloat() / 2) * d * invSqrtD - val s = sin(a) - val k = s * invSqrtD - var rot = Quaternion(cos(a), k * v[0], k * v[1], k * v[2]) - rot = AXES_OFFSET.times(rot) // no division - tracker.setRotation(rot) - } - if (packetType == 1 || packetType == 2 || packetType == 7) { - // Acceleration is in local device frame - // On flat surface / face up: - // Right side of the device is +X - // Front side (facing up) is +Z - // Mounted on body / standing up: - // Top side of the device is +Y - // Front side (facing out) is +Z - val scaleAccel = 1 / (1 shl 7).toFloat() // compile time evaluation - val acceleration = Vector3(a[0].toFloat(), a[1].toFloat(), a[2].toFloat()).times(scaleAccel) // no division - tracker.setAcceleration(acceleration) - } - if (packetType == 4) { - // Magnetometer is in local device frame - // On flat surface / face up: - // Right side of the device is +X - // Front side (facing up) is +Z - // Mounted on body / standing up: - // Top side of the device is +Y - // Front side (facing out) is +Z - val scaleMag = 1000 / (1 shl 10).toFloat() // compile time evaluation, and change gauss to milligauss - val magnetometer = Vector3(m[0].toFloat(), m[1].toFloat(), m[2].toFloat()).times(scaleMag) // no division - tracker.setMagVector(magnetometer) - } - if (packetType == 1 || packetType == 2 || packetType == 4 || packetType == 7) { - tracker.dataTick() // only data tick if there is rotation data - } - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/hid/HIDDevice.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/hid/HIDDevice.kt deleted file mode 100644 index a0fee7f7f8..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/hid/HIDDevice.kt +++ /dev/null @@ -1,13 +0,0 @@ -package dev.slimevr.tracking.trackers.hid - -import dev.slimevr.tracking.trackers.Device -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.udp.BoardType -import dev.slimevr.tracking.trackers.udp.MCUType - -class HIDDevice(val hidId: Int) : Device() { - override var hardwareIdentifier: String = "Unknown" - override var boardType: BoardType = BoardType.UNKNOWN - override var mcuType: MCUType = MCUType.UNKNOWN - fun getTracker(id: Int): Tracker? = trackers[id] -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/FeatureFlags.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/FeatureFlags.kt deleted file mode 100644 index 640af58078..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/FeatureFlags.kt +++ /dev/null @@ -1,80 +0,0 @@ -package dev.slimevr.tracking.trackers.udp - -import java.nio.ByteBuffer - -/** - * Bit packed flags, enum values start with 0 and indicate which bit it is. - * - * Change the enums and `flagsEnabled` inside to extend. - */ -class FirmwareFeatures { - enum class FirmwareFeatureFlags { - // EXAMPLE_FEATURE, - - // Add new flags here - REMOTE_COMMAND, - B64_WIFI_SCANNING, - SENSOR_CONFIG, - - BITS_TOTAL, - } - - fun has(flag: FirmwareFeatureFlags): Boolean { - val bit = flag.ordinal - return (flags[bit / 8].toInt() and (1 shl (bit % 8))) != 0 - } - - /** - * Whether the firmware supports the "feature flags" feature, - * set to true when we've received flags packet from the firmware. - */ - var available = false - private set - - companion object { - fun from(received: ByteBuffer, length: Int): FirmwareFeatures { - val res = FirmwareFeatures() - res.available = true - received.get(res.flags, 0, res.flags.size.coerceAtMost(length)) - return res - } - } - - private val flags = ByteArray(FirmwareFeatureFlags.BITS_TOTAL.ordinal / 8 + 1) -} - -enum class ServerFeatureFlags { - /** Server can parse bundle packets: `PACKET_BUNDLE` = 100 (0x64). */ - PROTOCOL_BUNDLE_SUPPORT, - - /** Server can parse bundle packets with compact headers and packed IMU rotation/acceleration frames: - - `PACKET_BUNDLE_COMPACT` = 101 (0x65), - - `PACKET_ROTATION_AND_ACCELERATION` = 23 (0x17). */ - PROTOCOL_BUNDLE_COMPACT_SUPPORT, - - // Add new flags here - - BITS_TOTAL, ; - - companion object { - val flagsEnabled: Set = setOf( - PROTOCOL_BUNDLE_SUPPORT, - PROTOCOL_BUNDLE_COMPACT_SUPPORT, - - // Add enabled flags here - ) - - val packed = run { - val byteLength = BITS_TOTAL.ordinal / 8 + 1 - val tempPacked = ByteArray(byteLength) - - for (flag in flagsEnabled) { - val bit = flag.ordinal - tempPacked[bit / 8] = - (tempPacked[bit / 8].toInt() or (1 shl (bit % 8))).toByte() - } - - tempPacked - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/FirmwareConstants.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/FirmwareConstants.kt deleted file mode 100644 index bac7d126b9..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/FirmwareConstants.kt +++ /dev/null @@ -1,179 +0,0 @@ -package dev.slimevr.tracking.trackers.udp - -enum class IMUType(val id: UInt) { - UNKNOWN(0u), - MPU9250(1u), - MPU6500(2u), - BNO080(3u), - BNO085(4u), - BNO055(5u), - MPU6050(6u), - BNO086(7u), - BMI160(8u), - ICM20948(9u), - ICM42688(10u), - BMI270(11u), - LSM6DS3TRC(12u), - LSM6DSV(13u), - LSM6DSO(14u), - LSM6DSR(15u), - ICM45686(16u), - ICM45605(17u), - ADC_RESISTANCE(18u), - DEV_RESERVED(250u), - ; - - fun getSolarType(): Int = this.id.toInt() - - companion object { - private val byId = entries.associateBy { it.id } - - @JvmStatic - fun getById(id: UInt): IMUType? = byId[id] - } -} - -enum class BoardType(val id: UInt) { - UNKNOWN(0u), - SLIMEVR_LEGACY(1u), - SLIMEVR_DEV(2u), - NODEMCU(3u), - CUSTOM(4u), - WROOM32(5u), - WEMOSD1MINI(6u), - TTGO_TBASE(7u), - ESP01(8u), - SLIMEVR(9u), - LOLIN_C3_MINI(10u), - BEETLE32C3(11u), - ESP32C3DEVKITM1(12u), - OWOTRACK(13u), - WRANGLER(14u), - MOCOPI(15u), - WEMOSWROOM02(16u), - XIAO_ESP32C3(17u), - HARITORA(18u), - ESP32C6DEVKITC1(19u), - GLOVE_IMU_SLIMEVR_DEV(20u), - GESTURES(21u), - SLIMEVR_V1_2(22u), - ESP32S3_SUPERMINI(23u), - GENERIC_NRF(24u), - SLIMEVR_BUTTERFLY_DEV(25u), - SLIMEVR_BUTTERFLY(26u), - DEV_RESERVED(250u), - ; - - fun getSolarType(): Int = this.id.toInt() - - override fun toString(): String = when (this) { - UNKNOWN -> "Unknown" - SLIMEVR_LEGACY -> "SlimeVR Legacy" - SLIMEVR_DEV -> "SlimeVR Dev" - NODEMCU -> "NodeMCU" - CUSTOM -> "Custom Board" - WROOM32 -> "WROOM32" - WEMOSD1MINI -> "Wemos D1 Mini" - TTGO_TBASE -> "TTGO T-Base" - ESP01 -> "ESP-01" - SLIMEVR -> "SlimeVR" - LOLIN_C3_MINI -> "Lolin C3 Mini" - BEETLE32C3 -> "Beetle ESP32-C3" - ESP32C3DEVKITM1 -> "Espressif ESP32-C3 DevKitM-1" - OWOTRACK -> "owoTrack" - WRANGLER -> "Wrangler Joycons" - MOCOPI -> "Sony Mocopi" - WEMOSWROOM02 -> "Wemos Wroom-02 D1 Mini" - GESTURES -> "litten Yº by Gestures" - XIAO_ESP32C3 -> "Seeed Studio XIAO ESP32C3" - HARITORA -> "Haritora" - ESP32C6DEVKITC1 -> "Espressif ESP32-C6 DevKitC-1" - GLOVE_IMU_SLIMEVR_DEV -> "SlimeVR Dev IMU Glove" - SLIMEVR_V1_2 -> "SlimeVR v1.2" - ESP32S3_SUPERMINI -> "ESP32-S3 SuperMini" - GENERIC_NRF -> "Generic nRF" - SLIMEVR_BUTTERFLY_DEV -> "SlimeVR Dev Butterfly" - SLIMEVR_BUTTERFLY -> "SlimeVR Butterfly" - DEV_RESERVED -> "Prototype" - } - - companion object { - private val byId = entries.associateBy { it.id } - - @JvmStatic - fun getById(id: UInt): BoardType? = byId[id] - } -} - -enum class MCUType(val id: UInt) { - UNKNOWN(0u), - ESP8266(1u), - ESP32(2u), - OWOTRACK_ANDROID(3u), - WRANGLER(4u), - OWOTRACK_IOS(5u), - ESP32_C3(6u), - MOCOPI(7u), - HARITORA(8u), - NRF52(9u), - NRF54L(10u), - DEV_RESERVED(250u), - ; - - fun getSolarType(): Int = this.id.toInt() - - companion object { - private val byId = entries.associateBy { it.id } - - @JvmStatic - fun getById(id: UInt): MCUType? = byId[id] - } -} - -enum class TrackerDataType(val id: UInt) { - ROTATION(0u), - FLEX_RESISTANCE(1u), - FLEX_ANGLE(2u), - ; - - fun getSolarType(): Int = this.id.toInt() - - companion object { - private val byId = entries.associateBy { it.id } - - @JvmStatic - fun getById(id: UInt): TrackerDataType? = byId[id] - } -} - -@JvmInline -value class ConfigTypeId(val v: UShort) - -enum class MagnetometerStatus { - NOT_SUPPORTED, - DISABLED, - ENABLED, - ; - - fun getSolarType(): Int = this.ordinal - - companion object { - private val byId = entries.associateBy { it.ordinal.toUByte() } - - @JvmStatic - fun getById(id: UByte): MagnetometerStatus? = byId[id] - } -} - -@JvmInline -value class SensorConfig(val v: UShort) { - val magStatus - get(): MagnetometerStatus { - if ((v.toUInt() shr 1) and 1u == 0u) return MagnetometerStatus.NOT_SUPPORTED - return if ((v and 1u) == 1u.toUShort()) { - MagnetometerStatus.ENABLED - } else { - MagnetometerStatus.DISABLED - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/SensorTap.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/SensorTap.kt deleted file mode 100644 index 2f4a07b6b7..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/SensorTap.kt +++ /dev/null @@ -1,11 +0,0 @@ -package dev.slimevr.tracking.trackers.udp - -data class SensorTap(val tapBits: Int) { - val doubleTap = tapBits and 0x40 > 0 - - enum class TapAxis { - X, - Y, - Z, - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/TrackersUDPServer.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/TrackersUDPServer.kt deleted file mode 100644 index ea5c6d5ba1..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/TrackersUDPServer.kt +++ /dev/null @@ -1,681 +0,0 @@ -package dev.slimevr.tracking.trackers.udp - -import com.jme3.math.FastMath -import dev.slimevr.NetworkProtocol -import dev.slimevr.VRServer -import dev.slimevr.config.config -import dev.slimevr.protocol.rpc.MAG_TIMEOUT -import dev.slimevr.tracking.trackers.* -import io.eiren.util.Util -import io.eiren.util.collections.FastList -import io.eiren.util.logging.LogManager -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Quaternion.Companion.fromRotationVector -import kotlinx.coroutines.* -import solarxr_protocol.rpc.ResetType -import java.net.DatagramPacket -import java.net.DatagramSocket -import java.net.InetSocketAddress -import java.net.NetworkInterface -import java.net.SocketAddress -import java.net.SocketTimeoutException -import java.nio.ByteBuffer -import java.nio.ByteOrder -import java.util.* -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ConcurrentLinkedDeque -import java.util.function.Consumer -import kotlin.collections.HashMap -import kotlin.coroutines.resume - -/** - * Receives trackers data by UDP using extended owoTrack protocol. - */ -class TrackersUDPServer(private val port: Int, name: String, private val trackersConsumer: Consumer) : Thread(name) { - private val random = Random() - private val connections: MutableList = FastList() - private val connectionsByAddress: MutableMap = HashMap() - private val connectionsByMAC: MutableMap = HashMap() - private val broadcastAddresses: List = try { - NetworkInterface.getNetworkInterfaces().asSequence().filter { - // Ignore loopback, PPP, virtual and disabled interfaces - !it.isLoopback && it.isUp && !it.isPointToPoint && !it.isVirtual - }.flatMap { - it.interfaceAddresses.asSequence() - }.map { - // This ignores IPv6 addresses - it.broadcast - }.filter { it != null && it.isSiteLocalAddress }.map { InetSocketAddress(it, this.port) }.toList() - } catch (e: Exception) { - LogManager.severe("[TrackerServer] Can't enumerate network interfaces", e) - emptyList() - } - private val parser = UDPProtocolParser() - - // 1500 is a common network MTU. 1472 is the maximum size of a UDP packet (1500 - 20 for IPv4 header - 8 for UDP header) - private val rcvBuffer = ByteArray(1500 - 20 - 8) - private val bb = ByteBuffer.wrap(rcvBuffer).order(ByteOrder.BIG_ENDIAN) - - // Gets initialized in this.run() - private lateinit var socket: DatagramSocket - private var lastKeepup = System.currentTimeMillis() - - private fun setUpNewConnection(handshakePacket: DatagramPacket, handshake: UDPPacket3Handshake) { - LogManager.info("[TrackerServer] Handshake received from ${handshakePacket.address}:${handshakePacket.port}") - val addr = handshakePacket.address - val socketAddr = handshakePacket.socketAddress - - // Check if it's a known device - VRServer.instance.configManager.vrConfig.let { vrConfig -> - if (vrConfig.isKnownDevice(handshake.macString)) return@let - val mac = handshake.macString ?: return@let - - VRServer.instance.handshakeHandler.sendUnknownHandshake(mac) - return - } - - // Get a connection either by an existing one, or by creating a new one - val connection: UDPDevice = synchronized(connections) { - connectionsByMAC[handshake.macString]?.apply { - // Look for an existing connection by the MAC address and update the - // connection information - connectionsByAddress.remove(address) - address = socketAddr - lastPacketNumber = 0 - ipAddress = addr - name = handshake.macString?.let { "udp://$it" } - descriptiveName = "udp:/$addr" - protocolVersion = handshake.protocolVersion - firmwareVersion = handshake.firmware - connectionsByAddress[address] = this - - val i = connections.indexOf(this) - LogManager - .info( - """ - [TrackerServer] Tracker $i handed over to address $socketAddr. - Board type: ${handshake.boardType}, - firmware name: ${handshake.firmware}, - protocol version: $protocolVersion, - mac: ${handshake.macString}, - name: $name - """.trimIndent(), - ) - } ?: connectionsByAddress[socketAddr]?.apply { - // Look for an existing connection by the socket address (IP and port) - // and update the connection information - lastPacketNumber = 0 - ipAddress = addr - name = handshake.macString?.let { "udp://$it" } - ?: "udp:/$addr" - descriptiveName = "udp:/$addr" - protocolVersion = handshake.protocolVersion - firmwareVersion = handshake.firmware - val i = connections.indexOf(this) - LogManager - .info( - """ - [TrackerServer] Tracker $i reconnected from address $socketAddr. - Board type: ${handshake.boardType}, - firmware name: ${handshake.firmware}, - protocol version: $protocolVersion, - mac: ${handshake.macString}, - name: $name - """.trimIndent(), - ) - } - } ?: run { - // No existing connection could be found, create a new one - - val connection = UDPDevice( - socketAddr, - addr, - handshake.macString ?: addr.hostAddress, - handshake.boardType, - handshake.mcuType, - ) - VRServer.instance.deviceManager.addDevice(connection) - connection.protocolVersion = handshake.protocolVersion - connection.protocol = if (handshake.firmware?.isEmpty() == true) { - // Only old owoTrack doesn't report firmware and have different packet IDs with SlimeVR - NetworkProtocol.OWO_LEGACY - } else { - NetworkProtocol.SLIMEVR_RAW - } - connection.name = handshake.macString?.let { "udp://$it" } - ?: "udp:/$addr" - // TODO: The missing slash in udp:// was intended because InetAddress.toString() - // returns "hostname/address" but it wasn't known that if hostname is empty - // string it just looks like "/address" lol. - // Fixing this would break config! - connection.descriptiveName = "udp:/$addr" - connection.firmwareVersion = handshake.firmware - synchronized(connections) { - // Register the new connection - val i = connections.size - connections.add(connection) - connectionsByAddress[socketAddr] = connection - if (handshake.macString != null) { - connectionsByMAC[handshake.macString!!] = connection - } - LogManager - .info( - """ - [TrackerServer] Tracker $i connected from address $socketAddr. - Board type: ${handshake.boardType}, - firmware name: ${handshake.firmware}, - protocol version: ${connection.protocolVersion}, - mac: ${handshake.macString}, - name: ${connection.name} - """.trimIndent(), - ) - } - if (connection.protocol == NetworkProtocol.OWO_LEGACY || connection.protocolVersion < 9) { - // Set up new sensor for older firmware. - // Firmware after 7 should send sensor status packet and sensor - // will be created when it's received - setUpSensor( - connection, - 0, - handshake.imuType, - 1, - MagnetometerStatus.NOT_SUPPORTED, - null, - TrackerDataType.ROTATION, - null, - ) - } - connection - } - connection.firmwareFeatures = FirmwareFeatures() - bb.limit(bb.capacity()) - bb.rewind() - parser.writeHandshakeResponse(bb, connection) - socket.send(DatagramPacket(rcvBuffer, bb.position(), connection.address)) - } - - private val mainScope = CoroutineScope(SupervisorJob()) - private fun setUpSensor( - connection: UDPDevice, - trackerId: Int, - sensorType: IMUType, - sensorStatus: Int, - magStatus: MagnetometerStatus, - trackerPosition: TrackerPosition?, - trackerDataType: TrackerDataType, - hasCompletedRestCalibration: Boolean?, - ) { - LogManager.info("[TrackerServer] Sensor $trackerId for ${connection.name} status: $sensorStatus") - var imuTracker = connection.getTracker(trackerId) - if (imuTracker == null) { - var formattedHWID = connection.hardwareIdentifier.replace(":", "").takeLast(5) - if (trackerId != 0) { - formattedHWID += " Extension" - if (trackerId > 1) { - formattedHWID += " $trackerId" - } - } - - imuTracker = Tracker( - connection, - VRServer.getNextLocalTrackerId(), - connection.name + "/" + trackerId, - "Tracker $formattedHWID", - trackerPosition, - trackerNum = trackerId, - hasRotation = true, - hasAcceleration = true, - userEditable = true, - imuType = if (trackerDataType == TrackerDataType.ROTATION) sensorType else null, - allowFiltering = true, - allowReset = true, - allowMounting = true, - usesTimeout = true, - magStatus = magStatus, - trackerDataType = trackerDataType, - ) - connection.trackers[trackerId] = imuTracker - trackersConsumer.accept(imuTracker) - LogManager.info("[TrackerServer] Added sensor $trackerId for ${connection.name}, ImuType $sensorType, DataType $trackerDataType, default TrackerPosition $trackerPosition") - } - val status = UDPPacket15SensorInfo.getStatus(sensorStatus) - if (status != null) imuTracker.status = status - - imuTracker.hasCompletedRestCalibration = hasCompletedRestCalibration - - if (magStatus == MagnetometerStatus.NOT_SUPPORTED) return - if (magStatus == MagnetometerStatus.ENABLED && - (!VRServer.instance.configManager.vrConfig.server.useMagnetometerOnAllTrackers || imuTracker.config.shouldHaveMagEnabled == false) - ) { - mainScope.launch { - withTimeoutOrNull(MAG_TIMEOUT) { - connection.setMag(false, trackerId) - } - } - } else if (magStatus == MagnetometerStatus.DISABLED && - VRServer.instance.configManager.vrConfig.server.useMagnetometerOnAllTrackers && - imuTracker.config.shouldHaveMagEnabled == true - ) { - mainScope.launch { - withTimeoutOrNull(MAG_TIMEOUT) { - connection.setMag(true, trackerId) - } - } - } - } - - private data class ConfigStateWaiter( - val expectedState: Boolean, - val channel: CancellableContinuation, - var ran: Boolean = false, - ) - - private val queues: MutableMap, Deque> = ConcurrentHashMap() - suspend fun setConfigFlag(device: UDPDevice, configTypeId: ConfigTypeId, state: Boolean, sensorId: Int = 255) { - if (device.timedOut) return - val triple = Triple(device.address, configTypeId, sensorId) - val queue = queues.computeIfAbsent(triple) { _ -> ConcurrentLinkedDeque() } - - suspendCancellableCoroutine { - val waiter = ConfigStateWaiter(state, it) - queue.add(waiter) - it.invokeOnCancellation { - queue.remove(waiter) - } - } - } - - private fun actualSetConfigFlag(device: UDPDevice, configTypeId: ConfigTypeId, state: Boolean, sensorId: Int) { - val packet = UDPPacket25SetConfigFlag(sensorId, configTypeId, state) - bb.limit(bb.capacity()) - bb.rewind() - parser.write(bb, null, packet) - socket.send(DatagramPacket(rcvBuffer, bb.position(), device.address)) - } - - override fun run() { - val serialBuffer2 = StringBuilder() - try { - socket = DatagramSocket(port) - var prevPacketTime = System.currentTimeMillis() - socket.soTimeout = 250 - while (true) { - var received: DatagramPacket? = null - try { - val hasActiveTrackers = connections.any { it.trackers.size > 0 } - if (!hasActiveTrackers) { - val discoveryPacketTime = System.currentTimeMillis() - if (discoveryPacketTime - prevPacketTime >= 2000) { - for (addr in broadcastAddresses) { - bb.limit(bb.capacity()) - bb.rewind() - parser.write(bb, null, UDPPacket0Heartbeat) - socket.send(DatagramPacket(rcvBuffer, bb.position(), addr)) - } - prevPacketTime = discoveryPacketTime - } - } - received = DatagramPacket(rcvBuffer, rcvBuffer.size) - socket.receive(received) - bb.limit(received.length) - bb.rewind() - val connection = synchronized(connections) { connectionsByAddress[received.socketAddress] } - parser.parse(bb, connection) - .filterNotNull() - .forEach { processPacket(received, it, connection) } - - queues.forEach { (t, p) -> - val q = p.firstOrNull() ?: return@forEach - if (q.ran) return@forEach - - val device = connectionsByAddress[t.first] ?: run { - p.removeFirst() - LogManager.info("[TrackerServer] Device ${t.first} not connected, so can't communicate with it") - return@forEach - } - actualSetConfigFlag(device, t.second, q.expectedState, t.third) - if (!device.timedOut) q.ran = true - } - } catch (ignored: SocketTimeoutException) { - } catch (e: Exception) { - LogManager.warning( - "[TrackerServer] Error parsing packet ${packetToString(received)}", - e, - ) - } - if (lastKeepup + 500 < System.currentTimeMillis()) { - lastKeepup = System.currentTimeMillis() - synchronized(connections) { - for (conn in connections) { - bb.limit(bb.capacity()) - bb.rewind() - parser.write(bb, conn, UDPPacket1Heartbeat) - socket.send(DatagramPacket(rcvBuffer, bb.position(), conn.address)) - if (conn.lastPacket + 1000 < System.currentTimeMillis()) { - if (!conn.timedOut) { - conn.timedOut = true - LogManager.info("[TrackerServer] Tracker timed out: $conn") - } - } else { - for (value in conn.trackers.values) { - if (value.status == TrackerStatus.DISCONNECTED || - value.status == TrackerStatus.TIMED_OUT - ) { - value.status = TrackerStatus.OK - } - } - conn.timedOut = false - } - - if (conn.serialBuffer.isNotEmpty() && - conn.lastSerialUpdate + 500L < System.currentTimeMillis() - ) { - serialBuffer2 - .append('[') - .append(conn.name) - .append("] ") - .append(conn.serialBuffer) - println(serialBuffer2) - serialBuffer2.setLength(0) - conn.serialBuffer.setLength(0) - } - - if (conn.lastPingPacketTime + 500 < System.currentTimeMillis()) { - conn.lastPingPacketId = random.nextInt() - conn.lastPingPacketTime = System.currentTimeMillis() - bb.limit(bb.capacity()) - bb.rewind() - bb.putInt(10) - bb.putLong(0) - bb.putInt(conn.lastPingPacketId) - socket.send(DatagramPacket(rcvBuffer, bb.position(), conn.address)) - } - } - } - } - } - } catch (e: Exception) { - e.printStackTrace() - } finally { - if (::socket.isInitialized) { - Util.close(socket) - } - } - } - - private fun processPacket(received: DatagramPacket, packet: UDPPacket, connection: UDPDevice?) { - val tracker: Tracker? - when (packet) { - is UDPPacket0Heartbeat, is UDPPacket1Heartbeat, is UDPPacket25SetConfigFlag -> {} - - is UDPPacket3Handshake -> setUpNewConnection(received, packet) - - is RotationPacket -> { - var rot = packet.rotation - rot = AXES_OFFSET.times(rot) - tracker = connection?.getTracker(packet.sensorId) - if (tracker == null) return - tracker.setRotation(rot) - if (packet is UDPPacket23RotationAndAcceleration) { - // sensorOffset is applied correctly since protocol 22 - // See: https://github.com/SlimeVR/SlimeVR-Tracker-ESP/pull/480 - if (connection.protocolVersion >= 22) { - tracker.setAcceleration(packet.acceleration) - } else { - tracker.setAcceleration(SENSOR_OFFSET_CORRECTION.sandwich(packet.acceleration)) - } - } - tracker.dataTick() - } - - is UDPPacket17RotationData -> { - tracker = connection?.getTracker(packet.sensorId) - if (tracker == null) return - var rot17 = packet.rotation - rot17 = AXES_OFFSET * rot17 - when (packet.dataType) { - UDPPacket17RotationData.DATA_TYPE_NORMAL -> { - tracker.setRotation(rot17) - tracker.dataTick() - // tracker.calibrationStatus = rotationData.calibrationInfo; - // Not implemented in server - } - - UDPPacket17RotationData.DATA_TYPE_CORRECTION -> { -// tracker.rotMagQuaternion.set(rot17); -// tracker.magCalibrationStatus = rotationData.calibrationInfo; -// tracker.hasNewCorrectionData = true; - // Not implemented in server - } - } - } - - is UDPPacket18MagnetometerAccuracy -> {} - - is UDPPacket4Acceleration -> { - tracker = connection?.getTracker(packet.sensorId) - if (tracker == null) return - // sensorOffset is applied correctly since protocol 22 - // See: https://github.com/SlimeVR/SlimeVR-Tracker-ESP/pull/480 - if (connection.protocolVersion >= 22) { - tracker.setAcceleration(packet.acceleration) - } else { - tracker.setAcceleration(SENSOR_OFFSET_CORRECTION.sandwich(packet.acceleration)) - } - } - - is UDPPacket10PingPong -> { - if (connection == null) return - if (connection.lastPingPacketId == packet.pingId) { - for (t in connection.trackers.values) { - t.ping = (System.currentTimeMillis() - connection.lastPingPacketTime).toInt() / 2 - t.dataTick() - } - } else { - LogManager.debug( - "[TrackerServer] Wrong ping id ${packet.pingId} != ${connection.lastPingPacketId}", - ) - } - } - - is UDPPacket11Serial -> { - if (connection == null) return - println("[${connection.name}] ${packet.serial}") - } - - is UDPPacket12BatteryLevel -> connection?.trackers?.values?.forEach { - it.batteryVoltage = packet.voltage - it.batteryLevel = packet.level * 100 - } - - is UDPPacket13Tap -> { - tracker = connection?.getTracker(packet.sensorId) - if (tracker == null) return - LogManager.info( - "[TrackerServer] Tap packet received from ${tracker.name}: ${packet.tap}", - ) - } - - is UDPPacket14Error -> { - LogManager.severe( - "[TrackerServer] Error received from ${received.socketAddress}: ${packet.errorNumber}", - ) - tracker = connection?.getTracker(packet.sensorId) - if (tracker == null) return - tracker.status = TrackerStatus.ERROR - } - - is UDPPacket15SensorInfo -> { - if (connection == null) return - val magStatus = packet.sensorConfig?.magStatus ?: MagnetometerStatus.NOT_SUPPORTED - setUpSensor( - connection, - packet.sensorId, - packet.sensorType, - packet.sensorStatus, - magStatus, - packet.trackerPosition, - packet.trackerDataType, - packet.hasCompletedRestCalibration, - ) - // Send ack - bb.limit(bb.capacity()) - bb.rewind() - parser.writeSensorInfoResponse(bb, connection, packet) - socket.send(DatagramPacket(rcvBuffer, bb.position(), connection.address)) - LogManager.info( - "[TrackerServer] Sensor info for ${connection.descriptiveName}/${packet.sensorId}: ${packet.sensorStatus}, mag $magStatus", - ) - } - - is UDPPacket19SignalStrength -> connection?.trackers?.values?.forEach { - it.signalStrength = packet.signalStrength - } - - is UDPPacket20Temperature -> { - tracker = connection?.getTracker(packet.sensorId) ?: return - tracker.temperature = packet.temperature - } - - is UDPPacket21UserAction -> { - if (connection == null) return - var name = "" - when (packet.type) { - UDPPacket21UserAction.RESET_FULL -> { - name = "Full reset" - VRServer.instance.scheduleResetTrackersFull( - RESET_SOURCE_NAME, - (VRServer.instance.configManager.vrConfig.resetsConfig.fullResetDelay * 1000).toLong(), - ) - } - - UDPPacket21UserAction.RESET_YAW -> { - name = "Yaw reset" - VRServer.instance.scheduleResetTrackersYaw(RESET_SOURCE_NAME, (VRServer.instance.configManager.vrConfig.resetsConfig.yawResetDelay * 1000).toLong()) - } - - UDPPacket21UserAction.RESET_MOUNTING -> { - name = "Mounting reset" - VRServer - .instance - .resetHandler - .sendStarted(ResetType.Mounting) - VRServer.instance.scheduleResetTrackersMounting(RESET_SOURCE_NAME, (VRServer.instance.configManager.vrConfig.resetsConfig.mountingResetDelay * 1000).toLong()) - } - - UDPPacket21UserAction.PAUSE_TRACKING -> { - name = "Pause tracking toggle" - VRServer.instance.togglePauseTracking(RESET_SOURCE_NAME) - } - } - - LogManager.info( - "[TrackerServer] User action from ${connection.descriptiveName} received. $name performed.", - ) - } - - is UDPPacket22FeatureFlags -> { - if (connection == null) return - // Respond with server flags - bb.limit(bb.capacity()) - bb.rewind() - parser.write(bb, connection, packet) - socket.send(DatagramPacket(rcvBuffer, bb.position(), connection.address)) - connection.firmwareFeatures = packet.firmwareFeatures - } - - is UDPPacket24AckConfigChange -> { - if (connection == null) return - val queue = queues[Triple(connection.address, packet.configType, packet.sensorId)] ?: run { - LogManager.severe("[TrackerServer] Error, acknowledgment of config change that we don't have in our queue.") - return - } - val changed = queue.removeFirst() - changed.channel.resume(true) - val trackers = if (SensorSpecificPacket.isGlobal(packet.sensorId)) { - connection.trackers.values.toList() - } else { - listOf(connection.getTracker(packet.sensorId) ?: return) - } - LogManager.info("[TrackerServer] Acknowledged config change on ${connection.descriptiveName} (${trackers.map { it.trackerNum }.joinToString()}). Config changed on ${packet.configType}") - } - - is UDPPacket26FlexData -> { - tracker = connection?.getTracker(packet.sensorId) - if (tracker == null) return - if (tracker.trackerDataType == TrackerDataType.FLEX_RESISTANCE) { - tracker.trackerFlexHandler.setFlexResistance(packet.flexData) - } else if (tracker.trackerDataType == TrackerDataType.FLEX_ANGLE) { - tracker.trackerFlexHandler.setFlexAngle(packet.flexData) - } - tracker.dataTick() - } - - is UDPPacket27Position -> { - tracker = connection?.getTracker(packet.sensorId) - if (tracker == null) return - tracker.position = packet.position - // dont call dataTick here as this is just position update - } - - is UDPPacket200ProtocolChange -> {} - } - } - - fun getConnections(): List = connections - - // FIXME: for some reason it ends up disconnecting after 30 seconds have passed instead of immediately - fun disconnectDevice(device: UDPDevice) { - synchronized(connections) { - connections.remove(device) - } - synchronized(connectionsByAddress) { - connectionsByAddress.filter { (_, dev) -> dev.id == device.id }.keys.forEach( - connectionsByAddress::remove, - ) - } - device.trackers.forEach { (_, tracker) -> - tracker.status = TrackerStatus.DISCONNECTED - } - - LogManager.info( - "[TrackerServer] Forcefully disconnected ${device.hardwareIdentifier} device.", - ) - } - - companion object { - /** - * Change between IMU axes and OpenGL/SteamVR axes - */ - private val AXES_OFFSET = fromRotationVector(-FastMath.HALF_PI, 0f, 0f) - - // TODO: Set this offset to Quaternion.IDENTITY when the firmware is corrected! - // 270 deg (-90 deg) default for officials - private val SENSOR_OFFSET_CORRECTION = Quaternion.rotationAroundZAxis(-FastMath.HALF_PI) - private const val RESET_SOURCE_NAME = "TrackerServer" - - private val hexFormat = HexFormat { - bytes.byteSeparator = "," - } - - private fun packetToString(packet: DatagramPacket?): String { - val sb = StringBuilder() - sb.append("DatagramPacket{") - if (packet == null) { - sb.append("null") - } else { - sb.append(packet.address.toString()) - sb.append(':') - sb.append(packet.port) - sb.append(',') - sb.append(packet.length) - sb.append(',') - sb.append('{') - sb.append(packet.data.toHexString(0, packet.length, hexFormat)) - sb.append('}') - } - sb.append('}') - return sb.toString() - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/UDPDevice.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/UDPDevice.kt deleted file mode 100644 index bed58741c8..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/UDPDevice.kt +++ /dev/null @@ -1,85 +0,0 @@ -package dev.slimevr.tracking.trackers.udp - -import dev.slimevr.NetworkProtocol -import dev.slimevr.VRServer -import dev.slimevr.tracking.trackers.Device -import dev.slimevr.tracking.trackers.Tracker -import java.net.InetAddress -import java.net.SocketAddress -import java.util.concurrent.ConcurrentHashMap - -class UDPDevice( - var address: SocketAddress, - var ipAddress: InetAddress, - override val hardwareIdentifier: String, - override val boardType: BoardType = BoardType.UNKNOWN, - override val mcuType: MCUType = MCUType.UNKNOWN, -) : Device(true) { - - override val id: Int = nextLocalDeviceId.incrementAndGet() - - @JvmField - var lastPacket = System.currentTimeMillis() - - @JvmField - var lastPingPacketId = -1 - - @JvmField - var lastPingPacketTime: Long = 0 - override var name: String? = null - set(name) { - super.name = name - field = name - } - - @JvmField - var descriptiveName: String? = null - - @JvmField - var serialBuffer = StringBuilder() - - @JvmField - var lastSerialUpdate: Long = 0 - - @JvmField - var lastPacketNumber: Long = -1 - - @JvmField - var protocol: NetworkProtocol? = null - - @JvmField - var protocolVersion = 0 - - @JvmField - var timedOut = false - override val trackers = ConcurrentHashMap() - - override suspend fun setMag(state: Boolean, sensorId: Int) { - if (sensorId == 255) { - VRServer.instance.trackersServer.setConfigFlag(this, ConfigTypeId(1u), state) - trackers.forEach { (_, t) -> t.setMagPrivate(state) } - } else { - require(trackers[sensorId] != null) { "There is no tracker $sensorId in device ${toString()}" } - VRServer.instance.trackersServer.setConfigFlag(this, ConfigTypeId(1u), state, sensorId) - trackers[sensorId]!!.setMagPrivate(state) - } - } - - var firmwareFeatures = FirmwareFeatures() - - fun isNextPacket(packetId: Long): Boolean { - if (packetId != 0L && packetId <= lastPacketNumber) return false - lastPacketNumber = packetId - return true - } - - override fun toString(): String = "udp:/$ipAddress" - - override var manufacturer: String? - get() = "SlimeVR" - set(manufacturer) { - super.manufacturer = manufacturer - } - - fun getTracker(id: Int): Tracker? = trackers[id] -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/UDPPacket.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/UDPPacket.kt deleted file mode 100644 index c83277b2cc..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/UDPPacket.kt +++ /dev/null @@ -1,454 +0,0 @@ -package dev.slimevr.tracking.trackers.udp - -import dev.slimevr.tracking.trackers.TrackerPosition -import dev.slimevr.tracking.trackers.TrackerStatus -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import java.io.IOException -import java.nio.BufferUnderflowException -import java.nio.ByteBuffer - -sealed class UDPPacket(val packetId: Int) { - @Throws(IOException::class, BufferUnderflowException::class) - open fun readData(buf: ByteBuffer) {} - - @Throws(IOException::class) - open fun writeData(buf: ByteBuffer) {} - - companion object { - /** - * Naively read null-terminated ASCII string from the byte buffer - * - * @param buf - * @return - * @throws IOException - */ - @Throws(IOException::class) - fun readASCIIString(buf: ByteBuffer): String { - val sb = StringBuilder() - while (true) { - val c = (buf.get().toInt() and 0xFF).toChar() - if (c.code == 0) break - sb.append(c) - } - return sb.toString() - } - - @JvmStatic - @Throws(IOException::class) - fun readASCIIString(buf: ByteBuffer, length: Int): String { - var length = length - val sb = StringBuilder() - while (length-- > 0) { - val c = (buf.get().toInt() and 0xFF).toChar() - if (c.code == 0) break - sb.append(c) - } - return sb.toString() - } - - /** - * Naively write null-terminated ASCII string to byte buffer - * - * @param str - * @param buf - * @throws IOException - */ - @Throws(IOException::class) - fun writeASCIIString(str: String, buf: ByteBuffer) { - for (element in str) { - buf.put((element.code and 0xFF).toByte()) - } - buf.put(0.toByte()) - } - } -} - -sealed interface SensorSpecificPacket { - val sensorId: Int - companion object { - /** - * Sensor with id 255 is "global" representing a whole device - * - * @param sensorId - * @return - */ - fun isGlobal(sensorId: Int): Boolean = sensorId == 255 - } -} - -sealed interface RotationPacket : SensorSpecificPacket { - var rotation: Quaternion -} - -data object UDPPacket0Heartbeat : UDPPacket(0) -data object UDPPacket1Heartbeat : UDPPacket(1) -data class UDPPacket1Rotation(override var rotation: Quaternion = Quaternion.IDENTITY) : - UDPPacket(1), - RotationPacket { - override val sensorId = 0 - override fun readData(buf: ByteBuffer) { - rotation = UDPUtils.getSafeBufferQuaternion(buf) - } -} - -data class UDPPacket3Handshake( - var boardType: BoardType = BoardType.UNKNOWN, - var imuType: IMUType = IMUType.UNKNOWN, - var mcuType: MCUType = MCUType.UNKNOWN, - var protocolVersion: Int = 0, - var firmware: String? = null, - var macString: String? = null, -) : UDPPacket(3) { - override fun readData(buf: ByteBuffer) { - if (buf.remaining() == 0) return - if (buf.remaining() > 3) { - boardType = BoardType.getById(buf.int.toUInt()) ?: BoardType.UNKNOWN - } - if (buf.remaining() > 3) { - imuType = IMUType.getById(buf.int.toUInt()) ?: IMUType.UNKNOWN - } - if (buf.remaining() > 3) { - mcuType = MCUType.getById(buf.int.toUInt()) ?: MCUType.UNKNOWN - } // MCU TYPE - if (buf.remaining() > 11) { - buf.int - buf.int - buf.int // IMU info - } - if (buf.remaining() > 3) protocolVersion = buf.int - var length = 0 - if (buf.remaining() > 0) length = buf.get().toInt() - // firmware version length is 1 longer than - // that because it's nul-terminated - firmware = readASCIIString(buf, length) - val mac = ByteArray(6) - if (buf.remaining() >= mac.size) { - buf.get(mac) - macString = String.format( - "%02X:%02X:%02X:%02X:%02X:%02X", - mac[0], - mac[1], - mac[2], - mac[3], - mac[4], - mac[5], - ) - if (macString == "00:00:00:00:00:00") macString = null - } - } - - override fun writeData(buf: ByteBuffer) { - // Never sent back in current protocol - // Handshake for RAW SlimeVR and legacy owoTrack has different packet id - // byte - // order from normal packets - // So it's handled by raw protocol call - } -} - -data class UDPPacket4Acceleration(var acceleration: Vector3 = Vector3.NULL) : - UDPPacket(4), - SensorSpecificPacket { - override var sensorId = 0 - override fun readData(buf: ByteBuffer) { - acceleration = Vector3(UDPUtils.getSafeBufferFloat(buf), UDPUtils.getSafeBufferFloat(buf), UDPUtils.getSafeBufferFloat(buf)) - - sensorId = try { - buf.get().toInt() and 0xFF - } catch (e: BufferUnderflowException) { - // for owo track app - 0 - } - } -} - -data class UDPPacket10PingPong(var pingId: Int = 0) : UDPPacket(10) { - override fun readData(buf: ByteBuffer) { - pingId = buf.int - } - - override fun writeData(buf: ByteBuffer) { - buf.putInt(pingId) - } -} - -data class UDPPacket11Serial(var serial: String = "") : UDPPacket(11) { - override fun readData(buf: ByteBuffer) { - val length = buf.int - val sb = StringBuilder(length) - for (i in 0 until length) { - val ch = Char(buf.get().toUShort()) - sb.append(ch) - } - serial = sb.toString() - } -} - -data class UDPPacket12BatteryLevel( - var voltage: Float = 0.0f, - var level: Float = 0.0f, -) : UDPPacket(12) { - - override fun readData(buf: ByteBuffer) { - voltage = UDPUtils.getSafeBufferFloat(buf) - if (buf.remaining() > 3) { - level = UDPUtils.getSafeBufferFloat(buf) - } else { - level = voltage - voltage = 0.0f - } - } -} - -data class UDPPacket13Tap(var tap: SensorTap = SensorTap(0)) : - UDPPacket(13), - SensorSpecificPacket { - override var sensorId = 0 - override fun readData(buf: ByteBuffer) { - sensorId = buf.get().toInt() and 0xFF - tap = SensorTap(buf.get().toInt() and 0xFF) - } -} - -data class UDPPacket14Error(var errorNumber: Int = 0) : - UDPPacket(14), - SensorSpecificPacket { - override var sensorId = 0 - override fun readData(buf: ByteBuffer) { - sensorId = buf.get().toInt() and 0xFF - errorNumber = buf.get().toInt() and 0xFF - } -} - -data class UDPPacket15SensorInfo( - var sensorStatus: Int = 0, - var sensorType: IMUType = IMUType.UNKNOWN, - var sensorConfig: SensorConfig? = null, - var hasCompletedRestCalibration: Boolean? = null, - var trackerPosition: TrackerPosition? = null, - var trackerDataType: TrackerDataType = TrackerDataType.ROTATION, -) : UDPPacket(15), - SensorSpecificPacket { - override var sensorId = 0 - override fun readData(buf: ByteBuffer) { - sensorId = buf.get().toInt() and 0xFF - sensorStatus = buf.get().toInt() and 0xFF - if (buf.remaining() > 0) { - sensorType = IMUType.getById(buf.get().toUInt() and 0xFFu) ?: IMUType.UNKNOWN - } - if (buf.remaining() > 1) { - sensorConfig = SensorConfig(buf.getShort().toUShort()) - } - if (buf.remaining() > 0) hasCompletedRestCalibration = buf.get().toInt() and 0xFF != 0 - if (buf.remaining() > 0) trackerPosition = TrackerPosition.getById(buf.get().toInt() and 0xFF) - if (buf.remaining() > 0) trackerDataType = TrackerDataType.getById(buf.get().toUInt() and 0xFFu) ?: TrackerDataType.ROTATION - } - - companion object { - fun getStatus(sensorStatus: Int): TrackerStatus? = when (sensorStatus) { - 0 -> TrackerStatus.DISCONNECTED - 1 -> TrackerStatus.OK - 2 -> TrackerStatus.ERROR - else -> null - } - } -} - -data class UDPPacket16Rotation2(override var rotation: Quaternion = Quaternion.IDENTITY) : - UDPPacket(16), - RotationPacket { - override val sensorId = 1 - override fun readData(buf: ByteBuffer) { - rotation = UDPUtils.getSafeBufferQuaternion(buf) - } -} - -data class UDPPacket17RotationData( - var rotation: Quaternion = Quaternion.IDENTITY, - var dataType: Int = 0, - var calibrationInfo: Int = 0, -) : UDPPacket(17), - SensorSpecificPacket { - override var sensorId: Int = 0 - override fun readData(buf: ByteBuffer) { - sensorId = buf.get().toInt() and 0xFF - dataType = buf.get().toInt() and 0xFF - rotation = UDPUtils.getSafeBufferQuaternion(buf) - calibrationInfo = buf.get().toInt() and 0xFF - } - - companion object { - const val DATA_TYPE_NORMAL = 1 - const val DATA_TYPE_CORRECTION = 2 - } -} - -data class UDPPacket18MagnetometerAccuracy(var accuracyInfo: Float = 0.0f) : - UDPPacket(18), - SensorSpecificPacket { - override var sensorId = 0 - override fun readData(buf: ByteBuffer) { - sensorId = buf.get().toInt() and 0xFF - accuracyInfo = UDPUtils.getSafeBufferFloat(buf) - } -} - -data class UDPPacket19SignalStrength(var signalStrength: Int = 0) : - UDPPacket(19), - SensorSpecificPacket { - override var sensorId = 0 - override fun readData(buf: ByteBuffer) { - sensorId = buf.get().toInt() and 0xFF - signalStrength = buf.get().toInt() - } -} - -data class UDPPacket20Temperature(var temperature: Float = 0.0f) : - UDPPacket(20), - SensorSpecificPacket { - override var sensorId = 0 - override fun readData(buf: ByteBuffer) { - sensorId = buf.get().toInt() and 0xFF - temperature = UDPUtils.getSafeBufferFloat(buf) - } -} - -data class UDPPacket21UserAction(var type: Int = 0) : UDPPacket(21) { - override fun readData(buf: ByteBuffer) { - type = buf.get().toInt() and 0xFF - } - - companion object { - const val RESET_FULL = 2 - const val RESET_YAW = 3 - const val RESET_MOUNTING = 4 - const val PAUSE_TRACKING = 5 - } -} - -class UDPPacket22FeatureFlags( - var firmwareFeatures: FirmwareFeatures = FirmwareFeatures(), -) : UDPPacket(22) { - override fun readData(buf: ByteBuffer) { - firmwareFeatures = FirmwareFeatures.from(buf, buf.remaining()) - } - - override fun writeData(buf: ByteBuffer) { - buf.put(ServerFeatureFlags.packed) - } -} - -data class UDPPacket23RotationAndAcceleration( - override var rotation: Quaternion = Quaternion.IDENTITY, - var acceleration: Vector3 = Vector3.NULL, -) : UDPPacket(23), - RotationPacket { - override var sensorId: Int = 0 - override fun readData(buf: ByteBuffer) { - // s16 s16 s16 s16 s16 s16 s16 - // qX qY qZ qW aX aY aZ - sensorId = buf.get().toInt() and 0xFF - val scaleR = 1 / (1 shl 15).toFloat() // Q15: 1 is represented as 0x7FFF and -1 as 0x8000 - val x = buf.short * scaleR - val y = buf.short * scaleR - val z = buf.short * scaleR - val w = buf.short * scaleR - rotation = Quaternion(w, x, y, z).unit() - val scaleA = 1 / (1 shl 7).toFloat() // The same as the HID scale - acceleration = Vector3(buf.short * scaleA, buf.short * scaleA, buf.short * scaleA) - } -} - -data class UDPPacket24AckConfigChange( - override var sensorId: Int = 0, - var configType: ConfigTypeId = ConfigTypeId(0u), -) : UDPPacket(24), - SensorSpecificPacket { - override fun readData(buf: ByteBuffer) { - sensorId = buf.get().toInt() and 0xFF - configType = ConfigTypeId(buf.getShort().toUShort()) - } -} - -data class UDPPacket25SetConfigFlag( - override var sensorId: Int = 255, - var configType: ConfigTypeId, - var state: Boolean, -) : UDPPacket(25), - SensorSpecificPacket { - override fun writeData(buf: ByteBuffer) { - buf.put(sensorId.toByte()) - buf.putShort(configType.v.toShort()) - buf.put(if (state) 1 else 0) - } -} - -data class UDPPacket26FlexData( - var flexData: Float = 0f, -) : UDPPacket(26), - SensorSpecificPacket { - - override var sensorId = 0 - override fun readData(buf: ByteBuffer) { - sensorId = buf.get().toInt() and 0xFF - flexData = UDPUtils.getSafeBufferFloat(buf) - } -} - -data class UDPPacket27Position( - var position: Vector3 = Vector3.NULL, -) : UDPPacket(27), - SensorSpecificPacket { - override var sensorId = 0 - override fun readData(buf: ByteBuffer) { - sensorId = buf.get().toInt() and 0xFF - val x = UDPUtils.getSafeBufferFloat(buf) - val y = UDPUtils.getSafeBufferFloat(buf) - val z = UDPUtils.getSafeBufferFloat(buf) - position = Vector3(x, y, z) - } -} - -data class UDPPacket200ProtocolChange( - var targetProtocol: Int = 0, - var targetProtocolVersion: Int = 0, -) : UDPPacket(200) { - override fun readData(buf: ByteBuffer) { - targetProtocol = buf.get().toInt() and 0xFF - targetProtocolVersion = buf.get().toInt() and 0xFF - } - - override fun writeData(buf: ByteBuffer) { - buf.put(targetProtocol.toByte()) - buf.put(targetProtocolVersion.toByte()) - } -} - -class UDPUtils { - companion object { - fun getSafeBufferQuaternion(byteBuffer: ByteBuffer): Quaternion { - val x = byteBuffer.getFloat() - val y = byteBuffer.getFloat() - val z = byteBuffer.getFloat() - val w = byteBuffer.getFloat() - - return if ( - (x.isNaN() || y.isNaN() || z.isNaN() || w.isNaN()) || - (x == 0f && y == 0f && z == 0f && w == 0f) - ) { - Quaternion.IDENTITY - } else { - Quaternion(w, x, y, z) - } - } - fun getSafeBufferFloat(byteBuffer: ByteBuffer): Float { - val value = byteBuffer.getFloat() - return if (value.isNaN()) { - 0f - } else { - value - } - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/UDPProtocolParser.kt b/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/UDPProtocolParser.kt deleted file mode 100644 index bf73740b1c..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracking/trackers/udp/UDPProtocolParser.kt +++ /dev/null @@ -1,169 +0,0 @@ -package dev.slimevr.tracking.trackers.udp - -import java.io.IOException -import java.nio.ByteBuffer -import java.nio.charset.StandardCharsets - -class UDPProtocolParser { - @Throws(IOException::class) - fun parse(buf: ByteBuffer, connection: UDPDevice?): Array { - val packetId = buf.int - val packetNumber = buf.long - if (connection != null) { - if (!connection.isNextPacket(packetNumber)) { - // Skip packet because it's not next - throw IOException( - "Out of order packet received: id $packetId, number $packetNumber, last ${connection.lastPacketNumber}, from $connection", - ) - } - connection.lastPacket = System.currentTimeMillis() - connection.trackers.forEach { (_, tracker) -> - tracker.heartbeat() - } - } - if (packetId == PACKET_BUNDLE) { - bundlePackets.clear() - while (buf.hasRemaining()) { - val bundlePacketLen = Math.min(buf.short.toInt(), buf.remaining()) - if (bundlePacketLen == 0) continue - - val bundlePacketStart = buf.position() - val bundleBuf = buf.slice() - bundleBuf.limit(bundlePacketLen) - val bundlePacketId = bundleBuf.int - val newPacket = getNewPacket(bundlePacketId) - newPacket?.let { - newPacket.readData(bundleBuf) - bundlePackets.add(newPacket) - } - - buf.position(bundlePacketStart + bundlePacketLen) - } - return bundlePackets.toTypedArray() - } else if (packetId == PACKET_BUNDLE_COMPACT) { - bundlePackets.clear() - while (buf.hasRemaining()) { - val bundlePacketLen = Math.min(buf.get().toUByte().toInt(), buf.remaining()) // 1 byte - if (bundlePacketLen == 0) continue - - val bundlePacketStart = buf.position() - val bundleBuf = buf.slice() - bundleBuf.limit(bundlePacketLen) - val bundlePacketId = bundleBuf.get().toUByte().toInt() // 1 byte - val newPacket = getNewPacket(bundlePacketId) - newPacket?.let { - newPacket.readData(bundleBuf) - bundlePackets.add(newPacket) - } - - buf.position(bundlePacketStart + bundlePacketLen) - } - return bundlePackets.toTypedArray() - } - - val newPacket = getNewPacket(packetId) - if (newPacket != null) { - newPacket.readData(buf) - } else { -// LogManager.log.debug( -// "[UDPProtocolParser] Skipped packet id " + -// packetId + " from " + connection -// ) - } - return arrayOf(newPacket) - } - - @Throws(IOException::class) - fun write(buf: ByteBuffer, connection: UDPDevice?, packet: UDPPacket) { - buf.putInt(packet.packetId) - buf.putLong(0) // Packet number is always 0 when sending data to trackers - packet.writeData(buf) - } - - @Throws(IOException::class) - fun writeHandshakeResponse(buf: ByteBuffer, connection: UDPDevice?) { - buf.put(HANDSHAKE_BUFFER) - } - - @Throws(IOException::class) - fun writeSensorInfoResponse( - buf: ByteBuffer, - connection: UDPDevice?, - packet: UDPPacket15SensorInfo, - ) { - buf.putInt(packet.packetId) - buf.put(packet.sensorId.toByte()) - buf.put(packet.sensorStatus.toByte()) - } - - protected fun getNewPacket(packetId: Int): UDPPacket? = when (packetId) { - PACKET_HEARTBEAT -> UDPPacket0Heartbeat - PACKET_ROTATION -> UDPPacket1Rotation() - PACKET_HANDSHAKE -> UDPPacket3Handshake() - PACKET_PING_PONG -> UDPPacket10PingPong() - PACKET_ACCEL -> UDPPacket4Acceleration() - PACKET_SERIAL -> UDPPacket11Serial() - PACKET_BATTERY_LEVEL -> UDPPacket12BatteryLevel() - PACKET_TAP -> UDPPacket13Tap() - PACKET_ERROR -> UDPPacket14Error() - PACKET_SENSOR_INFO -> UDPPacket15SensorInfo() - PACKET_ROTATION_2 -> UDPPacket16Rotation2() - PACKET_ROTATION_DATA -> UDPPacket17RotationData() - PACKET_MAGNETOMETER_ACCURACY -> UDPPacket18MagnetometerAccuracy() - PACKET_SIGNAL_STRENGTH -> UDPPacket19SignalStrength() - PACKET_TEMPERATURE -> UDPPacket20Temperature() - PACKET_USER_ACTION -> UDPPacket21UserAction() - PACKET_FEATURE_FLAGS -> UDPPacket22FeatureFlags() - PACKET_ACK_CONFIG_CHANGE -> UDPPacket24AckConfigChange() - PACKET_FLEX_DATA -> UDPPacket26FlexData() - PACKET_POSITION -> UDPPacket27Position() - PACKET_PROTOCOL_CHANGE -> UDPPacket200ProtocolChange() - else -> null - } - - companion object { - const val PACKET_HEARTBEAT = 0 - const val PACKET_ROTATION = 1 // Deprecated - - // public static final int PACKET_GYRO = 2; // Deprecated - const val PACKET_HANDSHAKE = 3 - const val PACKET_ACCEL = 4 - - // public static final int PACKET_MAG = 5; // Deprecated - // public static final int PACKET_RAW_CALIBRATION_DATA = 6; // Not parsed by - // server - // public static final int PACKET_CALIBRATION_FINISHED = 7; // Not parsed by - // server - // public static final int PACKET_CONFIG = 8; // Not parsed by server - // public static final int PACKET_RAW_MAGNETOMETER = 9 // Deprecated - const val PACKET_PING_PONG = 10 - const val PACKET_SERIAL = 11 - const val PACKET_BATTERY_LEVEL = 12 - const val PACKET_TAP = 13 - const val PACKET_ERROR = 14 - const val PACKET_SENSOR_INFO = 15 - const val PACKET_ROTATION_2 = 16 // Deprecated - const val PACKET_ROTATION_DATA = 17 - const val PACKET_MAGNETOMETER_ACCURACY = 18 - const val PACKET_SIGNAL_STRENGTH = 19 - const val PACKET_TEMPERATURE = 20 - const val PACKET_USER_ACTION = 21 - const val PACKET_FEATURE_FLAGS = 22 - const val PACKET_ROTATION_AND_ACCELERATION = 23 - const val PACKET_ACK_CONFIG_CHANGE = 24 - const val PACKET_SET_CONFIG_FLAG = 25 - const val PACKET_FLEX_DATA = 26 - const val PACKET_POSITION = 27 - const val PACKET_BUNDLE = 100 - const val PACKET_BUNDLE_COMPACT = 101 - const val PACKET_PROTOCOL_CHANGE = 200 - private val HANDSHAKE_BUFFER = ByteArray(64) - private val bundlePackets = ArrayList(128) - - init { - HANDSHAKE_BUFFER[0] = 3 - val str = "Hey OVR =D 5".toByteArray(StandardCharsets.US_ASCII) - System.arraycopy(str, 0, HANDSHAKE_BUFFER, 1, str.size) - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/TrackingChecklistManager.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/TrackingChecklistManager.kt deleted file mode 100644 index a39ac80a00..0000000000 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/TrackingChecklistManager.kt +++ /dev/null @@ -1,353 +0,0 @@ -package dev.slimevr.trackingchecklist - -import dev.slimevr.VRServer -import dev.slimevr.bridge.ISteamVRBridge -import dev.slimevr.config.MountingMethods -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.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerStatus -import dev.slimevr.tracking.trackers.TrackerUtils -import dev.slimevr.tracking.trackers.udp.TrackerDataType -import io.github.axisangles.ktmath.Quaternion -import solarxr_protocol.datatypes.DeviceIdT -import solarxr_protocol.datatypes.TrackerIdT -import solarxr_protocol.rpc.* -import java.util.* -import java.util.concurrent.CopyOnWriteArrayList -import kotlin.concurrent.timerTask - -interface TrackingChecklistListener { - fun onStepsUpdate() -} - -class TrackingChecklistManager(private val vrServer: VRServer) : VRCConfigListener { - - private val listeners: MutableList = CopyOnWriteArrayList() - val steps: MutableList = mutableListOf() - - private val updateTrackingChecklistTimer = Timer("TrackingChecklistTimer") - - // Simple flag set to true if reset mounting was performed at least once. - // This value is only runtime and never saved - var resetMountingCompleted = false - var feetResetMountingCompleted = false - - init { - createSteps() - vrServer.vrcConfigManager.addListener(this) - - updateTrackingChecklistTimer.scheduleAtFixedRate( - timerTask { - updateChecklist() - }, - 0, - 1000, - ) - } - - fun addListener(channel: TrackingChecklistListener) { - listeners.add(channel) - } - - fun removeListener(channel: TrackingChecklistListener) { - listeners.removeIf { channel == it } - } - - fun buildTrackersIds(trackers: List): Array = trackers.map { tracker -> - TrackerIdT().apply { - if (tracker.device != null) { - deviceId = DeviceIdT().apply { id = tracker.device.id } - } - trackerNum = tracker.trackerNum - } - }.toTypedArray() - - private fun createSteps() { - steps.add( - TrackingChecklistStepT().apply { - id = TrackingChecklistStepId.NETWORK_PROFILE_PUBLIC - enabled = vrServer.networkProfileChecker.isSupported - optional = false - ignorable = true - visibility = TrackingChecklistStepVisibility.WHEN_INVALID - }, - ) - - steps.add( - TrackingChecklistStepT().apply { - id = TrackingChecklistStepId.STEAMVR_DISCONNECTED - enabled = false - optional = false - ignorable = true - visibility = TrackingChecklistStepVisibility.WHEN_INVALID - }, - ) - - steps.add( - TrackingChecklistStepT().apply { - id = TrackingChecklistStepId.TRACKER_ERROR - valid = true // Default to valid - enabled = true - optional = false - ignorable = false - visibility = TrackingChecklistStepVisibility.WHEN_INVALID - }, - ) - - steps.add( - TrackingChecklistStepT().apply { - id = TrackingChecklistStepId.TRACKERS_REST_CALIBRATION - enabled = true - optional = false - ignorable = true - visibility = TrackingChecklistStepVisibility.ALWAYS - }, - ) - - steps.add( - TrackingChecklistStepT().apply { - id = TrackingChecklistStepId.FULL_RESET - enabled = true - optional = false - ignorable = false - visibility = TrackingChecklistStepVisibility.ALWAYS - }, - ) - - steps.add( - TrackingChecklistStepT().apply { - id = TrackingChecklistStepId.MOUNTING_CALIBRATION - valid = false - enabled = vrServer.configManager.vrConfig.resetsConfig.lastMountingMethod == MountingMethods.AUTOMATIC - optional = false - ignorable = true - visibility = TrackingChecklistStepVisibility.ALWAYS - }, - ) - - steps.add( - TrackingChecklistStepT().apply { - id = TrackingChecklistStepId.FEET_MOUNTING_CALIBRATION - valid = false - enabled = false - optional = false - ignorable = true - visibility = TrackingChecklistStepVisibility.ALWAYS - }, - ) - - steps.add( - TrackingChecklistStepT().apply { - id = TrackingChecklistStepId.UNASSIGNED_HMD - enabled = true - optional = false - ignorable = false - visibility = TrackingChecklistStepVisibility.WHEN_INVALID - }, - ) - - steps.add( - TrackingChecklistStepT().apply { - id = TrackingChecklistStepId.STAY_ALIGNED_CONFIGURED - enabled = true - optional = true - ignorable = true - visibility = TrackingChecklistStepVisibility.WHEN_INVALID - }, - ) - - steps.add( - TrackingChecklistStepT().apply { - id = TrackingChecklistStepId.VRCHAT_SETTINGS - enabled = vrServer.vrcConfigManager.isSupported - valid = true - optional = true - ignorable = true - visibility = TrackingChecklistStepVisibility.WHEN_INVALID - }, - ) - } - - fun updateChecklist() { - val assignedTrackers = - vrServer.allTrackers.filter { it.trackerPosition != null && it.status != TrackerStatus.DISCONNECTED } - val imuTrackers = - assignedTrackers.filter { it.isImu() && it.trackerDataType != TrackerDataType.FLEX_ANGLE } - - val trackersWithError = - imuTrackers.filter { it.status == TrackerStatus.ERROR } - updateValidity( - TrackingChecklistStepId.TRACKER_ERROR, - trackersWithError.isEmpty(), - ) { - if (trackersWithError.isNotEmpty()) { - it.extraData = TrackingChecklistExtraDataUnion().apply { - type = TrackingChecklistExtraData.TrackingChecklistTrackerError - value = TrackingChecklistTrackerErrorT().apply { - trackersId = buildTrackersIds(trackersWithError) - } - } - } else { - it.extraData = null - } - } - - val trackerRequireReset = imuTrackers.filter { - it.status !== TrackerStatus.ERROR && !it.isInternal && it.allowReset && it.needReset - } - // We ask for a full reset if you need to do mounting calibration but cant because you haven't done full reset in a while - // or if you have trackers that need reset after re-assigning - val usingSavedCalibration = vrServer.configManager.vrConfig.resetsConfig.saveMountingReset && imuTrackers.all { it.resetsHandler.mountRotFix != Quaternion.IDENTITY } - val needFullReset = (vrServer.configManager.vrConfig.resetsConfig.lastMountingMethod == MountingMethods.AUTOMATIC && !usingSavedCalibration && !resetMountingCompleted && !vrServer.serverGuards.canDoMounting) || trackerRequireReset.isNotEmpty() - updateValidity(TrackingChecklistStepId.FULL_RESET, !needFullReset) { - it.enabled = imuTrackers.isNotEmpty() - if (trackerRequireReset.isNotEmpty()) { - it.extraData = TrackingChecklistExtraDataUnion().apply { - type = TrackingChecklistExtraData.TrackingChecklistTrackerReset - value = TrackingChecklistTrackerResetT().apply { - trackersId = buildTrackersIds(trackerRequireReset) - } - } - resetMountingCompleted = false - feetResetMountingCompleted = false - } else { - it.extraData = null - } - } - val hmd = - vrServer.allTrackers.firstOrNull { it.status != TrackerStatus.DISCONNECTED && it.isHmd && !it.isInternal && it.status.sendData } - val assignedHmd = hmd == null || vrServer.humanPoseManager.skeleton.headTracker != null - updateValidity(TrackingChecklistStepId.UNASSIGNED_HMD, assignedHmd) { - if (!assignedHmd) { - it.extraData = TrackingChecklistExtraDataUnion().apply { - type = TrackingChecklistExtraData.TrackingChecklistUnassignedHMD - value = TrackingChecklistUnassignedHMDT().apply { - trackerId = TrackerIdT().apply { - if (hmd.device != null) { - deviceId = DeviceIdT().apply { id = hmd.device.id } - } - trackerNum = hmd.trackerNum - } - } - } - } else { - it.extraData = null - } - } - - val trackersNeedCalibration = imuTrackers.filter { - it.hasCompletedRestCalibration == false - } - updateValidity( - TrackingChecklistStepId.TRACKERS_REST_CALIBRATION, - trackersNeedCalibration.isEmpty(), - ) { - // Don't show the step if none of the trackers connected support IMU calibration - it.enabled = imuTrackers.any { t -> - t.hasCompletedRestCalibration != null - } - if (trackersNeedCalibration.isNotEmpty()) { - it.extraData = TrackingChecklistExtraDataUnion().apply { - type = TrackingChecklistExtraData.TrackingChecklistNeedCalibration - value = TrackingChecklistNeedCalibrationT().apply { - trackersId = buildTrackersIds(trackersNeedCalibration) - } - } - } else { - it.extraData = null - } - } - - val steamVRBridge = vrServer.getVRBridge(ISteamVRBridge::class.java) - if (steamVRBridge != null) { - val steamvrConnected = steamVRBridge.isConnected() - updateValidity( - TrackingChecklistStepId.STEAMVR_DISCONNECTED, - steamvrConnected, - ) { - it.enabled = true - if (!steamvrConnected) { - it.extraData = TrackingChecklistExtraDataUnion().apply { - type = TrackingChecklistExtraData.TrackingChecklistSteamVRDisconnected - value = TrackingChecklistSteamVRDisconnectedT().apply { - bridgeSettingsName = steamVRBridge.getBridgeConfigKey() - } - } - } else { - it.extraData = null - } - } - } - - if (vrServer.networkProfileChecker.isSupported) { - updateValidity(TrackingChecklistStepId.NETWORK_PROFILE_PUBLIC, vrServer.networkProfileChecker.publicNetworks.isEmpty()) { - if (vrServer.networkProfileChecker.publicNetworks.isNotEmpty()) { - it.extraData = TrackingChecklistExtraDataUnion().apply { - type = TrackingChecklistExtraData.TrackingChecklistPublicNetworks - value = TrackingChecklistPublicNetworksT().apply { - adapters = vrServer.networkProfileChecker.publicNetworks.map { it.name }.toTypedArray() - } - } - } else { - it.extraData = null - } - } - } - - updateValidity(TrackingChecklistStepId.MOUNTING_CALIBRATION, resetMountingCompleted) { - it.enabled = vrServer.configManager.vrConfig.resetsConfig.lastMountingMethod == MountingMethods.AUTOMATIC && imuTrackers.isNotEmpty() - } - - updateValidity(TrackingChecklistStepId.FEET_MOUNTING_CALIBRATION, feetResetMountingCompleted) { - it.enabled = - vrServer.configManager.vrConfig.resetsConfig.lastMountingMethod == MountingMethods.AUTOMATIC && - !vrServer.configManager.vrConfig.resetsConfig.resetMountingFeet && - imuTrackers.any { t -> TrackerUtils.feetsBodyParts.contains(t.trackerPosition?.bodyPart) } - } - - updateValidity(TrackingChecklistStepId.STAY_ALIGNED_CONFIGURED, vrServer.configManager.vrConfig.stayAlignedConfig.enabled) - - listeners.forEach { it.onStepsUpdate() } - } - - private fun updateValidity(id: Int, valid: Boolean, beforeUpdate: ((step: TrackingChecklistStepT) -> Unit)? = null) { - require(id != TrackingChecklistStepId.UNKNOWN) { - "id is unknown" - } - val step = steps.find { it.id == id } ?: error("step does not exists") - step.valid = valid - if (beforeUpdate != null) { - beforeUpdate(step) - } - } - - override fun onChange( - validity: VRCConfigValidity, - values: VRCConfigValues, - recommended: VRCConfigRecommendedValues, - muted: List, - ) { - updateValidity( - TrackingChecklistStepId.VRCHAT_SETTINGS, - VRCConfigValidity::class.java.declaredFields.asSequence().all { p -> - p.isAccessible = true - return@all p.get(validity) == true || muted.contains(p.name) - }, - ) - listeners.forEach { it.onStepsUpdate() } - } - - fun ignoreStep(step: TrackingChecklistStepT, ignore: Boolean) { - if (!step.ignorable) return - val ignoredSteps = vrServer.configManager.vrConfig.trackingChecklist.ignoredStepsIds - if (ignore && !ignoredSteps.contains(step.id)) { - ignoredSteps.add(step.id) - } else if (!ignore) { - ignoredSteps.remove(step.id) - } - vrServer.configManager.saveConfig() - } -} diff --git a/server/core/src/main/java/dev/slimevr/trackingpause/TrackingPauseHandler.kt b/server/core/src/main/java/dev/slimevr/trackingpause/TrackingPauseHandler.kt deleted file mode 100644 index 833dc25d47..0000000000 --- a/server/core/src/main/java/dev/slimevr/trackingpause/TrackingPauseHandler.kt +++ /dev/null @@ -1,19 +0,0 @@ -package dev.slimevr.trackingpause - -import java.util.concurrent.CopyOnWriteArrayList - -class TrackingPauseHandler { - private val listeners: MutableList = CopyOnWriteArrayList() - - fun sendTrackingPauseState(trackingPaused: Boolean) { - listeners.forEach { it.onTrackingPause(trackingPaused) } - } - - fun addListener(listener: TrackingPauseListener) { - listeners.add(listener) - } - - fun removeListener(listener: TrackingPauseListener) { - listeners.removeIf { it == listener } - } -} diff --git a/server/core/src/main/java/dev/slimevr/trackingpause/TrackingPauseListener.kt b/server/core/src/main/java/dev/slimevr/trackingpause/TrackingPauseListener.kt deleted file mode 100644 index 26182c04a8..0000000000 --- a/server/core/src/main/java/dev/slimevr/trackingpause/TrackingPauseListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package dev.slimevr.trackingpause - -interface TrackingPauseListener { - fun onTrackingPause(trackingPaused: Boolean) -} diff --git a/server/core/src/main/java/dev/slimevr/util/InterpolationHandler.kt b/server/core/src/main/java/dev/slimevr/util/InterpolationHandler.kt deleted file mode 100644 index 1be4995499..0000000000 --- a/server/core/src/main/java/dev/slimevr/util/InterpolationHandler.kt +++ /dev/null @@ -1,102 +0,0 @@ -package dev.slimevr.util - -import io.eiren.math.FloatMath.animateEase -import io.github.axisangles.ktmath.Quaternion - -class InterpolationHandler { - /** - * The starting rotation of this movement. - */ - var start = Quaternion.IDENTITY - private set - - /** - * The ending rotation of this movement. - */ - var end = Quaternion.IDENTITY - private set - - /** - * The time left for the current movement. - */ - var remainingTime = 0f - private set - - /** - * The total interval of interpolation for this movement. - */ - var totalTime = 0f - private set - - /** - * The current interpolated value for this tick. - */ - var curRotation = Quaternion.IDENTITY - private set - - /** - * Starts an interpolation from [start] to [end] over [interval] seconds. - * @param start The starting rotation to interpolate from. - * @param end The ending rotation to interpolate to. - * @param interval The amount of time that the interpolation will take. - * @param shortestDistance Whether the interpolation will take the shortest path or - * take the quaternion space path between [start] and [end]. - */ - @Synchronized - fun interpolate( - start: Quaternion = curRotation, - end: Quaternion, - interval: Float, - shortestDistance: Boolean = true, - ) { - this.totalTime = interval - remainingTime = interval - - this.start = if (shortestDistance) { - start.twinNearest(end) - } else { - start - } - this.end = end - - // The current state is at the start - // TODO: Maybe handle a mid-interval interpolation swap? - curRotation = this.start - } - - /** - * The main ticking function, computes [curRotation] for each tick and reduces - * [remainingTime] by [delta]. - * @param delta The time in seconds between the last time [tick] was run and the - * current tick. - */ - @Synchronized - fun tick(delta: Float) { - if (remainingTime > 0f) { - remainingTime -= delta - - // If we still need to interpolate after the delta change - if (remainingTime > 0f) { - // Remaining time decreases to 0, so the interpolation is reversed - curRotation = end.interpR( - start, - animateEase(remainingTime / totalTime), - ) - } else { - remainingTime = 0f - curRotation = end - } - } - } - - @Synchronized - fun reset() { - remainingTime = 0f - totalTime = 0f - - start = Quaternion.IDENTITY - end = Quaternion.IDENTITY - - curRotation = Quaternion.IDENTITY - } -} diff --git a/server/core/src/main/java/dev/slimevr/util/TickReducer.kt b/server/core/src/main/java/dev/slimevr/util/TickReducer.kt deleted file mode 100644 index b4cb2879d3..0000000000 --- a/server/core/src/main/java/dev/slimevr/util/TickReducer.kt +++ /dev/null @@ -1,59 +0,0 @@ -package dev.slimevr.util - -class TickReducer( - /** - * The function to run every tick interval. - */ - private val onTick: (delta: Float) -> Unit, - /** - * The tick interval in seconds. - */ - var interval: Float, - /** - * The amount of time in seconds that a tick can fire early. - */ - var resolution: Float = 0f, -) { - /** - * The amount of time in seconds since the last tick. - */ - private var tickDelta = 0f - - /** - * The offset in timing for the next frame to approximate a tick rate with low - * resolution ticking. - */ - private var tickOffset = 0f - - /** - * The main ticking function to be run at a fast tick rate. Runs [onTick] at the - * specified [interval] and [resolution]. - * @param delta The time in seconds between the last time [tick] was run and the - * current tick. - */ - @Synchronized - fun tick(delta: Float) { - // Update tick delta from delta - tickDelta += delta - - // If the next tick time is not within the given resolution - if (tickDelta + tickOffset + resolution < interval) return - - // Run tick, providing the delta time - onTick(tickDelta) - - // Reset tick timing including an offset to compensate for inaccuracy - // Define a maximum for the offset to prevent double ticking - tickOffset = (tickDelta - interval).coerceAtMost(interval / 2f) - tickDelta = 0f - } - - /** - * Resets the tick timing. - */ - @Synchronized - fun reset() { - tickDelta = 0f - tickOffset = 0f - } -} diff --git a/server/core/src/main/java/dev/slimevr/util/ann/VRServerThread.java b/server/core/src/main/java/dev/slimevr/util/ann/VRServerThread.java deleted file mode 100644 index 37d6d7180a..0000000000 --- a/server/core/src/main/java/dev/slimevr/util/ann/VRServerThread.java +++ /dev/null @@ -1,10 +0,0 @@ -package dev.slimevr.util.ann; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - - -@Retention(value = RetentionPolicy.SOURCE) -public @interface VRServerThread { - -} diff --git a/server/core/src/main/java/dev/slimevr/websocketapi/WebSocketVRBridge.kt b/server/core/src/main/java/dev/slimevr/websocketapi/WebSocketVRBridge.kt deleted file mode 100644 index 63ea4eecd7..0000000000 --- a/server/core/src/main/java/dev/slimevr/websocketapi/WebSocketVRBridge.kt +++ /dev/null @@ -1,239 +0,0 @@ -package dev.slimevr.websocketapi - -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.databind.node.ObjectNode -import dev.slimevr.VRServer -import dev.slimevr.VRServer.Companion.getNextLocalTrackerId -import dev.slimevr.VRServer.Companion.instance -import dev.slimevr.bridge.Bridge -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerPosition -import dev.slimevr.tracking.trackers.TrackerStatus -import io.eiren.util.collections.FastList -import io.eiren.util.logging.LogManager -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import org.java_websocket.WebSocket -import org.java_websocket.handshake.ClientHandshake -import java.util.* -import java.util.concurrent.atomic.AtomicBoolean - -class WebSocketVRBridge( - computedTrackers: List, - server: VRServer, -) : WebsocketAPI(server, server.protocolAPI), - Bridge { - private val computedTrackers: List = FastList(computedTrackers) - private val internalTrackers: MutableList = FastList(computedTrackers.size) - private val newHMDData = AtomicBoolean(false) - private val mapper = ObjectMapper() - private val internalHMDTracker = Tracker( - null, - 0, - "internal://HMD", - "internal://HMD", - TrackerPosition.HEAD, - hasPosition = true, - hasRotation = true, - isInternal = true, - isComputed = true, - ) - private var hmdTracker: Tracker? = null - - init { - for (t in computedTrackers) { - val ct = Tracker( - null, - t.id, - "internal://${t.name}", - "internal://${t.name}", - t.trackerPosition, - hasPosition = true, - hasRotation = true, - userEditable = true, - isInternal = true, - ) - ct.status = TrackerStatus.OK - internalTrackers.add(ct) - } - } - - override fun dataRead() { - if (newHMDData.compareAndSet(true, false)) { - if (hmdTracker == null) { - // Create HMD for websocket - val hmdDevice = server.deviceManager - .createDevice("WebSocketVRBridge", null, null) - hmdTracker = Tracker( - null, - getNextLocalTrackerId(), - "WebSocketHMD", - "WebSocketHMD", - TrackerPosition.HEAD, - hasPosition = true, - hasRotation = true, - userEditable = true, - isComputed = true, - ) - hmdTracker!!.status = TrackerStatus.OK - hmdDevice.trackers[0] = hmdTracker!! - server.registerTracker(hmdTracker!!) - } - - hmdTracker!!.position = internalHMDTracker.position - hmdTracker!!.setRotation(internalHMDTracker.getRotation()) - hmdTracker!!.dataTick() - } - } - - override fun dataWrite() { - for (i in computedTrackers.indices) { - val t = computedTrackers[i] - val it = internalTrackers[i] - if (t.hasPosition) it.position = t.position - if (t.hasRotation) it.setRotation(t.getRotation()) - } - } - - override fun onOpen(conn: WebSocket, handshake: ClientHandshake) { - super.onOpen(conn, handshake) - // Register trackers - for (i in internalTrackers.indices) { - val message = mapper.nodeFactory.objectNode() - message.put("type", "config") - message.put("tracker_id", "SlimeVR Tracker " + (i + 1)) - message - .put( - "location", - computedTrackers[i] - .trackerPosition - ?.trackerRole - ?.name - ?.lowercase(Locale.getDefault()), - ) - message.put("tracker_type", message["location"].asText()) - conn.send(message.toString()) - } - } - - override fun onMessage(conn: WebSocket, message: String) { - // LogManager.info(message); - try { - val json = mapper.readTree(message) as ObjectNode - if (json.has("type")) { - when (json["type"].asText()) { - "pos" -> { - parsePosition(json, conn) - return - } - - "action" -> { - parseAction(json, conn) - return - } - - // TODO Ignore it for now, it should only register HMD in our test case with id 0 - "config" -> { - LogManager.info("[WebSocket] Config received: $json") - return - } - } - } - LogManager - .warning( - "[WebSocket] Unrecognized message from " + - conn.remoteSocketAddress.address.hostAddress + - ": " + - message, - ) - } catch (e: Exception) { - LogManager - .severe( - "[WebSocket] Exception parsing message from " + - conn.remoteSocketAddress.address.hostAddress + - ". Message: " + - message, - e, - ) - } - } - - private fun parsePosition(json: ObjectNode, conn: WebSocket) { - if (json["tracker_id"].asInt() == 0) { - // Read HMD information - internalHMDTracker - .position = Vector3( - json["x"].asDouble().toFloat(), - json["y"].asDouble().toFloat() + 0.2f, - json["z"].asDouble().toFloat(), - ) - // TODO Wtf is this hack? VRWorkout issue? - internalHMDTracker - .setRotation( - Quaternion( - json["qw"].asDouble().toFloat(), - json["qx"].asDouble().toFloat(), - json["qy"].asDouble().toFloat(), - json["qz"].asDouble().toFloat(), - ), - ) - internalHMDTracker.dataTick() - newHMDData.set(true) - - // Send tracker info in reply - for (i in internalTrackers.indices) { - val message = mapper.nodeFactory.objectNode() - message.put("type", "pos") - message.put("src", "full") - message.put("tracker_id", "SlimeVR Tracker ${i + 1}") - - val t = internalTrackers[i] - message.put("x", t.position.x) - message.put("y", t.position.y) - message.put("z", t.position.z) - message.put("qx", t.getRotation().x) - message.put("qy", t.getRotation().y) - message.put("qz", t.getRotation().z) - message.put("qw", t.getRotation().w) - - conn.send(message.toString()) - } - } - } - - private fun parseAction(json: ObjectNode, conn: WebSocket) { - when (json["name"].asText()) { - "calibrate" -> instance.resetTrackersYaw(RESET_SOURCE_NAME) - "full_calibrate" -> instance.resetTrackersFull(RESET_SOURCE_NAME) - "mounting_calibrate" -> instance.resetTrackersMounting(RESET_SOURCE_NAME) - "mounting_clear" -> instance.clearTrackersMounting(RESET_SOURCE_NAME) - "toggle_pause_tracking" -> instance.togglePauseTracking(RESET_SOURCE_NAME) - } - } - - override fun onStart() { - LogManager.info("[WebSocket] Web Socket VR Bridge started on port $port") - connectionLostTimeout = 0 - connectionLostTimeout = 1 - // This has to be removed for Android - // (keepalive did not work for me @mgschwan) - } - - override fun addSharedTracker(tracker: Tracker?) { - // TODO Auto-generated method stub - } - - override fun removeSharedTracker(tracker: Tracker?) { - // TODO Auto-generated method stub - } - - override fun startBridge() { - start() - } - - override fun isConnected(): Boolean = super.getConnections().isNotEmpty() - - companion object { - private const val RESET_SOURCE_NAME = "WebSocketVRBridge" - } -} diff --git a/server/core/src/main/java/dev/slimevr/websocketapi/WebsocketAPI.java b/server/core/src/main/java/dev/slimevr/websocketapi/WebsocketAPI.java deleted file mode 100644 index 0e3cf870e4..0000000000 --- a/server/core/src/main/java/dev/slimevr/websocketapi/WebsocketAPI.java +++ /dev/null @@ -1,111 +0,0 @@ -package dev.slimevr.websocketapi; - -import dev.slimevr.VRServer; -import dev.slimevr.protocol.GenericConnection; -import dev.slimevr.protocol.ProtocolAPI; -import dev.slimevr.protocol.ProtocolAPIServer; -import io.eiren.util.logging.LogManager; -import org.java_websocket.WebSocket; -import org.java_websocket.drafts.Draft_6455; -import org.java_websocket.handshake.ClientHandshake; -import org.java_websocket.server.WebSocketServer; -import org.jetbrains.annotations.NotNull; - -import java.net.InetSocketAddress; -import java.nio.ByteBuffer; -import java.util.Collections; -import java.util.Objects; -import java.util.stream.Stream; - - -public class WebsocketAPI extends WebSocketServer implements ProtocolAPIServer { - - public final VRServer server; - public final ProtocolAPI protocolAPI; - - public WebsocketAPI(VRServer server, ProtocolAPI protocolAPI) { - super(new InetSocketAddress(21110), Collections.singletonList(new Draft_6455())); - this.server = server; - this.protocolAPI = protocolAPI; - - this.protocolAPI.registerAPIServer(this); - setReuseAddr(true); - } - - @Override - public void onOpen(WebSocket conn, ClientHandshake handshake) { - LogManager - .info( - "[WebSocketAPI] New connection from: " - + conn.getRemoteSocketAddress().getAddress().getHostAddress() - ); - conn.setAttachment(new WebsocketConnection(conn)); - } - - /** - * Helper function to get the string of the `conn` while handling `null` - */ - protected static String connAddr(WebSocket conn) { - if (conn == null) { - return "null"; - } - var remote = conn.getRemoteSocketAddress(); - if (remote == null) { - return conn.toString(); - } - var addr = remote.getAddress(); - if (addr == null) { - return remote.toString(); - } - return addr.getHostAddress(); - } - - @Override - public void onClose(WebSocket conn, int code, String reason, boolean remote) { - LogManager - .info( - "[WebSocketAPI] Disconnected: " - + connAddr(conn) - + ", (" - + code - + ") " - + reason - + ". Remote: " - + remote - ); - } - - @Override - public void onMessage(WebSocket conn, String message) { - } - - @Override - public void onMessage(WebSocket conn, ByteBuffer message) { - var connection = conn.getAttachment(); - if (connection != null) - this.protocolAPI.onMessage(connection, message); - } - - @Override - public void onError(WebSocket conn, Exception ex) { - LogManager - .severe( - "[WebSocket] Exception on connection " + connAddr(conn), - ex - ); - } - - @Override - public void onStart() { - LogManager.info("[WebSocketAPI] Web Socket API started on port " + getPort()); - setConnectionLostTimeout(0); - } - - @Override - public @NotNull Stream getApiConnections() { - return this.getConnections().stream().map(conn -> { - var c = conn.getAttachment(); - return (GenericConnection) c; - }).filter(Objects::nonNull); - } -} diff --git a/server/core/src/main/java/dev/slimevr/websocketapi/WebsocketConnection.java b/server/core/src/main/java/dev/slimevr/websocketapi/WebsocketConnection.java deleted file mode 100644 index 73340b7443..0000000000 --- a/server/core/src/main/java/dev/slimevr/websocketapi/WebsocketConnection.java +++ /dev/null @@ -1,44 +0,0 @@ -package dev.slimevr.websocketapi; - -import dev.slimevr.protocol.ConnectionContext; -import dev.slimevr.protocol.GenericConnection; -import org.java_websocket.WebSocket; -import org.java_websocket.exceptions.WebsocketNotConnectedException; - -import java.nio.ByteBuffer; -import java.util.UUID; - - -public class WebsocketConnection implements GenericConnection { - - public final ConnectionContext context; - public final WebSocket conn; - public UUID id; - - public WebsocketConnection(WebSocket conn) { - this.context = new ConnectionContext(); - this.conn = conn; - this.id = UUID.randomUUID(); - } - - @Override - public ConnectionContext getContext() { - return this.context; - } - - @Override - public void send(ByteBuffer bytes) { - if (this.conn.isOpen()) { - try { - this.conn.send(bytes.slice()); - } catch (WebsocketNotConnectedException ignored) { - // Race condition if it closes between our check and sending - } - } - } - - @Override - public UUID getConnectionId() { - return id; - } -} diff --git a/server/core/src/main/java/io/eiren/math/FloatMath.kt b/server/core/src/main/java/io/eiren/math/FloatMath.kt deleted file mode 100644 index 66e50faabb..0000000000 --- a/server/core/src/main/java/io/eiren/math/FloatMath.kt +++ /dev/null @@ -1,435 +0,0 @@ -package io.eiren.math - -import com.jme3.math.FastMath -import com.jme3.math.FastMath.normalize -import io.github.axisangles.ktmath.Vector3 -import kotlin.math.* - -object FloatMath { - const val PI: Float = Math.PI.toFloat() - const val TWO_PI: Float = (Math.PI * 2).toFloat() - const val ANGLE_EPSILON: Float = 0.028f // in degrees (float - - // epsilon for sin/cos) - val ANGLE_EPSILON_RAD: Float = toRad(ANGLE_EPSILON) - - const val ZERO_TOLERANCE_F: Float = FastMath.ZERO_TOLERANCE - const val ZERO_TOLERANCE_D: Double = 0.0001 - - val SQRT_TWO: Float = kotlin.math.sqrt(2.0).toFloat() - val INV_SQRT_TWO: Float = 1f / SQRT_TWO - val SQRT_THREE: Float = kotlin.math.sqrt(3.0).toFloat() - val INV_SQRT_THREE: Float = 1f / SQRT_THREE - const val TWO_FPI: Float = PI * 2 - - const val SIN_75_DEG: Float = 0.965926f - const val SIN_60_DEG: Float = 0.866025f - const val SIN_45_DEG: Float = 0.707107f - const val SIN_30_DEG: Float = 0.5f - const val SIN_15_DEG: Float = 0.258819f - - const val COS_75_DEG: Float = 0.258819f - const val COS_60_DEG: Float = 0.5f - const val COS_45_DEG: Float = 0.707107f - const val COS_30_DEG: Float = 0.866025f - const val COS_15_DEG: Float = 0.965926f - - const val TEN_BITS: Int = (0.inv() shl 10).inv() - const val TENTH_BIT: Int = 1 shl 10 - const val TEN_BITS_MAX: Int = (0.inv() shl 9).inv() - const val TEN_BITS_MAX_UNSIGNED: Int = (0.inv() shl 10).inv() - const val TWO_BITS: Int = (0.inv() shl 2).inv() - const val SECOND_BIT: Int = 1 shl 2 - const val TWO_BITS_MAX: Int = (0.inv() shl 1).inv() - const val TWO_BITS_MAX_UNSIGNED: Int = (0.inv() shl 2).inv() - - fun roundIfZero(x: Float): Float = if (kotlin.math.abs(x.toDouble()) < ZERO_TOLERANCE_F) 0.0f else x - - fun equalsToZero(x: Float): Boolean = kotlin.math.abs(x.toDouble()) < ZERO_TOLERANCE_F - - fun lessThanZero(x: Float): Boolean = (x < -ZERO_TOLERANCE_F) - - fun lessOrEqualsToZero(x: Float): Boolean = (x < ZERO_TOLERANCE_F) - - fun greaterThanZero(x: Float): Boolean = (x > ZERO_TOLERANCE_F) - - fun greaterOrEqualsToZero(x: Float): Boolean = (x > -ZERO_TOLERANCE_F) - - fun equalsToZero(x: Float, epsilon: Float): Boolean = kotlin.math.abs(x.toDouble()) < epsilon - - fun equalsWithEpsilon(x: Float, y: Float): Boolean = kotlin.math.abs((x - y).toDouble()) < ZERO_TOLERANCE_F - - fun equalsWithEpsilon(x: Float, y: Float, epsilon: Float): Boolean = kotlin.math.abs((x - y).toDouble()) < epsilon - - fun lessWithEpsilon(x: Float, y: Float): Boolean = (x < y - ZERO_TOLERANCE_F) - - fun lessOrEqualsWithEpsilon(x: Float, y: Float): Boolean = (x < y + ZERO_TOLERANCE_F) - - fun lessWithEpsilon(x: Float, y: Float, epsilon: Float): Boolean = (x < y - epsilon) - - fun lessOrEqualsWithEpsilon(x: Float, y: Float, epsilon: Float): Boolean = (x < y + epsilon) - - fun greaterWithEpsilon(x: Float, y: Float): Boolean = (x > y + ZERO_TOLERANCE_F) - - fun greaterOrEqualsWithEpsilon(x: Float, y: Float): Boolean = (x > y - ZERO_TOLERANCE_F) - - fun greaterWithEpsilon(x: Float, y: Float, epsilon: Float): Boolean = (x > y + epsilon) - - fun greaterOrEqualsWithEpsilon(x: Float, y: Float, epsilon: Float): Boolean = (x > y - epsilon) - - fun roundIfZero(x: Double): Double = if (kotlin.math.abs(x) < ZERO_TOLERANCE_D) 0.0 else x - - fun equalsToZero(x: Double): Boolean = kotlin.math.abs(x) < ZERO_TOLERANCE_D - - fun equalsWithEpsilon(x: Double, y: Double): Boolean = kotlin.math.abs(x - y) < ZERO_TOLERANCE_D - - fun lessWithEpsilon(x: Double, y: Double): Boolean = (x < y - ZERO_TOLERANCE_D) - - fun lessOrEqualsWithEpsilon(x: Double, y: Double): Boolean = (x < y + ZERO_TOLERANCE_D) - - fun greaterWithEpsilon(x: Double, y: Double): Boolean = (x > y + ZERO_TOLERANCE_D) - - fun greaterOrEqualsWithEpsilon(x: Double, y: Double): Boolean = (x > y - ZERO_TOLERANCE_D) - - fun toDegrees(angrad: Float): Float = angrad * 180.0f / PI - - fun toRad(deg: Float): Float = deg / 180.0f * PI - - fun radEqual(angle1: Float, angle2: Float): Boolean { - val diff = clampRad(angle1 - angle2) - return kotlin.math.abs(diff.toDouble()) < ANGLE_EPSILON_RAD - } - - fun degreesEqual(angle1: Float, angle2: Float): Boolean { - val diff = clampDegrees(angle1 - angle2) - return kotlin.math.abs(diff.toDouble()) < ANGLE_EPSILON - } - - @Deprecated( - "use {@link #normalizeRad(float)}", - ReplaceWith("normalizeRad(angle)", "io.eiren.math.FloatMath.normalizeRad"), - ) - fun clampRad(angle: Float): Float = normalizeRad(angle) - - fun normalizeRad(angle: Float): Float = normalize(angle, -FastMath.PI, FastMath.PI) - - @Deprecated( - "use {@link #normalizeDegrees(float)}", - ReplaceWith("normalizeDegrees(angle)", "io.eiren.math.FloatMath.normalizeDegrees"), - ) - fun clampDegrees(angle: Float): Float = normalizeDegrees(angle) - - fun normalizeDegrees(angle: Float): Float = normalize(angle, -180f, 180f) - - fun animateEase(t: Float): Float { - // Special case of Bezier interpolation (p0 = p1 = 0, p2 = p3 = 1) - return (3.0f - 2.0f * t) * t * t - } - - fun animateEaseIn(t: Float): Float = t * t - - /** - * Lineary remaps value from the source interval to the target interval. - * [details](https://en.wikipedia.org/wiki/Linear_interpolation) - */ - fun mapValue( - value: Float, - sourceStart: Float, - sourceEnd: Float, - targetStart: Float, - targetEnd: Float, - ): Float = ( - targetStart + - (value - sourceStart) * - (targetEnd - targetStart) / - (sourceEnd - sourceStart) - ) - - /** - * Clamps the given value and remaps to the target interval. - * - * - * Note the source interval values should be sorted. - */ - fun mapValueWithClampBefore( - value: Float, - sourceBottom: Float, - sourceTop: Float, - targetBottom: Float, - targetTop: Float, - ): Float = mapValue( - clamp(value, sourceBottom, sourceTop), - sourceBottom, - sourceTop, - targetBottom, - targetTop, - ) - - /** - * Remaps the given value to the target interval and clamps. - * - * - * Note the target interval values should be sorted. - */ - fun mapValueWithClampAfter( - value: Float, - sourceBottom: Float, - sourceTop: Float, - targetBottom: Float, - targetTop: Float, - ): Float = clamp( - mapValue(value, sourceBottom, sourceTop, targetBottom, targetTop), - targetBottom, - targetTop, - ) - - fun smoothstep(edge0: Float, edge1: Float, x: Float): Float { - // Scale, bias and saturate x to 0..1 range - var x = x - x = FastMath.clamp((x - edge0) / (edge1 - edge0), 0.0f, 1.0f) - // Evaluate polynomial - return x * x * (3f - 2f * x) - } - - fun smootherstep(edge0: Float, edge1: Float, x: Float): Float { - // Scale, and clamp x to 0..1 range - var x = x - x = FastMath.clamp((x - edge0) / (edge1 - edge0), 0.0f, 1.0f) - // Evaluate polynomial - return x * x * x * (x * (x * 6f - 15f) + 10f) - } - - /** - * Applies linear contrast (with clamping). - * - * @param t - input value in range (0..1) - * @param k - contrast factor in range (-1..1): - * - * * 1.0 - maximal contrast - * * **0.0** - bypass (returns input value) - * * -1.0 - minimal contrast (returns 0.5f for any input) - * - * @return contrasted value in range (0..1) - */ - fun contrastLinear(t: Float, k: Float): Float { - val x = 2f * t - 1f // -1..1 - val gamma = (1f + k) / (1f - k) - val f = FastMath.clamp(gamma * x, -1f, 1f) // -1..1 - return 0.5f * (f + 1f) // 0..1 - } - - /** - * Applies non-linear contrast by power function. - * - * @param t - input value in range (0..1) - * @param k - contrast factor in range (-1..1) exclusive: - * - * * 0.999 - maximal contrast - * * 0.0 - bypass (returns input value) - * * -0.999 - minimal contrast - * - * @return contrasted value in range (0..1) - */ - fun contrastPower(t: Float, k: Float): Float { - val x = 2f * t - 1f // -1..1 - val gamma = (1f - k) / (1f + k) - val f = sign(x) * abs(x).pow(gamma) // -1..1 - return 0.5f * (f + 1f) // 0..1 - } - - /** - * Applies non-linear contrast by square splines. - * - * @param t - input value in range (0..1) - * @param k - contrast factor in range (-1..1): - * - * * 1.0 - maximal contrast - * * 0.0 - bypass (returns input value) - * * -1.0 - minimal contrast - * - * @return contrasted value in range (0..1) - */ - fun contrastQuadricSpline(t: Float, k: Float): Float { - val x = 2f * t - 1f // -1..1 - val f = x * (1f + k * (1f - abs(x))) // -1..1 - return 0.5f * (f + 1f) // 0..1 - } - - /** - * Applies non-linear contrast by square splines inverted function. - * - * @param t - input value in range (0..1) - * @param k - contrast factor in range (-2..2): - * - * * 2.0 - maximal contrast - * * 0.0 - bypass (returns input value) - * * -2.0 - minimal contrast - * - * @return contrasted value in range (0..1) - */ - fun contrastInvertQuadricSpline(t: Float, k: Float): Float { - val x = 2f * t - 1f // -1..1 - val g = if (k > 0) { - sign(x) * sqrt(abs(x)) - 2f * x - } else { - sign(x) * (sqrt(1f - abs(x)) - 1f) - } - val f = (1f + k) * x + k * g // -1..1 - return 0.5f * (f + 1f) // 0..1 - } - - /** - * Applies non-linear contrast by cubic splines. - * - * @param t - input value in range (0..1) - * @param k - contrast factor in range (-1..1): - * - * * 1.0 - maximal contrast - * * 0.0 - bypass (returns input value) - * * -1.0 - minimal contrast - * - * @return contrasted value in range (0..1) - */ - fun contrastCubicSpline(t: Float, k: Float): Float { - val x = 2f * t - 1f // -1..1 - var f = x * (1f + abs(k) * (x * x - 1f)) - if (k < 0) f -= x * 3f * k * (1f - abs(x)) - return 0.5f * (f + 1f) // 0..1 - } - - fun fraction(f: Float): Float = f - f.toInt() - - fun fraction(d: Double): Double = d - d.toLong() - - fun distance(x0: Float, y0: Float, z0: Float, x1: Float, y1: Float, z1: Float): Float = distance(x1 - x0, y1 - y0, z1 - z0) - - fun distance(x: Float, y: Float, z: Float): Float = sqrt(sqrDistance(x, y, z)) - - fun sqrDistance(x: Float, y: Float, z: Float): Float = x * x + y * y + z * z - - fun distance(x: Float, y: Float): Float = sqrt(sqrDistance(x, y)) - - fun sqrDistance(x: Float, y: Float): Float = x * x + y * y - - fun sqrDistance(v: Vector3, x1: Float, y1: Float, z1: Float): Float = sqrDistance(x1 - v.x, y1 - v.y, z1 - v.z) - - fun sqrDistance(x0: Float, y0: Float, z0: Float, x1: Float, y1: Float, z1: Float): Float = sqrDistance(x1 - x0, y1 - y0, z1 - z0) - - /** - * The same as FastMath.clamp - */ - fun clamp(value: Float, min: Float, max: Float): Float = kotlin.math.max(min.toDouble(), kotlin.math.min(max.toDouble(), value.toDouble())).toFloat() - - fun int2101010RevToFloats(packedValue: Int, source: Vector3): Vector3 { - var x = source.x - var y = source.y - var z = source.z - - x = (packedValue and TEN_BITS_MAX).toFloat() - if ((packedValue and TENTH_BIT) != 0) x *= -1f - y = ((packedValue ushr 10) and TEN_BITS_MAX).toFloat() - if ((packedValue and (TENTH_BIT shl 10)) != 0) y *= -1f - z = ((packedValue ushr 20) and TEN_BITS_MAX).toFloat() - if ((packedValue and (TENTH_BIT shl 20)) != 0) z *= -1f - return Vector3(x, y, z) - } - - fun floatToInt210101Rev(values: Vector3): Int { - var store = 0 - store = store or ((values.x.toInt()) and TEN_BITS_MAX) - if (values.x < 0) store = store or TENTH_BIT - store = store or (((values.y.toInt()) and TEN_BITS_MAX) shl 10) - if (values.y < 0) store = store or (TENTH_BIT shl 10) - store = store or (((values.z.toInt()) and TEN_BITS_MAX) shl 20) - if (values.z < 0) store = store or (TENTH_BIT shl 20) - return store - } - - fun floatToInt210101RevNormalized(values: Vector3): Int { - var store = 0 - store = store or (((values.x * TEN_BITS).toInt()) and TEN_BITS_MAX) - if (values.x < 0) store = store or TENTH_BIT - store = store or ((((values.y * TEN_BITS).toInt()) and TEN_BITS_MAX) shl 10) - if (values.y < 0) store = store or (TENTH_BIT shl 10) - store = store or ((((values.z * TEN_BITS).toInt()) and TEN_BITS_MAX) shl 20) - if (values.z < 0) store = store or (TENTH_BIT shl 20) - return store - } - - fun floatToUnsignedInt210101Rev(values: Vector3): Int { - var store = 0 - store = store or ((values.x.toInt()) and TEN_BITS) - store = store or (((values.y.toInt()) and TEN_BITS) shl 10) - store = store or (((values.z.toInt()) and TEN_BITS) shl 20) - return store - } - - fun floatToUnsignedInt210101RevNormalized(values: Vector3): Int { - var store = 0 - store = store or (((values.x * TEN_BITS).toInt()) and TEN_BITS) - store = store or ((((values.y * TEN_BITS).toInt()) and TEN_BITS) shl 10) - store = store or ((((values.z * TEN_BITS).toInt()) and TEN_BITS) shl 20) - return store - } - - fun floatToInt210101Rev(x: Float, y: Float, z: Float): Int { - var store = 0 - store = store or ((x.toInt()) and TEN_BITS_MAX) - if (x < 0) store = store or TENTH_BIT - store = store or (((y.toInt()) and TEN_BITS_MAX) shl 10) - if (y < 0) store = store or (TENTH_BIT shl 10) - store = store or (((z.toInt()) and TEN_BITS_MAX) shl 20) - if (z < 0) store = store or (TENTH_BIT shl 20) - return store - } - - fun floatToUnsignedInt210101Rev(x: Float, y: Float, z: Float): Int { - var store = 0 - store = store or ((x.toInt()) and TEN_BITS) - store = store or (((y.toInt()) and TEN_BITS) shl 10) - store = store or (((z.toInt()) and TEN_BITS) shl 20) - return store - } - - fun unsignedInt2101010RevToFloats(packedValue: Int, source: Vector3): Vector3 { - var x = source.x - var y = source.y - var z = source.z - - x = (packedValue and TEN_BITS).toFloat() - y = ((packedValue ushr 10) and TEN_BITS).toFloat() - z = ((packedValue ushr 20) and TEN_BITS).toFloat() - - return Vector3(x, y, z) - } - - fun int2101010RevNormalizedToFloats(packedValue: Int, source: Vector3): Vector3 { - var source = source - source = int2101010RevToFloats(packedValue, source) - var x = source.x - var y = source.y - var z = source.z - - x /= TEN_BITS_MAX.toFloat() - y /= TEN_BITS_MAX.toFloat() - z /= TEN_BITS_MAX.toFloat() - return Vector3(x, y, z) - } - - fun unsignedInt2101010RevNormalizedToFloats( - packedValue: Int, - source: Vector3, - ): Vector3 { - var source = source - source = unsignedInt2101010RevToFloats(packedValue, source) - var x = source.x - var y = source.y - var z = source.z - - x /= TEN_BITS.toFloat() - y /= TEN_BITS.toFloat() - z /= TEN_BITS.toFloat() - - return Vector3(x, y, z) - } -} diff --git a/server/core/src/main/java/io/eiren/util/BufferedTimer.java b/server/core/src/main/java/io/eiren/util/BufferedTimer.java deleted file mode 100644 index a254c2a574..0000000000 --- a/server/core/src/main/java/io/eiren/util/BufferedTimer.java +++ /dev/null @@ -1,127 +0,0 @@ -package io.eiren.util; - -import com.jme3.system.NanoTimer; - - -/** - * This timer accumulate measured TPF and returns average/min/max FPS value - */ -public class BufferedTimer extends NanoTimer { - - private final float measureInterval; - private float averageTpf; - private float averageFps; - private float averageFrameRenderTime; - private float sumFrameRenderTime; - private float sumTpf; - private float minFpsCurrent; - private float maxFpsCurrent; - private float maxFps; - private float minFps; - private int count; - private boolean measured = false; - - /** - * Measure average tpf over the provided inverval in seconds - * - * @param measureInterval interval to measure averages over - */ - public BufferedTimer(float measureInterval) { - averageFps = 0; - sumTpf = 0; - count = 0; - this.measureInterval = measureInterval; - } - - public float getAverageFPS() { - return averageFps; - } - - public float getMinFPS() { - return minFps; - } - - public float getMaxFPS() { - return maxFps; - } - - public void addRenderTime(float renderTime) { - sumFrameRenderTime += renderTime; - } - - public float getAverageFrameRenderTime() { - return averageFrameRenderTime; - } - - public boolean isMeasured() { - if (measured) { - measured = false; - return true; - } - return false; - } - - public TimerSample getCurrentData() { - return new TimerSample(getFrameRate(), minFps, maxFps, averageFps); - } - - @Override - public void update() { - super.update(); - // Accumulate instant rate - sumTpf += getTimePerFrame(); - float fps = getFrameRate(); - if (fps < minFpsCurrent) - minFpsCurrent = fps; - if (fps > maxFpsCurrent) - maxFpsCurrent = fps; - ++count; - // Calculate results once per measure interval - if (!measured || sumTpf > measureInterval) { - // Average results - averageTpf = sumTpf / count; - averageFps = 1.0f / averageTpf; - averageFrameRenderTime = sumFrameRenderTime / count; - minFps = minFpsCurrent; - maxFps = maxFpsCurrent; - // Reset counter - sumTpf = 0; - sumFrameRenderTime = 0; - minFpsCurrent = Float.MAX_VALUE; - maxFpsCurrent = 0; - count = 0; - measured = true; - } - } - - public static class TimerSample { - - public float fps; - public float minFps; - public float maxFps; - public float averageFps; - - public TimerSample(float fps, float minFps, float maxFps, float averageFps) { - this.fps = fps; - this.minFps = minFps; - this.maxFps = maxFps; - this.averageFps = averageFps; - } - - public float getFps() { - return fps; - } - - public float getMinFps() { - return minFps; - } - - public float getMaxFps() { - return maxFps; - } - - public float getAverageFps() { - return averageFps; - } - } -} diff --git a/server/core/src/main/java/io/eiren/util/OperatingSystem.kt b/server/core/src/main/java/io/eiren/util/OperatingSystem.kt deleted file mode 100644 index 8a651e95e9..0000000000 --- a/server/core/src/main/java/io/eiren/util/OperatingSystem.kt +++ /dev/null @@ -1,69 +0,0 @@ -package io.eiren.util - -import java.io.File -import java.nio.file.Path -import java.util.* -import kotlin.io.path.Path - -enum class OperatingSystem( - val descriptor: String, - private val aliases: Array, -) { - LINUX("linux", arrayOf("linux", "unix")), - WINDOWS("windows", arrayOf("win")), - OSX("osx", arrayOf("mac")), - UNKNOWN("unknown", arrayOf()), - ; - - companion object { - val currentPlatform: OperatingSystem by lazy { - val osName = System.getProperty("os.name").lowercase(Locale.getDefault()) - entries.find { os -> os.aliases.any { alias -> osName.contains(alias) } }?.let { - return@lazy it - } - UNKNOWN - } - fun getJavaExecutable(forceConsole: Boolean): String { - val separator = System.getProperty("file.separator") - val path = System.getProperty("java.home") + separator + "bin" + separator - return if (currentPlatform == WINDOWS) { - if (!forceConsole && File(path + "javaw.exe").isFile) path + "javaw.exe" else path + "java.exe" - } else { - path + "java" - } - } - - val socketDirectory: String - get() { - var dir = System.getenv("SLIMEVR_SOCKET_DIR") - if (dir != null) return dir - if (currentPlatform == LINUX) { - dir = System.getenv("XDG_RUNTIME_DIR") - if (dir != null) return dir - } - return System.getProperty("java.io.tmpdir") - } - - fun resolveConfigDirectory(identifier: String): Path? = when (currentPlatform) { - LINUX -> System.getenv("XDG_CONFIG_HOME")?.let { Path(it, identifier) } - ?: System.getenv("HOME")?.let { Path(it, ".config", identifier) } - - WINDOWS -> System.getenv("AppData")?.let { Path(it, identifier) } - - OSX -> System.getenv("HOME")?.let { Path(it, "Library", "Application Support", identifier) } - - UNKNOWN -> null - } - - fun resolveLogDirectory(identifier: String): Path? = when (currentPlatform) { - LINUX -> System.getenv("XDG_DATA_HOME")?.let { Path(it, identifier, "logs") } - ?: System.getenv("HOME")?.let { Path(it, ".local", "share", identifier, "logs") } - - WINDOWS -> System.getenv("AppData")?.let { Path(it, identifier, "logs") } - - OSX -> System.getenv("HOME")?.let { Path(it, "Library", "Logs", identifier) } - - UNKNOWN -> null - } - } -} diff --git a/server/core/src/main/java/io/eiren/util/StringUtils.java b/server/core/src/main/java/io/eiren/util/StringUtils.java deleted file mode 100644 index 7ee9369640..0000000000 --- a/server/core/src/main/java/io/eiren/util/StringUtils.java +++ /dev/null @@ -1,35 +0,0 @@ -package io.eiren.util; - -import java.text.DecimalFormatSymbols; -import java.util.Locale; - - -public class StringUtils { - - private static char DECIMAL_SEP; - - public static char getDecimalSeparator() { - if (DECIMAL_SEP == '\u0000') { - final Locale l = Locale.getDefault(Locale.Category.FORMAT); - // Formatter.java always use "." in the Locale.US - DECIMAL_SEP = (l == null || l.equals(Locale.US) - ? '.' - : DecimalFormatSymbols.getInstance(l).getDecimalSeparator()); - } - return DECIMAL_SEP; - } - - public static String prettyNumber(float f) { - return prettyNumber(f, 4); - } - - public static String prettyNumber(float f, int numDigits) { - String str = String.format("%." + numDigits + "f", f); - if (numDigits != 0) - str = org.apache.commons.lang3.StringUtils.stripEnd(str, "0"); - char lastChar = str.charAt(str.length() - 1); - if (lastChar == getDecimalSeparator()) - str = str.substring(0, str.length() - 1); - return str; - } -} diff --git a/server/core/src/main/java/io/eiren/util/Util.java b/server/core/src/main/java/io/eiren/util/Util.java deleted file mode 100644 index a50980bdf2..0000000000 --- a/server/core/src/main/java/io/eiren/util/Util.java +++ /dev/null @@ -1,176 +0,0 @@ -package io.eiren.util; - -import java.io.Closeable; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashSet; -import java.util.Iterator; -import java.util.Map; -import java.util.Set; -import java.util.Map.Entry; - - -public class Util { - - public static void close(Object r) { - try { - if (r != null) { - if (r instanceof Closeable) - ((Closeable) r).close(); - else if (r instanceof AutoCloseable) - ((AutoCloseable) r).close(); - } - } catch (Exception ignored) {} - } - - public static void close(Object r1, Object r2) { - close(r1); - close(r2); - } - - public static void close(Object... r) { - for (Object o : r) - try { - if (o != null) { - if (o instanceof Closeable) - ((Closeable) o).close(); - else if (o instanceof AutoCloseable) - ((AutoCloseable) o).close(); - } - } catch (Exception ignored) {} - } - - public static void close(AutoCloseable... r) { - for (AutoCloseable autoCloseable : r) - try { - if (autoCloseable != null) - autoCloseable.close(); - } catch (Exception ignored) {} - } - - public static void close(Closeable... r) { - for (Closeable closeable : r) - try { - if (closeable != null) - closeable.close(); - } catch (Exception ignored) {} - } - - /** - *

- * Performs a deep toString of provided object. It shows content of arrays, - * collections and maps (trove not supported yet). - *

- *

- * Highly ineffective, use only for debug. - *

- * - * @param object - * @return - */ - public static String toString(Object object) { - if (object == null) - return "null"; - StringBuilder buf = new StringBuilder(); - elementToString(object, buf, new HashSet<>()); - return buf.toString(); - } - - private static void deepToString(Map m, StringBuilder buf, Set dejaVu) { - if (m == null) { - buf.append("null"); - return; - } - if (m.size() == 0) { - buf.append("{}"); - return; - } - dejaVu.add(m); - buf.append('{'); - Iterator> iterator = m.entrySet().iterator(); - boolean has = false; - while (iterator.hasNext()) { - if (has) - buf.append(','); - Entry e = iterator.next(); - elementToString(e.getKey(), buf, dejaVu); - buf.append(':'); - elementToString(e.getValue(), buf, dejaVu); - has = true; - } - buf.append('}'); - dejaVu.remove(m); - } - - private static void deepToString( - Collection list, - StringBuilder buf, - Set dejaVu - ) { - Object[] array = list.toArray(); - deepToString(array, buf, dejaVu); - } - - private static void deepToString(Object[] a, StringBuilder buf, Set dejaVu) { - if (a == null) { - buf.append("null"); - return; - } - if (a.length == 0) { - buf.append("[]"); - return; - } - dejaVu.add(a); - buf.append('['); - for (int i = 0; i < a.length; i++) { - if (i != 0) - buf.append(','); - Object element = a[i]; - elementToString(element, buf, dejaVu); - } - buf.append(']'); - dejaVu.remove(a); - } - - @SuppressWarnings("unchecked") - private static void elementToString(Object element, StringBuilder buf, Set dejaVu) { - if (element == null) { - buf.append("null"); - } else { - Class eClass = element.getClass(); - if (eClass.isArray()) { - if (eClass == byte[].class) - buf.append(Arrays.toString((byte[]) element)); - else if (eClass == short[].class) - buf.append(Arrays.toString((short[]) element)); - else if (eClass == int[].class) - buf.append(Arrays.toString((int[]) element)); - else if (eClass == long[].class) - buf.append(Arrays.toString((long[]) element)); - else if (eClass == char[].class) - buf.append(Arrays.toString((char[]) element)); - else if (eClass == float[].class) - buf.append(Arrays.toString((float[]) element)); - else if (eClass == double[].class) - buf.append(Arrays.toString((double[]) element)); - else if (eClass == boolean[].class) - buf.append(Arrays.toString((boolean[]) element)); - else { // element is an array of object references - if (dejaVu.contains(element)) - buf.append("[...]"); - else - deepToString((Object[]) element, buf, dejaVu); - } - } else { // element is non-null and not an array - if (element instanceof Collection) - deepToString((Collection) element, buf, dejaVu); - else if (element instanceof Map) - deepToString((Map) element, buf, dejaVu); - else if (element instanceof CharSequence) - buf.append('"').append(element).append('"'); - else - buf.append(element); - } - } - } -} diff --git a/server/core/src/main/java/io/eiren/util/ann/AWTThread.java b/server/core/src/main/java/io/eiren/util/ann/AWTThread.java deleted file mode 100644 index a4ca9828c7..0000000000 --- a/server/core/src/main/java/io/eiren/util/ann/AWTThread.java +++ /dev/null @@ -1,10 +0,0 @@ -package io.eiren.util.ann; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - - -@Retention(value = RetentionPolicy.SOURCE) -public @interface AWTThread { - -} diff --git a/server/core/src/main/java/io/eiren/util/ann/DebugSwitch.java b/server/core/src/main/java/io/eiren/util/ann/DebugSwitch.java deleted file mode 100644 index 9b7827bec4..0000000000 --- a/server/core/src/main/java/io/eiren/util/ann/DebugSwitch.java +++ /dev/null @@ -1,9 +0,0 @@ -package io.eiren.util.ann; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Target; - - -@Target({ ElementType.FIELD, ElementType.METHOD }) -public @interface DebugSwitch { -} diff --git a/server/core/src/main/java/io/eiren/util/ann/NativeUnsafe.java b/server/core/src/main/java/io/eiren/util/ann/NativeUnsafe.java deleted file mode 100644 index 52052dcb36..0000000000 --- a/server/core/src/main/java/io/eiren/util/ann/NativeUnsafe.java +++ /dev/null @@ -1,19 +0,0 @@ -package io.eiren.util.ann; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - - -/** - * Marks methods and classes that use unsafe or direct access to memory. Proceed - * with caution. - * - * @author Rena - */ -@Retention(value = RetentionPolicy.RUNTIME) -@Target({ ElementType.METHOD, ElementType.TYPE }) -public @interface NativeUnsafe { - -} diff --git a/server/core/src/main/java/io/eiren/util/ann/Synchronize.java b/server/core/src/main/java/io/eiren/util/ann/Synchronize.java deleted file mode 100644 index d18d9f8339..0000000000 --- a/server/core/src/main/java/io/eiren/util/ann/Synchronize.java +++ /dev/null @@ -1,30 +0,0 @@ -package io.eiren.util.ann; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - - -/** - *

- * Означает необходимость обязательной синхронизации этого меcта во внешних - * методах. В аргументах передаётся название поля для синхронизации. - *

- *

- * Методы, помеченные данной аннотацией могут вызывать только Thread-Safe - * методы, либо методы, помеченные такой же аннотацией с тем же полем - * синхронизации. - *

- *

- * Поля, помеченные данной аннотацией должны быть синхронизированны на указанное - * поле при чтении или записи. - *

- * - * @see {@link ThreadSafe}, {@link ThreadSecure}, {@link ThreadSafeSingle} - * @author Rena - */ -@Retention(value = RetentionPolicy.SOURCE) -public @interface Synchronize { - - String[] value(); - -} diff --git a/server/core/src/main/java/io/eiren/util/ann/ThreadSafe.java b/server/core/src/main/java/io/eiren/util/ann/ThreadSafe.java deleted file mode 100644 index c74fcfb39b..0000000000 --- a/server/core/src/main/java/io/eiren/util/ann/ThreadSafe.java +++ /dev/null @@ -1,27 +0,0 @@ -package io.eiren.util.ann; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - - -/** - *

- * Методы, помеченные этой аннотацией должны быть Thread-Safe. - *

- *

- * Важно: данные методы гарантированно должны обеспечивать потоковую - * безопасность, но не обязаны обеспечивать концессивность (полноту данных или - * точность синхронизации). - *

- *

- * Для полностью потоко-безопасных методов можно использовать аннотацию - * {@link ThreadSecure}. - *

- * - * @see {@link ThreadSecure}, {@link Synchronize}, {@link ThreadSafeSingle} - * @author Rena - */ -@Retention(value = RetentionPolicy.SOURCE) -public @interface ThreadSafe { - -} diff --git a/server/core/src/main/java/io/eiren/util/ann/ThreadSafeSingle.java b/server/core/src/main/java/io/eiren/util/ann/ThreadSafeSingle.java deleted file mode 100644 index 0e918499e9..0000000000 --- a/server/core/src/main/java/io/eiren/util/ann/ThreadSafeSingle.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.eiren.util.ann; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - - -/** - * Соблюдает те же требования что и {@link ThreadSafe} но при условии, что сам - * метод вызывается только из одного потока одновременно. - * - * @see {@link ThreadSafe}, {@link ThreadSecure}, {@link Synchronize} - * @author Rena - */ -@Retention(value = RetentionPolicy.SOURCE) -public @interface ThreadSafeSingle { - -} diff --git a/server/core/src/main/java/io/eiren/util/ann/ThreadSecure.java b/server/core/src/main/java/io/eiren/util/ann/ThreadSecure.java deleted file mode 100644 index 2b375fe365..0000000000 --- a/server/core/src/main/java/io/eiren/util/ann/ThreadSecure.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.eiren.util.ann; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - - -/** - *

- * Методы, помеченные этой аннотацией должны быть полностью Thread-Safe. - *

- *

- * Важно: данные методы гарантированно должны обеспечивать потоковую - * безопасность и консистентность (полноту данных и точность синхронизации). - *

- * - * @see {@link ThreadSafe}, {@link Synchronize}, {@link ThreadSafeSingle} - * @author Rena - */ -@Retention(value = RetentionPolicy.SOURCE) -public @interface ThreadSecure { - -} diff --git a/server/core/src/main/java/io/eiren/util/ann/Transient.java b/server/core/src/main/java/io/eiren/util/ann/Transient.java deleted file mode 100644 index 3502dff425..0000000000 --- a/server/core/src/main/java/io/eiren/util/ann/Transient.java +++ /dev/null @@ -1,39 +0,0 @@ -package io.eiren.util.ann; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - - -/** - *

- * Означает что поле используется для временных или быстро изменяющихся - * переменных. - *

- *

- * Поле помеченное этой аннотацией не влияет на долгосрочное состояние объекта, - * не участвует в сериализации, вычислении equals и hashCode, не определяет - * поведение объекта для внешнего кода. Поэтому такие поля не должны - * использоваться внешним кодом, их состояние имеет смысл только для самого - * объекта в котором они объявлены. - *

- * Примеры: - *
    - *
  • Временный объект, который используется в методах для внутренних - * вычислений. Например векторные и матричные вычисления.
  • - *
  • Внутренний флаг для мультитрединга. Например, флаг апдейта графического - * состояния взводимый из игрового потока.
  • - *
  • Выведенное значение или структура, которое инициализируется самим - * объектом по фиксированному правилу. Например, производное значение от - * переменной параметризующей объект. Инициализируемый в конструкторе lookup - * table.
  • - *
- * - * @author tort32 - */ -@Retention(value = RetentionPolicy.SOURCE) -@Target({ ElementType.FIELD }) -public @interface Transient { - -} diff --git a/server/core/src/main/java/io/eiren/util/collections/FastList.java b/server/core/src/main/java/io/eiren/util/collections/FastList.java deleted file mode 100644 index 4f4ee0749b..0000000000 --- a/server/core/src/main/java/io/eiren/util/collections/FastList.java +++ /dev/null @@ -1,546 +0,0 @@ -package io.eiren.util.collections; - -import java.util.*; -import java.util.function.Consumer; -import java.util.function.Predicate; -import java.util.function.UnaryOperator; - - -@SuppressWarnings("unchecked") -public class FastList extends AbstractList - implements RandomAccess, Cloneable, RemoveAtSwapList { - - private static final Object[] emptyArray = new Object[0]; - - public static final int MAX_ARRAY_SIZE = 2147483639; - - protected int size = 0; - protected Object[] array; - - public FastList(int capacity) { - array = capacity == 0 ? emptyArray : new Object[capacity]; - } - - public FastList() { - this(5); - } - - public FastList(Collection source) { - this(source.size()); - addAll(source); - } - - public FastList(FastList source) { - this(source.size); - addAllInternal(0, source.array, source.size); - } - - public FastList(E[] source) { - this(source.length); - addAll(source); - } - - public FastList(E source) { - this(); - add(source); - } - - private FastList(Object[] arr, int size) { - this(size); - System.arraycopy(arr, 0, array, 0, size); - this.size = size; - } - - private FastList(boolean f) { - } - - public static FastList reuseArray(E[] source) { - FastList list = new FastList<>(true); - list.array = source; - list.size = source.length; - return list; - } - - private void checkBounds(int index) { - if (index < 0 || index >= size) - throw new ArrayIndexOutOfBoundsException( - new StringBuilder("Index: ") - .append(index) - .append(", size: ") - .append(size) - .toString() - ); - } - - public void ensureCapacity(int numToFit) { - if (array.length < size + numToFit) - grow(numToFit + size); - } - - private void grow(int i) { - int j = array.length; - int k = j + (j >> 1); - if (k - i < 0) - k = i; - if (k - 2147483639 > 0) - k = hugeCapacity(i); - array = Arrays.copyOf(array, k); - } - - private static int hugeCapacity(int i) { - if (i < 0) - throw new OutOfMemoryError("Huge capacity negative: " + i); - else - return i <= MAX_ARRAY_SIZE ? MAX_ARRAY_SIZE : 2147483647; - } - - public void copyInto(Object[] anArray) { - System.arraycopy(array, 0, anArray, 0, size); - } - - @Override - public E get(int index) { - checkBounds(index); - return (E) array[index]; - } - - public E unsafeGet(int index) { - return (E) array[index]; - } - - @Override - public boolean isEmpty() { - return size == 0; - } - - @Override - public int size() { - return size; - } - - @Override - public int indexOf(Object obj) { - for (int j = 0; j < size; j++) - if (obj == array[j]) - return j; - return -1; - } - - @Override - public int lastIndexOf(Object obj) { - for (int j = size - 1; j >= 0; j--) - if (obj == array[j]) - return j; - return -1; - } - - @Override - public boolean contains(Object obj) { - return indexOf(obj) >= 0; - } - - public void trimToSize() { - int i = array.length; - if (size < i) - array = Arrays.copyOf(array, size); - } - - @Override - public Object[] toArray() { - return Arrays.copyOf(array, size); - } - - @Override - public T[] toArray(T[] aobj) { - if (aobj.length < size) - return (T[]) Arrays.copyOf(array, size, aobj.getClass()); - System.arraycopy(array, 0, aobj, 0, size); - if (aobj.length > size) - aobj[size] = null; - return aobj; - } - - @Override - public boolean add(E e) { - ensureCapacity(1); - array[size++] = e; - return true; - } - - @Override - public E remove(int i) { - checkBounds(i); - E obj = (E) array[i]; - removeInternal(i); - return obj; - } - - @Override - public boolean remove(Object obj) { - for (int j = 0; j < size; j++) - if (obj == array[j]) { - removeInternal(j); - return true; - } - return false; - } - - public boolean removeAll(Object[] toRemove) { - boolean removed = false; - for (int i = toRemove.length - 1; i >= 0; --i) { - int index = indexOf(toRemove[i]); - if (index != -1) { - removeInternal(index); - removed = true; - } - } - return removed; - } - - protected void removeInternal(int i) { - int j = size - i - 1; - if (j > 0) - System.arraycopy(array, i + 1, array, i, j); - array[--size] = null; - } - - public void unsafeRemove(int i) { - removeInternal(i); - } - - @Override - public boolean removeAll(Collection c) { - Objects.requireNonNull(c); - return batchRemove(c, false); - } - - @Override - public boolean retainAll(Collection c) { - Objects.requireNonNull(c); - return batchRemove(c, true); - } - - private boolean batchRemove(Collection c, boolean complement) { - final Object[] elementData = this.array; - int r = 0, w = 0; - boolean modified = false; - try { - for (; r < size; r++) - if (c.contains(elementData[r]) == complement) - elementData[w++] = elementData[r]; - } finally { - // Preserve behavioral compatibility with AbstractCollection, - // even if c.contains() throws. - if (r != size) { - System.arraycopy(elementData, r, elementData, w, size - r); - w += size - r; - } - if (w != size) { - for (int i = w; i < size; i++) - elementData[i] = null; - size = w; - modified = true; - } - } - return modified; - } - - @Override - public void clear() { - for (int i = 0; i < size; i++) - array[i] = null; - size = 0; - } - - public void fakeClear() { - size = 0; - } - - @Override - public boolean addAll(Collection collection) { - return addAll(size, collection); - } - - public void addAll(E[] arr) { - addAllInternal(size, arr, arr.length); - } - - public void addAll(E[] arr, int limit) { - addAllInternal(size, arr, limit); - } - - public void addAll(int index, E[] arr) { - addAllInternal(index, arr, arr.length); - } - - public void addAll(int index, E[] arr, int limit) { - addAllInternal(index, arr, limit); - } - - private void addAllInternal(int index, Object[] arr, int limit) { - if (limit > arr.length) - limit = arr.length; - if (limit == 1) { - add(index, (E) arr[0]); - } else if (limit > 0) { - if (index >= size) { - ensureCapacity(size - index + limit); - System.arraycopy(arr, 0, array, index, limit); - size = index + limit; - } else { - if (array.length < size + limit) { - Object[] newArray = new Object[size + limit]; - System.arraycopy(array, 0, newArray, 0, index); - System.arraycopy(arr, 0, newArray, index, limit); - System.arraycopy(array, index, newArray, index + limit, size - index); - array = newArray; - } else { - System.arraycopy(array, index, array, index + 1, size - index); - System.arraycopy(arr, 0, array, index, limit); - } - size += limit; - } - } - } - - @Override - public boolean addAll(int index, Collection collection) { - if (collection.size() > 0) { - if (collection instanceof FastList) { - addAllInternal( - index, - ((FastList) collection).array, - collection.size() - ); - } else if (collection instanceof RandomAccess) { - Object[] arr = collection.toArray(new Object[collection.size()]); - addAllInternal(index, arr, arr.length); - } else { - if (index >= size) { - ensureCapacity(size - index + collection.size()); - Iterator iterator = collection.iterator(); - int i = index; - while (iterator.hasNext()) - array[i++] = iterator.next(); - size = index + collection.size(); - } else { - if (array.length < size + collection.size()) { - Object[] newArray = new Object[size + collection.size()]; - System.arraycopy(array, 0, newArray, 0, index); - Iterator iterator = collection.iterator(); - int i = index; - while (iterator.hasNext()) - newArray[i++] = iterator.next(); - System - .arraycopy( - array, - index, - newArray, - index + collection.size(), - size - index - ); - array = newArray; - } else { - System.arraycopy(array, index, array, index + 1, size - index); - for (E e : collection) - array[index++] = e; - } - size += collection.size(); - } - } - return true; - } - return false; - } - - @Override - public void add(int index, E element) { - if (index >= size) { - ensureCapacity(size - index + 1); - size = index + 1; - array[index] = element; - } else { - if (array.length < size + 1) { - Object[] newArray = new Object[size + 1]; - System.arraycopy(array, 0, newArray, 0, index); - newArray[index] = element; - System.arraycopy(array, index, newArray, index + 1, size - index); - array = newArray; - } else { - System.arraycopy(array, index, array, index + 1, size - index); - array[index] = element; - } - size++; - } - } - - @Override - public E set(int index, E element) { - checkBounds(index); - E oldValue = (E) array[index]; - array[index] = element; - return oldValue; - } - - @Override - public FastList clone() { - return new FastList<>(array, size); - } - - @Override - public void forEach(Consumer action) { - Objects.requireNonNull(action); - final int expectedModCount = modCount; - final E[] elementData = (E[]) this.array; - final int size = this.size; - for (int i = 0; modCount == expectedModCount && i < size; i++) { - action.accept(elementData[i]); - } - if (modCount != expectedModCount) { - throw new ConcurrentModificationException(); - } - } - - @Override - public E removeAtSwap(int i) { - checkBounds(i); - E obj = (E) array[i]; - removeAtSwapInternal(i); - return obj; - } - - @Override - public boolean removeAtSwap(Object obj) { - for (int j = 0; j < size; j++) - if (obj == array[j]) { - removeAtSwapInternal(j); - return true; - } - return false; - } - - protected void removeAtSwapInternal(int i) { - int j = size - i - 1; - if (j > 0) - array[i] = array[size - 1]; - array[--size] = null; - } - - @Override - public void removeRange(int i, int toIndex) { - checkBounds(i); - checkBounds(toIndex); - int j = size - toIndex - 1; - if (j > 0) - System.arraycopy(array, toIndex + 1, array, i, j); - size -= (toIndex - i + 1); - Arrays.fill(array, i, toIndex, null); - } - - @Override - public void replaceAll(UnaryOperator operator) { - Objects.requireNonNull(operator); - for (int i = 0; i < size; ++i) - set(i, operator.apply(get(i))); - } - - @Override - public void sort(Comparator c) { - Arrays.sort((E[]) array, 0, size, c); - } - - @Override - public int hashCode() { - int hashCode = 1; - for (int i = 0; i < size; ++i) { - Object o = array[i]; - hashCode = 31 * hashCode + (o == null ? 0 : o.hashCode()); - } - return hashCode; - } - - @Override - public Spliterator spliterator() { - return Spliterators.spliterator(array, 0, size, Spliterator.ORDERED); - } - - /** - * Special comodification iterator. Use with caution. - *

- * To get element type correctly assign result to reference type - * {@code FastList.SkipFastListIterator} - * - * @return skip iterator to iterate this list in thread-safe manner - */ - public SkipFastListIterator skipIterator() { - return new SkipFastListIterator(); - } - - @Override - public boolean removeIf(Predicate filter) { - Objects.requireNonNull(filter); - // figure out which elements are to be removed - // any exception thrown from the filter predicate at this stage - // will leave the collection unmodified - int removeCount = 0; - final BitSet removeSet = new BitSet(size); - final int expectedModCount = modCount; - final int size = this.size; - for (int i = 0; modCount == expectedModCount && i < size; i++) { - final E element = (E) array[i]; - if (filter.test(element)) { - removeSet.set(i); - removeCount++; - } - } - if (modCount != expectedModCount) { - throw new ConcurrentModificationException(); - } - - // shift surviving elements left over the spaces left by removed - // elements - final boolean anyToRemove = removeCount > 0; - if (anyToRemove) { - final int newSize = size - removeCount; - for (int i = 0, j = 0; (i < size) && (j < newSize); i++, j++) { - i = removeSet.nextClearBit(i); - array[j] = array[i]; - } - for (int k = newSize; k < size; k++) { - array[k] = null; // Let gc do its work - } - this.size = newSize; - if (modCount != expectedModCount) { - throw new ConcurrentModificationException(); - } - modCount++; - } - - return anyToRemove; - } - - public class SkipFastListIterator implements ResettableIterator, SkipIterator { - - public int position; - - @Override - public boolean hasNext() { - return position < size; - } - - @Override - public E next() { - Object[] arr = array; - if (arr.length > position) { - return (E) arr[position++]; - } - position++; // Increase position so hasNext() never loops infinitely - return null; - } - - @Override - public void reset() { - position = 0; - } - } -} diff --git a/server/core/src/main/java/io/eiren/util/collections/RemoveAtSwapFastList.java b/server/core/src/main/java/io/eiren/util/collections/RemoveAtSwapFastList.java deleted file mode 100644 index 1c00001e1e..0000000000 --- a/server/core/src/main/java/io/eiren/util/collections/RemoveAtSwapFastList.java +++ /dev/null @@ -1,41 +0,0 @@ -package io.eiren.util.collections; - -import java.util.Collection; - - -/** - * FastList that performs Remove-At-Swap on stanard remove() operations. - * - *

- * Remove operations breaks ordering of this list - * - * @author Rena - * - * @param - */ -public class RemoveAtSwapFastList extends FastList { - - public RemoveAtSwapFastList(int capacity) { - super(capacity); - } - - public RemoveAtSwapFastList() { - } - - public RemoveAtSwapFastList(Collection source) { - super(source); - } - - public RemoveAtSwapFastList(E[] source) { - super(source); - } - - public RemoveAtSwapFastList(E source) { - super(source); - } - - @Override - protected void removeInternal(int i) { - super.removeAtSwapInternal(i); - } -} diff --git a/server/core/src/main/java/io/eiren/util/collections/RemoveAtSwapList.java b/server/core/src/main/java/io/eiren/util/collections/RemoveAtSwapList.java deleted file mode 100644 index cd77323bb0..0000000000 --- a/server/core/src/main/java/io/eiren/util/collections/RemoveAtSwapList.java +++ /dev/null @@ -1,11 +0,0 @@ -package io.eiren.util.collections; - -import java.util.List; - - -public interface RemoveAtSwapList extends List { - - E removeAtSwap(int i); - - boolean removeAtSwap(Object object); -} diff --git a/server/core/src/main/java/io/eiren/util/collections/ResettableIterator.java b/server/core/src/main/java/io/eiren/util/collections/ResettableIterator.java deleted file mode 100644 index 9ef8b9ee17..0000000000 --- a/server/core/src/main/java/io/eiren/util/collections/ResettableIterator.java +++ /dev/null @@ -1,17 +0,0 @@ -package io.eiren.util.collections; - -import java.util.Iterator; - - -/** - * {@link Iterator} that can be reset and iterated from the start by using - * {@link #reset()} - * - * @author Rena - * - * @param - */ -public interface ResettableIterator extends Iterator { - - void reset(); -} diff --git a/server/core/src/main/java/io/eiren/util/collections/SkipIterator.java b/server/core/src/main/java/io/eiren/util/collections/SkipIterator.java deleted file mode 100644 index 9c79579adc..0000000000 --- a/server/core/src/main/java/io/eiren/util/collections/SkipIterator.java +++ /dev/null @@ -1,16 +0,0 @@ -package io.eiren.util.collections; - -import java.util.Iterator; - - -/** - * {@link Iterator} that can return null on {@link #next()} or can lie on - * {@link #hasNext()}. It is not thread-secure! - * - * @param the type of elements returned by this iterator - */ -public interface SkipIterator extends Iterator { - - @Override - E next(); -} diff --git a/server/core/src/main/java/io/eiren/util/logging/DefaultGLog.java b/server/core/src/main/java/io/eiren/util/logging/DefaultGLog.java deleted file mode 100644 index 26f7ab7081..0000000000 --- a/server/core/src/main/java/io/eiren/util/logging/DefaultGLog.java +++ /dev/null @@ -1,137 +0,0 @@ -package io.eiren.util.logging; - -import java.util.concurrent.ArrayBlockingQueue; -import java.util.logging.Level; -import java.util.logging.Logger; - - -public class DefaultGLog extends Thread implements IGLog { - - private final Logger logger; - - public static class LogEntry { - - private Level level; - private String message; - private Throwable t; - - public LogEntry(Level level, String message, Throwable t) { - this(level, message); - this.t = t; - } - - public LogEntry(Level level, String message) { - this.level = level; - this.message = message; - this.t = null; - } - - public Level getLevel() { - return level; - } - - public String getMessage() { - return message; - } - - public Throwable getException() { - return t; - } - } - - private final ArrayBlockingQueue queue = new ArrayBlockingQueue<>(50000); - private volatile LoggerRecorder recorder; - - @Override - public void info(String message) { - add(new LogEntry(Level.INFO, message)); - } - - @Override - public void info(String message, Throwable t) { - add(new LogEntry(Level.INFO, message, t)); - } - - @Override - public void severe(String message) { - add(new LogEntry(Level.SEVERE, message)); - } - - @Override - public void severe(String message, Throwable t) { - add(new LogEntry(Level.SEVERE, message, t)); - } - - @Override - public void warning(String message) { - add(new LogEntry(Level.WARNING, message)); - } - - @Override - public void warning(String message, Throwable t) { - add(new LogEntry(Level.WARNING, message, t)); - } - - @Override - public void debug(String message) { - add(new LogEntry(Level.INFO, "[DBG] " + message)); - } - - @Override - public void debug(String message, Throwable t) { - add(new LogEntry(Level.INFO, "[DBG] " + message, t)); - } - - @Override - public void log(Level level, String message) { - add(new LogEntry(level, message)); - } - - @Override - public void log(Level level, String message, Throwable t) { - add(new LogEntry(level, message, t)); - } - - private void add(LogEntry entry) { - try { - queue.put(entry); - } catch (InterruptedException ignored) {} - try { - if (recorder != null) - recorder.addEntry(entry); - } catch (NullPointerException ignored) {} - } - - @Override - public void setRecorder(LoggerRecorder recorder) { - this.recorder = recorder; - } - - @Override - public LoggerRecorder removeRecorder() { - LoggerRecorder lr = this.recorder; - this.recorder = null; - return lr; - } - - public DefaultGLog(Logger logger) { - super("Logger"); - this.logger = logger; - this.setDaemon(true); - this.setPriority(7); - this.start(); - } - - @Override - public void run() { - while (true) { - try { - LogEntry log = queue.take(); - if (log.t != null) - logger.log(log.level, log.message, log.t); - else - logger.log(log.level, log.message); - } catch (InterruptedException ignored) {} - } - } -} diff --git a/server/core/src/main/java/io/eiren/util/logging/FileLogFormatter.java b/server/core/src/main/java/io/eiren/util/logging/FileLogFormatter.java deleted file mode 100644 index d0be04b0ec..0000000000 --- a/server/core/src/main/java/io/eiren/util/logging/FileLogFormatter.java +++ /dev/null @@ -1,58 +0,0 @@ -package io.eiren.util.logging; - -import java.io.PrintWriter; -import java.io.StringWriter; -import java.text.SimpleDateFormat; -import java.util.logging.Formatter; -import java.util.logging.Level; -import java.util.logging.LogRecord; - - -public class FileLogFormatter extends Formatter { - - private static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); - - @Override - public String format(LogRecord record) { - StringBuilder sb = new StringBuilder(); - sb.append(dateFormat.format(record.getMillis())); - Level localLevel = record.getLevel(); - if (localLevel == Level.FINEST) - sb.append(" [FINEST] "); - else if (localLevel == Level.FINER) - sb.append(" [FINER] "); - else if (localLevel == Level.FINE) - sb.append(" [FINE] "); - else if (localLevel == Level.INFO) - sb.append(" [INFO] "); - else if (localLevel == Level.WARNING) - sb.append(" [WARNING] "); - else if (localLevel == Level.SEVERE) - sb.append(" [SEVERE] "); - else - sb.append(" [").append(localLevel.getLocalizedName()).append("] "); - - sb.append(record.getMessage()); - sb.append('\n'); - - Throwable localThrowable = record.getThrown(); - if (localThrowable != null) { - StringWriter localStringWriter = new StringWriter(); - localThrowable.printStackTrace(new PrintWriter(localStringWriter)); - sb.append(localStringWriter); - } - - String message = sb.toString(); - Object[] parameters = record.getParameters(); - if (parameters == null || parameters.length == 0) - return message; - if ( - message.contains("{0") - || message.contains("{1") - || message.contains("{2") - || message.contains("{3") - ) - return java.text.MessageFormat.format(message, parameters); - return message; - } -} diff --git a/server/core/src/main/java/io/eiren/util/logging/FileLogHandler.kt b/server/core/src/main/java/io/eiren/util/logging/FileLogHandler.kt deleted file mode 100644 index dda319cc0f..0000000000 --- a/server/core/src/main/java/io/eiren/util/logging/FileLogHandler.kt +++ /dev/null @@ -1,308 +0,0 @@ -package io.eiren.util.logging - -import java.io.BufferedOutputStream -import java.io.DataOutputStream -import java.io.File -import java.io.FileNotFoundException -import java.io.FileOutputStream -import java.nio.file.Path -import java.time.LocalDateTime -import java.time.format.DateTimeFormatter -import java.util.logging.ErrorManager -import java.util.logging.LogRecord -import java.util.logging.StreamHandler - -/** - * A log handler that manages a set of log files, deleting the oldest log(s) when limits - * are reached. - * @param path The directory to store and discover the log files in. - * @param logTag The identifier tag for log files (ex. "slimevr-server"). - * @param dateFormat The format to use for the date and time in the log file names. - * @param limit The independent log file size limit in bytes. - * @param maxCount The collective log file count limit. - * @param collectiveLimit The collective log file size limit in bytes. - */ -class FileLogHandler @JvmOverloads constructor( - private val path: Path, - private val logTag: String, - private val dateFormat: DateTimeFormatter, - private val limit: Int, - private val maxCount: Int, - private val collectiveLimit: Long = -1, -) : StreamHandler() { - - /** - * A log [File] with a [LocalDateTime] and [count] associated to it. - */ - inner class DatedLogFile(val file: File, val dateTime: LocalDateTime, val count: Int) : Comparable { - override fun compareTo(o: DatedLogFile): Int { - val dtCompare = dateTime.compareTo(o.dateTime) - return if (dtCompare != 0) dtCompare else count.compareTo(o.count) - } - } - - /** - * The symbol to use for separating file name sections. - */ - private val sectionSeparator = '_' - - /** - * The file suffix to use for logs. - */ - private val logSuffix = ".log" - - /** - * The list of log files being managed by this [FileLogHandler]. - */ - private val logFiles: MutableList - - /** - * The [LocalDateTime] to use for log files created by this [FileLogHandler] - * instance. - */ - private val dateTime: LocalDateTime = LocalDateTime.now() - - /** - * The formatted string representing [dateTime]. - */ - private val date: String = dateTime.format(dateFormat) - - /** - * The current file [DataOutputStream]. - */ - private var curStream: DataOutputStream? = null - - /** - * The current file count for logs based on the same [dateTime] made by this - * [FileLogHandler] instance. - */ - private var fileCount = 0 - - /** - * The current collective size of all log files in bytes. - */ - private var collectiveSize: Long = 0 - - init { - // Find old logs to manage - logFiles = findLogs(path) - if (collectiveLimit > 0) { - collectiveSize = sumFileSizes(logFiles) - } - - // Create new log and delete over the count - newFile() - } - - /** - * Parses [file]'s name and returns a parsed [DatedLogFile]. - * @param file The file to parse the name of. - * @return The parsed [DatedLogFile]. - */ - private fun parseFileName(file: File): DatedLogFile? { - val name = file.getName() - - // Log name should have at least two separators, one integer, and at - // least one char for the datetime (4 chars) - if (!name.startsWith(logTag) || !name.endsWith(logSuffix) || name.length < (logTag.length + logSuffix.length + 4)) { - // Ignore non-matching files - return null - } - - val dateEnd = name.lastIndexOf(sectionSeparator) - if (dateEnd < 0) { - // Ignore non-matching files - return null - } - - try { - // Move past the tag, then between the two separators - val dateTimeStr = name.substring(logTag.length + 1, dateEnd) - val dateTime = LocalDateTime.parse(dateTimeStr, dateFormat) - - // Move past the date separator and behind the suffix - - val logNum = name.substring(dateEnd + 1, name.length - logSuffix.length).toInt() - - return DatedLogFile(file, dateTime, logNum) - } catch (_: Exception) { - // Unable to parse log file, probably not valid - return null - } - } - - /** - * Finds all the log files parseable by [parseFileName] in the provided path, - * [path]. - * @param path The path to check for log files. - * @return A list containing all parsed [DatedLogFile]s from [path]. - */ - private fun findLogs(path: Path): MutableList { - val logFiles = mutableListOf() - - val files = path.toFile().listFiles() - if (files == null) return logFiles - - // Find all parseable log files - for (log in files) { - val parsedFile = parseFileName(log) - if (parsedFile != null) { - logFiles.add(parsedFile) - } - } - - return logFiles - } - - /** - * Computes the sum of the file sizes from the provided [logFiles]. - * @param logFiles The list of [DatedLogFile]s to compute the size of. - * @return The size of all the provided [DatedLogFile]s in bytes. - */ - private fun sumFileSizes(logFiles: List): Long { - var size: Long = 0 - for (log in logFiles) { - size += log.file.length() - } - return size - } - - /** - * Tries to delete the provided [file]. - * @param file The [File] to delete. - */ - private fun deleteFile(file: File) { - if (file.delete()) return - - file.deleteOnExit() - reportError( - "Failed to delete file, deleting on exit.", - null, - ErrorManager.GENERIC_FAILURE, - ) - } - - /** - * Returns the earliest [DatedLogFile] from the provided list, [logFiles]. - * @param logFiles The [DatedLogFile]s to find the earliest file from. - * @return The earliest [DatedLogFile] or null if none could be found. - */ - private fun getEarliestFile(logFiles: List): DatedLogFile? { - var earliest: DatedLogFile? = null - - for (log in logFiles) { - if (earliest == null || log < earliest) { - earliest = log - } - } - - return earliest - } - - /** - * Deletes the earliest log file from [logFiles]. - */ - @Synchronized - private fun deleteEarliestFile() { - val earliest = getEarliestFile(logFiles) - if (earliest == null) return - - // If we have a collective limit, update the current size and clamp - if (collectiveLimit > 0) { - collectiveSize = ( - collectiveSize - earliest.file.length() - ).coerceAtLeast(0) - } - - logFiles.remove(earliest) - deleteFile(earliest.file) - } - - /** - * Deletes the earliest log files until the [collectiveSize] plus [curFileSize] is - * less than [collectiveLimit]. - * @param curFileSize The size of the current log file in bytes. - */ - @Synchronized - private fun deleteOverCollectiveLimit(curFileSize: Int = 0) { - if (collectiveLimit <= 0) return - - // Delete files over the collective size limit, including the current stream - while (!logFiles.isEmpty() && collectiveSize + curFileSize >= collectiveLimit) { - deleteEarliestFile() - } - } - - /** - * Creates a new log file, closing [curStream] automatically and incrementing - * [fileCount]. This method also handles deleting old log files based on the - * specified limits: [limit], [maxCount], and [collectiveLimit]. - */ - @Synchronized - private fun newFile() { - // Clear the last log file - val lastStream = curStream - if (lastStream != null) { - // Flush the log first - close() - // Then accumulate the amount written after - collectiveSize += lastStream.size() - } - - if (maxCount > 0) { - // Delete files over the count - while (logFiles.size >= maxCount) { - deleteEarliestFile() - } - } - - // Handle collective limit - deleteOverCollectiveLimit() - - try { - val newFile = path.resolve( - logTag + sectionSeparator + date + sectionSeparator + fileCount + logSuffix, - ).toFile() - - // Use DataOutputStream to count bytes written - val newStream = DataOutputStream( - BufferedOutputStream(FileOutputStream(newFile)), - ) - // Closes the last stream automatically if not already done - setOutputStream(newStream) - curStream = newStream - - // Add log to the tracking list to be deleted if needed - logFiles.add(DatedLogFile(newFile, dateTime, fileCount)) - fileCount += 1 - } catch (e: FileNotFoundException) { - reportError(null, e, ErrorManager.OPEN_FAILURE) - } - } - - /** - * Publishes the provided [record]. This automatically manages deleting old log - * files based on [collectiveLimit] as the new log is written and creating new log - * files after the file size limit, [limit], is reached. - */ - @Synchronized - override fun publish(record: LogRecord?) { - if (!isLoggable(record)) return - - // Push the log - super.publish(record) - // Then flush, so we always have the latest output - flush() - - // The number of bytes written to the current log file - val curFileSize = curStream!!.size() - - // Handle the collective limit as we write to the new log file - deleteOverCollectiveLimit(curFileSize) - - // If written above the log limit, make a new file - if (limit > 0 && curFileSize >= limit) { - newFile() - } - } -} diff --git a/server/core/src/main/java/io/eiren/util/logging/IGLog.java b/server/core/src/main/java/io/eiren/util/logging/IGLog.java deleted file mode 100644 index db4d328f87..0000000000 --- a/server/core/src/main/java/io/eiren/util/logging/IGLog.java +++ /dev/null @@ -1,48 +0,0 @@ -package io.eiren.util.logging; - -import java.util.logging.Level; - - -public interface IGLog { - - void info(String message); - - void severe(String message); - - void warning(String message); - - void debug(String message); - - default void info(String message, Throwable t) { - log(Level.INFO, message, t); - } - - default void severe(String message, Throwable t) { - log(Level.SEVERE, message, t); - } - - default void warning(String message, Throwable t) { - log(Level.WARNING, message, t); - } - - default void debug(String message, Throwable t) { - log(Level.INFO, "[DBG] " + message, t); - } - - void log(Level level, String message); - - void log(Level level, String message, Throwable t); - - void setRecorder(LoggerRecorder recorder); - - LoggerRecorder removeRecorder(); - - class GLevel extends Level { - - private static final long serialVersionUID = -539856764608026895L; - - private GLevel(String s, int i) { - super(s, i); - } - } -} diff --git a/server/core/src/main/java/io/eiren/util/logging/LogManager.java b/server/core/src/main/java/io/eiren/util/logging/LogManager.java deleted file mode 100644 index bd1fe0b76d..0000000000 --- a/server/core/src/main/java/io/eiren/util/logging/LogManager.java +++ /dev/null @@ -1,128 +0,0 @@ -package io.eiren.util.logging; - -import java.io.File; -import java.io.IOException; -import java.io.PrintStream; -import java.time.format.DateTimeFormatter; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.logging.ConsoleHandler; -import java.util.logging.Handler; -import java.util.logging.Level; -import java.util.logging.Logger; - - -public class LogManager { - - private static AtomicBoolean initialized = new AtomicBoolean(false); - - public static Logger global = Logger.getLogger(""); - public static final IGLog log = new DefaultGLog(global); - public static ConsoleHandler handler; - - public static void initialize(File mainLogDir) - throws SecurityException, IOException { - if (initialized.getAndSet(true)) - return; - FileLogFormatter loc = new FileLogFormatter(); - if (mainLogDir != null) { - // Ensure the log directory exists - if (!mainLogDir.exists()) - mainLogDir.mkdirs(); - - FileLogHandler fileHandler = new FileLogHandler( - mainLogDir.toPath(), - "slimevr-server", - DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"), - 25 * 1000000, // 25 MB max file size - 8, // 8 max log files - 100 * 1000000 // 100 MB max collective size - ); - fileHandler.setFormatter(loc); - global.addHandler(fileHandler); - } - } - - public static void replaceMainHandler(ConsoleHandler newHandler) { - handler.close(); - global.removeHandler(handler); - handler = newHandler; - global.addHandler(newHandler); - } - - public static void addHandler(Handler add) { - global.addHandler(add); - } - - public static void removeHandler(Handler remove) { - global.removeHandler(remove); - } - - public static void enablePreciseTimestamp() { - handler.setFormatter(new PreciseConsoleLogFormatter()); - } - - public static void info(String message) { - log.info(message); - } - - public static void severe(String message) { - log.severe(message); - } - - public static void warning(String message) { - log.warning(message); - } - - public static void debug(String message) { - log.debug(message); - } - - public static void info(String message, Throwable t) { - log.info(message, t); - } - - public static void severe(String message, Throwable t) { - log.severe(message, t); - } - - public static void warning(String message, Throwable t) { - log.warning(message, t); - } - - public static void debug(String message, Throwable t) { - log.debug(message, t); - } - - public static void log(Level level, String message) { - log.log(level, message); - } - - public static void log(Level level, String message, Throwable t) { - log.log(level, message, t); - } - - public static void closeLogger() { - for (Handler handler : global.getHandlers()) { - handler.close(); - removeHandler(handler); - } - } - - static { - boolean hasConsoleHandler = false; - for (Handler h : global.getHandlers()) { - if (h instanceof ConsoleHandler) { - handler = (ConsoleHandler) h; - hasConsoleHandler = true; - } - } - if (!hasConsoleHandler) { - handler = new ConsoleHandler(); - global.addHandler(handler); - } - handler.setFormatter(new ShortConsoleLogFormatter()); - - System.setOut(new PrintStream(new LoggerOutputStream(log, Level.INFO), true)); - System.setErr(new PrintStream(new LoggerOutputStream(log, Level.SEVERE), true)); - } -} diff --git a/server/core/src/main/java/io/eiren/util/logging/LoggerOutputStream.java b/server/core/src/main/java/io/eiren/util/logging/LoggerOutputStream.java deleted file mode 100644 index 8527e637bd..0000000000 --- a/server/core/src/main/java/io/eiren/util/logging/LoggerOutputStream.java +++ /dev/null @@ -1,52 +0,0 @@ -package io.eiren.util.logging; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.util.logging.Level; - - -public class LoggerOutputStream extends ByteArrayOutputStream { - - private static final String separator = System.getProperty("line.separator"); - - private final IGLog logger; - private final Level level; - private final String prefix; - private final StringBuilder buffer = new StringBuilder(); - - public LoggerOutputStream(IGLog logger, Level level) { - this(logger, level, ""); - } - - public LoggerOutputStream(IGLog logger, Level level, String prefix) { - super(); - this.logger = logger; - this.level = level; - this.prefix = prefix; - } - - @Override - public void flush() throws IOException { - synchronized (this) { - super.flush(); - String record = this.toString(); - super.reset(); - if (record.length() > 0) { - buffer.append(record); - if (record.contains(separator)) { - String s = buffer.toString(); - String[] split = s.split(separator); - for (String value : split) - logger.log(level, prefix + value); - buffer.setLength(0); - // buffer.append(split[split.length - 1]); - } - } - } - } - - @Override - public void close() throws IOException { - flush(); - } -} diff --git a/server/core/src/main/java/io/eiren/util/logging/LoggerRecorder.java b/server/core/src/main/java/io/eiren/util/logging/LoggerRecorder.java deleted file mode 100644 index 589eca2365..0000000000 --- a/server/core/src/main/java/io/eiren/util/logging/LoggerRecorder.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.eiren.util.logging; - -import java.util.List; - -import io.eiren.util.collections.FastList; -import io.eiren.util.logging.DefaultGLog.LogEntry; - - -public class LoggerRecorder { - - private final List recorded = new FastList<>(); - - public LoggerRecorder() { - } - - public synchronized void addEntry(LogEntry e) { - recorded.add(e); - } - - public List getEntries() { - return recorded; - } -} diff --git a/server/core/src/main/java/io/eiren/util/logging/PreciseConsoleLogFormatter.java b/server/core/src/main/java/io/eiren/util/logging/PreciseConsoleLogFormatter.java deleted file mode 100644 index 69abe91ddd..0000000000 --- a/server/core/src/main/java/io/eiren/util/logging/PreciseConsoleLogFormatter.java +++ /dev/null @@ -1,32 +0,0 @@ -package io.eiren.util.logging; - -import java.text.SimpleDateFormat; -import java.util.logging.LogRecord; - - -/** - * Format message timestamp as time passed from the start with milliseconds. - */ -public class PreciseConsoleLogFormatter extends ShortConsoleLogFormatter { - - private final long startMills; - - public PreciseConsoleLogFormatter() { - startMills = System.currentTimeMillis(); - } - - @Override - protected SimpleDateFormat createDateFormat() { - return new SimpleDateFormat("mm:ss.SSS"); - } - - @Override - protected void buildMessage(StringBuilder builder, LogRecord record) { - builder.append(date.format(record.getMillis() - startMills)); - builder.append(" ["); - builder.append(record.getLevel().getLocalizedName().toUpperCase()); - builder.append("] "); - builder.append(record.getMessage()); - builder.append('\n'); - } -} diff --git a/server/core/src/main/java/io/eiren/util/logging/ShortConsoleLogFormatter.java b/server/core/src/main/java/io/eiren/util/logging/ShortConsoleLogFormatter.java deleted file mode 100644 index 566e4722b7..0000000000 --- a/server/core/src/main/java/io/eiren/util/logging/ShortConsoleLogFormatter.java +++ /dev/null @@ -1,58 +0,0 @@ -package io.eiren.util.logging; - -import java.io.PrintWriter; -import java.io.StringWriter; -import java.text.SimpleDateFormat; -import java.util.logging.Formatter; -import java.util.logging.LogRecord; - - -public class ShortConsoleLogFormatter extends Formatter { - - protected final SimpleDateFormat date; - - public ShortConsoleLogFormatter() { - this.date = createDateFormat(); - } - - protected SimpleDateFormat createDateFormat() { - return new SimpleDateFormat("HH:mm:ss"); - } - - protected void buildMessage(StringBuilder builder, LogRecord record) { - builder.append(date.format(record.getMillis())); - builder.append(" ["); - builder.append(record.getLevel().getLocalizedName().toUpperCase()); - builder.append("] "); - builder.append(record.getMessage()); - builder.append('\n'); - } - - @Override - public String format(LogRecord record) { - StringBuilder builder = new StringBuilder(); - Throwable ex = record.getThrown(); - - buildMessage(builder, record); - - if (ex != null) { - StringWriter writer = new StringWriter(); - ex.printStackTrace(new PrintWriter(writer)); - builder.append(writer); - } - - String message = builder.toString(); - Object[] parameters = record.getParameters(); - if (parameters == null || parameters.length == 0) - return message; - if ( - message.contains("{0") - || message.contains("{1") - || message.contains("{2") - || message.contains("{3") - ) - return java.text.MessageFormat.format(message, parameters); - return message; - } - -} diff --git a/server/core/src/test/java/dev/slimevr/unit/LegTweaksTests.kt b/server/core/src/test/java/dev/slimevr/unit/LegTweaksTests.kt deleted file mode 100644 index 830a60f9db..0000000000 --- a/server/core/src/test/java/dev/slimevr/unit/LegTweaksTests.kt +++ /dev/null @@ -1,72 +0,0 @@ -package dev.slimevr.unit - -import dev.slimevr.tracking.processor.HumanPoseManager -import dev.slimevr.tracking.processor.config.SkeletonConfigToggles -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerPosition -import dev.slimevr.tracking.trackers.TrackerRole -import dev.slimevr.tracking.trackers.TrackerStatus -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.QuaternionTest -import io.github.axisangles.ktmath.Vector3 -import org.junit.jupiter.api.Test -import kotlin.test.assertFails - -class LegTweaksTests { - - @Test - fun toeSnap() { - val hmd = Tracker( - null, - 0, - "test:headset", - "Headset", - TrackerPosition.HEAD, - hasPosition = true, - hasRotation = true, - isComputed = true, - imuType = null, - allowReset = false, - allowMounting = false, - isHmd = true, - trackRotDirection = false, - ) - hmd.status = TrackerStatus.OK - - val hpm = HumanPoseManager(listOf(hmd)) - val height = hpm.userHeightFromConfig - val lFoot = hpm.getComputedTracker(TrackerRole.LEFT_FOOT) - - assert(height > 0f) { - "Skeleton was not populated with default proportions (height = $height)" - } - val lFootLen = hpm.skeleton.leftFootBone.length - assert(lFootLen > 0f) { - "Skeleton's left foot has no length (length = $lFootLen)" - } - - // Skeleton setup - hpm.skeleton.hasKneeTrackers = true - hpm.setToggle(SkeletonConfigToggles.TOE_SNAP, true) - - // Set the floor height - hmd.position = Vector3(0f, height, 0f) - hpm.update() - - // Validate initial state - QuaternionTest.assertEquals(Quaternion.IDENTITY, lFoot.getRotation()) - - // Ensure `leftToeTouched` and `rightToeTouched` are true - hmd.position = Vector3(0f, height - 0.02f, 0f) - hpm.update() - - // Lift skeleton within toe snap range - hmd.position = Vector3(0f, height + 0.02f, 0f) - hpm.update() - - // This should fail now that the toes are snapped - assertFails { - QuaternionTest.assertEquals(Quaternion.IDENTITY, lFoot.getRotation()) - } - } -} diff --git a/server/core/src/test/java/dev/slimevr/unit/MountingResetTests.kt b/server/core/src/test/java/dev/slimevr/unit/MountingResetTests.kt deleted file mode 100644 index 79f0718a9a..0000000000 --- a/server/core/src/test/java/dev/slimevr/unit/MountingResetTests.kt +++ /dev/null @@ -1,163 +0,0 @@ -package dev.slimevr.unit - -import com.jme3.math.FastMath -import dev.slimevr.VRServer.Companion.getNextLocalTrackerId -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.udp.IMUType -import dev.slimevr.unit.TrackerTestUtils.assertAnglesApproxEqual -import dev.slimevr.unit.TrackerTestUtils.deg -import dev.slimevr.unit.TrackerTestUtils.yaw -import io.github.axisangles.ktmath.EulerAngles -import io.github.axisangles.ktmath.EulerOrder -import io.github.axisangles.ktmath.Quaternion -import org.junit.jupiter.api.DynamicTest -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestFactory - -/** - * Tests [TrackerResetsHandler.resetMounting] - * - * Head rotation does not get reset. - * Tracker yaw is set to head yaw on reset. - */ -class MountingResetTests { - - @TestFactory - fun testResetAndMounting(): List = TrackerTestUtils.directions.flatMap { e -> - TrackerTestUtils.directions.map { m -> - DynamicTest.dynamicTest( - "Full and Mounting Reset Test of Tracker (Expected: ${deg(e)}, reference: ${deg(m)})", - ) { - checkResetMounting(e, m) - } - } - } - - private fun checkResetMounting(expected: Quaternion, reference: Quaternion) { - // Compute the pitch/roll for the expected mounting - val trackerRot = expected * TrackerTestUtils.frontRot / expected - - val tracker = Tracker( - null, - getNextLocalTrackerId(), - "test", - "test", - null, - hasRotation = true, - imuType = IMUType.UNKNOWN, - allowReset = true, - allowMounting = true, - trackRotDirection = false, - ) - - // Apply full reset and mounting - tracker.setRotation(Quaternion.IDENTITY) - tracker.resetsHandler.resetFull(Quaternion.IDENTITY) - tracker.setRotation(trackerRot) - tracker.resetsHandler.resetMounting(Quaternion.IDENTITY) - - val expectedYaw = yaw(expected) - val resultYaw = yaw(tracker.resetsHandler.mountRotFix) - assertAnglesApproxEqual( - expectedYaw, - resultYaw, - "Resulting mounting yaw after full reset is not equal to reference yaw (${deg(expectedYaw)} vs ${deg(resultYaw)})", - ) - - // Apply full reset and mounting plus offset - tracker.setRotation(Quaternion.IDENTITY) - tracker.resetsHandler.resetFull(reference) - // Apply an offset of reference to the rotation - tracker.setRotation(reference * trackerRot) - // Since reference is the offset from quat identity (reset) and the rotation, - // it needs to be applied twice - tracker.resetsHandler.resetMounting(reference * reference) - - val expectedYaw2 = yaw(expected) - val resultYaw2 = yaw(tracker.resetsHandler.mountRotFix) - assertAnglesApproxEqual( - expectedYaw2, - resultYaw2, - "Resulting mounting yaw after full reset with offset is not equal to reference yaw (${deg(expectedYaw2)} vs ${deg(resultYaw2)})", - ) - - // Apply yaw reset and mounting - tracker.setRotation(Quaternion.IDENTITY) - tracker.resetsHandler.resetFull(reference) - tracker.resetsHandler.resetYaw(Quaternion.IDENTITY) - tracker.setRotation(trackerRot) - tracker.resetsHandler.resetMounting(Quaternion.IDENTITY) - - val expectedYaw3 = yaw(expected) - val resultYaw3 = yaw(tracker.resetsHandler.mountRotFix) - assertAnglesApproxEqual( - expectedYaw3, - resultYaw3, - "Resulting mounting yaw after yaw reset is not equal to reference yaw (${deg(expectedYaw3)} vs ${deg(resultYaw3)})", - ) - - // Apply yaw reset and mounting plus offset - tracker.setRotation(Quaternion.IDENTITY) - tracker.resetsHandler.resetFull(Quaternion.IDENTITY) - tracker.resetsHandler.resetYaw(reference) - // Apply an offset of reference to the rotation - tracker.setRotation(reference * trackerRot) - // Since reference is the offset from quat identity (reset) and the rotation, - // it needs to be applied twice - tracker.resetsHandler.resetMounting(reference * reference) - - val expectedYaw4 = yaw(expected) - val resultYaw4 = yaw(tracker.resetsHandler.mountRotFix) - assertAnglesApproxEqual( - expectedYaw3, - resultYaw3, - "Resulting mounting yaw after yaw reset with offset is not equal to reference yaw (${deg(expectedYaw4)} vs ${deg(resultYaw4)})", - ) - } - - @Test - fun testYawAfter() { - val expected = Quaternion.SLIMEVR.RIGHT - val reference = EulerAngles(EulerOrder.YZX, FastMath.PI / 8f, FastMath.HALF_PI, 0f).toQuaternion() - // Compute the pitch/roll for the expected mounting - val trackerRot = expected * TrackerTestUtils.frontRot / expected - - val tracker = Tracker( - null, - getNextLocalTrackerId(), - "test", - "test", - null, - hasRotation = true, - imuType = IMUType.UNKNOWN, - allowReset = true, - allowMounting = true, - trackRotDirection = false, - ) - - // Apply full reset and mounting - tracker.setRotation(Quaternion.IDENTITY) - tracker.resetsHandler.resetFull(Quaternion.IDENTITY) - tracker.setRotation(trackerRot) - tracker.resetsHandler.resetMounting(Quaternion.IDENTITY) - - val expectedYaw = yaw(expected) - val resultYaw = yaw(tracker.resetsHandler.mountRotFix) - assertAnglesApproxEqual( - expectedYaw, - resultYaw, - "Resulting mounting yaw after full reset is not equal to reference yaw (${deg(expectedYaw)} vs ${deg(resultYaw)})", - ) - - tracker.setRotation(reference * reference) - tracker.resetsHandler.resetYaw(reference) - - val expectedYaw2 = yaw(reference) - val resultYaw2 = yaw(tracker.getRotation()) - assertAnglesApproxEqual( - expectedYaw2, - resultYaw2, - "Resulting rotation after yaw reset is not equal to reference yaw (${deg(expectedYaw2)} vs ${deg(resultYaw2)})", - ) - } -} diff --git a/server/core/src/test/java/dev/slimevr/unit/ReferenceAdjustmentsTests.kt b/server/core/src/test/java/dev/slimevr/unit/ReferenceAdjustmentsTests.kt deleted file mode 100644 index 5a90b1ddb9..0000000000 --- a/server/core/src/test/java/dev/slimevr/unit/ReferenceAdjustmentsTests.kt +++ /dev/null @@ -1,337 +0,0 @@ -package dev.slimevr.unit - -import com.jme3.math.FastMath -import dev.slimevr.VRServer.Companion.getNextLocalTrackerId -import dev.slimevr.tracking.processor.TransformNode -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.udp.IMUType -import io.eiren.math.FloatMath -import io.eiren.util.StringUtils.prettyNumber -import io.github.axisangles.ktmath.EulerAngles -import io.github.axisangles.ktmath.EulerOrder -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3.Companion.POS_Y -import org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.DynamicTest -import org.junit.jupiter.api.TestFactory -import java.util.stream.Stream -import kotlin.streams.asStream - -/** - * Tests [TrackerResetsHandler.resetFull] - */ -class ReferenceAdjustmentsTests { - @get:TestFactory - val testsYaw: Stream - get() = anglesSet - .map { p: AnglesSet -> - DynamicTest.dynamicTest( - "Adjustment Yaw Test of Tracker(${p.pitch},${p.yaw},${p.roll})", - ) { - yaws.forEach { - checkReferenceAdjustmentYaw( - q(p.pitch.toFloat(), p.yaw.toFloat(), p.roll.toFloat()), - 0, - it, - 0, - ) - } - } - } - - @get:TestFactory - val testsFull: Stream - get() = anglesSet - .map { p: AnglesSet -> - DynamicTest.dynamicTest( - "Adjustment Full Test of Tracker(${p.pitch},${p.yaw},${p.roll})", - ) { - anglesSet - .forEach { - checkReferenceAdjustmentFull( - q(p.pitch.toFloat(), p.yaw.toFloat(), p.roll.toFloat()), - it.pitch, - it.yaw, - it.roll, - ) - } - } - } - - // TODO : Test is not passing because the test is wrong - // See issue https://github.com/SlimeVR/SlimeVR-Server/issues/55 - // @TestFactory - val testsForRotation: Stream - get() = anglesSet - .flatMap { p: AnglesSet -> - yaws.asSequence().map { - DynamicTest.dynamicTest( - "Adjustment Rotation Test of Tracker(${p.pitch},${p.yaw},${p.roll}), Ref $it", - ) { - testAdjustedTrackerRotation( - q(p.pitch.toFloat(), p.yaw.toFloat(), p.roll.toFloat()), - 0, - it, - 0, - ) - } - }.asStream() - } - - fun checkReferenceAdjustmentFull( - trackerQuat: Quaternion, - refPitch: Int, - refYaw: Int, - refRoll: Int, - ) { - var referenceQuat = q(refPitch.toFloat(), refYaw.toFloat(), refRoll.toFloat()) - val tracker = Tracker( - null, - getNextLocalTrackerId(), - "test", - "test", - null, - hasRotation = true, - imuType = IMUType.UNKNOWN, - allowReset = true, - ) - tracker.setRotation(trackerQuat) - tracker.resetsHandler.resetFull(referenceQuat) - val read = tracker.getRotation() - Assertions.assertNotNull(read, "Adjusted tracker didn't return rotation") - - // Use only yaw HMD rotation - referenceQuat = referenceQuat.project(POS_Y).unit() - Assertions.assertEquals( - QuatEqualFullWithEpsilon(referenceQuat), - QuatEqualFullWithEpsilon(read), - "Adjusted quat is not equal to reference quat (${toDegs(referenceQuat)} vs ${toDegs(read)})", - ) - } - - fun checkReferenceAdjustmentYaw( - trackerQuat: Quaternion, - refPitch: Int, - refYaw: Int, - refRoll: Int, - ) { - // FIXME - val referenceQuat = q(refPitch.toFloat(), refYaw.toFloat(), refRoll.toFloat()) - val tracker = Tracker( - null, - getNextLocalTrackerId(), - "test", - "test", - null, - hasRotation = true, - imuType = IMUType.UNKNOWN, - allowReset = true, - ) - tracker.setRotation(trackerQuat) - tracker.resetsHandler.resetYaw(referenceQuat) - val read = tracker.getRotation() - Assertions.assertNotNull(read, "Adjusted tracker didn't return rotation") - Assertions.assertEquals( - QuatEqualYawWithEpsilon(referenceQuat), - QuatEqualYawWithEpsilon(read), - "Adjusted quat is not equal to reference quat (${toDegs(referenceQuat)} vs ${toDegs(read)})", - ) - } - - private fun testAdjustedTrackerRotation( - trackerQuat: Quaternion, - refPitch: Int, - refYaw: Int, - refRoll: Int, - ) { - val referenceQuat = q(refPitch.toFloat(), refYaw.toFloat(), refRoll.toFloat()) - val tracker = Tracker( - null, - getNextLocalTrackerId(), - "test", - "test", - null, - hasRotation = true, - imuType = IMUType.UNKNOWN, - allowReset = true, - ) - tracker.setRotation(trackerQuat) - tracker.resetsHandler.resetFull(referenceQuat) - - // Use only yaw HMD rotation - referenceQuat.project(POS_Y).unit() - val trackerNode = TransformNode(true) - val rotationNode = TransformNode(true) - rotationNode.attachChild(trackerNode) - trackerNode.localTransform.rotation = tracker.getRawRotation() - var yaw = 0 - while (yaw <= 360) { - var pitch = -90 - while (pitch <= 90) { - var roll = -90 - while (roll <= 90) { - val rotation = EulerAngles( - EulerOrder.YZX, - pitch * FastMath.DEG_TO_RAD, - yaw * FastMath.DEG_TO_RAD, - roll * FastMath.DEG_TO_RAD, - ).toQuaternion() - val rotationCompare = EulerAngles( - EulerOrder.YZX, - pitch * FastMath.DEG_TO_RAD, - (yaw + refYaw) * FastMath.DEG_TO_RAD, - roll * FastMath.DEG_TO_RAD, - ).toQuaternion() - rotationNode.localTransform.rotation = rotation - rotationNode.update() - tracker.setRotation(trackerNode.worldTransform.rotation) - val angles = tracker.getRawRotation().toEulerAngles(EulerOrder.YZX) - val anglesAdj = tracker.getRotation().toEulerAngles(EulerOrder.YZX) - val anglesDiff = tracker - .getRotation() - .inv() - .times(rotationCompare) - .toEulerAngles(EulerOrder.YZX) - if (!PRINT_TEST_RESULTS) { - Assertions.assertTrue( - FloatMath.equalsToZero(anglesDiff.x) && - FloatMath.equalsToZero(anglesDiff.y) && - FloatMath.equalsToZero(anglesDiff.z), - name(yaw, pitch, roll, angles, anglesAdj, anglesDiff), - ) - } else { - if (FloatMath.equalsToZero(anglesDiff.x) && - FloatMath.equalsToZero(anglesDiff.y) && - FloatMath.equalsToZero(anglesDiff.z) - ) { - successes++ - } else { - errors++ - } - println(name(yaw, pitch, roll, angles, anglesAdj, anglesDiff)) - } - roll += 15 - } - pitch += 15 - } - yaw += 30 - } - if (PRINT_TEST_RESULTS) println("Errors: $errors, successes: $successes") - } - - private data class QuatEqualYawWithEpsilon(val q: Quaternion) { - override fun equals(other: Any?): Boolean { - if (other == null) return false - val q2: Quaternion = when (other) { - is Quaternion -> other - is QuatEqualYawWithEpsilon -> other.q - else -> return false - } - var degs1 = q.toEulerAngles(EulerOrder.YZX) - var degs2 = q2.toEulerAngles(EulerOrder.YZX) - if (degs1.y < -FloatMath.ANGLE_EPSILON_RAD) { - degs1 = EulerAngles( - EulerOrder.YZX, - degs1.x, - degs1.y + FastMath.TWO_PI, - degs1.z, - ) - } - if (degs2.y < -FloatMath.ANGLE_EPSILON_RAD) { - degs2 = EulerAngles( - EulerOrder.YZX, - degs2.x, - degs2.y + FastMath.TWO_PI, - degs2.z, - ) - } - return FloatMath.equalsWithEpsilon(degs1.y, degs2.y) - } - - override fun hashCode(): Int = q.hashCode() - } - - data class QuatEqualFullWithEpsilon(val q: Quaternion) { - override fun hashCode(): Int = q.hashCode() - - override fun equals(other: Any?): Boolean { - if (other == null) return false - val q2: Quaternion = when (other) { - is Quaternion -> other - is QuatEqualFullWithEpsilon -> other.q - else -> return false - } - var degs1 = q.toEulerAngles(EulerOrder.YZX) - var degs2 = q2.toEulerAngles(EulerOrder.YZX) - if (degs1.y < -FloatMath.ANGLE_EPSILON_RAD) { - degs1 = EulerAngles( - EulerOrder.YZX, - degs1.x, - degs1.y + FastMath.TWO_PI, - degs1.z, - ) - } - if (degs2.y < -FloatMath.ANGLE_EPSILON_RAD) { - degs2 = EulerAngles( - EulerOrder.YZX, - degs2.x, - degs2.y + FastMath.TWO_PI, - degs2.z, - ) - } - return ( - FloatMath.equalsWithEpsilon(degs1.x, degs2.x) && - FloatMath.equalsWithEpsilon(degs1.y, degs2.y) && - FloatMath.equalsWithEpsilon(degs1.z, degs2.z) - ) - } - } - - companion object { - private val yaws = intArrayOf(0, 45, 90, 180, 270) - private val pitches = intArrayOf(0, 15, 35, -15, -35) - private val rolls = intArrayOf(0, 15, 35, -15, -35) - private const val PRINT_TEST_RESULTS = false - private var errors = 0 - private var successes = 0 - - val anglesSet: Stream - get() = yaws.asSequence() - .zip(pitches.asSequence()) - .zip(rolls.asSequence()) { (yaw, pitch), roll -> - AnglesSet(pitch, yaw, roll) - }.asStream() - - private fun name( - yaw: Int, - pitch: Int, - roll: Int, - angles: EulerAngles, - anglesAdj: EulerAngles, - anglesDiff: EulerAngles, - ): String = """Rot: $yaw/$pitch/$roll. - Angles: ${prettyNumber(angles.x * FastMath.RAD_TO_DEG, 1)}/${prettyNumber(anglesAdj.x * FastMath.RAD_TO_DEG, 1)}, - ${prettyNumber(angles.y * FastMath.RAD_TO_DEG, 1)}/${prettyNumber(anglesAdj.y * FastMath.RAD_TO_DEG, 1)}, - ${prettyNumber(angles.z * FastMath.RAD_TO_DEG, 1)}/${prettyNumber(anglesAdj.z * FastMath.RAD_TO_DEG, 1)}. - Diff: ${prettyNumber(anglesDiff.x * FastMath.RAD_TO_DEG, 1)}, - ${prettyNumber(anglesDiff.y * FastMath.RAD_TO_DEG, 1)}, - ${prettyNumber(anglesDiff.z * FastMath.RAD_TO_DEG, 1)} - """.replace('\n', ' ') - - fun q(pitch: Float, yaw: Float, roll: Float): Quaternion = EulerAngles( - EulerOrder.YZX, - pitch * FastMath.DEG_TO_RAD, - yaw * FastMath.DEG_TO_RAD, - roll * FastMath.DEG_TO_RAD, - ).toQuaternion() - - fun toDegs(q: Quaternion): String { - val (_, x, y, z) = q.toEulerAngles(EulerOrder.YZX) - return "${prettyNumber(x * FastMath.RAD_TO_DEG, 0)}, " + - "${prettyNumber(y * FastMath.RAD_TO_DEG, 0)}, " + - prettyNumber(z * FastMath.RAD_TO_DEG, 0) - } - } -} - -data class AnglesSet(val pitch: Int, val yaw: Int, val roll: Int) diff --git a/server/core/src/test/java/dev/slimevr/unit/SkeletonResetTests.kt b/server/core/src/test/java/dev/slimevr/unit/SkeletonResetTests.kt deleted file mode 100644 index cbbc3f734f..0000000000 --- a/server/core/src/test/java/dev/slimevr/unit/SkeletonResetTests.kt +++ /dev/null @@ -1,122 +0,0 @@ -package dev.slimevr.unit - -import com.jme3.math.FastMath -import dev.slimevr.tracking.processor.HumanPoseManager -import dev.slimevr.tracking.trackers.TrackerPosition -import dev.slimevr.unit.TrackerTestUtils.assertAnglesApproxEqual -import dev.slimevr.unit.TrackerTestUtils.quatApproxEqual -import io.github.axisangles.ktmath.EulerAngles -import io.github.axisangles.ktmath.EulerOrder -import io.github.axisangles.ktmath.Quaternion -import org.junit.jupiter.api.Test - -class SkeletonResetTests { - - val resetSource = "Unit Test" - - @Test - fun testSkeletonFullReset() { - val trackers = TestTrackerSet() - - // Initialize skeleton and everything - val hpm = HumanPoseManager(trackers.allL) - - val headRot1 = EulerAngles(EulerOrder.YZX, 0f, FastMath.HALF_PI, FastMath.QUARTER_PI).toQuaternion() - val expectRot1 = EulerAngles(EulerOrder.YZX, 0f, FastMath.HALF_PI, 0f).toQuaternion() - - // Randomize tracker orientations, these should be zeroed and matched to the - // headset yaw by full reset - for ((i, tracker) in trackers.set.withIndex()) { - tracker.setRotation(TrackerTestUtils.testRotFromIndex(i)) - } - trackers.head.setRotation(headRot1) - hpm.resetTrackersFull(resetSource) - - for (tracker in trackers.set) { - val actual = tracker.getRotation() - assert(quatApproxEqual(expectRot1, actual)) { - "\"${tracker.name}\" did not reset to the reference rotation. Expected <$expectRot1>, actual <$actual>." - } - } - } - - @Test - fun testSkeletonYawReset() { - val trackers = TestTrackerSet() - - // Initialize skeleton and everything - val hpm = HumanPoseManager(trackers.allL) - - // Randomize full tracker orientations, these should match the headset yaw but - // retain orientation otherwise - for ((i, tracker) in trackers.set.withIndex()) { - // Offset index so it's different from last reset - tracker.setRotation(TrackerTestUtils.testRotFromIndex(i)) - } - trackers.head.setRotation(Quaternion.IDENTITY) - hpm.resetTrackersYaw(resetSource) - - for (tracker in trackers.set) { - val yaw = TrackerTestUtils.yaw(tracker.getRotation()) - assertAnglesApproxEqual(0f, yaw, "\"${tracker.name}\" did not reset to the reference rotation.") - } - } - - @Test - fun testSkeletonMountReset() { - val trackers = TestTrackerSet() - - // Initialize skeleton and everything - val hpm = HumanPoseManager(trackers.allL) - - // Just a bunch of random mounting orientations - val expected = arrayOf( - Pair(trackers.chest, Quaternion.SLIMEVR.FRONT), - Pair(trackers.hip, Quaternion.SLIMEVR.RIGHT), - Pair(trackers.leftThigh, Quaternion.SLIMEVR.BACK), - Pair(trackers.leftCalf, Quaternion.SLIMEVR.LEFT), - Pair(trackers.rightThigh, Quaternion.SLIMEVR.FRONT), - Pair(trackers.rightCalf, Quaternion.SLIMEVR.RIGHT), - ) - // Rotate the tracker to fit the expected mounting orientation - for ((tracker, mountRot) in expected) { - tracker.setRotation(mkTrackMount(mountRot)) - } - // Then perform a mounting reset - hpm.resetTrackersMounting(resetSource) - - for ((tracker, mountRot) in expected) { - // Some mounting needs to be inverted (when in a specific pose) - // TODO: Make this less hardcoded, accept alternative poses - val expectedMounting = when (tracker.trackerPosition) { - TrackerPosition.CHEST, - TrackerPosition.HIP, - TrackerPosition.LEFT_LOWER_LEG, - TrackerPosition.RIGHT_LOWER_LEG, - -> mountRot * Quaternion.SLIMEVR.FRONT - - TrackerPosition.LEFT_UPPER_LEG, - TrackerPosition.RIGHT_UPPER_LEG, - -> mountRot - - else -> mountRot - } - val actualMounting = tracker.resetsHandler.mountRotFix - - // Make sure yaw matches - val expectedY = TrackerTestUtils.yaw(expectedMounting) - val actualY = TrackerTestUtils.yaw(actualMounting) - assertAnglesApproxEqual(expectedY, actualY, "\"${tracker.name}\" did not reset to the reference rotation.") - - // X and Z components should be zero for mounting - assert(FastMath.isApproxZero(actualMounting.x)) { - "\"${tracker.name}\" did not reset to the reference rotation. Expected <0.0>, actual <${actualMounting.x}>." - } - assert(FastMath.isApproxZero(actualMounting.z)) { - "\"${tracker.name}\" did not reset to the reference rotation. Expected <0.0>, actual <${actualMounting.z}>." - } - } - } - - fun mkTrackMount(rot: Quaternion): Quaternion = rot * TrackerTestUtils.frontRot / rot -} diff --git a/server/core/src/test/java/dev/slimevr/unit/TestTrackerSet.kt b/server/core/src/test/java/dev/slimevr/unit/TestTrackerSet.kt deleted file mode 100644 index cac3f38514..0000000000 --- a/server/core/src/test/java/dev/slimevr/unit/TestTrackerSet.kt +++ /dev/null @@ -1,69 +0,0 @@ -package dev.slimevr.unit - -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerPosition -import dev.slimevr.tracking.trackers.TrackerStatus - -class TestTrackerSet( - val computed: Boolean = false, - val positional: Boolean = false, - val resetHead: Boolean = false, -) { - - val head = mkTrack(0, TrackerPosition.HEAD, true) - - val chest = mkTrack(1, TrackerPosition.CHEST) - val hip = mkTrack(2, TrackerPosition.HIP) - - val leftThigh = mkTrack(3, TrackerPosition.LEFT_UPPER_LEG) - val leftCalf = mkTrack(4, TrackerPosition.LEFT_LOWER_LEG) - - val rightThigh = mkTrack(5, TrackerPosition.RIGHT_UPPER_LEG) - val rightCalf = mkTrack(6, TrackerPosition.RIGHT_LOWER_LEG) - - /** - * All the trackers in the set. - */ - val set = arrayOf( - chest, - hip, - leftThigh, - leftCalf, - rightThigh, - rightCalf, - ) - - /** - * All the trackers in the set as a list. - */ - val setL = set.asList() - - /** - * All the trackers in the set plus the headset. - */ - val all = set + head - - /** - * All the trackers in the set plus the headset as a list. - */ - val allL = all.asList() - - fun mkTrack(id: Int, pos: TrackerPosition, isHmd: Boolean = false): Tracker { - val tracker = Tracker( - device = null, - id = id, - name = pos.name, - trackerPosition = pos, - trackerNum = 0, - hasPosition = positional || isHmd, - hasRotation = true, - isComputed = computed || isHmd, - allowReset = resetHead || !isHmd, - allowMounting = resetHead || !isHmd, - isHmd = isHmd, - trackRotDirection = false, - ) - tracker.status = TrackerStatus.OK - return tracker - } -} diff --git a/server/core/src/test/java/dev/slimevr/unit/TrackerTestUtils.kt b/server/core/src/test/java/dev/slimevr/unit/TrackerTestUtils.kt deleted file mode 100644 index 8668e2d225..0000000000 --- a/server/core/src/test/java/dev/slimevr/unit/TrackerTestUtils.kt +++ /dev/null @@ -1,87 +0,0 @@ -package dev.slimevr.unit - -import com.jme3.math.FastMath -import io.github.axisangles.ktmath.EulerOrder -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import org.junit.jupiter.api.AssertionFailureBuilder -import kotlin.math.abs - -object TrackerTestUtils { - val directions = arrayOf( - Quaternion.SLIMEVR.FRONT, - Quaternion.SLIMEVR.FRONT_LEFT, - Quaternion.SLIMEVR.LEFT, - Quaternion.SLIMEVR.BACK_LEFT, - Quaternion.SLIMEVR.FRONT_RIGHT, - Quaternion.SLIMEVR.RIGHT, - Quaternion.SLIMEVR.BACK_RIGHT, - Quaternion.SLIMEVR.BACK, - ) - - val frontRot = Quaternion(0.707f, 0.707f, 0f, 0f) - - // A diverse range of quaternions to be used for testing where specific rotations - // do not matter - val testRots = arrayOf( - // Various rotations - frontRot, - Quaternion(0.707f, 0f, 0f, 0.707f), - Quaternion(0.854f, 0.354f, 0.146f, 0.354f), - // Pure yaw rotations - Quaternion.SLIMEVR.FRONT, - Quaternion.SLIMEVR.LEFT, - Quaternion.SLIMEVR.RIGHT, - // Axes - Quaternion.I, - Quaternion.K, - // Negative axes (same rotations, different sign) - -Quaternion.I, - -Quaternion.K, - // Identity - Quaternion.IDENTITY, - ) - - fun testRotFromIndex(index: Int): Quaternion = testRots[abs(index) % testRots.size] - - /** - * Makes a radian angle positive - */ - fun posRad(rot: Float): Float { - // Reduce the rotation to the smallest form - val redRot = rot % FastMath.TWO_PI - return abs(if (rot < 0f) FastMath.TWO_PI + redRot else redRot) - } - - /** - * Gets the yaw of a rotation in radians - */ - fun yaw(rot: Quaternion): Float = posRad(rot.toEulerAngles(EulerOrder.YZX).y) - - /** - * Converts radians to degrees - */ - fun deg(rot: Float): Float = rot * FastMath.RAD_TO_DEG - - fun deg(rot: Quaternion): Float = deg(yaw(rot)) - - private fun anglesApproxEqual(a: Float, b: Float): Boolean = FastMath.isApproxEqual(a, b) || - FastMath.isApproxEqual(a - FastMath.TWO_PI, b) || - FastMath.isApproxEqual(a, b - FastMath.TWO_PI) - - fun assertAnglesApproxEqual(expected: Float, actual: Float, message: String?) { - if (!anglesApproxEqual(expected, actual)) { - AssertionFailureBuilder.assertionFailure().message(message) - .expected(expected).actual(actual).buildAndThrow() - } - } - - fun quatApproxEqual(q1: Quaternion, q2: Quaternion, tolerance: Float = FastMath.ZERO_TOLERANCE): Boolean = FastMath.isApproxEqual(q1.w, q2.w, tolerance) && - FastMath.isApproxEqual(q1.x, q2.x, tolerance) && - FastMath.isApproxEqual(q1.y, q2.y, tolerance) && - FastMath.isApproxEqual(q1.z, q2.z, tolerance) - - fun vectorApproxEqual(v1: Vector3, v2: Vector3, tolerance: Float = FastMath.ZERO_TOLERANCE): Boolean = FastMath.isApproxEqual(v1.x, v2.x, tolerance) && - FastMath.isApproxEqual(v1.y, v2.y, tolerance) && - FastMath.isApproxEqual(v1.z, v2.z, tolerance) -} diff --git a/server/core/src/test/java/dev/slimevr/unit/TrackingPauseTests.kt b/server/core/src/test/java/dev/slimevr/unit/TrackingPauseTests.kt deleted file mode 100644 index f87dbbbbf6..0000000000 --- a/server/core/src/test/java/dev/slimevr/unit/TrackingPauseTests.kt +++ /dev/null @@ -1,62 +0,0 @@ -package dev.slimevr.unit - -import dev.slimevr.tracking.processor.HumanPoseManager -import dev.slimevr.unit.TrackerTestUtils.quatApproxEqual -import dev.slimevr.unit.TrackerTestUtils.vectorApproxEqual -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import org.junit.jupiter.api.Test -import kotlin.test.assertNotNull - -class TrackingPauseTests { - - val resetSource = "Unit Test" - - @Test - fun testTrackingPause() { - val trackers = TestTrackerSet() - - // Initialize skeleton and everything - val hpm = HumanPoseManager(trackers.allL) - // TODO: This being enabled makes waist position infinity initially but not - // later? Something weird is going on here... - hpm.setLegTweaksEnabled(false) - hpm.update() - - // Store expected tracker data - val expected = mutableMapOf>() - for (tracker in hpm.computedTrackers) { - expected.put(tracker.id, Pair(tracker.getRotation(), tracker.position)) - } - - // Pause tracking, nothing should move after this - hpm.setPauseTracking(true, resetSource) - - // Randomize tracker orientations, this should not affect the skeleton! - for ((i, tracker) in trackers.set.withIndex()) { - tracker.setRotation(TrackerTestUtils.testRotFromIndex(i)) - } - - // Tick the skeleton with random tracker rotations - hpm.update() - - // Since we paused before moving anything, the output should still be identity - for (tracker in hpm.computedTrackers) { - val trackerExpected = expected[tracker.id] - assertNotNull(trackerExpected) - - val expectedRot = trackerExpected.first - val expectedPos = trackerExpected.second - - val actualRot = tracker.getRotation() - val actualPos = tracker.position - - assert(quatApproxEqual(expectedRot, actualRot)) { - "\"${tracker.name}\" moved after being paused. Expected <$expectedRot>, actual <$actualRot>." - } - assert(vectorApproxEqual(expectedPos, actualPos)) { - "\"${tracker.name}\" moved after being paused. Expected <$expectedPos>, actual <$actualPos>." - } - } - } -} diff --git a/server/core/src/test/java/dev/slimevr/unit/TwinExtendedBackTests.kt b/server/core/src/test/java/dev/slimevr/unit/TwinExtendedBackTests.kt deleted file mode 100644 index 1d500db423..0000000000 --- a/server/core/src/test/java/dev/slimevr/unit/TwinExtendedBackTests.kt +++ /dev/null @@ -1,55 +0,0 @@ -package dev.slimevr.unit - -import com.jme3.math.FastMath -import io.github.axisangles.ktmath.EulerAngles -import io.github.axisangles.ktmath.EulerOrder -import io.github.axisangles.ktmath.Quaternion -import org.junit.jupiter.api.DynamicTest -import org.junit.jupiter.api.TestFactory -import kotlin.math.sign -import kotlin.test.assertEquals - -class TwinExtendedBackTests { - - // Bottom back quarter - val negRange = -269..-180 - - // Front two quarters and top back quarter - val posRange = -179..90 - - val pitchRange = negRange.map { - AngleTest(it.toFloat(), -1f) - }.plus( - posRange.map { - AngleTest(it.toFloat(), 1f) - }, - ) - - @get:TestFactory - val pitchTests: List - get() = pitchRange - .map { a: AngleTest -> - DynamicTest.dynamicTest( - "Dot product test for ${if (a.sign > 0f) "positive" else "negative"} signs <$a>", - ) { - testSign( - Quaternion.IDENTITY, - EulerAngles( - EulerOrder.YZX, - a.pitch * FastMath.DEG_TO_RAD, - 0f, - 0f, - ).toQuaternion(), - a.sign, - ) - } - } - - fun testSign(ref: Quaternion, extended: Quaternion, expectedSign: Float) { - val result = extended.twinExtendedBack(ref) - val dot = ref.dot(result) - assertEquals(expectedSign.sign, dot.sign, "Resulting dot ($dot) does not match the expected sign ($expectedSign)") - } - - data class AngleTest(val pitch: Float, val sign: Float) -} diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index 1950bdbcc3..c71c9dd3ea 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -18,17 +18,17 @@ plugins { kotlin { jvmToolchain { - languageVersion.set(JavaLanguageVersion.of(17)) + languageVersion.set(JavaLanguageVersion.of(22)) } } java { toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) + languageVersion.set(JavaLanguageVersion.of(22)) } } tasks.withType { compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) + jvmTarget.set(JvmTarget.JVM_22) freeCompilerArgs.set(listOf("-Xvalue-classes")) } } @@ -58,9 +58,6 @@ dependencies { implementation(project(":server:core")) implementation(project(":solarxr-protocol")) - implementation("commons-cli:commons-cli:1.11.0") - implementation("org.apache.commons:commons-lang3:3.20.0") - implementation("com.google.protobuf:protobuf-java:4.31.1") implementation("net.java.dev.jna:jna:5.+") implementation("net.java.dev.jna:jna-platform:5.+") implementation("com.fazecast:jSerialComm:2.11.3") { diff --git a/server/desktop/protobuf_update.bat b/server/desktop/protobuf_update.bat deleted file mode 100644 index 0daebc314c..0000000000 --- a/server/desktop/protobuf_update.bat +++ /dev/null @@ -1 +0,0 @@ -protoc --proto_path=../../../SlimeVR-OpenVR-Driver/src/bridge --java_out=./src/main/java ProtobufMessages.proto diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/DesktopNetworkProfileChecker.kt b/server/desktop/src/main/java/dev/slimevr/desktop/DesktopNetworkProfileChecker.kt deleted file mode 100644 index 3fbf796673..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/DesktopNetworkProfileChecker.kt +++ /dev/null @@ -1,285 +0,0 @@ -package dev.slimevr.desktop - -import com.sun.jna.Pointer -import com.sun.jna.platform.win32.COM.COMException -import com.sun.jna.platform.win32.COM.COMUtils -import com.sun.jna.platform.win32.COM.Dispatch -import com.sun.jna.platform.win32.Guid.CLSID -import com.sun.jna.platform.win32.Guid.IID -import com.sun.jna.platform.win32.OaIdl.VARIANT_BOOLByReference -import com.sun.jna.platform.win32.Ole32 -import com.sun.jna.platform.win32.OleAuto -import com.sun.jna.platform.win32.WTypes -import com.sun.jna.platform.win32.WinNT.HRESULT -import com.sun.jna.ptr.IntByReference -import com.sun.jna.ptr.PointerByReference -import dev.slimevr.ConnectivityFlags -import dev.slimevr.NetworkCategory -import dev.slimevr.NetworkInfo -import dev.slimevr.NetworkProfileChecker -import dev.slimevr.VRServer -import io.eiren.util.OperatingSystem -import java.util.* -import kotlin.concurrent.scheduleAtFixedRate - -/** - * @see INetworkConnection interface (netlistmgr.h) - */ -@Suppress("ktlint:standard:backing-property-naming", "ktlint:standard:function-naming") -class INetworkConnection(instance: Pointer?) : - Dispatch(instance), - AutoCloseable { - override fun close() { - Release() - } - companion object VTable { - // IUnknown +3 - // IDispatch +4 - // IEnumNetworkConnections - const val GetNetwork = 7 - const val IsConnectedToInternet = 8 - const val IsConnected = 9 - const val GetConnectivity = 10 - const val GetConnectionId = 11 - const val GetAdapterId = 12 - const val GetDomainType = 13 - } - - @Throws(COMException::class) - fun GetNetwork(): INetwork { - val pNetwork = PointerByReference() - val hr = _invokeNativeInt(VTable.GetNetwork, arrayOf(pointer, pNetwork)) - COMUtils.checkRC(HRESULT(hr)) - return INetwork(pNetwork.value) - } -} - -/** - * @see IEnumNetworkConnections interface (netlistmgr.h) - */ -@Suppress("ktlint:standard:backing-property-naming", "ktlint:standard:function-naming") -class IEnumNetworkConnections(instance: Pointer?) : - Dispatch(instance), - AutoCloseable { - override fun close() { - Release() - } - companion object VTable { - // IUnknown +3 - // IDispatch +4 - // IEnumNetworkConnections - const val _NewEnum = 7 - const val Next = 8 - const val Skip = 9 - const val Reset = 10 - const val Clone = 11 - } - - @Throws(COMException::class) - fun Next(): INetworkConnection? { - val ppNetworkConnections = PointerByReference() - val nFetched = IntByReference() - val hr = _invokeNativeInt(VTable.Next, arrayOf(pointer, 1, ppNetworkConnections, nFetched)) - COMUtils.checkRC(HRESULT(hr)) - if (nFetched.value.equals(0)) return null - return INetworkConnection(ppNetworkConnections.value) - } -} - -/** - * @see INetworkListManager interface (netlistmgr.h) - */ -@Suppress("ktlint:standard:backing-property-naming", "ktlint:standard:function-naming") -class INetworkListManager(instance: Pointer?) : - Dispatch(instance), - AutoCloseable { - override fun close() { - Release() - } - companion object VTable { - // IUnknown +3 - // IDispatch +4 - // INetworkListManager - const val GetNetworks = 7 - const val GetNetwork = 8 - const val GetNetworkConnections = 9 - const val GetNetworkConnection = 10 - const val IsConnectedToInternet = 11 - const val IsConnected = 12 - const val GetConnectivity = 13 - } - - @Throws(COMException::class) - fun GetNetworkConnections(): IEnumNetworkConnections { - val pEnumNetworkConnections = PointerByReference() - val hr = _invokeNativeInt(VTable.GetNetworkConnections, arrayOf(pointer, pEnumNetworkConnections)) - COMUtils.checkRC(HRESULT(hr)) - return IEnumNetworkConnections(pEnumNetworkConnections.value) - } -} - -/** - * @see INetwork interface (netlistmgr.h) - */ -@Suppress("ktlint:standard:backing-property-naming", "ktlint:standard:function-naming") -class INetwork(instance: Pointer?) : - Dispatch(instance), - AutoCloseable { - override fun close() { - Release() - } - companion object VTable { - // IUnknown +3 - // IDispatch +4 - // INetworkListManager - const val GetName = 7 - const val SetName = 8 - const val GetDescription = 9 - const val SetDescription = 10 - const val GetNetworkId = 11 - const val GetDomainType = 12 - const val GetNetworkConnections = 13 - const val GetTimeCreatedAndConnected = 14 - const val IsConnectedToInternet = 15 - const val IsConnected = 16 - const val GetConnectivity = 17 - const val GetCategory = 18 - const val SetCategory = 19 - } - - @Throws(COMException::class) - private fun getNativeString(vtableId: Int): String? { - val pStr = PointerByReference() - val hr = _invokeNativeInt(vtableId, arrayOf(pointer, pStr)) - COMUtils.checkRC(HRESULT(hr)) - val bstr = WTypes.BSTR(pStr.value) - val stringValue = bstr.value - OleAuto.INSTANCE.SysFreeString(bstr) - return stringValue - } - - @Throws(COMException::class) - fun GetName(): String? = getNativeString(VTable.GetName) - - @Throws(COMException::class) - fun GetDescription(): String? = getNativeString(VTable.GetDescription) - - @Throws(COMException::class) - fun IsConnected(): Boolean { - val bool = VARIANT_BOOLByReference() - val hr = _invokeNativeInt(VTable.IsConnected, arrayOf(pointer, bool)) - COMUtils.checkRC(HRESULT(hr)) - return bool.value.booleanValue() - } - - @Throws(COMException::class) - fun GetConnectivity(): Set { - val connectivity = IntByReference() - val hr = _invokeNativeInt(VTable.GetConnectivity, arrayOf(pointer, connectivity)) - COMUtils.checkRC(HRESULT(hr)) - return ConnectivityFlags.fromInt(connectivity.value) - } - - @Throws(COMException::class) - fun GetCategory(): NetworkCategory? { - val category = IntByReference() - val hr = _invokeNativeInt(VTable.GetCategory, arrayOf(pointer, category)) - COMUtils.checkRC(HRESULT(hr)) - return NetworkCategory.fromInt(category.value) - } -} - -/** - * Network List Manager API wrapper - * @see Network List Manager API - * @throws COMException - */ -class COMNetworkManager -@Throws(COMException::class) -constructor() : AutoCloseable { - var instance: INetworkListManager - private var shouldUninitialize = false - - init { - shouldUninitialize = COMUtils.SUCCEEDED(Ole32.INSTANCE.CoInitialize(null)) - - val CLSID_NetworkListManager = CLSID("dcb00c01-570f-4a9b-8d69-199fdba5723b") - val IID_INetworkListManager = IID("dcb00000-570f-4a9b-8d69-199fdba5723b") - - val ptr = PointerByReference() - val hr = Ole32.INSTANCE.CoCreateInstance( - CLSID_NetworkListManager, - null, - WTypes.CLSCTX_ALL, - IID_INetworkListManager, - ptr, - ) - try { - COMUtils.checkRC(hr) - } catch (err: Exception) { - if (shouldUninitialize) { - Ole32.INSTANCE.CoUninitialize() - } - throw err - } - - instance = INetworkListManager(ptr.value) - } - - override fun close() { - instance.close() - if (shouldUninitialize) { - Ole32.INSTANCE.CoUninitialize() - } - } -} - -fun enumerateNetworks(): List? { - if (OperatingSystem.currentPlatform != OperatingSystem.WINDOWS) { - return null - } - try { - COMNetworkManager().use { netmgr -> - netmgr.instance.GetNetworkConnections().use { conns -> - return generateSequence { conns.Next() } - .map { conn -> - conn.use { - conn.GetNetwork().use { network -> - NetworkInfo( - network.GetName(), - network.GetDescription(), - network.GetCategory(), - network.GetConnectivity(), - network.IsConnected(), - ) - } - } - }.toList() - } - } - } catch (err: Exception) { - println(err.stackTraceToString()) - } - return null -} - -class DesktopNetworkProfileChecker(private val server: VRServer) : NetworkProfileChecker() { - private val updateTickTimer = Timer("NetworkProfileCheck") - private var publicNetworksLocal: List = listOf() - - override val isSupported: Boolean - get() = OperatingSystem.currentPlatform == OperatingSystem.WINDOWS - - override val publicNetworks: List - get() = publicNetworksLocal - - init { - if (OperatingSystem.currentPlatform == OperatingSystem.WINDOWS) { - this.updateTickTimer.scheduleAtFixedRate(0, 3000) { - publicNetworksLocal = enumerateNetworks()?.filter { net -> - net.connected == true && net.category == NetworkCategory.PUBLIC - } ?: listOf() - } - } - } -} 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 22267c260a..c4c3412fad 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -2,259 +2,6 @@ package dev.slimevr.desktop -import dev.slimevr.Keybinding -import dev.slimevr.SLIMEVR_IDENTIFIER -import dev.slimevr.VRServer -import dev.slimevr.bridge.Bridge -import dev.slimevr.config.ConfigManager -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 -import dev.slimevr.desktop.platform.windows.WindowsNamedPipeBridge -import dev.slimevr.desktop.serial.DesktopSerialHandler -import dev.slimevr.desktop.tracking.trackers.hid.DesktopHIDManager -import dev.slimevr.tracking.trackers.Tracker -import io.eiren.util.OperatingSystem -import io.eiren.util.collections.FastList -import io.eiren.util.logging.LogManager -import org.apache.commons.cli.CommandLine -import org.apache.commons.cli.CommandLineParser -import org.apache.commons.cli.DefaultParser -import org.apache.commons.cli.HelpFormatter -import org.apache.commons.cli.Options -import org.apache.commons.lang3.SystemUtils -import java.io.File -import java.io.IOException -import java.lang.System -import java.net.DatagramSocket -import java.net.ServerSocket -import java.nio.file.Files -import java.nio.file.Paths -import javax.swing.JOptionPane -import kotlin.concurrent.thread -import kotlin.io.path.Path -import kotlin.io.path.exists -import kotlin.io.path.pathString -import kotlin.system.exitProcess - -val VERSION = - (GIT_VERSION_TAG.ifEmpty { GIT_COMMIT_HASH }) + - if (GIT_CLEAN) "" else "-dirty" - fun main(args: Array) { - System.setProperty("awt.useSystemAAFontSettings", "on") - System.setProperty("swing.aatext", "true") - - val parser: CommandLineParser = DefaultParser() - val formatter = HelpFormatter() - val options = Options() - options.addOption("h", "help", false, "Show help") - options.addOption("V", "version", false, "Show version") - val cmd: CommandLine = try { - parser.parse(options, args, true) - } catch (e: org.apache.commons.cli.ParseException) { - formatter.printHelp("slimevr.jar", options) - exitProcess(1) - } - - if (cmd.hasOption("help")) { - formatter.printHelp("slimevr.jar", options) - exitProcess(0) - } - if (cmd.hasOption("version")) { - println("SlimeVR Server $VERSION") - exitProcess(0) - } - - if (cmd.args.isEmpty()) { - System.err.println("No command specified, expected 'run'") - exitProcess(1) - } - if (!cmd.args[0].equals("run", true)) { - System.err.println("Unknown command: ${cmd.args[0]}, expected 'run'") - exitProcess(1) - } - - val dir = OperatingSystem.resolveLogDirectory(SLIMEVR_IDENTIFIER)?.toFile()?.absoluteFile - ?: File("").absoluteFile - try { - LogManager.initialize(dir) - } catch (e1: java.lang.Exception) { - e1.printStackTrace() - } - LogManager.info("Using log folder: $dir") - LogManager.info("Running version $VERSION") - if (!SystemUtils.isJavaVersionAtLeast(org.apache.commons.lang3.JavaVersion.JAVA_17)) { - LogManager.severe("SlimeVR start-up error! A minimum of Java 17 is required.") - JOptionPane - .showMessageDialog( - null, - "SlimeVR start-up error! A minimum of Java 17 is required.", - "SlimeVR: Java Runtime Mismatch", - JOptionPane.ERROR_MESSAGE, - ) - LogManager.closeLogger() - return - } - - val configDir = resolveConfig() - LogManager.info("Using config dir: $configDir") - - val configManager = ConfigManager(configDir) - configManager.loadConfig() - - try { - DatagramSocket(configManager.vrConfig.server.trackerPort).close() - ServerSocket(21110).close() - } catch (e: IOException) { - val message = "SlimeVR start-up error! A required port (${configManager.vrConfig.server.trackerPort} and 21110) is busy. " + - "Make sure there is no other instance of SlimeVR Server running." - LogManager - .severe(message) - JOptionPane - .showMessageDialog( - null, - message, - "SlimeVR: Ports are busy", - JOptionPane.ERROR_MESSAGE, - ) - LogManager.closeLogger() - return - } - try { - val vrServer = VRServer( - ::provideBridges, - { _ -> DesktopSerialHandler() }, - { _ -> DesktopSerialFlashingHandler() }, - { _ -> DesktopVRCConfigHandler() }, - { server -> DesktopNetworkProfileChecker(server) }, - configManager = configManager, - ) - vrServer.start() - - // Start service for USB HID trackers - DesktopHIDManager( - "Sensors HID service", - ) { tracker: Tracker -> vrServer.registerTracker(tracker) } - - Keybinding(vrServer) - val scanner = thread { - while (true) { - if (readln() == "exit") { - vrServer.interrupt() - break - } - } - } - - vrServer.join() - scanner.join() - LogManager.closeLogger() - exitProcess(0) - } catch (e: Throwable) { - e.printStackTrace() - exitProcess(1) - } -} - -fun provideBridges( - server: VRServer, - computedTrackers: List, -): Sequence = sequence { - when (OperatingSystem.currentPlatform) { - OperatingSystem.WINDOWS -> { - // Create named pipe bridge for SteamVR driver - yield( - WindowsNamedPipeBridge( - server, - "steamvr", - "SteamVR Driver Bridge", - """\\.\pipe\SlimeVRDriver""", - computedTrackers, - ), - ) - - // Create named pipe bridge for SteamVR input - yield( - WindowsNamedPipeBridge( - server, - "steamvr_feeder", - "SteamVR Feeder Bridge", - """\\.\pipe\SlimeVRInput""", - FastList(), - ), - ) - } - - OperatingSystem.LINUX -> { - var linuxBridge: SteamVRBridge? = null - try { - linuxBridge = UnixSocketBridge( - server, - "steamvr", - "SteamVR Driver Bridge", - Paths.get(OperatingSystem.socketDirectory, "SlimeVRDriver") - .toString(), - computedTrackers, - ) - } catch (ex: Exception) { - LogManager.severe( - "Failed to initiate Unix socket, disabling driver bridge...", - ex, - ) - } - if (linuxBridge != null) { - // Close the named socket on shutdown, or otherwise it's not going to get removed - Runtime.getRuntime().addShutdownHook( - Thread { - try { - (linuxBridge as? UnixSocketBridge)?.close() - } catch (e: Exception) { - throw RuntimeException(e) - } - }, - ) - yield(linuxBridge) - } - - yield( - UnixSocketBridge( - server, - "steamvr_feeder", - "SteamVR Feeder Bridge", - Paths.get(OperatingSystem.socketDirectory, "SlimeVRInput") - .toString(), - FastList(), - ), - ) - - yield( - UnixSocketRpcBridge( - server, - Paths.get(OperatingSystem.socketDirectory, "SlimeVRRpc") - .toString(), - computedTrackers, - ), - ) - } - - else -> {} - } -} - -const val CONFIG_FILENAME = "vrconfig.yml" -fun resolveConfig(): String { - // If config folder exists, then save config on relative path - if (Path("config/").exists()) { - return CONFIG_FILENAME - } - - val configFile = OperatingSystem.resolveConfigDirectory(SLIMEVR_IDENTIFIER)?.resolve(CONFIG_FILENAME) ?: return CONFIG_FILENAME - if (!configFile.exists() && Path(CONFIG_FILENAME).exists()) { - LogManager.info("Moved local config file to appdata folder") - Files.move(Path(CONFIG_FILENAME), configFile) - } - return configFile.pathString + println("Hello world!") } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/firmware/DesktopSerialFlashingHandler.kt b/server/desktop/src/main/java/dev/slimevr/desktop/firmware/DesktopSerialFlashingHandler.kt deleted file mode 100644 index 1d626bf6e4..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/firmware/DesktopSerialFlashingHandler.kt +++ /dev/null @@ -1,88 +0,0 @@ -package dev.slimevr.desktop.firmware - -import com.fazecast.jSerialComm.SerialPort -import dev.slimevr.firmware.SerialFlashingHandler -import io.eiren.util.logging.LogManager -import dev.slimevr.serial.SerialPort as SerialPortWrapper - -class DesktopSerialFlashingHandler : SerialFlashingHandler { - private var port: SerialPort? = null - - override fun openSerial(port: Any) { - if (port !is SerialPortWrapper) { - error("Not a serial port") - } - val ports = SerialPort.getCommPorts() - val comPort = ports.find { it.portLocation == port.portLocation } - ?: error("Unable to find port ${port.portLocation}") - if (comPort.isOpen) { - comPort.closePort() - } - if (!comPort.openPort(1000)) { - error("unable to open port") - } - this.port = comPort - } - - override fun closeSerial() { - val p = port ?: error("no port to close") - try { - p.closePort() - LogManager.info("Port closed") - } catch (e: Exception) { - error("unable to close port") - } - } - - override fun setDTR(value: Boolean) { - val p = port ?: error("no port to set DTR") - if (value) { - p.setDTR() - } else { - p.clearDTR() - } - } - - override fun setRTS(value: Boolean) { - val p = port ?: error("no port to set RTS") - if (value) { - p.setRTS() - } else { - p.clearRTS() - } - } - - override fun write(data: ByteArray) { - val p = port ?: error("no port to write") - p.writeBytes(data, data.size) - } - - override fun read(length: Int): ByteArray { - val p = port ?: error("no port to read") - val data = ByteArray(length) - p.readBytes(data, length) - return data - } - - override fun changeBaud(baud: Int) { - val p = port ?: error("no port to set the baud") - if (!p.setBaudRate(baud)) { - error("Unable to change baudrate") - } - } - - override fun setReadTimeout(timeout: Long) { - val p = port ?: error("no port to set the timeout") - p.setComPortTimeouts(SerialPort.TIMEOUT_READ_BLOCKING, timeout.toInt(), 0) - } - - override fun availableBytes(): Int { - val p = port ?: error("no port to check available bytes") - return p.bytesAvailable() - } - - override fun flushIOBuffers() { - val p = port ?: error("no port to flush") - p.flushIOBuffers() - } -} 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 deleted file mode 100644 index e780fa852b..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/games/vrchat/DesktopVRCConfigHandler.kt +++ /dev/null @@ -1,87 +0,0 @@ -package dev.slimevr.desktop.games.vrchat - -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 - -const val VRC_REG_PATH = "Software\\VRChat\\VRChat" - -class DesktopVRCConfigHandler : VRCConfigHandler() { - - private val getDevicesTimer = Timer("FetchVRCConfigTimer") - private val regEdit: AbstractRegEdit = - if (OperatingSystem.currentPlatform == OperatingSystem.WINDOWS) { - RegEditWindows() - } else { - RegEditLinux() - } - - private var configState: VRCConfigValues? = null - private var vrcConfigKeys: Map - lateinit var onChange: (config: VRCConfigValues) -> Unit - - private fun intValue(key: String): Int? { - val realKey = vrcConfigKeys[key] ?: return null - return regEdit.getDwordValue(VRC_REG_PATH, realKey) - } - - private fun doubleValue(key: String): Double? { - val realKey = vrcConfigKeys[key] ?: return null - return regEdit.getQwordValue(VRC_REG_PATH, realKey) - } - - init { - vrcConfigKeys = if (OperatingSystem.currentPlatform == OperatingSystem.WINDOWS || - OperatingSystem.currentPlatform == OperatingSystem.LINUX - ) { - regEdit.getVRChatKeys(VRC_REG_PATH) - } else { - mapOf() - } - } - - private fun updateCurrentState() { - vrcConfigKeys = regEdit.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 || - OperatingSystem.currentPlatform == OperatingSystem.LINUX - ) && - vrcConfigKeys.isNotEmpty() - - override fun initHandler(onChange: (config: VRCConfigValues) -> Unit) { - this.onChange = onChange - if (isSupported) { - updateCurrentState() - getDevicesTimer.scheduleAtFixedRate( - timerTask { - updateCurrentState() - }, - 0, - 3000, - ) - } - } -} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/games/vrchat/RegEdit.kt b/server/desktop/src/main/java/dev/slimevr/desktop/games/vrchat/RegEdit.kt deleted file mode 100644 index 3bbf88acc2..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/games/vrchat/RegEdit.kt +++ /dev/null @@ -1,159 +0,0 @@ -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 io.eiren.util.logging.LogManager -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 - -abstract class AbstractRegEdit { - abstract fun getQwordValue(path: String, key: String): Double? - abstract fun getDwordValue(path: String, key: String): Int? - abstract fun getVRChatKeys(path: String): Map -} - -class RegEditWindows : AbstractRegEdit() { - // Vrchat is dumb and write 64 bit doubles in the registry as DWORD instead of QWORD. - // so we have to be creative - override 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) { - LogManager.severe("[VRChatRegEdit] 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) { - LogManager.severe("[VRChatRegEdit] Error: Cannot read registry key") - return null - } - return lpData.getDouble(0) - } - - override fun getDwordValue(path: String, key: String): Int? = try { - val data = Advapi32Util.registryGetIntValue(WinReg.HKEY_CURRENT_USER, path, key) - data - } catch (e: Exception) { - LogManager.severe("[VRChatRegEdit] Error reading DWORD: ${e.message}") - null - } - - override 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) { - LogManager.severe("[VRChatRegEdit] Error reading Values from VRC registry: ${e.message}") - } - return keysMap - } -} - -class RegEditLinux : AbstractRegEdit() { - init { - if (USER_REG_PATH == null) { - LogManager.info("[VRChatRegEdit] Couldn't find any VRChat registry file") - } else { - LogManager.info("[VRChatRegEdit] Using VRChat registry file: $USER_REG_PATH") - } - } - lateinit var registry: Map - - override fun getQwordValue(path: String, key: String): Double? { - val value = registry[key] ?: return null - if (!value.startsWith("hex(4):")) { - LogManager.severe("[VRChatRegEdit] Couldn't find value with the expected type") - return null - } - return ByteBuffer.wrap(value.substring(7).hexToByteArray(HEX_FORMAT)) - .order(ByteOrder.LITTLE_ENDIAN) - .double - } - - override fun getDwordValue(path: String, key: String): Int? = try { - val value = registry[key] ?: return null - if (value.startsWith("dword:")) { - value.substring(6).toInt() - } else { - throw InvalidObjectException("The requested key is not a DWORD but it is instead a $value") - } - } catch (e: Exception) { - LogManager.severe("[VRChatRegEdit] Error reading DWORD: ${e.message}") - null - } - - override fun getVRChatKeys(path: String): Map { - val keysMap = mutableMapOf() - val map = mutableMapOf() - - try { - BufferedReader(FileReader(USER_REG_PATH?.toFile() ?: return keysMap)).use { reader -> - // The reg file uses double backward-slash for paths - val actualPath = "[${path.replace("\\", """\\""")}]" - while (reader.ready()) { - val line = reader.readLine() - if (!line.startsWith(actualPath)) continue - // Skip the `#time` line - reader.readLine() - while (reader.ready()) { - val keyValue = reader.readLine() - if (keyValue == "") break - - KEY_VALUE_PATTERN.matchEntire(keyValue)?.let { - map[it.groupValues[1]] = it.groupValues[2] - keysMap[it.groupValues[1].replace("""_h\d+$""".toRegex(), "")] = it.groupValues[1] - } - } - break - } - } - } catch (e: Exception) { - LogManager.severe("[VRChatRegEdit] Error reading Values from VRC registry: ${e.message}") - } - registry = map - return keysMap - } - - companion object { - const val USER_REG_SUBPATH = "steamapps/compatdata/438100/pfx/user.reg" - val USER_REG_PATH = - System.getenv("HOME")?.let { - Path(it, ".steam", "root", USER_REG_SUBPATH).let { if (it.exists()) it else null } - ?: Path(it, ".steam", "debian-installation", USER_REG_SUBPATH).let { if (it.exists()) it else null } - ?: Path(it, ".var", "app", "com.valvesoftware.Steam", "data", "Steam", USER_REG_SUBPATH).let { if (it.exists()) it else null } - } - val KEY_VALUE_PATTERN = Regex(""""(.+)"=(.+)""") - - val HEX_FORMAT = HexFormat { - upperCase = false - bytes.byteSeparator = "," - } - } -} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/platform/ProtobufBridge.kt b/server/desktop/src/main/java/dev/slimevr/desktop/platform/ProtobufBridge.kt deleted file mode 100644 index fdbee06725..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/platform/ProtobufBridge.kt +++ /dev/null @@ -1,300 +0,0 @@ -package dev.slimevr.desktop.platform - -import dev.slimevr.VRServer.Companion.instance -import dev.slimevr.bridge.BridgeThread -import dev.slimevr.bridge.ISteamVRBridge -import dev.slimevr.desktop.platform.ProtobufMessages.* -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerStatus -import dev.slimevr.tracking.trackers.TrackerStatus.Companion.getById -import dev.slimevr.tracking.trackers.TrackerUtils -import dev.slimevr.util.ann.VRServerThread -import io.eiren.util.ann.Synchronize -import io.eiren.util.ann.ThreadSafe -import io.eiren.util.collections.FastList -import io.eiren.util.logging.LogManager -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import java.util.Queue -import java.util.concurrent.LinkedBlockingQueue -import kotlin.collections.HashMap - -abstract class ProtobufBridge(@JvmField protected val bridgeName: String) : ISteamVRBridge { - @JvmField - @VRServerThread - protected val sharedTrackers: MutableList = FastList() - - @ThreadSafe - private val inputQueue: Queue = LinkedBlockingQueue() - - @ThreadSafe - private val outputQueue: Queue = LinkedBlockingQueue() - - @Synchronize("self") - private val remoteTrackersBySerial: MutableMap = HashMap() - - @Synchronize("self") - private val remoteTrackersByTrackerId: MutableMap = HashMap() - private var hadNewData = false - - /** - * Wakes the bridge thread, implementation is platform-specific. - */ - @ThreadSafe - protected abstract fun signalSend() - - @BridgeThread - protected abstract fun sendMessageReal(message: ProtobufMessage?): Boolean - - private var remoteProtocolVersion: Int = 0 - - @BridgeThread - protected fun messageReceived(message: ProtobufMessage) { - inputQueue.add(message) - } - - @ThreadSafe - protected fun sendMessage(message: ProtobufMessage) { - outputQueue.add(message) - signalSend() - } - - @BridgeThread - protected fun updateMessageQueue() { - var message: ProtobufMessage? - while ((outputQueue.poll().also { message = it }) != null) { - if (!sendMessageReal(message)) return - } - } - - @VRServerThread - override fun dataRead() { - hadNewData = false - var message: ProtobufMessage? - while ((inputQueue.poll().also { message = it }) != null) { - processMessageReceived(message) - hadNewData = true - } - } - - @VRServerThread - protected fun trackerOverrideUpdate(source: Tracker, target: Tracker) { - target.position = source.position - target.setRotation(source.getRotation()) - target.status = source.status - target.batteryLevel = source.batteryLevel - target.batteryVoltage = source.batteryVoltage - target.dataTick() - } - - @VRServerThread - override fun dataWrite() { - if (!hadNewData) { - // Don't write anything if no message were received, we - // always process at the - // speed of the other side - return - } - for (tracker in sharedTrackers) { - writeTrackerUpdate(tracker) - writeBatteryUpdate(tracker) - } - } - - @VRServerThread - protected fun writeTrackerUpdate(localTracker: Tracker?) { - val builder = ProtobufMessages.Position.newBuilder().setTrackerId( - localTracker!!.id, - ) - if (localTracker.hasPosition) { - val pos = localTracker.position - builder.setX(pos.x) - builder.setY(pos.y) - builder.setZ(pos.z) - } - if (localTracker.hasRotation) { - val rot = localTracker.getRotation() - builder.setQx(rot.x) - builder.setQy(rot.y) - builder.setQz(rot.z) - builder.setQw(rot.w) - } - sendMessage(ProtobufMessage.newBuilder().setPosition(builder).build()) - } - - @VRServerThread - protected open fun writeBatteryUpdate(localTracker: Tracker) { - return - } - - @VRServerThread - protected fun processMessageReceived(message: ProtobufMessage?) { - // if(!message.hasPosition()) - // LogManager.log.info("[" + bridgeName + "] MSG: " + message); - if (message!!.hasPosition()) { - positionReceived(message.position) - } else if (message.hasUserAction()) { - userActionReceived(message.userAction) - } else if (message.hasTrackerStatus()) { - trackerStatusReceived(message.trackerStatus) - } else if (message.hasTrackerAdded()) { - trackerAddedReceived(message.trackerAdded) - } else if (message.hasBattery()) { - batteryReceived(message.battery) - } else if (message.hasVersion()) { - versionReceived(message.version) - } - } - - @VRServerThread - protected fun positionReceived(positionMessage: ProtobufMessages.Position) { - val tracker = getInternalRemoteTrackerById(positionMessage.trackerId) - if (tracker != null) { - if (positionMessage.hasX()) { - tracker - .position = Vector3( - positionMessage.x, - positionMessage.y, - positionMessage.z, - ) - } - - tracker - .setRotation( - Quaternion( - positionMessage.qw, - positionMessage.qx, - positionMessage.qy, - positionMessage.qz, - ), - - ) - tracker.dataTick() - } - } - - @VRServerThread - protected open fun versionReceived(versionMessage: Version) { - remoteProtocolVersion = versionMessage.protocolVersion - if (remoteProtocolVersion == PROTOCOL_VERSION) { - LogManager.info("[ProtobufBridge] Driver protocol version matches the server protocol version: $remoteProtocolVersion") - } else { - LogManager.warning("[ProtobufBridge] Driver protocol version ($remoteProtocolVersion) doesn't match the server protocol version ($PROTOCOL_VERSION)") - } - } - - @VRServerThread - protected open fun batteryReceived(batteryMessage: Battery) { - return - } - - @VRServerThread - protected abstract fun createNewTracker(trackerAdded: TrackerAdded): Tracker - - @VRServerThread - protected fun trackerAddedReceived(trackerAdded: TrackerAdded) { - var tracker = getInternalRemoteTrackerById(trackerAdded.trackerId) - if (tracker != null) { - // TODO reinit? - return - } - tracker = createNewTracker(trackerAdded) - synchronized(remoteTrackersBySerial) { - remoteTrackersBySerial.put(tracker!!.name, tracker) - } - synchronized(remoteTrackersByTrackerId) { - remoteTrackersByTrackerId.put(tracker!!.trackerNum, tracker) - } - instance.registerTracker(tracker!!) - } - - @VRServerThread - protected fun userActionReceived(userAction: ProtobufMessages.UserAction) { - val resetSourceName = String.format("%s: %s", resetSourceNamePrefix, bridgeName) - when (userAction.name) { - "reset" -> // TODO : Check pose field - instance.resetTrackersFull(resetSourceName) - - "fast_reset" -> instance.resetTrackersYaw(resetSourceName) - - "mounting_reset" -> instance.resetTrackersMounting(resetSourceName) - - "feet_mounting_reset" -> instance.resetTrackersMounting( - resetSourceName, - TrackerUtils.feetsBodyParts, - ) - - "pause_tracking" -> - instance - .togglePauseTracking(resetSourceName) - } - } - - @VRServerThread - protected fun trackerStatusReceived(trackerStatus: ProtobufMessages.TrackerStatus) { - val tracker = getInternalRemoteTrackerById(trackerStatus.trackerId) - if (tracker != null) { - tracker.status = getById(trackerStatus.statusValue)!! - } - } - - @ThreadSafe - protected fun getInternalRemoteTrackerById(trackerId: Int): Tracker? { - synchronized(remoteTrackersByTrackerId) { - return remoteTrackersByTrackerId[trackerId] - } - } - - @VRServerThread - protected fun reconnected() { - for (tracker in sharedTrackers) { - val builder = TrackerAdded - .newBuilder() - .setTrackerId(tracker.id) - .setTrackerName(tracker.name) - .setTrackerSerial(tracker.name) - .setTrackerRole(tracker.trackerPosition!!.trackerRole!!.id) - sendMessage(ProtobufMessage.newBuilder().setTrackerAdded(builder).build()) - } - } - - @VRServerThread - protected fun disconnected() { - synchronized(remoteTrackersByTrackerId) { - for ((_, value) in remoteTrackersByTrackerId) { - value.status = TrackerStatus.DISCONNECTED - } - } - } - - @VRServerThread - override fun addSharedTracker(tracker: Tracker?) { - if (sharedTrackers.contains(tracker) || tracker == null) return - sharedTrackers.add(tracker) - val builder = TrackerAdded - .newBuilder() - .setTrackerId(tracker.id) - .setTrackerName(tracker.name) - .setTrackerSerial(tracker.name) - .setTrackerRole(tracker.trackerPosition!!.trackerRole!!.id) - sendMessage(ProtobufMessage.newBuilder().setTrackerAdded(builder).build()) - } - - @VRServerThread - override fun removeSharedTracker(tracker: Tracker?) { - // Remove shared tracker - sharedTrackers.remove(tracker) - - // Set the tracker's status as disconnected - val statusBuilder = ProtobufMessages.TrackerStatus - .newBuilder() - .setTrackerId(tracker!!.id) - statusBuilder.setStatus(ProtobufMessages.TrackerStatus.Status.DISCONNECTED) - sendMessage(ProtobufMessage.newBuilder().setTrackerStatus(statusBuilder).build()) - } - - companion object { - private const val resetSourceNamePrefix = "ProtobufBridge" - private const val PROTOCOL_VERSION = 1 - } -} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/platform/ProtobufMessages.java b/server/desktop/src/main/java/dev/slimevr/desktop/platform/ProtobufMessages.java deleted file mode 100644 index ebb8e0f03c..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/platform/ProtobufMessages.java +++ /dev/null @@ -1,9167 +0,0 @@ -// Generated by the protocol buffer compiler. DO NOT EDIT! -// NO CHECKED-IN PROTOBUF GENCODE -// source: ProtobufMessages.proto -// Protobuf Java Version: 4.31.1 - -package dev.slimevr.desktop.platform; - -@com.google.protobuf.Generated -public final class ProtobufMessages { - private ProtobufMessages() { - } - - static { - com.google.protobuf.RuntimeVersion - .validateProtobufGencodeVersion( - com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, - /* major= */ 4, - /* minor= */ 31, - /* patch= */ 1, - /* suffix= */ "", - ProtobufMessages.class.getName() - ); - } - - public static void registerAllExtensions( - com.google.protobuf.ExtensionRegistryLite registry - ) { - } - - public static void registerAllExtensions( - com.google.protobuf.ExtensionRegistry registry - ) { - registerAllExtensions( - (com.google.protobuf.ExtensionRegistryLite) registry - ); - } - - public interface PingPongOrBuilder extends - // @@protoc_insertion_point(interface_extends:messages.PingPong) - com.google.protobuf.MessageOrBuilder { - } - - /** - * Protobuf type {@code messages.PingPong} - */ - public static final class PingPong extends - com.google.protobuf.GeneratedMessage implements - // @@protoc_insertion_point(message_implements:messages.PingPong) - PingPongOrBuilder { - private static final long serialVersionUID = 0L; - static { - com.google.protobuf.RuntimeVersion - .validateProtobufGencodeVersion( - com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, - /* major= */ 4, - /* minor= */ 31, - /* patch= */ 1, - /* suffix= */ "", - PingPong.class.getName() - ); - } - - // Use PingPong.newBuilder() to construct. - private PingPong(com.google.protobuf.GeneratedMessage.Builder builder) { - super(builder); - } - - private PingPong() { - } - - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_PingPong_descriptor; - } - - @java.lang.Override - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_PingPong_fieldAccessorTable - .ensureFieldAccessorsInitialized( - dev.slimevr.desktop.platform.ProtobufMessages.PingPong.class, - dev.slimevr.desktop.platform.ProtobufMessages.PingPong.Builder.class - ); - } - - private byte memoizedIsInitialized = -1; - - @java.lang.Override - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized == 1) - return true; - if (isInitialized == 0) - return false; - - memoizedIsInitialized = 1; - return true; - } - - @java.lang.Override - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - getUnknownFields().writeTo(output); - } - - @java.lang.Override - public int getSerializedSize() { - int size = memoizedSize; - if (size != -1) - return size; - - size = 0; - size += getUnknownFields().getSerializedSize(); - memoizedSize = size; - return size; - } - - @java.lang.Override - public boolean equals(final java.lang.Object obj) { - if (obj == this) { - return true; - } - if (!(obj instanceof dev.slimevr.desktop.platform.ProtobufMessages.PingPong)) { - return super.equals(obj); - } - dev.slimevr.desktop.platform.ProtobufMessages.PingPong other = (dev.slimevr.desktop.platform.ProtobufMessages.PingPong) obj; - - if (!getUnknownFields().equals(other.getUnknownFields())) - return false; - return true; - } - - @java.lang.Override - public int hashCode() { - if (memoizedHashCode != 0) { - return memoizedHashCode; - } - int hash = 41; - hash = (19 * hash) + getDescriptor().hashCode(); - hash = (29 * hash) + getUnknownFields().hashCode(); - memoizedHashCode = hash; - return hash; - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.PingPong parseFrom( - java.nio.ByteBuffer data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.PingPong parseFrom( - java.nio.ByteBuffer data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.PingPong parseFrom( - com.google.protobuf.ByteString data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.PingPong parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.PingPong parseFrom(byte[] data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.PingPong parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.PingPong parseFrom( - java.io.InputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.PingPong parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.PingPong parseDelimitedFrom( - java.io.InputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseDelimitedWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.PingPong parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseDelimitedWithIOException(PARSER, input, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.PingPong parseFrom( - com.google.protobuf.CodedInputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.PingPong parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input, extensionRegistry); - } - - @java.lang.Override - public Builder newBuilderForType() { - return newBuilder(); - } - - public static Builder newBuilder() { - return DEFAULT_INSTANCE.toBuilder(); - } - - public static Builder newBuilder( - dev.slimevr.desktop.platform.ProtobufMessages.PingPong prototype - ) { - return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); - } - - @java.lang.Override - public Builder toBuilder() { - return this == DEFAULT_INSTANCE - ? new Builder() - : new Builder().mergeFrom(this); - } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent - ) { - Builder builder = new Builder(parent); - return builder; - } - - /** - * Protobuf type {@code messages.PingPong} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder implements - // @@protoc_insertion_point(builder_implements:messages.PingPong) - dev.slimevr.desktop.platform.ProtobufMessages.PingPongOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_PingPong_descriptor; - } - - @java.lang.Override - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_PingPong_fieldAccessorTable - .ensureFieldAccessorsInitialized( - dev.slimevr.desktop.platform.ProtobufMessages.PingPong.class, - dev.slimevr.desktop.platform.ProtobufMessages.PingPong.Builder.class - ); - } - - // Construct using - // dev.slimevr.desktop.platform.ProtobufMessages.PingPong.newBuilder() - private Builder() { - - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent - ) { - super(parent); - - } - - @java.lang.Override - public Builder clear() { - super.clear(); - return this; - } - - @java.lang.Override - public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_PingPong_descriptor; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.PingPong getDefaultInstanceForType() { - return dev.slimevr.desktop.platform.ProtobufMessages.PingPong.getDefaultInstance(); - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.PingPong build() { - dev.slimevr.desktop.platform.ProtobufMessages.PingPong result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.PingPong buildPartial() { - dev.slimevr.desktop.platform.ProtobufMessages.PingPong result = new dev.slimevr.desktop.platform.ProtobufMessages.PingPong( - this - ); - onBuilt(); - return result; - } - - @java.lang.Override - public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof dev.slimevr.desktop.platform.ProtobufMessages.PingPong) { - return mergeFrom( - (dev.slimevr.desktop.platform.ProtobufMessages.PingPong) other - ); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom(dev.slimevr.desktop.platform.ProtobufMessages.PingPong other) { - if ( - other - == dev.slimevr.desktop.platform.ProtobufMessages.PingPong - .getDefaultInstance() - ) - return this; - this.mergeUnknownFields(other.getUnknownFields()); - onChanged(); - return this; - } - - @java.lang.Override - public final boolean isInitialized() { - return true; - } - - @java.lang.Override - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - if (extensionRegistry == null) { - throw new java.lang.NullPointerException(); - } - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - default: { - if (!super.parseUnknownField(input, extensionRegistry, tag)) { - done = true; // was an endgroup tag - } - break; - } // default: - } // switch (tag) - } // while (!done) - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.unwrapIOException(); - } finally { - onChanged(); - } // finally - return this; - } - - // @@protoc_insertion_point(builder_scope:messages.PingPong) - } - - // @@protoc_insertion_point(class_scope:messages.PingPong) - private static final dev.slimevr.desktop.platform.ProtobufMessages.PingPong DEFAULT_INSTANCE; - static { - DEFAULT_INSTANCE = new dev.slimevr.desktop.platform.ProtobufMessages.PingPong(); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.PingPong getDefaultInstance() { - return DEFAULT_INSTANCE; - } - - private static final com.google.protobuf.Parser PARSER = new com.google.protobuf.AbstractParser() { - @java.lang.Override - public PingPong parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - Builder builder = newBuilder(); - try { - builder.mergeFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(builder.buildPartial()); - } catch (com.google.protobuf.UninitializedMessageException e) { - throw e - .asInvalidProtocolBufferException() - .setUnfinishedMessage(builder.buildPartial()); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException(e) - .setUnfinishedMessage(builder.buildPartial()); - } - return builder.buildPartial(); - } - }; - - public static com.google.protobuf.Parser parser() { - return PARSER; - } - - @java.lang.Override - public com.google.protobuf.Parser getParserForType() { - return PARSER; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.PingPong getDefaultInstanceForType() { - return DEFAULT_INSTANCE; - } - - } - - public interface VersionOrBuilder extends - // @@protoc_insertion_point(interface_extends:messages.Version) - com.google.protobuf.MessageOrBuilder { - - /** - * int32 protocol_version = 1; - * - * @return The protocolVersion. - */ - int getProtocolVersion(); - } - - /** - * Protobuf type {@code messages.Version} - */ - public static final class Version extends - com.google.protobuf.GeneratedMessage implements - // @@protoc_insertion_point(message_implements:messages.Version) - VersionOrBuilder { - private static final long serialVersionUID = 0L; - static { - com.google.protobuf.RuntimeVersion - .validateProtobufGencodeVersion( - com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, - /* major= */ 4, - /* minor= */ 31, - /* patch= */ 1, - /* suffix= */ "", - Version.class.getName() - ); - } - - // Use Version.newBuilder() to construct. - private Version(com.google.protobuf.GeneratedMessage.Builder builder) { - super(builder); - } - - private Version() { - } - - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_Version_descriptor; - } - - @java.lang.Override - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_Version_fieldAccessorTable - .ensureFieldAccessorsInitialized( - dev.slimevr.desktop.platform.ProtobufMessages.Version.class, - dev.slimevr.desktop.platform.ProtobufMessages.Version.Builder.class - ); - } - - public static final int PROTOCOL_VERSION_FIELD_NUMBER = 1; - private int protocolVersion_ = 0; - - /** - * int32 protocol_version = 1; - * - * @return The protocolVersion. - */ - @java.lang.Override - public int getProtocolVersion() { - return protocolVersion_; - } - - private byte memoizedIsInitialized = -1; - - @java.lang.Override - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized == 1) - return true; - if (isInitialized == 0) - return false; - - memoizedIsInitialized = 1; - return true; - } - - @java.lang.Override - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - if (protocolVersion_ != 0) { - output.writeInt32(1, protocolVersion_); - } - getUnknownFields().writeTo(output); - } - - @java.lang.Override - public int getSerializedSize() { - int size = memoizedSize; - if (size != -1) - return size; - - size = 0; - if (protocolVersion_ != 0) { - size += com.google.protobuf.CodedOutputStream - .computeInt32Size(1, protocolVersion_); - } - size += getUnknownFields().getSerializedSize(); - memoizedSize = size; - return size; - } - - @java.lang.Override - public boolean equals(final java.lang.Object obj) { - if (obj == this) { - return true; - } - if (!(obj instanceof dev.slimevr.desktop.platform.ProtobufMessages.Version)) { - return super.equals(obj); - } - dev.slimevr.desktop.platform.ProtobufMessages.Version other = (dev.slimevr.desktop.platform.ProtobufMessages.Version) obj; - - if ( - getProtocolVersion() - != other.getProtocolVersion() - ) - return false; - if (!getUnknownFields().equals(other.getUnknownFields())) - return false; - return true; - } - - @java.lang.Override - public int hashCode() { - if (memoizedHashCode != 0) { - return memoizedHashCode; - } - int hash = 41; - hash = (19 * hash) + getDescriptor().hashCode(); - hash = (37 * hash) + PROTOCOL_VERSION_FIELD_NUMBER; - hash = (53 * hash) + getProtocolVersion(); - hash = (29 * hash) + getUnknownFields().hashCode(); - memoizedHashCode = hash; - return hash; - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Version parseFrom( - java.nio.ByteBuffer data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Version parseFrom( - java.nio.ByteBuffer data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Version parseFrom( - com.google.protobuf.ByteString data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Version parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Version parseFrom(byte[] data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Version parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Version parseFrom( - java.io.InputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Version parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Version parseDelimitedFrom( - java.io.InputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseDelimitedWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Version parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseDelimitedWithIOException(PARSER, input, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Version parseFrom( - com.google.protobuf.CodedInputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Version parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input, extensionRegistry); - } - - @java.lang.Override - public Builder newBuilderForType() { - return newBuilder(); - } - - public static Builder newBuilder() { - return DEFAULT_INSTANCE.toBuilder(); - } - - public static Builder newBuilder( - dev.slimevr.desktop.platform.ProtobufMessages.Version prototype - ) { - return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); - } - - @java.lang.Override - public Builder toBuilder() { - return this == DEFAULT_INSTANCE - ? new Builder() - : new Builder().mergeFrom(this); - } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent - ) { - Builder builder = new Builder(parent); - return builder; - } - - /** - * Protobuf type {@code messages.Version} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder implements - // @@protoc_insertion_point(builder_implements:messages.Version) - dev.slimevr.desktop.platform.ProtobufMessages.VersionOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_Version_descriptor; - } - - @java.lang.Override - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_Version_fieldAccessorTable - .ensureFieldAccessorsInitialized( - dev.slimevr.desktop.platform.ProtobufMessages.Version.class, - dev.slimevr.desktop.platform.ProtobufMessages.Version.Builder.class - ); - } - - // Construct using - // dev.slimevr.desktop.platform.ProtobufMessages.Version.newBuilder() - private Builder() { - - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent - ) { - super(parent); - - } - - @java.lang.Override - public Builder clear() { - super.clear(); - bitField0_ = 0; - protocolVersion_ = 0; - return this; - } - - @java.lang.Override - public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_Version_descriptor; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Version getDefaultInstanceForType() { - return dev.slimevr.desktop.platform.ProtobufMessages.Version.getDefaultInstance(); - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Version build() { - dev.slimevr.desktop.platform.ProtobufMessages.Version result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Version buildPartial() { - dev.slimevr.desktop.platform.ProtobufMessages.Version result = new dev.slimevr.desktop.platform.ProtobufMessages.Version( - this - ); - if (bitField0_ != 0) { - buildPartial0(result); - } - onBuilt(); - return result; - } - - private void buildPartial0( - dev.slimevr.desktop.platform.ProtobufMessages.Version result - ) { - int from_bitField0_ = bitField0_; - if (((from_bitField0_ & 0x00000001) != 0)) { - result.protocolVersion_ = protocolVersion_; - } - } - - @java.lang.Override - public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof dev.slimevr.desktop.platform.ProtobufMessages.Version) { - return mergeFrom((dev.slimevr.desktop.platform.ProtobufMessages.Version) other); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom(dev.slimevr.desktop.platform.ProtobufMessages.Version other) { - if ( - other - == dev.slimevr.desktop.platform.ProtobufMessages.Version - .getDefaultInstance() - ) - return this; - if (other.getProtocolVersion() != 0) { - setProtocolVersion(other.getProtocolVersion()); - } - this.mergeUnknownFields(other.getUnknownFields()); - onChanged(); - return this; - } - - @java.lang.Override - public final boolean isInitialized() { - return true; - } - - @java.lang.Override - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - if (extensionRegistry == null) { - throw new java.lang.NullPointerException(); - } - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - case 8: { - protocolVersion_ = input.readInt32(); - bitField0_ |= 0x00000001; - break; - } // case 8 - default: { - if (!super.parseUnknownField(input, extensionRegistry, tag)) { - done = true; // was an endgroup tag - } - break; - } // default: - } // switch (tag) - } // while (!done) - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.unwrapIOException(); - } finally { - onChanged(); - } // finally - return this; - } - - private int bitField0_; - - private int protocolVersion_; - - /** - * int32 protocol_version = 1; - * - * @return The protocolVersion. - */ - @java.lang.Override - public int getProtocolVersion() { - return protocolVersion_; - } - - /** - * int32 protocol_version = 1; - * - * @param value The protocolVersion to set. - * @return This builder for chaining. - */ - public Builder setProtocolVersion(int value) { - - protocolVersion_ = value; - bitField0_ |= 0x00000001; - onChanged(); - return this; - } - - /** - * int32 protocol_version = 1; - * - * @return This builder for chaining. - */ - public Builder clearProtocolVersion() { - bitField0_ = (bitField0_ & ~0x00000001); - protocolVersion_ = 0; - onChanged(); - return this; - } - - // @@protoc_insertion_point(builder_scope:messages.Version) - } - - // @@protoc_insertion_point(class_scope:messages.Version) - private static final dev.slimevr.desktop.platform.ProtobufMessages.Version DEFAULT_INSTANCE; - static { - DEFAULT_INSTANCE = new dev.slimevr.desktop.platform.ProtobufMessages.Version(); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Version getDefaultInstance() { - return DEFAULT_INSTANCE; - } - - private static final com.google.protobuf.Parser PARSER = new com.google.protobuf.AbstractParser() { - @java.lang.Override - public Version parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - Builder builder = newBuilder(); - try { - builder.mergeFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(builder.buildPartial()); - } catch (com.google.protobuf.UninitializedMessageException e) { - throw e - .asInvalidProtocolBufferException() - .setUnfinishedMessage(builder.buildPartial()); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException(e) - .setUnfinishedMessage(builder.buildPartial()); - } - return builder.buildPartial(); - } - }; - - public static com.google.protobuf.Parser parser() { - return PARSER; - } - - @java.lang.Override - public com.google.protobuf.Parser getParserForType() { - return PARSER; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Version getDefaultInstanceForType() { - return DEFAULT_INSTANCE; - } - - } - - public interface PositionOrBuilder extends - // @@protoc_insertion_point(interface_extends:messages.Position) - com.google.protobuf.MessageOrBuilder { - - /** - * int32 tracker_id = 1; - * - * @return The trackerId. - */ - int getTrackerId(); - - /** - * optional float x = 2; - * - * @return Whether the x field is set. - */ - boolean hasX(); - - /** - * optional float x = 2; - * - * @return The x. - */ - float getX(); - - /** - * optional float y = 3; - * - * @return Whether the y field is set. - */ - boolean hasY(); - - /** - * optional float y = 3; - * - * @return The y. - */ - float getY(); - - /** - * optional float z = 4; - * - * @return Whether the z field is set. - */ - boolean hasZ(); - - /** - * optional float z = 4; - * - * @return The z. - */ - float getZ(); - - /** - * float qx = 5; - * - * @return The qx. - */ - float getQx(); - - /** - * float qy = 6; - * - * @return The qy. - */ - float getQy(); - - /** - * float qz = 7; - * - * @return The qz. - */ - float getQz(); - - /** - * float qw = 8; - * - * @return The qw. - */ - float getQw(); - - /** - * optional .messages.Position.DataSource data_source = 9; - * - * @return Whether the dataSource field is set. - */ - boolean hasDataSource(); - - /** - * optional .messages.Position.DataSource data_source = 9; - * - * @return The enum numeric value on the wire for dataSource. - */ - int getDataSourceValue(); - - /** - * optional .messages.Position.DataSource data_source = 9; - * - * @return The dataSource. - */ - dev.slimevr.desktop.platform.ProtobufMessages.Position.DataSource getDataSource(); - } - - /** - * Protobuf type {@code messages.Position} - */ - public static final class Position extends - com.google.protobuf.GeneratedMessage implements - // @@protoc_insertion_point(message_implements:messages.Position) - PositionOrBuilder { - private static final long serialVersionUID = 0L; - static { - com.google.protobuf.RuntimeVersion - .validateProtobufGencodeVersion( - com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, - /* major= */ 4, - /* minor= */ 31, - /* patch= */ 1, - /* suffix= */ "", - Position.class.getName() - ); - } - - // Use Position.newBuilder() to construct. - private Position(com.google.protobuf.GeneratedMessage.Builder builder) { - super(builder); - } - - private Position() { - dataSource_ = 0; - } - - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_Position_descriptor; - } - - @java.lang.Override - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_Position_fieldAccessorTable - .ensureFieldAccessorsInitialized( - dev.slimevr.desktop.platform.ProtobufMessages.Position.class, - dev.slimevr.desktop.platform.ProtobufMessages.Position.Builder.class - ); - } - - /** - * Protobuf enum {@code messages.Position.DataSource} - */ - public enum DataSource - implements com.google.protobuf.ProtocolMessageEnum { - /** - * NONE = 0; - */ - NONE(0), - /** - * IMU = 1; - */ - IMU(1), - /** - * PRECISION = 2; - */ - PRECISION(2), - /** - * FULL = 3; - */ - FULL(3), - UNRECOGNIZED(-1), - ; - - static { - com.google.protobuf.RuntimeVersion - .validateProtobufGencodeVersion( - com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, - /* major= */ 4, - /* minor= */ 31, - /* patch= */ 1, - /* suffix= */ "", - DataSource.class.getName() - ); - } - /** - * NONE = 0; - */ - public static final int NONE_VALUE = 0; - /** - * IMU = 1; - */ - public static final int IMU_VALUE = 1; - /** - * PRECISION = 2; - */ - public static final int PRECISION_VALUE = 2; - /** - * FULL = 3; - */ - public static final int FULL_VALUE = 3; - - - public final int getNumber() { - if (this == UNRECOGNIZED) { - throw new java.lang.IllegalArgumentException( - "Can't get the number of an unknown enum value." - ); - } - return value; - } - - /** - * @param value The numeric wire value of the corresponding enum - * entry. - * @return The enum associated with the given numeric wire value. - * @deprecated Use {@link #forNumber(int)} instead. - */ - @java.lang.Deprecated - public static DataSource valueOf(int value) { - return forNumber(value); - } - - /** - * @param value The numeric wire value of the corresponding enum - * entry. - * @return The enum associated with the given numeric wire value. - */ - public static DataSource forNumber(int value) { - switch (value) { - case 0: - return NONE; - case 1: - return IMU; - case 2: - return PRECISION; - case 3: - return FULL; - default: - return null; - } - } - - public static com.google.protobuf.Internal.EnumLiteMap internalGetValueMap() { - return internalValueMap; - } - - private static final com.google.protobuf.Internal.EnumLiteMap internalValueMap = new com.google.protobuf.Internal.EnumLiteMap() { - public DataSource findValueByNumber(int number) { - return DataSource.forNumber(number); - } - }; - - public final com.google.protobuf.Descriptors.EnumValueDescriptor getValueDescriptor() { - if (this == UNRECOGNIZED) { - throw new java.lang.IllegalStateException( - "Can't get the descriptor of an unrecognized enum value." - ); - } - return getDescriptor().getValues().get(ordinal()); - } - - public final com.google.protobuf.Descriptors.EnumDescriptor getDescriptorForType() { - return getDescriptor(); - } - - public static com.google.protobuf.Descriptors.EnumDescriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.Position - .getDescriptor() - .getEnumTypes() - .get(0); - } - - private static final DataSource[] VALUES = values(); - - public static DataSource valueOf( - com.google.protobuf.Descriptors.EnumValueDescriptor desc - ) { - if (desc.getType() != getDescriptor()) { - throw new java.lang.IllegalArgumentException( - "EnumValueDescriptor is not for this type." - ); - } - if (desc.getIndex() == -1) { - return UNRECOGNIZED; - } - return VALUES[desc.getIndex()]; - } - - private final int value; - - private DataSource(int value) { - this.value = value; - } - - // @@protoc_insertion_point(enum_scope:messages.Position.DataSource) - } - - private int bitField0_; - public static final int TRACKER_ID_FIELD_NUMBER = 1; - private int trackerId_ = 0; - - /** - * int32 tracker_id = 1; - * - * @return The trackerId. - */ - @java.lang.Override - public int getTrackerId() { - return trackerId_; - } - - public static final int X_FIELD_NUMBER = 2; - private float x_ = 0F; - - /** - * optional float x = 2; - * - * @return Whether the x field is set. - */ - @java.lang.Override - public boolean hasX() { - return ((bitField0_ & 0x00000001) != 0); - } - - /** - * optional float x = 2; - * - * @return The x. - */ - @java.lang.Override - public float getX() { - return x_; - } - - public static final int Y_FIELD_NUMBER = 3; - private float y_ = 0F; - - /** - * optional float y = 3; - * - * @return Whether the y field is set. - */ - @java.lang.Override - public boolean hasY() { - return ((bitField0_ & 0x00000002) != 0); - } - - /** - * optional float y = 3; - * - * @return The y. - */ - @java.lang.Override - public float getY() { - return y_; - } - - public static final int Z_FIELD_NUMBER = 4; - private float z_ = 0F; - - /** - * optional float z = 4; - * - * @return Whether the z field is set. - */ - @java.lang.Override - public boolean hasZ() { - return ((bitField0_ & 0x00000004) != 0); - } - - /** - * optional float z = 4; - * - * @return The z. - */ - @java.lang.Override - public float getZ() { - return z_; - } - - public static final int QX_FIELD_NUMBER = 5; - private float qx_ = 0F; - - /** - * float qx = 5; - * - * @return The qx. - */ - @java.lang.Override - public float getQx() { - return qx_; - } - - public static final int QY_FIELD_NUMBER = 6; - private float qy_ = 0F; - - /** - * float qy = 6; - * - * @return The qy. - */ - @java.lang.Override - public float getQy() { - return qy_; - } - - public static final int QZ_FIELD_NUMBER = 7; - private float qz_ = 0F; - - /** - * float qz = 7; - * - * @return The qz. - */ - @java.lang.Override - public float getQz() { - return qz_; - } - - public static final int QW_FIELD_NUMBER = 8; - private float qw_ = 0F; - - /** - * float qw = 8; - * - * @return The qw. - */ - @java.lang.Override - public float getQw() { - return qw_; - } - - public static final int DATA_SOURCE_FIELD_NUMBER = 9; - private int dataSource_ = 0; - - /** - * optional .messages.Position.DataSource data_source = 9; - * - * @return Whether the dataSource field is set. - */ - @java.lang.Override - public boolean hasDataSource() { - return ((bitField0_ & 0x00000008) != 0); - } - - /** - * optional .messages.Position.DataSource data_source = 9; - * - * @return The enum numeric value on the wire for dataSource. - */ - @java.lang.Override - public int getDataSourceValue() { - return dataSource_; - } - - /** - * optional .messages.Position.DataSource data_source = 9; - * - * @return The dataSource. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Position.DataSource getDataSource() { - dev.slimevr.desktop.platform.ProtobufMessages.Position.DataSource result = dev.slimevr.desktop.platform.ProtobufMessages.Position.DataSource - .forNumber(dataSource_); - return result == null - ? dev.slimevr.desktop.platform.ProtobufMessages.Position.DataSource.UNRECOGNIZED - : result; - } - - private byte memoizedIsInitialized = -1; - - @java.lang.Override - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized == 1) - return true; - if (isInitialized == 0) - return false; - - memoizedIsInitialized = 1; - return true; - } - - @java.lang.Override - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - if (trackerId_ != 0) { - output.writeInt32(1, trackerId_); - } - if (((bitField0_ & 0x00000001) != 0)) { - output.writeFloat(2, x_); - } - if (((bitField0_ & 0x00000002) != 0)) { - output.writeFloat(3, y_); - } - if (((bitField0_ & 0x00000004) != 0)) { - output.writeFloat(4, z_); - } - if (java.lang.Float.floatToRawIntBits(qx_) != 0) { - output.writeFloat(5, qx_); - } - if (java.lang.Float.floatToRawIntBits(qy_) != 0) { - output.writeFloat(6, qy_); - } - if (java.lang.Float.floatToRawIntBits(qz_) != 0) { - output.writeFloat(7, qz_); - } - if (java.lang.Float.floatToRawIntBits(qw_) != 0) { - output.writeFloat(8, qw_); - } - if (((bitField0_ & 0x00000008) != 0)) { - output.writeEnum(9, dataSource_); - } - getUnknownFields().writeTo(output); - } - - @java.lang.Override - public int getSerializedSize() { - int size = memoizedSize; - if (size != -1) - return size; - - size = 0; - if (trackerId_ != 0) { - size += com.google.protobuf.CodedOutputStream - .computeInt32Size(1, trackerId_); - } - if (((bitField0_ & 0x00000001) != 0)) { - size += com.google.protobuf.CodedOutputStream - .computeFloatSize(2, x_); - } - if (((bitField0_ & 0x00000002) != 0)) { - size += com.google.protobuf.CodedOutputStream - .computeFloatSize(3, y_); - } - if (((bitField0_ & 0x00000004) != 0)) { - size += com.google.protobuf.CodedOutputStream - .computeFloatSize(4, z_); - } - if (java.lang.Float.floatToRawIntBits(qx_) != 0) { - size += com.google.protobuf.CodedOutputStream - .computeFloatSize(5, qx_); - } - if (java.lang.Float.floatToRawIntBits(qy_) != 0) { - size += com.google.protobuf.CodedOutputStream - .computeFloatSize(6, qy_); - } - if (java.lang.Float.floatToRawIntBits(qz_) != 0) { - size += com.google.protobuf.CodedOutputStream - .computeFloatSize(7, qz_); - } - if (java.lang.Float.floatToRawIntBits(qw_) != 0) { - size += com.google.protobuf.CodedOutputStream - .computeFloatSize(8, qw_); - } - if (((bitField0_ & 0x00000008) != 0)) { - size += com.google.protobuf.CodedOutputStream - .computeEnumSize(9, dataSource_); - } - size += getUnknownFields().getSerializedSize(); - memoizedSize = size; - return size; - } - - @java.lang.Override - public boolean equals(final java.lang.Object obj) { - if (obj == this) { - return true; - } - if (!(obj instanceof dev.slimevr.desktop.platform.ProtobufMessages.Position)) { - return super.equals(obj); - } - dev.slimevr.desktop.platform.ProtobufMessages.Position other = (dev.slimevr.desktop.platform.ProtobufMessages.Position) obj; - - if ( - getTrackerId() - != other.getTrackerId() - ) - return false; - if (hasX() != other.hasX()) - return false; - if (hasX()) { - if ( - java.lang.Float.floatToIntBits(getX()) - != java.lang.Float - .floatToIntBits( - other.getX() - ) - ) - return false; - } - if (hasY() != other.hasY()) - return false; - if (hasY()) { - if ( - java.lang.Float.floatToIntBits(getY()) - != java.lang.Float - .floatToIntBits( - other.getY() - ) - ) - return false; - } - if (hasZ() != other.hasZ()) - return false; - if (hasZ()) { - if ( - java.lang.Float.floatToIntBits(getZ()) - != java.lang.Float - .floatToIntBits( - other.getZ() - ) - ) - return false; - } - if ( - java.lang.Float.floatToIntBits(getQx()) - != java.lang.Float - .floatToIntBits( - other.getQx() - ) - ) - return false; - if ( - java.lang.Float.floatToIntBits(getQy()) - != java.lang.Float - .floatToIntBits( - other.getQy() - ) - ) - return false; - if ( - java.lang.Float.floatToIntBits(getQz()) - != java.lang.Float - .floatToIntBits( - other.getQz() - ) - ) - return false; - if ( - java.lang.Float.floatToIntBits(getQw()) - != java.lang.Float - .floatToIntBits( - other.getQw() - ) - ) - return false; - if (hasDataSource() != other.hasDataSource()) - return false; - if (hasDataSource()) { - if (dataSource_ != other.dataSource_) - return false; - } - if (!getUnknownFields().equals(other.getUnknownFields())) - return false; - return true; - } - - @java.lang.Override - public int hashCode() { - if (memoizedHashCode != 0) { - return memoizedHashCode; - } - int hash = 41; - hash = (19 * hash) + getDescriptor().hashCode(); - hash = (37 * hash) + TRACKER_ID_FIELD_NUMBER; - hash = (53 * hash) + getTrackerId(); - if (hasX()) { - hash = (37 * hash) + X_FIELD_NUMBER; - hash = (53 * hash) - + java.lang.Float - .floatToIntBits( - getX() - ); - } - if (hasY()) { - hash = (37 * hash) + Y_FIELD_NUMBER; - hash = (53 * hash) - + java.lang.Float - .floatToIntBits( - getY() - ); - } - if (hasZ()) { - hash = (37 * hash) + Z_FIELD_NUMBER; - hash = (53 * hash) - + java.lang.Float - .floatToIntBits( - getZ() - ); - } - hash = (37 * hash) + QX_FIELD_NUMBER; - hash = (53 * hash) - + java.lang.Float - .floatToIntBits( - getQx() - ); - hash = (37 * hash) + QY_FIELD_NUMBER; - hash = (53 * hash) - + java.lang.Float - .floatToIntBits( - getQy() - ); - hash = (37 * hash) + QZ_FIELD_NUMBER; - hash = (53 * hash) - + java.lang.Float - .floatToIntBits( - getQz() - ); - hash = (37 * hash) + QW_FIELD_NUMBER; - hash = (53 * hash) - + java.lang.Float - .floatToIntBits( - getQw() - ); - if (hasDataSource()) { - hash = (37 * hash) + DATA_SOURCE_FIELD_NUMBER; - hash = (53 * hash) + dataSource_; - } - hash = (29 * hash) + getUnknownFields().hashCode(); - memoizedHashCode = hash; - return hash; - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Position parseFrom( - java.nio.ByteBuffer data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Position parseFrom( - java.nio.ByteBuffer data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Position parseFrom( - com.google.protobuf.ByteString data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Position parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Position parseFrom(byte[] data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Position parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Position parseFrom( - java.io.InputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Position parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Position parseDelimitedFrom( - java.io.InputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseDelimitedWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Position parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseDelimitedWithIOException(PARSER, input, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Position parseFrom( - com.google.protobuf.CodedInputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Position parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input, extensionRegistry); - } - - @java.lang.Override - public Builder newBuilderForType() { - return newBuilder(); - } - - public static Builder newBuilder() { - return DEFAULT_INSTANCE.toBuilder(); - } - - public static Builder newBuilder( - dev.slimevr.desktop.platform.ProtobufMessages.Position prototype - ) { - return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); - } - - @java.lang.Override - public Builder toBuilder() { - return this == DEFAULT_INSTANCE - ? new Builder() - : new Builder().mergeFrom(this); - } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent - ) { - Builder builder = new Builder(parent); - return builder; - } - - /** - * Protobuf type {@code messages.Position} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder implements - // @@protoc_insertion_point(builder_implements:messages.Position) - dev.slimevr.desktop.platform.ProtobufMessages.PositionOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_Position_descriptor; - } - - @java.lang.Override - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_Position_fieldAccessorTable - .ensureFieldAccessorsInitialized( - dev.slimevr.desktop.platform.ProtobufMessages.Position.class, - dev.slimevr.desktop.platform.ProtobufMessages.Position.Builder.class - ); - } - - // Construct using - // dev.slimevr.desktop.platform.ProtobufMessages.Position.newBuilder() - private Builder() { - - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent - ) { - super(parent); - - } - - @java.lang.Override - public Builder clear() { - super.clear(); - bitField0_ = 0; - trackerId_ = 0; - x_ = 0F; - y_ = 0F; - z_ = 0F; - qx_ = 0F; - qy_ = 0F; - qz_ = 0F; - qw_ = 0F; - dataSource_ = 0; - return this; - } - - @java.lang.Override - public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_Position_descriptor; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Position getDefaultInstanceForType() { - return dev.slimevr.desktop.platform.ProtobufMessages.Position.getDefaultInstance(); - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Position build() { - dev.slimevr.desktop.platform.ProtobufMessages.Position result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Position buildPartial() { - dev.slimevr.desktop.platform.ProtobufMessages.Position result = new dev.slimevr.desktop.platform.ProtobufMessages.Position( - this - ); - if (bitField0_ != 0) { - buildPartial0(result); - } - onBuilt(); - return result; - } - - private void buildPartial0( - dev.slimevr.desktop.platform.ProtobufMessages.Position result - ) { - int from_bitField0_ = bitField0_; - if (((from_bitField0_ & 0x00000001) != 0)) { - result.trackerId_ = trackerId_; - } - int to_bitField0_ = 0; - if (((from_bitField0_ & 0x00000002) != 0)) { - result.x_ = x_; - to_bitField0_ |= 0x00000001; - } - if (((from_bitField0_ & 0x00000004) != 0)) { - result.y_ = y_; - to_bitField0_ |= 0x00000002; - } - if (((from_bitField0_ & 0x00000008) != 0)) { - result.z_ = z_; - to_bitField0_ |= 0x00000004; - } - if (((from_bitField0_ & 0x00000010) != 0)) { - result.qx_ = qx_; - } - if (((from_bitField0_ & 0x00000020) != 0)) { - result.qy_ = qy_; - } - if (((from_bitField0_ & 0x00000040) != 0)) { - result.qz_ = qz_; - } - if (((from_bitField0_ & 0x00000080) != 0)) { - result.qw_ = qw_; - } - if (((from_bitField0_ & 0x00000100) != 0)) { - result.dataSource_ = dataSource_; - to_bitField0_ |= 0x00000008; - } - result.bitField0_ |= to_bitField0_; - } - - @java.lang.Override - public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof dev.slimevr.desktop.platform.ProtobufMessages.Position) { - return mergeFrom( - (dev.slimevr.desktop.platform.ProtobufMessages.Position) other - ); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom(dev.slimevr.desktop.platform.ProtobufMessages.Position other) { - if ( - other - == dev.slimevr.desktop.platform.ProtobufMessages.Position - .getDefaultInstance() - ) - return this; - if (other.getTrackerId() != 0) { - setTrackerId(other.getTrackerId()); - } - if (other.hasX()) { - setX(other.getX()); - } - if (other.hasY()) { - setY(other.getY()); - } - if (other.hasZ()) { - setZ(other.getZ()); - } - if (java.lang.Float.floatToRawIntBits(other.getQx()) != 0) { - setQx(other.getQx()); - } - if (java.lang.Float.floatToRawIntBits(other.getQy()) != 0) { - setQy(other.getQy()); - } - if (java.lang.Float.floatToRawIntBits(other.getQz()) != 0) { - setQz(other.getQz()); - } - if (java.lang.Float.floatToRawIntBits(other.getQw()) != 0) { - setQw(other.getQw()); - } - if (other.hasDataSource()) { - setDataSourceValue(other.getDataSourceValue()); - } - this.mergeUnknownFields(other.getUnknownFields()); - onChanged(); - return this; - } - - @java.lang.Override - public final boolean isInitialized() { - return true; - } - - @java.lang.Override - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - if (extensionRegistry == null) { - throw new java.lang.NullPointerException(); - } - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - case 8: { - trackerId_ = input.readInt32(); - bitField0_ |= 0x00000001; - break; - } // case 8 - case 21: { - x_ = input.readFloat(); - bitField0_ |= 0x00000002; - break; - } // case 21 - case 29: { - y_ = input.readFloat(); - bitField0_ |= 0x00000004; - break; - } // case 29 - case 37: { - z_ = input.readFloat(); - bitField0_ |= 0x00000008; - break; - } // case 37 - case 45: { - qx_ = input.readFloat(); - bitField0_ |= 0x00000010; - break; - } // case 45 - case 53: { - qy_ = input.readFloat(); - bitField0_ |= 0x00000020; - break; - } // case 53 - case 61: { - qz_ = input.readFloat(); - bitField0_ |= 0x00000040; - break; - } // case 61 - case 69: { - qw_ = input.readFloat(); - bitField0_ |= 0x00000080; - break; - } // case 69 - case 72: { - dataSource_ = input.readEnum(); - bitField0_ |= 0x00000100; - break; - } // case 72 - default: { - if (!super.parseUnknownField(input, extensionRegistry, tag)) { - done = true; // was an endgroup tag - } - break; - } // default: - } // switch (tag) - } // while (!done) - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.unwrapIOException(); - } finally { - onChanged(); - } // finally - return this; - } - - private int bitField0_; - - private int trackerId_; - - /** - * int32 tracker_id = 1; - * - * @return The trackerId. - */ - @java.lang.Override - public int getTrackerId() { - return trackerId_; - } - - /** - * int32 tracker_id = 1; - * - * @param value The trackerId to set. - * @return This builder for chaining. - */ - public Builder setTrackerId(int value) { - - trackerId_ = value; - bitField0_ |= 0x00000001; - onChanged(); - return this; - } - - /** - * int32 tracker_id = 1; - * - * @return This builder for chaining. - */ - public Builder clearTrackerId() { - bitField0_ = (bitField0_ & ~0x00000001); - trackerId_ = 0; - onChanged(); - return this; - } - - private float x_; - - /** - * optional float x = 2; - * - * @return Whether the x field is set. - */ - @java.lang.Override - public boolean hasX() { - return ((bitField0_ & 0x00000002) != 0); - } - - /** - * optional float x = 2; - * - * @return The x. - */ - @java.lang.Override - public float getX() { - return x_; - } - - /** - * optional float x = 2; - * - * @param value The x to set. - * @return This builder for chaining. - */ - public Builder setX(float value) { - - x_ = value; - bitField0_ |= 0x00000002; - onChanged(); - return this; - } - - /** - * optional float x = 2; - * - * @return This builder for chaining. - */ - public Builder clearX() { - bitField0_ = (bitField0_ & ~0x00000002); - x_ = 0F; - onChanged(); - return this; - } - - private float y_; - - /** - * optional float y = 3; - * - * @return Whether the y field is set. - */ - @java.lang.Override - public boolean hasY() { - return ((bitField0_ & 0x00000004) != 0); - } - - /** - * optional float y = 3; - * - * @return The y. - */ - @java.lang.Override - public float getY() { - return y_; - } - - /** - * optional float y = 3; - * - * @param value The y to set. - * @return This builder for chaining. - */ - public Builder setY(float value) { - - y_ = value; - bitField0_ |= 0x00000004; - onChanged(); - return this; - } - - /** - * optional float y = 3; - * - * @return This builder for chaining. - */ - public Builder clearY() { - bitField0_ = (bitField0_ & ~0x00000004); - y_ = 0F; - onChanged(); - return this; - } - - private float z_; - - /** - * optional float z = 4; - * - * @return Whether the z field is set. - */ - @java.lang.Override - public boolean hasZ() { - return ((bitField0_ & 0x00000008) != 0); - } - - /** - * optional float z = 4; - * - * @return The z. - */ - @java.lang.Override - public float getZ() { - return z_; - } - - /** - * optional float z = 4; - * - * @param value The z to set. - * @return This builder for chaining. - */ - public Builder setZ(float value) { - - z_ = value; - bitField0_ |= 0x00000008; - onChanged(); - return this; - } - - /** - * optional float z = 4; - * - * @return This builder for chaining. - */ - public Builder clearZ() { - bitField0_ = (bitField0_ & ~0x00000008); - z_ = 0F; - onChanged(); - return this; - } - - private float qx_; - - /** - * float qx = 5; - * - * @return The qx. - */ - @java.lang.Override - public float getQx() { - return qx_; - } - - /** - * float qx = 5; - * - * @param value The qx to set. - * @return This builder for chaining. - */ - public Builder setQx(float value) { - - qx_ = value; - bitField0_ |= 0x00000010; - onChanged(); - return this; - } - - /** - * float qx = 5; - * - * @return This builder for chaining. - */ - public Builder clearQx() { - bitField0_ = (bitField0_ & ~0x00000010); - qx_ = 0F; - onChanged(); - return this; - } - - private float qy_; - - /** - * float qy = 6; - * - * @return The qy. - */ - @java.lang.Override - public float getQy() { - return qy_; - } - - /** - * float qy = 6; - * - * @param value The qy to set. - * @return This builder for chaining. - */ - public Builder setQy(float value) { - - qy_ = value; - bitField0_ |= 0x00000020; - onChanged(); - return this; - } - - /** - * float qy = 6; - * - * @return This builder for chaining. - */ - public Builder clearQy() { - bitField0_ = (bitField0_ & ~0x00000020); - qy_ = 0F; - onChanged(); - return this; - } - - private float qz_; - - /** - * float qz = 7; - * - * @return The qz. - */ - @java.lang.Override - public float getQz() { - return qz_; - } - - /** - * float qz = 7; - * - * @param value The qz to set. - * @return This builder for chaining. - */ - public Builder setQz(float value) { - - qz_ = value; - bitField0_ |= 0x00000040; - onChanged(); - return this; - } - - /** - * float qz = 7; - * - * @return This builder for chaining. - */ - public Builder clearQz() { - bitField0_ = (bitField0_ & ~0x00000040); - qz_ = 0F; - onChanged(); - return this; - } - - private float qw_; - - /** - * float qw = 8; - * - * @return The qw. - */ - @java.lang.Override - public float getQw() { - return qw_; - } - - /** - * float qw = 8; - * - * @param value The qw to set. - * @return This builder for chaining. - */ - public Builder setQw(float value) { - - qw_ = value; - bitField0_ |= 0x00000080; - onChanged(); - return this; - } - - /** - * float qw = 8; - * - * @return This builder for chaining. - */ - public Builder clearQw() { - bitField0_ = (bitField0_ & ~0x00000080); - qw_ = 0F; - onChanged(); - return this; - } - - private int dataSource_ = 0; - - /** - * optional .messages.Position.DataSource data_source = 9; - * - * @return Whether the dataSource field is set. - */ - @java.lang.Override - public boolean hasDataSource() { - return ((bitField0_ & 0x00000100) != 0); - } - - /** - * optional .messages.Position.DataSource data_source = 9; - * - * @return The enum numeric value on the wire for dataSource. - */ - @java.lang.Override - public int getDataSourceValue() { - return dataSource_; - } - - /** - * optional .messages.Position.DataSource data_source = 9; - * - * @param value The enum numeric value on the wire for dataSource to - * set. - * @return This builder for chaining. - */ - public Builder setDataSourceValue(int value) { - dataSource_ = value; - bitField0_ |= 0x00000100; - onChanged(); - return this; - } - - /** - * optional .messages.Position.DataSource data_source = 9; - * - * @return The dataSource. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Position.DataSource getDataSource() { - dev.slimevr.desktop.platform.ProtobufMessages.Position.DataSource result = dev.slimevr.desktop.platform.ProtobufMessages.Position.DataSource - .forNumber(dataSource_); - return result == null - ? dev.slimevr.desktop.platform.ProtobufMessages.Position.DataSource.UNRECOGNIZED - : result; - } - - /** - * optional .messages.Position.DataSource data_source = 9; - * - * @param value The dataSource to set. - * @return This builder for chaining. - */ - public Builder setDataSource( - dev.slimevr.desktop.platform.ProtobufMessages.Position.DataSource value - ) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000100; - dataSource_ = value.getNumber(); - onChanged(); - return this; - } - - /** - * optional .messages.Position.DataSource data_source = 9; - * - * @return This builder for chaining. - */ - public Builder clearDataSource() { - bitField0_ = (bitField0_ & ~0x00000100); - dataSource_ = 0; - onChanged(); - return this; - } - - // @@protoc_insertion_point(builder_scope:messages.Position) - } - - // @@protoc_insertion_point(class_scope:messages.Position) - private static final dev.slimevr.desktop.platform.ProtobufMessages.Position DEFAULT_INSTANCE; - static { - DEFAULT_INSTANCE = new dev.slimevr.desktop.platform.ProtobufMessages.Position(); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Position getDefaultInstance() { - return DEFAULT_INSTANCE; - } - - private static final com.google.protobuf.Parser PARSER = new com.google.protobuf.AbstractParser() { - @java.lang.Override - public Position parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - Builder builder = newBuilder(); - try { - builder.mergeFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(builder.buildPartial()); - } catch (com.google.protobuf.UninitializedMessageException e) { - throw e - .asInvalidProtocolBufferException() - .setUnfinishedMessage(builder.buildPartial()); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException(e) - .setUnfinishedMessage(builder.buildPartial()); - } - return builder.buildPartial(); - } - }; - - public static com.google.protobuf.Parser parser() { - return PARSER; - } - - @java.lang.Override - public com.google.protobuf.Parser getParserForType() { - return PARSER; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Position getDefaultInstanceForType() { - return DEFAULT_INSTANCE; - } - - } - - public interface UserActionOrBuilder extends - // @@protoc_insertion_point(interface_extends:messages.UserAction) - com.google.protobuf.MessageOrBuilder { - - /** - * string name = 1; - * - * @return The name. - */ - java.lang.String getName(); - - /** - * string name = 1; - * - * @return The bytes for name. - */ - com.google.protobuf.ByteString getNameBytes(); - - /** - * map<string, string> action_arguments = 2; - */ - int getActionArgumentsCount(); - - /** - * map<string, string> action_arguments = 2; - */ - boolean containsActionArguments( - java.lang.String key - ); - - /** - * Use {@link #getActionArgumentsMap()} instead. - */ - @java.lang.Deprecated - java.util.Map getActionArguments(); - - /** - * map<string, string> action_arguments = 2; - */ - java.util.Map getActionArgumentsMap(); - - /** - * map<string, string> action_arguments = 2; - */ - /* nullable */ - java.lang.String getActionArgumentsOrDefault( - java.lang.String key, - /* nullable */ - java.lang.String defaultValue - ); - - /** - * map<string, string> action_arguments = 2; - */ - java.lang.String getActionArgumentsOrThrow( - java.lang.String key - ); - } - - /** - * Protobuf type {@code messages.UserAction} - */ - public static final class UserAction extends - com.google.protobuf.GeneratedMessage implements - // @@protoc_insertion_point(message_implements:messages.UserAction) - UserActionOrBuilder { - private static final long serialVersionUID = 0L; - static { - com.google.protobuf.RuntimeVersion - .validateProtobufGencodeVersion( - com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, - /* major= */ 4, - /* minor= */ 31, - /* patch= */ 1, - /* suffix= */ "", - UserAction.class.getName() - ); - } - - // Use UserAction.newBuilder() to construct. - private UserAction(com.google.protobuf.GeneratedMessage.Builder builder) { - super(builder); - } - - private UserAction() { - name_ = ""; - } - - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_UserAction_descriptor; - } - - @SuppressWarnings({ "rawtypes" }) - @java.lang.Override - protected com.google.protobuf.MapFieldReflectionAccessor internalGetMapFieldReflection( - int number - ) { - switch (number) { - case 2: - return internalGetActionArguments(); - default: - throw new RuntimeException( - "Invalid map field number: " + number - ); - } - } - - @java.lang.Override - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_UserAction_fieldAccessorTable - .ensureFieldAccessorsInitialized( - dev.slimevr.desktop.platform.ProtobufMessages.UserAction.class, - dev.slimevr.desktop.platform.ProtobufMessages.UserAction.Builder.class - ); - } - - public static final int NAME_FIELD_NUMBER = 1; - @SuppressWarnings("serial") - private volatile java.lang.Object name_ = ""; - - /** - * string name = 1; - * - * @return The name. - */ - @java.lang.Override - public java.lang.String getName() { - java.lang.Object ref = name_; - if (ref instanceof java.lang.String) { - return (java.lang.String) ref; - } else { - com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; - java.lang.String s = bs.toStringUtf8(); - name_ = s; - return s; - } - } - - /** - * string name = 1; - * - * @return The bytes for name. - */ - @java.lang.Override - public com.google.protobuf.ByteString getNameBytes() { - java.lang.Object ref = name_; - if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = com.google.protobuf.ByteString - .copyFromUtf8( - (java.lang.String) ref - ); - name_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - - public static final int ACTION_ARGUMENTS_FIELD_NUMBER = 2; - - private static final class ActionArgumentsDefaultEntryHolder { - static final com.google.protobuf.MapEntry defaultEntry = com.google.protobuf.MapEntry.newDefaultInstance( - dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_UserAction_ActionArgumentsEntry_descriptor, - com.google.protobuf.WireFormat.FieldType.STRING, - "", - com.google.protobuf.WireFormat.FieldType.STRING, - "" - ); - } - - @SuppressWarnings("serial") - private com.google.protobuf.MapField actionArguments_; - - private com.google.protobuf.MapField internalGetActionArguments() { - if (actionArguments_ == null) { - return com.google.protobuf.MapField - .emptyMapField( - ActionArgumentsDefaultEntryHolder.defaultEntry - ); - } - return actionArguments_; - } - - public int getActionArgumentsCount() { - return internalGetActionArguments().getMap().size(); - } - - /** - * map<string, string> action_arguments = 2; - */ - @java.lang.Override - public boolean containsActionArguments( - java.lang.String key - ) { - if (key == null) { - throw new NullPointerException("map key"); - } - return internalGetActionArguments().getMap().containsKey(key); - } - - /** - * Use {@link #getActionArgumentsMap()} instead. - */ - @java.lang.Override - @java.lang.Deprecated - public java.util.Map getActionArguments() { - return getActionArgumentsMap(); - } - - /** - * map<string, string> action_arguments = 2; - */ - @java.lang.Override - public java.util.Map getActionArgumentsMap() { - return internalGetActionArguments().getMap(); - } - - /** - * map<string, string> action_arguments = 2; - */ - @java.lang.Override - public /* nullable */ - java.lang.String getActionArgumentsOrDefault( - java.lang.String key, - /* nullable */ - java.lang.String defaultValue - ) { - if (key == null) { - throw new NullPointerException("map key"); - } - java.util.Map map = internalGetActionArguments() - .getMap(); - return map.containsKey(key) ? map.get(key) : defaultValue; - } - - /** - * map<string, string> action_arguments = 2; - */ - @java.lang.Override - public java.lang.String getActionArgumentsOrThrow( - java.lang.String key - ) { - if (key == null) { - throw new NullPointerException("map key"); - } - java.util.Map map = internalGetActionArguments() - .getMap(); - if (!map.containsKey(key)) { - throw new java.lang.IllegalArgumentException(); - } - return map.get(key); - } - - private byte memoizedIsInitialized = -1; - - @java.lang.Override - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized == 1) - return true; - if (isInitialized == 0) - return false; - - memoizedIsInitialized = 1; - return true; - } - - @java.lang.Override - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - if (!com.google.protobuf.GeneratedMessage.isStringEmpty(name_)) { - com.google.protobuf.GeneratedMessage.writeString(output, 1, name_); - } - com.google.protobuf.GeneratedMessage - .serializeStringMapTo( - output, - internalGetActionArguments(), - ActionArgumentsDefaultEntryHolder.defaultEntry, - 2 - ); - getUnknownFields().writeTo(output); - } - - @java.lang.Override - public int getSerializedSize() { - int size = memoizedSize; - if (size != -1) - return size; - - size = 0; - if (!com.google.protobuf.GeneratedMessage.isStringEmpty(name_)) { - size += com.google.protobuf.GeneratedMessage.computeStringSize(1, name_); - } - for ( - java.util.Map.Entry entry : internalGetActionArguments() - .getMap() - .entrySet() - ) { - com.google.protobuf.MapEntry actionArguments__ = ActionArgumentsDefaultEntryHolder.defaultEntry - .newBuilderForType() - .setKey(entry.getKey()) - .setValue(entry.getValue()) - .build(); - size += com.google.protobuf.CodedOutputStream - .computeMessageSize(2, actionArguments__); - } - size += getUnknownFields().getSerializedSize(); - memoizedSize = size; - return size; - } - - @java.lang.Override - public boolean equals(final java.lang.Object obj) { - if (obj == this) { - return true; - } - if (!(obj instanceof dev.slimevr.desktop.platform.ProtobufMessages.UserAction)) { - return super.equals(obj); - } - dev.slimevr.desktop.platform.ProtobufMessages.UserAction other = (dev.slimevr.desktop.platform.ProtobufMessages.UserAction) obj; - - if ( - !getName() - .equals(other.getName()) - ) - return false; - if ( - !internalGetActionArguments() - .equals( - other.internalGetActionArguments() - ) - ) - return false; - if (!getUnknownFields().equals(other.getUnknownFields())) - return false; - return true; - } - - @java.lang.Override - public int hashCode() { - if (memoizedHashCode != 0) { - return memoizedHashCode; - } - int hash = 41; - hash = (19 * hash) + getDescriptor().hashCode(); - hash = (37 * hash) + NAME_FIELD_NUMBER; - hash = (53 * hash) + getName().hashCode(); - if (!internalGetActionArguments().getMap().isEmpty()) { - hash = (37 * hash) + ACTION_ARGUMENTS_FIELD_NUMBER; - hash = (53 * hash) + internalGetActionArguments().hashCode(); - } - hash = (29 * hash) + getUnknownFields().hashCode(); - memoizedHashCode = hash; - return hash; - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.UserAction parseFrom( - java.nio.ByteBuffer data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.UserAction parseFrom( - java.nio.ByteBuffer data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.UserAction parseFrom( - com.google.protobuf.ByteString data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.UserAction parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.UserAction parseFrom( - byte[] data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.UserAction parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.UserAction parseFrom( - java.io.InputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.UserAction parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.UserAction parseDelimitedFrom( - java.io.InputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseDelimitedWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.UserAction parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseDelimitedWithIOException(PARSER, input, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.UserAction parseFrom( - com.google.protobuf.CodedInputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.UserAction parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input, extensionRegistry); - } - - @java.lang.Override - public Builder newBuilderForType() { - return newBuilder(); - } - - public static Builder newBuilder() { - return DEFAULT_INSTANCE.toBuilder(); - } - - public static Builder newBuilder( - dev.slimevr.desktop.platform.ProtobufMessages.UserAction prototype - ) { - return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); - } - - @java.lang.Override - public Builder toBuilder() { - return this == DEFAULT_INSTANCE - ? new Builder() - : new Builder().mergeFrom(this); - } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent - ) { - Builder builder = new Builder(parent); - return builder; - } - - /** - * Protobuf type {@code messages.UserAction} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder implements - // @@protoc_insertion_point(builder_implements:messages.UserAction) - dev.slimevr.desktop.platform.ProtobufMessages.UserActionOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_UserAction_descriptor; - } - - @SuppressWarnings({ "rawtypes" }) - protected com.google.protobuf.MapFieldReflectionAccessor internalGetMapFieldReflection( - int number - ) { - switch (number) { - case 2: - return internalGetActionArguments(); - default: - throw new RuntimeException( - "Invalid map field number: " + number - ); - } - } - - @SuppressWarnings({ "rawtypes" }) - protected com.google.protobuf.MapFieldReflectionAccessor internalGetMutableMapFieldReflection( - int number - ) { - switch (number) { - case 2: - return internalGetMutableActionArguments(); - default: - throw new RuntimeException( - "Invalid map field number: " + number - ); - } - } - - @java.lang.Override - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_UserAction_fieldAccessorTable - .ensureFieldAccessorsInitialized( - dev.slimevr.desktop.platform.ProtobufMessages.UserAction.class, - dev.slimevr.desktop.platform.ProtobufMessages.UserAction.Builder.class - ); - } - - // Construct using - // dev.slimevr.desktop.platform.ProtobufMessages.UserAction.newBuilder() - private Builder() { - - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent - ) { - super(parent); - - } - - @java.lang.Override - public Builder clear() { - super.clear(); - bitField0_ = 0; - name_ = ""; - internalGetMutableActionArguments().clear(); - return this; - } - - @java.lang.Override - public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_UserAction_descriptor; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.UserAction getDefaultInstanceForType() { - return dev.slimevr.desktop.platform.ProtobufMessages.UserAction - .getDefaultInstance(); - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.UserAction build() { - dev.slimevr.desktop.platform.ProtobufMessages.UserAction result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.UserAction buildPartial() { - dev.slimevr.desktop.platform.ProtobufMessages.UserAction result = new dev.slimevr.desktop.platform.ProtobufMessages.UserAction( - this - ); - if (bitField0_ != 0) { - buildPartial0(result); - } - onBuilt(); - return result; - } - - private void buildPartial0( - dev.slimevr.desktop.platform.ProtobufMessages.UserAction result - ) { - int from_bitField0_ = bitField0_; - if (((from_bitField0_ & 0x00000001) != 0)) { - result.name_ = name_; - } - if (((from_bitField0_ & 0x00000002) != 0)) { - result.actionArguments_ = internalGetActionArguments(); - result.actionArguments_.makeImmutable(); - } - } - - @java.lang.Override - public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof dev.slimevr.desktop.platform.ProtobufMessages.UserAction) { - return mergeFrom( - (dev.slimevr.desktop.platform.ProtobufMessages.UserAction) other - ); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom( - dev.slimevr.desktop.platform.ProtobufMessages.UserAction other - ) { - if ( - other - == dev.slimevr.desktop.platform.ProtobufMessages.UserAction - .getDefaultInstance() - ) - return this; - if (!other.getName().isEmpty()) { - name_ = other.name_; - bitField0_ |= 0x00000001; - onChanged(); - } - internalGetMutableActionArguments() - .mergeFrom( - other.internalGetActionArguments() - ); - bitField0_ |= 0x00000002; - this.mergeUnknownFields(other.getUnknownFields()); - onChanged(); - return this; - } - - @java.lang.Override - public final boolean isInitialized() { - return true; - } - - @java.lang.Override - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - if (extensionRegistry == null) { - throw new java.lang.NullPointerException(); - } - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - case 10: { - name_ = input.readStringRequireUtf8(); - bitField0_ |= 0x00000001; - break; - } // case 10 - case 18: { - com.google.protobuf.MapEntry actionArguments__ = input - .readMessage( - ActionArgumentsDefaultEntryHolder.defaultEntry - .getParserForType(), - extensionRegistry - ); - internalGetMutableActionArguments() - .getMutableMap() - .put( - actionArguments__.getKey(), - actionArguments__.getValue() - ); - bitField0_ |= 0x00000002; - break; - } // case 18 - default: { - if (!super.parseUnknownField(input, extensionRegistry, tag)) { - done = true; // was an endgroup tag - } - break; - } // default: - } // switch (tag) - } // while (!done) - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.unwrapIOException(); - } finally { - onChanged(); - } // finally - return this; - } - - private int bitField0_; - - private java.lang.Object name_ = ""; - - /** - * string name = 1; - * - * @return The name. - */ - public java.lang.String getName() { - java.lang.Object ref = name_; - if (!(ref instanceof java.lang.String)) { - com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; - java.lang.String s = bs.toStringUtf8(); - name_ = s; - return s; - } else { - return (java.lang.String) ref; - } - } - - /** - * string name = 1; - * - * @return The bytes for name. - */ - public com.google.protobuf.ByteString getNameBytes() { - java.lang.Object ref = name_; - if (ref instanceof String) { - com.google.protobuf.ByteString b = com.google.protobuf.ByteString - .copyFromUtf8( - (java.lang.String) ref - ); - name_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - - /** - * string name = 1; - * - * @param value The name to set. - * @return This builder for chaining. - */ - public Builder setName( - java.lang.String value - ) { - if (value == null) { - throw new NullPointerException(); - } - name_ = value; - bitField0_ |= 0x00000001; - onChanged(); - return this; - } - - /** - * string name = 1; - * - * @return This builder for chaining. - */ - public Builder clearName() { - name_ = getDefaultInstance().getName(); - bitField0_ = (bitField0_ & ~0x00000001); - onChanged(); - return this; - } - - /** - * string name = 1; - * - * @param value The bytes for name to set. - * @return This builder for chaining. - */ - public Builder setNameBytes( - com.google.protobuf.ByteString value - ) { - if (value == null) { - throw new NullPointerException(); - } - checkByteStringIsUtf8(value); - name_ = value; - bitField0_ |= 0x00000001; - onChanged(); - return this; - } - - private com.google.protobuf.MapField actionArguments_; - - private com.google.protobuf.MapField internalGetActionArguments() { - if (actionArguments_ == null) { - return com.google.protobuf.MapField - .emptyMapField( - ActionArgumentsDefaultEntryHolder.defaultEntry - ); - } - return actionArguments_; - } - - private com.google.protobuf.MapField internalGetMutableActionArguments() { - if (actionArguments_ == null) { - actionArguments_ = com.google.protobuf.MapField - .newMapField( - ActionArgumentsDefaultEntryHolder.defaultEntry - ); - } - if (!actionArguments_.isMutable()) { - actionArguments_ = actionArguments_.copy(); - } - bitField0_ |= 0x00000002; - onChanged(); - return actionArguments_; - } - - public int getActionArgumentsCount() { - return internalGetActionArguments().getMap().size(); - } - - /** - * map<string, string> action_arguments = 2; - */ - @java.lang.Override - public boolean containsActionArguments( - java.lang.String key - ) { - if (key == null) { - throw new NullPointerException("map key"); - } - return internalGetActionArguments().getMap().containsKey(key); - } - - /** - * Use {@link #getActionArgumentsMap()} instead. - */ - @java.lang.Override - @java.lang.Deprecated - public java.util.Map getActionArguments() { - return getActionArgumentsMap(); - } - - /** - * map<string, string> action_arguments = 2; - */ - @java.lang.Override - public java.util.Map getActionArgumentsMap() { - return internalGetActionArguments().getMap(); - } - - /** - * map<string, string> action_arguments = 2; - */ - @java.lang.Override - public /* nullable */ - java.lang.String getActionArgumentsOrDefault( - java.lang.String key, - /* nullable */ - java.lang.String defaultValue - ) { - if (key == null) { - throw new NullPointerException("map key"); - } - java.util.Map map = internalGetActionArguments() - .getMap(); - return map.containsKey(key) ? map.get(key) : defaultValue; - } - - /** - * map<string, string> action_arguments = 2; - */ - @java.lang.Override - public java.lang.String getActionArgumentsOrThrow( - java.lang.String key - ) { - if (key == null) { - throw new NullPointerException("map key"); - } - java.util.Map map = internalGetActionArguments() - .getMap(); - if (!map.containsKey(key)) { - throw new java.lang.IllegalArgumentException(); - } - return map.get(key); - } - - public Builder clearActionArguments() { - bitField0_ = (bitField0_ & ~0x00000002); - internalGetMutableActionArguments() - .getMutableMap() - .clear(); - return this; - } - - /** - * map<string, string> action_arguments = 2; - */ - public Builder removeActionArguments( - java.lang.String key - ) { - if (key == null) { - throw new NullPointerException("map key"); - } - internalGetMutableActionArguments() - .getMutableMap() - .remove(key); - return this; - } - - /** - * Use alternate mutation accessors instead. - */ - @java.lang.Deprecated - public java.util.Map getMutableActionArguments() { - bitField0_ |= 0x00000002; - return internalGetMutableActionArguments().getMutableMap(); - } - - /** - * map<string, string> action_arguments = 2; - */ - public Builder putActionArguments( - java.lang.String key, - java.lang.String value - ) { - if (key == null) { - throw new NullPointerException("map key"); - } - if (value == null) { - throw new NullPointerException("map value"); - } - internalGetMutableActionArguments() - .getMutableMap() - .put(key, value); - bitField0_ |= 0x00000002; - return this; - } - - /** - * map<string, string> action_arguments = 2; - */ - public Builder putAllActionArguments( - java.util.Map values - ) { - internalGetMutableActionArguments() - .getMutableMap() - .putAll(values); - bitField0_ |= 0x00000002; - return this; - } - - // @@protoc_insertion_point(builder_scope:messages.UserAction) - } - - // @@protoc_insertion_point(class_scope:messages.UserAction) - private static final dev.slimevr.desktop.platform.ProtobufMessages.UserAction DEFAULT_INSTANCE; - static { - DEFAULT_INSTANCE = new dev.slimevr.desktop.platform.ProtobufMessages.UserAction(); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.UserAction getDefaultInstance() { - return DEFAULT_INSTANCE; - } - - private static final com.google.protobuf.Parser PARSER = new com.google.protobuf.AbstractParser() { - @java.lang.Override - public UserAction parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - Builder builder = newBuilder(); - try { - builder.mergeFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(builder.buildPartial()); - } catch (com.google.protobuf.UninitializedMessageException e) { - throw e - .asInvalidProtocolBufferException() - .setUnfinishedMessage(builder.buildPartial()); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException(e) - .setUnfinishedMessage(builder.buildPartial()); - } - return builder.buildPartial(); - } - }; - - public static com.google.protobuf.Parser parser() { - return PARSER; - } - - @java.lang.Override - public com.google.protobuf.Parser getParserForType() { - return PARSER; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.UserAction getDefaultInstanceForType() { - return DEFAULT_INSTANCE; - } - - } - - public interface TrackerAddedOrBuilder extends - // @@protoc_insertion_point(interface_extends:messages.TrackerAdded) - com.google.protobuf.MessageOrBuilder { - - /** - * int32 tracker_id = 1; - * - * @return The trackerId. - */ - int getTrackerId(); - - /** - * string tracker_serial = 2; - * - * @return The trackerSerial. - */ - java.lang.String getTrackerSerial(); - - /** - * string tracker_serial = 2; - * - * @return The bytes for trackerSerial. - */ - com.google.protobuf.ByteString getTrackerSerialBytes(); - - /** - * string tracker_name = 3; - * - * @return The trackerName. - */ - java.lang.String getTrackerName(); - - /** - * string tracker_name = 3; - * - * @return The bytes for trackerName. - */ - com.google.protobuf.ByteString getTrackerNameBytes(); - - /** - * int32 tracker_role = 4; - * - * @return The trackerRole. - */ - int getTrackerRole(); - - /** - * string manufacturer = 5; - * - * @return The manufacturer. - */ - java.lang.String getManufacturer(); - - /** - * string manufacturer = 5; - * - * @return The bytes for manufacturer. - */ - com.google.protobuf.ByteString getManufacturerBytes(); - } - - /** - * Protobuf type {@code messages.TrackerAdded} - */ - public static final class TrackerAdded extends - com.google.protobuf.GeneratedMessage implements - // @@protoc_insertion_point(message_implements:messages.TrackerAdded) - TrackerAddedOrBuilder { - private static final long serialVersionUID = 0L; - static { - com.google.protobuf.RuntimeVersion - .validateProtobufGencodeVersion( - com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, - /* major= */ 4, - /* minor= */ 31, - /* patch= */ 1, - /* suffix= */ "", - TrackerAdded.class.getName() - ); - } - - // Use TrackerAdded.newBuilder() to construct. - private TrackerAdded(com.google.protobuf.GeneratedMessage.Builder builder) { - super(builder); - } - - private TrackerAdded() { - trackerSerial_ = ""; - trackerName_ = ""; - manufacturer_ = ""; - } - - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_TrackerAdded_descriptor; - } - - @java.lang.Override - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_TrackerAdded_fieldAccessorTable - .ensureFieldAccessorsInitialized( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded.class, - dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded.Builder.class - ); - } - - public static final int TRACKER_ID_FIELD_NUMBER = 1; - private int trackerId_ = 0; - - /** - * int32 tracker_id = 1; - * - * @return The trackerId. - */ - @java.lang.Override - public int getTrackerId() { - return trackerId_; - } - - public static final int TRACKER_SERIAL_FIELD_NUMBER = 2; - @SuppressWarnings("serial") - private volatile java.lang.Object trackerSerial_ = ""; - - /** - * string tracker_serial = 2; - * - * @return The trackerSerial. - */ - @java.lang.Override - public java.lang.String getTrackerSerial() { - java.lang.Object ref = trackerSerial_; - if (ref instanceof java.lang.String) { - return (java.lang.String) ref; - } else { - com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; - java.lang.String s = bs.toStringUtf8(); - trackerSerial_ = s; - return s; - } - } - - /** - * string tracker_serial = 2; - * - * @return The bytes for trackerSerial. - */ - @java.lang.Override - public com.google.protobuf.ByteString getTrackerSerialBytes() { - java.lang.Object ref = trackerSerial_; - if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = com.google.protobuf.ByteString - .copyFromUtf8( - (java.lang.String) ref - ); - trackerSerial_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - - public static final int TRACKER_NAME_FIELD_NUMBER = 3; - @SuppressWarnings("serial") - private volatile java.lang.Object trackerName_ = ""; - - /** - * string tracker_name = 3; - * - * @return The trackerName. - */ - @java.lang.Override - public java.lang.String getTrackerName() { - java.lang.Object ref = trackerName_; - if (ref instanceof java.lang.String) { - return (java.lang.String) ref; - } else { - com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; - java.lang.String s = bs.toStringUtf8(); - trackerName_ = s; - return s; - } - } - - /** - * string tracker_name = 3; - * - * @return The bytes for trackerName. - */ - @java.lang.Override - public com.google.protobuf.ByteString getTrackerNameBytes() { - java.lang.Object ref = trackerName_; - if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = com.google.protobuf.ByteString - .copyFromUtf8( - (java.lang.String) ref - ); - trackerName_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - - public static final int TRACKER_ROLE_FIELD_NUMBER = 4; - private int trackerRole_ = 0; - - /** - * int32 tracker_role = 4; - * - * @return The trackerRole. - */ - @java.lang.Override - public int getTrackerRole() { - return trackerRole_; - } - - public static final int MANUFACTURER_FIELD_NUMBER = 5; - @SuppressWarnings("serial") - private volatile java.lang.Object manufacturer_ = ""; - - /** - * string manufacturer = 5; - * - * @return The manufacturer. - */ - @java.lang.Override - public java.lang.String getManufacturer() { - java.lang.Object ref = manufacturer_; - if (ref instanceof java.lang.String) { - return (java.lang.String) ref; - } else { - com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; - java.lang.String s = bs.toStringUtf8(); - manufacturer_ = s; - return s; - } - } - - /** - * string manufacturer = 5; - * - * @return The bytes for manufacturer. - */ - @java.lang.Override - public com.google.protobuf.ByteString getManufacturerBytes() { - java.lang.Object ref = manufacturer_; - if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = com.google.protobuf.ByteString - .copyFromUtf8( - (java.lang.String) ref - ); - manufacturer_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - - private byte memoizedIsInitialized = -1; - - @java.lang.Override - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized == 1) - return true; - if (isInitialized == 0) - return false; - - memoizedIsInitialized = 1; - return true; - } - - @java.lang.Override - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - if (trackerId_ != 0) { - output.writeInt32(1, trackerId_); - } - if (!com.google.protobuf.GeneratedMessage.isStringEmpty(trackerSerial_)) { - com.google.protobuf.GeneratedMessage.writeString(output, 2, trackerSerial_); - } - if (!com.google.protobuf.GeneratedMessage.isStringEmpty(trackerName_)) { - com.google.protobuf.GeneratedMessage.writeString(output, 3, trackerName_); - } - if (trackerRole_ != 0) { - output.writeInt32(4, trackerRole_); - } - if (!com.google.protobuf.GeneratedMessage.isStringEmpty(manufacturer_)) { - com.google.protobuf.GeneratedMessage.writeString(output, 5, manufacturer_); - } - getUnknownFields().writeTo(output); - } - - @java.lang.Override - public int getSerializedSize() { - int size = memoizedSize; - if (size != -1) - return size; - - size = 0; - if (trackerId_ != 0) { - size += com.google.protobuf.CodedOutputStream - .computeInt32Size(1, trackerId_); - } - if (!com.google.protobuf.GeneratedMessage.isStringEmpty(trackerSerial_)) { - size += com.google.protobuf.GeneratedMessage.computeStringSize(2, trackerSerial_); - } - if (!com.google.protobuf.GeneratedMessage.isStringEmpty(trackerName_)) { - size += com.google.protobuf.GeneratedMessage.computeStringSize(3, trackerName_); - } - if (trackerRole_ != 0) { - size += com.google.protobuf.CodedOutputStream - .computeInt32Size(4, trackerRole_); - } - if (!com.google.protobuf.GeneratedMessage.isStringEmpty(manufacturer_)) { - size += com.google.protobuf.GeneratedMessage.computeStringSize(5, manufacturer_); - } - size += getUnknownFields().getSerializedSize(); - memoizedSize = size; - return size; - } - - @java.lang.Override - public boolean equals(final java.lang.Object obj) { - if (obj == this) { - return true; - } - if (!(obj instanceof dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded)) { - return super.equals(obj); - } - dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded other = (dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded) obj; - - if ( - getTrackerId() - != other.getTrackerId() - ) - return false; - if ( - !getTrackerSerial() - .equals(other.getTrackerSerial()) - ) - return false; - if ( - !getTrackerName() - .equals(other.getTrackerName()) - ) - return false; - if ( - getTrackerRole() - != other.getTrackerRole() - ) - return false; - if ( - !getManufacturer() - .equals(other.getManufacturer()) - ) - return false; - if (!getUnknownFields().equals(other.getUnknownFields())) - return false; - return true; - } - - @java.lang.Override - public int hashCode() { - if (memoizedHashCode != 0) { - return memoizedHashCode; - } - int hash = 41; - hash = (19 * hash) + getDescriptor().hashCode(); - hash = (37 * hash) + TRACKER_ID_FIELD_NUMBER; - hash = (53 * hash) + getTrackerId(); - hash = (37 * hash) + TRACKER_SERIAL_FIELD_NUMBER; - hash = (53 * hash) + getTrackerSerial().hashCode(); - hash = (37 * hash) + TRACKER_NAME_FIELD_NUMBER; - hash = (53 * hash) + getTrackerName().hashCode(); - hash = (37 * hash) + TRACKER_ROLE_FIELD_NUMBER; - hash = (53 * hash) + getTrackerRole(); - hash = (37 * hash) + MANUFACTURER_FIELD_NUMBER; - hash = (53 * hash) + getManufacturer().hashCode(); - hash = (29 * hash) + getUnknownFields().hashCode(); - memoizedHashCode = hash; - return hash; - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded parseFrom( - java.nio.ByteBuffer data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded parseFrom( - java.nio.ByteBuffer data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded parseFrom( - com.google.protobuf.ByteString data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded parseFrom( - byte[] data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded parseFrom( - java.io.InputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded parseDelimitedFrom( - java.io.InputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseDelimitedWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseDelimitedWithIOException(PARSER, input, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded parseFrom( - com.google.protobuf.CodedInputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input, extensionRegistry); - } - - @java.lang.Override - public Builder newBuilderForType() { - return newBuilder(); - } - - public static Builder newBuilder() { - return DEFAULT_INSTANCE.toBuilder(); - } - - public static Builder newBuilder( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded prototype - ) { - return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); - } - - @java.lang.Override - public Builder toBuilder() { - return this == DEFAULT_INSTANCE - ? new Builder() - : new Builder().mergeFrom(this); - } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent - ) { - Builder builder = new Builder(parent); - return builder; - } - - /** - * Protobuf type {@code messages.TrackerAdded} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder implements - // @@protoc_insertion_point(builder_implements:messages.TrackerAdded) - dev.slimevr.desktop.platform.ProtobufMessages.TrackerAddedOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_TrackerAdded_descriptor; - } - - @java.lang.Override - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_TrackerAdded_fieldAccessorTable - .ensureFieldAccessorsInitialized( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded.class, - dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded.Builder.class - ); - } - - // Construct using - // dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded.newBuilder() - private Builder() { - - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent - ) { - super(parent); - - } - - @java.lang.Override - public Builder clear() { - super.clear(); - bitField0_ = 0; - trackerId_ = 0; - trackerSerial_ = ""; - trackerName_ = ""; - trackerRole_ = 0; - manufacturer_ = ""; - return this; - } - - @java.lang.Override - public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_TrackerAdded_descriptor; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded getDefaultInstanceForType() { - return dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded - .getDefaultInstance(); - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded build() { - dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded buildPartial() { - dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded result = new dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded( - this - ); - if (bitField0_ != 0) { - buildPartial0(result); - } - onBuilt(); - return result; - } - - private void buildPartial0( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded result - ) { - int from_bitField0_ = bitField0_; - if (((from_bitField0_ & 0x00000001) != 0)) { - result.trackerId_ = trackerId_; - } - if (((from_bitField0_ & 0x00000002) != 0)) { - result.trackerSerial_ = trackerSerial_; - } - if (((from_bitField0_ & 0x00000004) != 0)) { - result.trackerName_ = trackerName_; - } - if (((from_bitField0_ & 0x00000008) != 0)) { - result.trackerRole_ = trackerRole_; - } - if (((from_bitField0_ & 0x00000010) != 0)) { - result.manufacturer_ = manufacturer_; - } - } - - @java.lang.Override - public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded) { - return mergeFrom( - (dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded) other - ); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded other - ) { - if ( - other - == dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded - .getDefaultInstance() - ) - return this; - if (other.getTrackerId() != 0) { - setTrackerId(other.getTrackerId()); - } - if (!other.getTrackerSerial().isEmpty()) { - trackerSerial_ = other.trackerSerial_; - bitField0_ |= 0x00000002; - onChanged(); - } - if (!other.getTrackerName().isEmpty()) { - trackerName_ = other.trackerName_; - bitField0_ |= 0x00000004; - onChanged(); - } - if (other.getTrackerRole() != 0) { - setTrackerRole(other.getTrackerRole()); - } - if (!other.getManufacturer().isEmpty()) { - manufacturer_ = other.manufacturer_; - bitField0_ |= 0x00000010; - onChanged(); - } - this.mergeUnknownFields(other.getUnknownFields()); - onChanged(); - return this; - } - - @java.lang.Override - public final boolean isInitialized() { - return true; - } - - @java.lang.Override - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - if (extensionRegistry == null) { - throw new java.lang.NullPointerException(); - } - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - case 8: { - trackerId_ = input.readInt32(); - bitField0_ |= 0x00000001; - break; - } // case 8 - case 18: { - trackerSerial_ = input.readStringRequireUtf8(); - bitField0_ |= 0x00000002; - break; - } // case 18 - case 26: { - trackerName_ = input.readStringRequireUtf8(); - bitField0_ |= 0x00000004; - break; - } // case 26 - case 32: { - trackerRole_ = input.readInt32(); - bitField0_ |= 0x00000008; - break; - } // case 32 - case 42: { - manufacturer_ = input.readStringRequireUtf8(); - bitField0_ |= 0x00000010; - break; - } // case 42 - default: { - if (!super.parseUnknownField(input, extensionRegistry, tag)) { - done = true; // was an endgroup tag - } - break; - } // default: - } // switch (tag) - } // while (!done) - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.unwrapIOException(); - } finally { - onChanged(); - } // finally - return this; - } - - private int bitField0_; - - private int trackerId_; - - /** - * int32 tracker_id = 1; - * - * @return The trackerId. - */ - @java.lang.Override - public int getTrackerId() { - return trackerId_; - } - - /** - * int32 tracker_id = 1; - * - * @param value The trackerId to set. - * @return This builder for chaining. - */ - public Builder setTrackerId(int value) { - - trackerId_ = value; - bitField0_ |= 0x00000001; - onChanged(); - return this; - } - - /** - * int32 tracker_id = 1; - * - * @return This builder for chaining. - */ - public Builder clearTrackerId() { - bitField0_ = (bitField0_ & ~0x00000001); - trackerId_ = 0; - onChanged(); - return this; - } - - private java.lang.Object trackerSerial_ = ""; - - /** - * string tracker_serial = 2; - * - * @return The trackerSerial. - */ - public java.lang.String getTrackerSerial() { - java.lang.Object ref = trackerSerial_; - if (!(ref instanceof java.lang.String)) { - com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; - java.lang.String s = bs.toStringUtf8(); - trackerSerial_ = s; - return s; - } else { - return (java.lang.String) ref; - } - } - - /** - * string tracker_serial = 2; - * - * @return The bytes for trackerSerial. - */ - public com.google.protobuf.ByteString getTrackerSerialBytes() { - java.lang.Object ref = trackerSerial_; - if (ref instanceof String) { - com.google.protobuf.ByteString b = com.google.protobuf.ByteString - .copyFromUtf8( - (java.lang.String) ref - ); - trackerSerial_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - - /** - * string tracker_serial = 2; - * - * @param value The trackerSerial to set. - * @return This builder for chaining. - */ - public Builder setTrackerSerial( - java.lang.String value - ) { - if (value == null) { - throw new NullPointerException(); - } - trackerSerial_ = value; - bitField0_ |= 0x00000002; - onChanged(); - return this; - } - - /** - * string tracker_serial = 2; - * - * @return This builder for chaining. - */ - public Builder clearTrackerSerial() { - trackerSerial_ = getDefaultInstance().getTrackerSerial(); - bitField0_ = (bitField0_ & ~0x00000002); - onChanged(); - return this; - } - - /** - * string tracker_serial = 2; - * - * @param value The bytes for trackerSerial to set. - * @return This builder for chaining. - */ - public Builder setTrackerSerialBytes( - com.google.protobuf.ByteString value - ) { - if (value == null) { - throw new NullPointerException(); - } - checkByteStringIsUtf8(value); - trackerSerial_ = value; - bitField0_ |= 0x00000002; - onChanged(); - return this; - } - - private java.lang.Object trackerName_ = ""; - - /** - * string tracker_name = 3; - * - * @return The trackerName. - */ - public java.lang.String getTrackerName() { - java.lang.Object ref = trackerName_; - if (!(ref instanceof java.lang.String)) { - com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; - java.lang.String s = bs.toStringUtf8(); - trackerName_ = s; - return s; - } else { - return (java.lang.String) ref; - } - } - - /** - * string tracker_name = 3; - * - * @return The bytes for trackerName. - */ - public com.google.protobuf.ByteString getTrackerNameBytes() { - java.lang.Object ref = trackerName_; - if (ref instanceof String) { - com.google.protobuf.ByteString b = com.google.protobuf.ByteString - .copyFromUtf8( - (java.lang.String) ref - ); - trackerName_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - - /** - * string tracker_name = 3; - * - * @param value The trackerName to set. - * @return This builder for chaining. - */ - public Builder setTrackerName( - java.lang.String value - ) { - if (value == null) { - throw new NullPointerException(); - } - trackerName_ = value; - bitField0_ |= 0x00000004; - onChanged(); - return this; - } - - /** - * string tracker_name = 3; - * - * @return This builder for chaining. - */ - public Builder clearTrackerName() { - trackerName_ = getDefaultInstance().getTrackerName(); - bitField0_ = (bitField0_ & ~0x00000004); - onChanged(); - return this; - } - - /** - * string tracker_name = 3; - * - * @param value The bytes for trackerName to set. - * @return This builder for chaining. - */ - public Builder setTrackerNameBytes( - com.google.protobuf.ByteString value - ) { - if (value == null) { - throw new NullPointerException(); - } - checkByteStringIsUtf8(value); - trackerName_ = value; - bitField0_ |= 0x00000004; - onChanged(); - return this; - } - - private int trackerRole_; - - /** - * int32 tracker_role = 4; - * - * @return The trackerRole. - */ - @java.lang.Override - public int getTrackerRole() { - return trackerRole_; - } - - /** - * int32 tracker_role = 4; - * - * @param value The trackerRole to set. - * @return This builder for chaining. - */ - public Builder setTrackerRole(int value) { - - trackerRole_ = value; - bitField0_ |= 0x00000008; - onChanged(); - return this; - } - - /** - * int32 tracker_role = 4; - * - * @return This builder for chaining. - */ - public Builder clearTrackerRole() { - bitField0_ = (bitField0_ & ~0x00000008); - trackerRole_ = 0; - onChanged(); - return this; - } - - private java.lang.Object manufacturer_ = ""; - - /** - * string manufacturer = 5; - * - * @return The manufacturer. - */ - public java.lang.String getManufacturer() { - java.lang.Object ref = manufacturer_; - if (!(ref instanceof java.lang.String)) { - com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; - java.lang.String s = bs.toStringUtf8(); - manufacturer_ = s; - return s; - } else { - return (java.lang.String) ref; - } - } - - /** - * string manufacturer = 5; - * - * @return The bytes for manufacturer. - */ - public com.google.protobuf.ByteString getManufacturerBytes() { - java.lang.Object ref = manufacturer_; - if (ref instanceof String) { - com.google.protobuf.ByteString b = com.google.protobuf.ByteString - .copyFromUtf8( - (java.lang.String) ref - ); - manufacturer_ = b; - return b; - } else { - return (com.google.protobuf.ByteString) ref; - } - } - - /** - * string manufacturer = 5; - * - * @param value The manufacturer to set. - * @return This builder for chaining. - */ - public Builder setManufacturer( - java.lang.String value - ) { - if (value == null) { - throw new NullPointerException(); - } - manufacturer_ = value; - bitField0_ |= 0x00000010; - onChanged(); - return this; - } - - /** - * string manufacturer = 5; - * - * @return This builder for chaining. - */ - public Builder clearManufacturer() { - manufacturer_ = getDefaultInstance().getManufacturer(); - bitField0_ = (bitField0_ & ~0x00000010); - onChanged(); - return this; - } - - /** - * string manufacturer = 5; - * - * @param value The bytes for manufacturer to set. - * @return This builder for chaining. - */ - public Builder setManufacturerBytes( - com.google.protobuf.ByteString value - ) { - if (value == null) { - throw new NullPointerException(); - } - checkByteStringIsUtf8(value); - manufacturer_ = value; - bitField0_ |= 0x00000010; - onChanged(); - return this; - } - - // @@protoc_insertion_point(builder_scope:messages.TrackerAdded) - } - - // @@protoc_insertion_point(class_scope:messages.TrackerAdded) - private static final dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded DEFAULT_INSTANCE; - static { - DEFAULT_INSTANCE = new dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded(); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded getDefaultInstance() { - return DEFAULT_INSTANCE; - } - - private static final com.google.protobuf.Parser PARSER = new com.google.protobuf.AbstractParser() { - @java.lang.Override - public TrackerAdded parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - Builder builder = newBuilder(); - try { - builder.mergeFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(builder.buildPartial()); - } catch (com.google.protobuf.UninitializedMessageException e) { - throw e - .asInvalidProtocolBufferException() - .setUnfinishedMessage(builder.buildPartial()); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException(e) - .setUnfinishedMessage(builder.buildPartial()); - } - return builder.buildPartial(); - } - }; - - public static com.google.protobuf.Parser parser() { - return PARSER; - } - - @java.lang.Override - public com.google.protobuf.Parser getParserForType() { - return PARSER; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded getDefaultInstanceForType() { - return DEFAULT_INSTANCE; - } - - } - - public interface TrackerStatusOrBuilder extends - // @@protoc_insertion_point(interface_extends:messages.TrackerStatus) - com.google.protobuf.MessageOrBuilder { - - /** - * int32 tracker_id = 1; - * - * @return The trackerId. - */ - int getTrackerId(); - - /** - * .messages.TrackerStatus.Status status = 2; - * - * @return The enum numeric value on the wire for status. - */ - int getStatusValue(); - - /** - * .messages.TrackerStatus.Status status = 2; - * - * @return The status. - */ - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Status getStatus(); - - /** - * map<string, string> extra = 3; - */ - int getExtraCount(); - - /** - * map<string, string> extra = 3; - */ - boolean containsExtra( - java.lang.String key - ); - - /** - * Use {@link #getExtraMap()} instead. - */ - @java.lang.Deprecated - java.util.Map getExtra(); - - /** - * map<string, string> extra = 3; - */ - java.util.Map getExtraMap(); - - /** - * map<string, string> extra = 3; - */ - /* nullable */ - java.lang.String getExtraOrDefault( - java.lang.String key, - /* nullable */ - java.lang.String defaultValue - ); - - /** - * map<string, string> extra = 3; - */ - java.lang.String getExtraOrThrow( - java.lang.String key - ); - - /** - * optional .messages.TrackerStatus.Confidence confidence = 4; - * - * @return Whether the confidence field is set. - */ - boolean hasConfidence(); - - /** - * optional .messages.TrackerStatus.Confidence confidence = 4; - * - * @return The enum numeric value on the wire for confidence. - */ - int getConfidenceValue(); - - /** - * optional .messages.TrackerStatus.Confidence confidence = 4; - * - * @return The confidence. - */ - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Confidence getConfidence(); - } - - /** - * Protobuf type {@code messages.TrackerStatus} - */ - public static final class TrackerStatus extends - com.google.protobuf.GeneratedMessage implements - // @@protoc_insertion_point(message_implements:messages.TrackerStatus) - TrackerStatusOrBuilder { - private static final long serialVersionUID = 0L; - static { - com.google.protobuf.RuntimeVersion - .validateProtobufGencodeVersion( - com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, - /* major= */ 4, - /* minor= */ 31, - /* patch= */ 1, - /* suffix= */ "", - TrackerStatus.class.getName() - ); - } - - // Use TrackerStatus.newBuilder() to construct. - private TrackerStatus(com.google.protobuf.GeneratedMessage.Builder builder) { - super(builder); - } - - private TrackerStatus() { - status_ = 0; - confidence_ = 0; - } - - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_TrackerStatus_descriptor; - } - - @SuppressWarnings({ "rawtypes" }) - @java.lang.Override - protected com.google.protobuf.MapFieldReflectionAccessor internalGetMapFieldReflection( - int number - ) { - switch (number) { - case 3: - return internalGetExtra(); - default: - throw new RuntimeException( - "Invalid map field number: " + number - ); - } - } - - @java.lang.Override - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_TrackerStatus_fieldAccessorTable - .ensureFieldAccessorsInitialized( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.class, - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Builder.class - ); - } - - /** - * Protobuf enum {@code messages.TrackerStatus.Status} - */ - public enum Status - implements com.google.protobuf.ProtocolMessageEnum { - /** - * DISCONNECTED = 0; - */ - DISCONNECTED(0), - /** - * OK = 1; - */ - OK(1), - /** - * BUSY = 2; - */ - BUSY(2), - /** - * ERROR = 3; - */ - ERROR(3), - /** - * OCCLUDED = 4; - */ - OCCLUDED(4), - UNRECOGNIZED(-1), - ; - - static { - com.google.protobuf.RuntimeVersion - .validateProtobufGencodeVersion( - com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, - /* major= */ 4, - /* minor= */ 31, - /* patch= */ 1, - /* suffix= */ "", - Status.class.getName() - ); - } - /** - * DISCONNECTED = 0; - */ - public static final int DISCONNECTED_VALUE = 0; - /** - * OK = 1; - */ - public static final int OK_VALUE = 1; - /** - * BUSY = 2; - */ - public static final int BUSY_VALUE = 2; - /** - * ERROR = 3; - */ - public static final int ERROR_VALUE = 3; - /** - * OCCLUDED = 4; - */ - public static final int OCCLUDED_VALUE = 4; - - - public final int getNumber() { - if (this == UNRECOGNIZED) { - throw new java.lang.IllegalArgumentException( - "Can't get the number of an unknown enum value." - ); - } - return value; - } - - /** - * @param value The numeric wire value of the corresponding enum - * entry. - * @return The enum associated with the given numeric wire value. - * @deprecated Use {@link #forNumber(int)} instead. - */ - @java.lang.Deprecated - public static Status valueOf(int value) { - return forNumber(value); - } - - /** - * @param value The numeric wire value of the corresponding enum - * entry. - * @return The enum associated with the given numeric wire value. - */ - public static Status forNumber(int value) { - switch (value) { - case 0: - return DISCONNECTED; - case 1: - return OK; - case 2: - return BUSY; - case 3: - return ERROR; - case 4: - return OCCLUDED; - default: - return null; - } - } - - public static com.google.protobuf.Internal.EnumLiteMap internalGetValueMap() { - return internalValueMap; - } - - private static final com.google.protobuf.Internal.EnumLiteMap internalValueMap = new com.google.protobuf.Internal.EnumLiteMap() { - public Status findValueByNumber(int number) { - return Status.forNumber(number); - } - }; - - public final com.google.protobuf.Descriptors.EnumValueDescriptor getValueDescriptor() { - if (this == UNRECOGNIZED) { - throw new java.lang.IllegalStateException( - "Can't get the descriptor of an unrecognized enum value." - ); - } - return getDescriptor().getValues().get(ordinal()); - } - - public final com.google.protobuf.Descriptors.EnumDescriptor getDescriptorForType() { - return getDescriptor(); - } - - public static com.google.protobuf.Descriptors.EnumDescriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus - .getDescriptor() - .getEnumTypes() - .get(0); - } - - private static final Status[] VALUES = values(); - - public static Status valueOf( - com.google.protobuf.Descriptors.EnumValueDescriptor desc - ) { - if (desc.getType() != getDescriptor()) { - throw new java.lang.IllegalArgumentException( - "EnumValueDescriptor is not for this type." - ); - } - if (desc.getIndex() == -1) { - return UNRECOGNIZED; - } - return VALUES[desc.getIndex()]; - } - - private final int value; - - private Status(int value) { - this.value = value; - } - - // @@protoc_insertion_point(enum_scope:messages.TrackerStatus.Status) - } - - /** - * Protobuf enum {@code messages.TrackerStatus.Confidence} - */ - public enum Confidence - implements com.google.protobuf.ProtocolMessageEnum { - /** - * NO = 0; - */ - NO(0), - /** - * LOW = 1; - */ - LOW(1), - /** - * MEDIUM = 5; - */ - MEDIUM(5), - /** - * HIGH = 10; - */ - HIGH(10), - UNRECOGNIZED(-1), - ; - - static { - com.google.protobuf.RuntimeVersion - .validateProtobufGencodeVersion( - com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, - /* major= */ 4, - /* minor= */ 31, - /* patch= */ 1, - /* suffix= */ "", - Confidence.class.getName() - ); - } - /** - * NO = 0; - */ - public static final int NO_VALUE = 0; - /** - * LOW = 1; - */ - public static final int LOW_VALUE = 1; - /** - * MEDIUM = 5; - */ - public static final int MEDIUM_VALUE = 5; - /** - * HIGH = 10; - */ - public static final int HIGH_VALUE = 10; - - - public final int getNumber() { - if (this == UNRECOGNIZED) { - throw new java.lang.IllegalArgumentException( - "Can't get the number of an unknown enum value." - ); - } - return value; - } - - /** - * @param value The numeric wire value of the corresponding enum - * entry. - * @return The enum associated with the given numeric wire value. - * @deprecated Use {@link #forNumber(int)} instead. - */ - @java.lang.Deprecated - public static Confidence valueOf(int value) { - return forNumber(value); - } - - /** - * @param value The numeric wire value of the corresponding enum - * entry. - * @return The enum associated with the given numeric wire value. - */ - public static Confidence forNumber(int value) { - switch (value) { - case 0: - return NO; - case 1: - return LOW; - case 5: - return MEDIUM; - case 10: - return HIGH; - default: - return null; - } - } - - public static com.google.protobuf.Internal.EnumLiteMap internalGetValueMap() { - return internalValueMap; - } - - private static final com.google.protobuf.Internal.EnumLiteMap internalValueMap = new com.google.protobuf.Internal.EnumLiteMap() { - public Confidence findValueByNumber(int number) { - return Confidence.forNumber(number); - } - }; - - public final com.google.protobuf.Descriptors.EnumValueDescriptor getValueDescriptor() { - if (this == UNRECOGNIZED) { - throw new java.lang.IllegalStateException( - "Can't get the descriptor of an unrecognized enum value." - ); - } - return getDescriptor().getValues().get(ordinal()); - } - - public final com.google.protobuf.Descriptors.EnumDescriptor getDescriptorForType() { - return getDescriptor(); - } - - public static com.google.protobuf.Descriptors.EnumDescriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus - .getDescriptor() - .getEnumTypes() - .get(1); - } - - private static final Confidence[] VALUES = values(); - - public static Confidence valueOf( - com.google.protobuf.Descriptors.EnumValueDescriptor desc - ) { - if (desc.getType() != getDescriptor()) { - throw new java.lang.IllegalArgumentException( - "EnumValueDescriptor is not for this type." - ); - } - if (desc.getIndex() == -1) { - return UNRECOGNIZED; - } - return VALUES[desc.getIndex()]; - } - - private final int value; - - private Confidence(int value) { - this.value = value; - } - - // @@protoc_insertion_point(enum_scope:messages.TrackerStatus.Confidence) - } - - private int bitField0_; - public static final int TRACKER_ID_FIELD_NUMBER = 1; - private int trackerId_ = 0; - - /** - * int32 tracker_id = 1; - * - * @return The trackerId. - */ - @java.lang.Override - public int getTrackerId() { - return trackerId_; - } - - public static final int STATUS_FIELD_NUMBER = 2; - private int status_ = 0; - - /** - * .messages.TrackerStatus.Status status = 2; - * - * @return The enum numeric value on the wire for status. - */ - @java.lang.Override - public int getStatusValue() { - return status_; - } - - /** - * .messages.TrackerStatus.Status status = 2; - * - * @return The status. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Status getStatus() { - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Status result = dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Status - .forNumber(status_); - return result == null - ? dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Status.UNRECOGNIZED - : result; - } - - public static final int EXTRA_FIELD_NUMBER = 3; - - private static final class ExtraDefaultEntryHolder { - static final com.google.protobuf.MapEntry defaultEntry = com.google.protobuf.MapEntry.newDefaultInstance( - dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_TrackerStatus_ExtraEntry_descriptor, - com.google.protobuf.WireFormat.FieldType.STRING, - "", - com.google.protobuf.WireFormat.FieldType.STRING, - "" - ); - } - - @SuppressWarnings("serial") - private com.google.protobuf.MapField extra_; - - private com.google.protobuf.MapField internalGetExtra() { - if (extra_ == null) { - return com.google.protobuf.MapField - .emptyMapField( - ExtraDefaultEntryHolder.defaultEntry - ); - } - return extra_; - } - - public int getExtraCount() { - return internalGetExtra().getMap().size(); - } - - /** - * map<string, string> extra = 3; - */ - @java.lang.Override - public boolean containsExtra( - java.lang.String key - ) { - if (key == null) { - throw new NullPointerException("map key"); - } - return internalGetExtra().getMap().containsKey(key); - } - - /** - * Use {@link #getExtraMap()} instead. - */ - @java.lang.Override - @java.lang.Deprecated - public java.util.Map getExtra() { - return getExtraMap(); - } - - /** - * map<string, string> extra = 3; - */ - @java.lang.Override - public java.util.Map getExtraMap() { - return internalGetExtra().getMap(); - } - - /** - * map<string, string> extra = 3; - */ - @java.lang.Override - public /* nullable */ - java.lang.String getExtraOrDefault( - java.lang.String key, - /* nullable */ - java.lang.String defaultValue - ) { - if (key == null) { - throw new NullPointerException("map key"); - } - java.util.Map map = internalGetExtra().getMap(); - return map.containsKey(key) ? map.get(key) : defaultValue; - } - - /** - * map<string, string> extra = 3; - */ - @java.lang.Override - public java.lang.String getExtraOrThrow( - java.lang.String key - ) { - if (key == null) { - throw new NullPointerException("map key"); - } - java.util.Map map = internalGetExtra().getMap(); - if (!map.containsKey(key)) { - throw new java.lang.IllegalArgumentException(); - } - return map.get(key); - } - - public static final int CONFIDENCE_FIELD_NUMBER = 4; - private int confidence_ = 0; - - /** - * optional .messages.TrackerStatus.Confidence confidence = 4; - * - * @return Whether the confidence field is set. - */ - @java.lang.Override - public boolean hasConfidence() { - return ((bitField0_ & 0x00000001) != 0); - } - - /** - * optional .messages.TrackerStatus.Confidence confidence = 4; - * - * @return The enum numeric value on the wire for confidence. - */ - @java.lang.Override - public int getConfidenceValue() { - return confidence_; - } - - /** - * optional .messages.TrackerStatus.Confidence confidence = 4; - * - * @return The confidence. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Confidence getConfidence() { - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Confidence result = dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Confidence - .forNumber(confidence_); - return result == null - ? dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Confidence.UNRECOGNIZED - : result; - } - - private byte memoizedIsInitialized = -1; - - @java.lang.Override - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized == 1) - return true; - if (isInitialized == 0) - return false; - - memoizedIsInitialized = 1; - return true; - } - - @java.lang.Override - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - if (trackerId_ != 0) { - output.writeInt32(1, trackerId_); - } - if ( - status_ - != dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Status.DISCONNECTED - .getNumber() - ) { - output.writeEnum(2, status_); - } - com.google.protobuf.GeneratedMessage - .serializeStringMapTo( - output, - internalGetExtra(), - ExtraDefaultEntryHolder.defaultEntry, - 3 - ); - if (((bitField0_ & 0x00000001) != 0)) { - output.writeEnum(4, confidence_); - } - getUnknownFields().writeTo(output); - } - - @java.lang.Override - public int getSerializedSize() { - int size = memoizedSize; - if (size != -1) - return size; - - size = 0; - if (trackerId_ != 0) { - size += com.google.protobuf.CodedOutputStream - .computeInt32Size(1, trackerId_); - } - if ( - status_ - != dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Status.DISCONNECTED - .getNumber() - ) { - size += com.google.protobuf.CodedOutputStream - .computeEnumSize(2, status_); - } - for ( - java.util.Map.Entry entry : internalGetExtra() - .getMap() - .entrySet() - ) { - com.google.protobuf.MapEntry extra__ = ExtraDefaultEntryHolder.defaultEntry - .newBuilderForType() - .setKey(entry.getKey()) - .setValue(entry.getValue()) - .build(); - size += com.google.protobuf.CodedOutputStream - .computeMessageSize(3, extra__); - } - if (((bitField0_ & 0x00000001) != 0)) { - size += com.google.protobuf.CodedOutputStream - .computeEnumSize(4, confidence_); - } - size += getUnknownFields().getSerializedSize(); - memoizedSize = size; - return size; - } - - @java.lang.Override - public boolean equals(final java.lang.Object obj) { - if (obj == this) { - return true; - } - if (!(obj instanceof dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus)) { - return super.equals(obj); - } - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus other = (dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus) obj; - - if ( - getTrackerId() - != other.getTrackerId() - ) - return false; - if (status_ != other.status_) - return false; - if ( - !internalGetExtra() - .equals( - other.internalGetExtra() - ) - ) - return false; - if (hasConfidence() != other.hasConfidence()) - return false; - if (hasConfidence()) { - if (confidence_ != other.confidence_) - return false; - } - if (!getUnknownFields().equals(other.getUnknownFields())) - return false; - return true; - } - - @java.lang.Override - public int hashCode() { - if (memoizedHashCode != 0) { - return memoizedHashCode; - } - int hash = 41; - hash = (19 * hash) + getDescriptor().hashCode(); - hash = (37 * hash) + TRACKER_ID_FIELD_NUMBER; - hash = (53 * hash) + getTrackerId(); - hash = (37 * hash) + STATUS_FIELD_NUMBER; - hash = (53 * hash) + status_; - if (!internalGetExtra().getMap().isEmpty()) { - hash = (37 * hash) + EXTRA_FIELD_NUMBER; - hash = (53 * hash) + internalGetExtra().hashCode(); - } - if (hasConfidence()) { - hash = (37 * hash) + CONFIDENCE_FIELD_NUMBER; - hash = (53 * hash) + confidence_; - } - hash = (29 * hash) + getUnknownFields().hashCode(); - memoizedHashCode = hash; - return hash; - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus parseFrom( - java.nio.ByteBuffer data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus parseFrom( - java.nio.ByteBuffer data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus parseFrom( - com.google.protobuf.ByteString data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus parseFrom( - byte[] data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus parseFrom( - java.io.InputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus parseDelimitedFrom( - java.io.InputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseDelimitedWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseDelimitedWithIOException(PARSER, input, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus parseFrom( - com.google.protobuf.CodedInputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input, extensionRegistry); - } - - @java.lang.Override - public Builder newBuilderForType() { - return newBuilder(); - } - - public static Builder newBuilder() { - return DEFAULT_INSTANCE.toBuilder(); - } - - public static Builder newBuilder( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus prototype - ) { - return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); - } - - @java.lang.Override - public Builder toBuilder() { - return this == DEFAULT_INSTANCE - ? new Builder() - : new Builder().mergeFrom(this); - } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent - ) { - Builder builder = new Builder(parent); - return builder; - } - - /** - * Protobuf type {@code messages.TrackerStatus} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder implements - // @@protoc_insertion_point(builder_implements:messages.TrackerStatus) - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatusOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_TrackerStatus_descriptor; - } - - @SuppressWarnings({ "rawtypes" }) - protected com.google.protobuf.MapFieldReflectionAccessor internalGetMapFieldReflection( - int number - ) { - switch (number) { - case 3: - return internalGetExtra(); - default: - throw new RuntimeException( - "Invalid map field number: " + number - ); - } - } - - @SuppressWarnings({ "rawtypes" }) - protected com.google.protobuf.MapFieldReflectionAccessor internalGetMutableMapFieldReflection( - int number - ) { - switch (number) { - case 3: - return internalGetMutableExtra(); - default: - throw new RuntimeException( - "Invalid map field number: " + number - ); - } - } - - @java.lang.Override - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_TrackerStatus_fieldAccessorTable - .ensureFieldAccessorsInitialized( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.class, - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Builder.class - ); - } - - // Construct using - // dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.newBuilder() - private Builder() { - - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent - ) { - super(parent); - - } - - @java.lang.Override - public Builder clear() { - super.clear(); - bitField0_ = 0; - trackerId_ = 0; - status_ = 0; - internalGetMutableExtra().clear(); - confidence_ = 0; - return this; - } - - @java.lang.Override - public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_TrackerStatus_descriptor; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus getDefaultInstanceForType() { - return dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus - .getDefaultInstance(); - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus build() { - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus buildPartial() { - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus result = new dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus( - this - ); - if (bitField0_ != 0) { - buildPartial0(result); - } - onBuilt(); - return result; - } - - private void buildPartial0( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus result - ) { - int from_bitField0_ = bitField0_; - if (((from_bitField0_ & 0x00000001) != 0)) { - result.trackerId_ = trackerId_; - } - if (((from_bitField0_ & 0x00000002) != 0)) { - result.status_ = status_; - } - if (((from_bitField0_ & 0x00000004) != 0)) { - result.extra_ = internalGetExtra(); - result.extra_.makeImmutable(); - } - int to_bitField0_ = 0; - if (((from_bitField0_ & 0x00000008) != 0)) { - result.confidence_ = confidence_; - to_bitField0_ |= 0x00000001; - } - result.bitField0_ |= to_bitField0_; - } - - @java.lang.Override - public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus) { - return mergeFrom( - (dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus) other - ); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus other - ) { - if ( - other - == dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus - .getDefaultInstance() - ) - return this; - if (other.getTrackerId() != 0) { - setTrackerId(other.getTrackerId()); - } - if (other.status_ != 0) { - setStatusValue(other.getStatusValue()); - } - internalGetMutableExtra() - .mergeFrom( - other.internalGetExtra() - ); - bitField0_ |= 0x00000004; - if (other.hasConfidence()) { - setConfidenceValue(other.getConfidenceValue()); - } - this.mergeUnknownFields(other.getUnknownFields()); - onChanged(); - return this; - } - - @java.lang.Override - public final boolean isInitialized() { - return true; - } - - @java.lang.Override - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - if (extensionRegistry == null) { - throw new java.lang.NullPointerException(); - } - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - case 8: { - trackerId_ = input.readInt32(); - bitField0_ |= 0x00000001; - break; - } // case 8 - case 16: { - status_ = input.readEnum(); - bitField0_ |= 0x00000002; - break; - } // case 16 - case 26: { - com.google.protobuf.MapEntry extra__ = input - .readMessage( - ExtraDefaultEntryHolder.defaultEntry.getParserForType(), - extensionRegistry - ); - internalGetMutableExtra() - .getMutableMap() - .put( - extra__.getKey(), - extra__.getValue() - ); - bitField0_ |= 0x00000004; - break; - } // case 26 - case 32: { - confidence_ = input.readEnum(); - bitField0_ |= 0x00000008; - break; - } // case 32 - default: { - if (!super.parseUnknownField(input, extensionRegistry, tag)) { - done = true; // was an endgroup tag - } - break; - } // default: - } // switch (tag) - } // while (!done) - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.unwrapIOException(); - } finally { - onChanged(); - } // finally - return this; - } - - private int bitField0_; - - private int trackerId_; - - /** - * int32 tracker_id = 1; - * - * @return The trackerId. - */ - @java.lang.Override - public int getTrackerId() { - return trackerId_; - } - - /** - * int32 tracker_id = 1; - * - * @param value The trackerId to set. - * @return This builder for chaining. - */ - public Builder setTrackerId(int value) { - - trackerId_ = value; - bitField0_ |= 0x00000001; - onChanged(); - return this; - } - - /** - * int32 tracker_id = 1; - * - * @return This builder for chaining. - */ - public Builder clearTrackerId() { - bitField0_ = (bitField0_ & ~0x00000001); - trackerId_ = 0; - onChanged(); - return this; - } - - private int status_ = 0; - - /** - * .messages.TrackerStatus.Status status = 2; - * - * @return The enum numeric value on the wire for status. - */ - @java.lang.Override - public int getStatusValue() { - return status_; - } - - /** - * .messages.TrackerStatus.Status status = 2; - * - * @param value The enum numeric value on the wire for status to - * set. - * @return This builder for chaining. - */ - public Builder setStatusValue(int value) { - status_ = value; - bitField0_ |= 0x00000002; - onChanged(); - return this; - } - - /** - * .messages.TrackerStatus.Status status = 2; - * - * @return The status. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Status getStatus() { - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Status result = dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Status - .forNumber(status_); - return result == null - ? dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Status.UNRECOGNIZED - : result; - } - - /** - * .messages.TrackerStatus.Status status = 2; - * - * @param value The status to set. - * @return This builder for chaining. - */ - public Builder setStatus( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Status value - ) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000002; - status_ = value.getNumber(); - onChanged(); - return this; - } - - /** - * .messages.TrackerStatus.Status status = 2; - * - * @return This builder for chaining. - */ - public Builder clearStatus() { - bitField0_ = (bitField0_ & ~0x00000002); - status_ = 0; - onChanged(); - return this; - } - - private com.google.protobuf.MapField extra_; - - private com.google.protobuf.MapField internalGetExtra() { - if (extra_ == null) { - return com.google.protobuf.MapField - .emptyMapField( - ExtraDefaultEntryHolder.defaultEntry - ); - } - return extra_; - } - - private com.google.protobuf.MapField internalGetMutableExtra() { - if (extra_ == null) { - extra_ = com.google.protobuf.MapField - .newMapField( - ExtraDefaultEntryHolder.defaultEntry - ); - } - if (!extra_.isMutable()) { - extra_ = extra_.copy(); - } - bitField0_ |= 0x00000004; - onChanged(); - return extra_; - } - - public int getExtraCount() { - return internalGetExtra().getMap().size(); - } - - /** - * map<string, string> extra = 3; - */ - @java.lang.Override - public boolean containsExtra( - java.lang.String key - ) { - if (key == null) { - throw new NullPointerException("map key"); - } - return internalGetExtra().getMap().containsKey(key); - } - - /** - * Use {@link #getExtraMap()} instead. - */ - @java.lang.Override - @java.lang.Deprecated - public java.util.Map getExtra() { - return getExtraMap(); - } - - /** - * map<string, string> extra = 3; - */ - @java.lang.Override - public java.util.Map getExtraMap() { - return internalGetExtra().getMap(); - } - - /** - * map<string, string> extra = 3; - */ - @java.lang.Override - public /* nullable */ - java.lang.String getExtraOrDefault( - java.lang.String key, - /* nullable */ - java.lang.String defaultValue - ) { - if (key == null) { - throw new NullPointerException("map key"); - } - java.util.Map map = internalGetExtra().getMap(); - return map.containsKey(key) ? map.get(key) : defaultValue; - } - - /** - * map<string, string> extra = 3; - */ - @java.lang.Override - public java.lang.String getExtraOrThrow( - java.lang.String key - ) { - if (key == null) { - throw new NullPointerException("map key"); - } - java.util.Map map = internalGetExtra().getMap(); - if (!map.containsKey(key)) { - throw new java.lang.IllegalArgumentException(); - } - return map.get(key); - } - - public Builder clearExtra() { - bitField0_ = (bitField0_ & ~0x00000004); - internalGetMutableExtra() - .getMutableMap() - .clear(); - return this; - } - - /** - * map<string, string> extra = 3; - */ - public Builder removeExtra( - java.lang.String key - ) { - if (key == null) { - throw new NullPointerException("map key"); - } - internalGetMutableExtra() - .getMutableMap() - .remove(key); - return this; - } - - /** - * Use alternate mutation accessors instead. - */ - @java.lang.Deprecated - public java.util.Map getMutableExtra() { - bitField0_ |= 0x00000004; - return internalGetMutableExtra().getMutableMap(); - } - - /** - * map<string, string> extra = 3; - */ - public Builder putExtra( - java.lang.String key, - java.lang.String value - ) { - if (key == null) { - throw new NullPointerException("map key"); - } - if (value == null) { - throw new NullPointerException("map value"); - } - internalGetMutableExtra() - .getMutableMap() - .put(key, value); - bitField0_ |= 0x00000004; - return this; - } - - /** - * map<string, string> extra = 3; - */ - public Builder putAllExtra( - java.util.Map values - ) { - internalGetMutableExtra() - .getMutableMap() - .putAll(values); - bitField0_ |= 0x00000004; - return this; - } - - private int confidence_ = 0; - - /** - * optional .messages.TrackerStatus.Confidence confidence = 4; - * - * @return Whether the confidence field is set. - */ - @java.lang.Override - public boolean hasConfidence() { - return ((bitField0_ & 0x00000008) != 0); - } - - /** - * optional .messages.TrackerStatus.Confidence confidence = 4; - * - * @return The enum numeric value on the wire for confidence. - */ - @java.lang.Override - public int getConfidenceValue() { - return confidence_; - } - - /** - * optional .messages.TrackerStatus.Confidence confidence = 4; - * - * @param value The enum numeric value on the wire for confidence to - * set. - * @return This builder for chaining. - */ - public Builder setConfidenceValue(int value) { - confidence_ = value; - bitField0_ |= 0x00000008; - onChanged(); - return this; - } - - /** - * optional .messages.TrackerStatus.Confidence confidence = 4; - * - * @return The confidence. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Confidence getConfidence() { - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Confidence result = dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Confidence - .forNumber(confidence_); - return result == null - ? dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Confidence.UNRECOGNIZED - : result; - } - - /** - * optional .messages.TrackerStatus.Confidence confidence = 4; - * - * @param value The confidence to set. - * @return This builder for chaining. - */ - public Builder setConfidence( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Confidence value - ) { - if (value == null) { - throw new NullPointerException(); - } - bitField0_ |= 0x00000008; - confidence_ = value.getNumber(); - onChanged(); - return this; - } - - /** - * optional .messages.TrackerStatus.Confidence confidence = 4; - * - * @return This builder for chaining. - */ - public Builder clearConfidence() { - bitField0_ = (bitField0_ & ~0x00000008); - confidence_ = 0; - onChanged(); - return this; - } - - // @@protoc_insertion_point(builder_scope:messages.TrackerStatus) - } - - // @@protoc_insertion_point(class_scope:messages.TrackerStatus) - private static final dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus DEFAULT_INSTANCE; - static { - DEFAULT_INSTANCE = new dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus(); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus getDefaultInstance() { - return DEFAULT_INSTANCE; - } - - private static final com.google.protobuf.Parser PARSER = new com.google.protobuf.AbstractParser() { - @java.lang.Override - public TrackerStatus parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - Builder builder = newBuilder(); - try { - builder.mergeFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(builder.buildPartial()); - } catch (com.google.protobuf.UninitializedMessageException e) { - throw e - .asInvalidProtocolBufferException() - .setUnfinishedMessage(builder.buildPartial()); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException(e) - .setUnfinishedMessage(builder.buildPartial()); - } - return builder.buildPartial(); - } - }; - - public static com.google.protobuf.Parser parser() { - return PARSER; - } - - @java.lang.Override - public com.google.protobuf.Parser getParserForType() { - return PARSER; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus getDefaultInstanceForType() { - return DEFAULT_INSTANCE; - } - - } - - public interface BatteryOrBuilder extends - // @@protoc_insertion_point(interface_extends:messages.Battery) - com.google.protobuf.MessageOrBuilder { - - /** - * int32 tracker_id = 1; - * - * @return The trackerId. - */ - int getTrackerId(); - - /** - * float battery_level = 2; - * - * @return The batteryLevel. - */ - float getBatteryLevel(); - - /** - * bool is_charging = 3; - * - * @return The isCharging. - */ - boolean getIsCharging(); - } - - /** - * Protobuf type {@code messages.Battery} - */ - public static final class Battery extends - com.google.protobuf.GeneratedMessage implements - // @@protoc_insertion_point(message_implements:messages.Battery) - BatteryOrBuilder { - private static final long serialVersionUID = 0L; - static { - com.google.protobuf.RuntimeVersion - .validateProtobufGencodeVersion( - com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, - /* major= */ 4, - /* minor= */ 31, - /* patch= */ 1, - /* suffix= */ "", - Battery.class.getName() - ); - } - - // Use Battery.newBuilder() to construct. - private Battery(com.google.protobuf.GeneratedMessage.Builder builder) { - super(builder); - } - - private Battery() { - } - - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_Battery_descriptor; - } - - @java.lang.Override - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_Battery_fieldAccessorTable - .ensureFieldAccessorsInitialized( - dev.slimevr.desktop.platform.ProtobufMessages.Battery.class, - dev.slimevr.desktop.platform.ProtobufMessages.Battery.Builder.class - ); - } - - public static final int TRACKER_ID_FIELD_NUMBER = 1; - private int trackerId_ = 0; - - /** - * int32 tracker_id = 1; - * - * @return The trackerId. - */ - @java.lang.Override - public int getTrackerId() { - return trackerId_; - } - - public static final int BATTERY_LEVEL_FIELD_NUMBER = 2; - private float batteryLevel_ = 0F; - - /** - * float battery_level = 2; - * - * @return The batteryLevel. - */ - @java.lang.Override - public float getBatteryLevel() { - return batteryLevel_; - } - - public static final int IS_CHARGING_FIELD_NUMBER = 3; - private boolean isCharging_ = false; - - /** - * bool is_charging = 3; - * - * @return The isCharging. - */ - @java.lang.Override - public boolean getIsCharging() { - return isCharging_; - } - - private byte memoizedIsInitialized = -1; - - @java.lang.Override - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized == 1) - return true; - if (isInitialized == 0) - return false; - - memoizedIsInitialized = 1; - return true; - } - - @java.lang.Override - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - if (trackerId_ != 0) { - output.writeInt32(1, trackerId_); - } - if (java.lang.Float.floatToRawIntBits(batteryLevel_) != 0) { - output.writeFloat(2, batteryLevel_); - } - if (isCharging_ != false) { - output.writeBool(3, isCharging_); - } - getUnknownFields().writeTo(output); - } - - @java.lang.Override - public int getSerializedSize() { - int size = memoizedSize; - if (size != -1) - return size; - - size = 0; - if (trackerId_ != 0) { - size += com.google.protobuf.CodedOutputStream - .computeInt32Size(1, trackerId_); - } - if (java.lang.Float.floatToRawIntBits(batteryLevel_) != 0) { - size += com.google.protobuf.CodedOutputStream - .computeFloatSize(2, batteryLevel_); - } - if (isCharging_ != false) { - size += com.google.protobuf.CodedOutputStream - .computeBoolSize(3, isCharging_); - } - size += getUnknownFields().getSerializedSize(); - memoizedSize = size; - return size; - } - - @java.lang.Override - public boolean equals(final java.lang.Object obj) { - if (obj == this) { - return true; - } - if (!(obj instanceof dev.slimevr.desktop.platform.ProtobufMessages.Battery)) { - return super.equals(obj); - } - dev.slimevr.desktop.platform.ProtobufMessages.Battery other = (dev.slimevr.desktop.platform.ProtobufMessages.Battery) obj; - - if ( - getTrackerId() - != other.getTrackerId() - ) - return false; - if ( - java.lang.Float.floatToIntBits(getBatteryLevel()) - != java.lang.Float - .floatToIntBits( - other.getBatteryLevel() - ) - ) - return false; - if ( - getIsCharging() - != other.getIsCharging() - ) - return false; - if (!getUnknownFields().equals(other.getUnknownFields())) - return false; - return true; - } - - @java.lang.Override - public int hashCode() { - if (memoizedHashCode != 0) { - return memoizedHashCode; - } - int hash = 41; - hash = (19 * hash) + getDescriptor().hashCode(); - hash = (37 * hash) + TRACKER_ID_FIELD_NUMBER; - hash = (53 * hash) + getTrackerId(); - hash = (37 * hash) + BATTERY_LEVEL_FIELD_NUMBER; - hash = (53 * hash) - + java.lang.Float - .floatToIntBits( - getBatteryLevel() - ); - hash = (37 * hash) + IS_CHARGING_FIELD_NUMBER; - hash = (53 * hash) - + com.google.protobuf.Internal - .hashBoolean( - getIsCharging() - ); - hash = (29 * hash) + getUnknownFields().hashCode(); - memoizedHashCode = hash; - return hash; - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Battery parseFrom( - java.nio.ByteBuffer data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Battery parseFrom( - java.nio.ByteBuffer data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Battery parseFrom( - com.google.protobuf.ByteString data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Battery parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Battery parseFrom(byte[] data) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Battery parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Battery parseFrom( - java.io.InputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Battery parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Battery parseDelimitedFrom( - java.io.InputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseDelimitedWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Battery parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseDelimitedWithIOException(PARSER, input, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Battery parseFrom( - com.google.protobuf.CodedInputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Battery parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input, extensionRegistry); - } - - @java.lang.Override - public Builder newBuilderForType() { - return newBuilder(); - } - - public static Builder newBuilder() { - return DEFAULT_INSTANCE.toBuilder(); - } - - public static Builder newBuilder( - dev.slimevr.desktop.platform.ProtobufMessages.Battery prototype - ) { - return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); - } - - @java.lang.Override - public Builder toBuilder() { - return this == DEFAULT_INSTANCE - ? new Builder() - : new Builder().mergeFrom(this); - } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent - ) { - Builder builder = new Builder(parent); - return builder; - } - - /** - * Protobuf type {@code messages.Battery} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder implements - // @@protoc_insertion_point(builder_implements:messages.Battery) - dev.slimevr.desktop.platform.ProtobufMessages.BatteryOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_Battery_descriptor; - } - - @java.lang.Override - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_Battery_fieldAccessorTable - .ensureFieldAccessorsInitialized( - dev.slimevr.desktop.platform.ProtobufMessages.Battery.class, - dev.slimevr.desktop.platform.ProtobufMessages.Battery.Builder.class - ); - } - - // Construct using - // dev.slimevr.desktop.platform.ProtobufMessages.Battery.newBuilder() - private Builder() { - - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent - ) { - super(parent); - - } - - @java.lang.Override - public Builder clear() { - super.clear(); - bitField0_ = 0; - trackerId_ = 0; - batteryLevel_ = 0F; - isCharging_ = false; - return this; - } - - @java.lang.Override - public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_Battery_descriptor; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Battery getDefaultInstanceForType() { - return dev.slimevr.desktop.platform.ProtobufMessages.Battery.getDefaultInstance(); - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Battery build() { - dev.slimevr.desktop.platform.ProtobufMessages.Battery result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Battery buildPartial() { - dev.slimevr.desktop.platform.ProtobufMessages.Battery result = new dev.slimevr.desktop.platform.ProtobufMessages.Battery( - this - ); - if (bitField0_ != 0) { - buildPartial0(result); - } - onBuilt(); - return result; - } - - private void buildPartial0( - dev.slimevr.desktop.platform.ProtobufMessages.Battery result - ) { - int from_bitField0_ = bitField0_; - if (((from_bitField0_ & 0x00000001) != 0)) { - result.trackerId_ = trackerId_; - } - if (((from_bitField0_ & 0x00000002) != 0)) { - result.batteryLevel_ = batteryLevel_; - } - if (((from_bitField0_ & 0x00000004) != 0)) { - result.isCharging_ = isCharging_; - } - } - - @java.lang.Override - public Builder mergeFrom(com.google.protobuf.Message other) { - if (other instanceof dev.slimevr.desktop.platform.ProtobufMessages.Battery) { - return mergeFrom((dev.slimevr.desktop.platform.ProtobufMessages.Battery) other); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom(dev.slimevr.desktop.platform.ProtobufMessages.Battery other) { - if ( - other - == dev.slimevr.desktop.platform.ProtobufMessages.Battery - .getDefaultInstance() - ) - return this; - if (other.getTrackerId() != 0) { - setTrackerId(other.getTrackerId()); - } - if (java.lang.Float.floatToRawIntBits(other.getBatteryLevel()) != 0) { - setBatteryLevel(other.getBatteryLevel()); - } - if (other.getIsCharging() != false) { - setIsCharging(other.getIsCharging()); - } - this.mergeUnknownFields(other.getUnknownFields()); - onChanged(); - return this; - } - - @java.lang.Override - public final boolean isInitialized() { - return true; - } - - @java.lang.Override - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - if (extensionRegistry == null) { - throw new java.lang.NullPointerException(); - } - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - case 8: { - trackerId_ = input.readInt32(); - bitField0_ |= 0x00000001; - break; - } // case 8 - case 21: { - batteryLevel_ = input.readFloat(); - bitField0_ |= 0x00000002; - break; - } // case 21 - case 24: { - isCharging_ = input.readBool(); - bitField0_ |= 0x00000004; - break; - } // case 24 - default: { - if (!super.parseUnknownField(input, extensionRegistry, tag)) { - done = true; // was an endgroup tag - } - break; - } // default: - } // switch (tag) - } // while (!done) - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.unwrapIOException(); - } finally { - onChanged(); - } // finally - return this; - } - - private int bitField0_; - - private int trackerId_; - - /** - * int32 tracker_id = 1; - * - * @return The trackerId. - */ - @java.lang.Override - public int getTrackerId() { - return trackerId_; - } - - /** - * int32 tracker_id = 1; - * - * @param value The trackerId to set. - * @return This builder for chaining. - */ - public Builder setTrackerId(int value) { - - trackerId_ = value; - bitField0_ |= 0x00000001; - onChanged(); - return this; - } - - /** - * int32 tracker_id = 1; - * - * @return This builder for chaining. - */ - public Builder clearTrackerId() { - bitField0_ = (bitField0_ & ~0x00000001); - trackerId_ = 0; - onChanged(); - return this; - } - - private float batteryLevel_; - - /** - * float battery_level = 2; - * - * @return The batteryLevel. - */ - @java.lang.Override - public float getBatteryLevel() { - return batteryLevel_; - } - - /** - * float battery_level = 2; - * - * @param value The batteryLevel to set. - * @return This builder for chaining. - */ - public Builder setBatteryLevel(float value) { - - batteryLevel_ = value; - bitField0_ |= 0x00000002; - onChanged(); - return this; - } - - /** - * float battery_level = 2; - * - * @return This builder for chaining. - */ - public Builder clearBatteryLevel() { - bitField0_ = (bitField0_ & ~0x00000002); - batteryLevel_ = 0F; - onChanged(); - return this; - } - - private boolean isCharging_; - - /** - * bool is_charging = 3; - * - * @return The isCharging. - */ - @java.lang.Override - public boolean getIsCharging() { - return isCharging_; - } - - /** - * bool is_charging = 3; - * - * @param value The isCharging to set. - * @return This builder for chaining. - */ - public Builder setIsCharging(boolean value) { - - isCharging_ = value; - bitField0_ |= 0x00000004; - onChanged(); - return this; - } - - /** - * bool is_charging = 3; - * - * @return This builder for chaining. - */ - public Builder clearIsCharging() { - bitField0_ = (bitField0_ & ~0x00000004); - isCharging_ = false; - onChanged(); - return this; - } - - // @@protoc_insertion_point(builder_scope:messages.Battery) - } - - // @@protoc_insertion_point(class_scope:messages.Battery) - private static final dev.slimevr.desktop.platform.ProtobufMessages.Battery DEFAULT_INSTANCE; - static { - DEFAULT_INSTANCE = new dev.slimevr.desktop.platform.ProtobufMessages.Battery(); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.Battery getDefaultInstance() { - return DEFAULT_INSTANCE; - } - - private static final com.google.protobuf.Parser PARSER = new com.google.protobuf.AbstractParser() { - @java.lang.Override - public Battery parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - Builder builder = newBuilder(); - try { - builder.mergeFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(builder.buildPartial()); - } catch (com.google.protobuf.UninitializedMessageException e) { - throw e - .asInvalidProtocolBufferException() - .setUnfinishedMessage(builder.buildPartial()); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException(e) - .setUnfinishedMessage(builder.buildPartial()); - } - return builder.buildPartial(); - } - }; - - public static com.google.protobuf.Parser parser() { - return PARSER; - } - - @java.lang.Override - public com.google.protobuf.Parser getParserForType() { - return PARSER; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Battery getDefaultInstanceForType() { - return DEFAULT_INSTANCE; - } - - } - - public interface ProtobufMessageOrBuilder extends - // @@protoc_insertion_point(interface_extends:messages.ProtobufMessage) - com.google.protobuf.MessageOrBuilder { - - /** - * .messages.Position position = 1; - * - * @return Whether the position field is set. - */ - boolean hasPosition(); - - /** - * .messages.Position position = 1; - * - * @return The position. - */ - dev.slimevr.desktop.platform.ProtobufMessages.Position getPosition(); - - /** - * .messages.Position position = 1; - */ - dev.slimevr.desktop.platform.ProtobufMessages.PositionOrBuilder getPositionOrBuilder(); - - /** - * .messages.UserAction user_action = 2; - * - * @return Whether the userAction field is set. - */ - boolean hasUserAction(); - - /** - * .messages.UserAction user_action = 2; - * - * @return The userAction. - */ - dev.slimevr.desktop.platform.ProtobufMessages.UserAction getUserAction(); - - /** - * .messages.UserAction user_action = 2; - */ - dev.slimevr.desktop.platform.ProtobufMessages.UserActionOrBuilder getUserActionOrBuilder(); - - /** - * .messages.TrackerAdded tracker_added = 3; - * - * @return Whether the trackerAdded field is set. - */ - boolean hasTrackerAdded(); - - /** - * .messages.TrackerAdded tracker_added = 3; - * - * @return The trackerAdded. - */ - dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded getTrackerAdded(); - - /** - * .messages.TrackerAdded tracker_added = 3; - */ - dev.slimevr.desktop.platform.ProtobufMessages.TrackerAddedOrBuilder getTrackerAddedOrBuilder(); - - /** - * .messages.TrackerStatus tracker_status = 4; - * - * @return Whether the trackerStatus field is set. - */ - boolean hasTrackerStatus(); - - /** - * .messages.TrackerStatus tracker_status = 4; - * - * @return The trackerStatus. - */ - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus getTrackerStatus(); - - /** - * .messages.TrackerStatus tracker_status = 4; - */ - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatusOrBuilder getTrackerStatusOrBuilder(); - - /** - * .messages.Battery battery = 5; - * - * @return Whether the battery field is set. - */ - boolean hasBattery(); - - /** - * .messages.Battery battery = 5; - * - * @return The battery. - */ - dev.slimevr.desktop.platform.ProtobufMessages.Battery getBattery(); - - /** - * .messages.Battery battery = 5; - */ - dev.slimevr.desktop.platform.ProtobufMessages.BatteryOrBuilder getBatteryOrBuilder(); - - /** - * .messages.Version version = 6; - * - * @return Whether the version field is set. - */ - boolean hasVersion(); - - /** - * .messages.Version version = 6; - * - * @return The version. - */ - dev.slimevr.desktop.platform.ProtobufMessages.Version getVersion(); - - /** - * .messages.Version version = 6; - */ - dev.slimevr.desktop.platform.ProtobufMessages.VersionOrBuilder getVersionOrBuilder(); - - dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage.MessageCase getMessageCase(); - } - - /** - * Protobuf type {@code messages.ProtobufMessage} - */ - public static final class ProtobufMessage extends - com.google.protobuf.GeneratedMessage implements - // @@protoc_insertion_point(message_implements:messages.ProtobufMessage) - ProtobufMessageOrBuilder { - private static final long serialVersionUID = 0L; - static { - com.google.protobuf.RuntimeVersion - .validateProtobufGencodeVersion( - com.google.protobuf.RuntimeVersion.RuntimeDomain.PUBLIC, - /* major= */ 4, - /* minor= */ 31, - /* patch= */ 1, - /* suffix= */ "", - ProtobufMessage.class.getName() - ); - } - - // Use ProtobufMessage.newBuilder() to construct. - private ProtobufMessage(com.google.protobuf.GeneratedMessage.Builder builder) { - super(builder); - } - - private ProtobufMessage() { - } - - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_ProtobufMessage_descriptor; - } - - @java.lang.Override - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_ProtobufMessage_fieldAccessorTable - .ensureFieldAccessorsInitialized( - dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage.class, - dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage.Builder.class - ); - } - - private int messageCase_ = 0; - @SuppressWarnings("serial") - private java.lang.Object message_; - - public enum MessageCase - implements com.google.protobuf.Internal.EnumLite, - com.google.protobuf.AbstractMessage.InternalOneOfEnum { - POSITION(1), - USER_ACTION(2), - TRACKER_ADDED(3), - TRACKER_STATUS(4), - BATTERY(5), - VERSION(6), - MESSAGE_NOT_SET(0); - - private final int value; - - private MessageCase(int value) { - this.value = value; - } - - /** - * @param value The number of the enum to look for. - * @return The enum associated with the given number. - * @deprecated Use {@link #forNumber(int)} instead. - */ - @java.lang.Deprecated - public static MessageCase valueOf(int value) { - return forNumber(value); - } - - public static MessageCase forNumber(int value) { - switch (value) { - case 1: - return POSITION; - case 2: - return USER_ACTION; - case 3: - return TRACKER_ADDED; - case 4: - return TRACKER_STATUS; - case 5: - return BATTERY; - case 6: - return VERSION; - case 0: - return MESSAGE_NOT_SET; - default: - return null; - } - } - - public int getNumber() { - return this.value; - } - }; - - public MessageCase getMessageCase() { - return MessageCase - .forNumber( - messageCase_ - ); - } - - public static final int POSITION_FIELD_NUMBER = 1; - - /** - * .messages.Position position = 1; - * - * @return Whether the position field is set. - */ - @java.lang.Override - public boolean hasPosition() { - return messageCase_ == 1; - } - - /** - * .messages.Position position = 1; - * - * @return The position. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Position getPosition() { - if (messageCase_ == 1) { - return (dev.slimevr.desktop.platform.ProtobufMessages.Position) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.Position.getDefaultInstance(); - } - - /** - * .messages.Position position = 1; - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.PositionOrBuilder getPositionOrBuilder() { - if (messageCase_ == 1) { - return (dev.slimevr.desktop.platform.ProtobufMessages.Position) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.Position.getDefaultInstance(); - } - - public static final int USER_ACTION_FIELD_NUMBER = 2; - - /** - * .messages.UserAction user_action = 2; - * - * @return Whether the userAction field is set. - */ - @java.lang.Override - public boolean hasUserAction() { - return messageCase_ == 2; - } - - /** - * .messages.UserAction user_action = 2; - * - * @return The userAction. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.UserAction getUserAction() { - if (messageCase_ == 2) { - return (dev.slimevr.desktop.platform.ProtobufMessages.UserAction) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.UserAction.getDefaultInstance(); - } - - /** - * .messages.UserAction user_action = 2; - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.UserActionOrBuilder getUserActionOrBuilder() { - if (messageCase_ == 2) { - return (dev.slimevr.desktop.platform.ProtobufMessages.UserAction) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.UserAction.getDefaultInstance(); - } - - public static final int TRACKER_ADDED_FIELD_NUMBER = 3; - - /** - * .messages.TrackerAdded tracker_added = 3; - * - * @return Whether the trackerAdded field is set. - */ - @java.lang.Override - public boolean hasTrackerAdded() { - return messageCase_ == 3; - } - - /** - * .messages.TrackerAdded tracker_added = 3; - * - * @return The trackerAdded. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded getTrackerAdded() { - if (messageCase_ == 3) { - return (dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded.getDefaultInstance(); - } - - /** - * .messages.TrackerAdded tracker_added = 3; - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerAddedOrBuilder getTrackerAddedOrBuilder() { - if (messageCase_ == 3) { - return (dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded.getDefaultInstance(); - } - - public static final int TRACKER_STATUS_FIELD_NUMBER = 4; - - /** - * .messages.TrackerStatus tracker_status = 4; - * - * @return Whether the trackerStatus field is set. - */ - @java.lang.Override - public boolean hasTrackerStatus() { - return messageCase_ == 4; - } - - /** - * .messages.TrackerStatus tracker_status = 4; - * - * @return The trackerStatus. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus getTrackerStatus() { - if (messageCase_ == 4) { - return (dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.getDefaultInstance(); - } - - /** - * .messages.TrackerStatus tracker_status = 4; - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatusOrBuilder getTrackerStatusOrBuilder() { - if (messageCase_ == 4) { - return (dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.getDefaultInstance(); - } - - public static final int BATTERY_FIELD_NUMBER = 5; - - /** - * .messages.Battery battery = 5; - * - * @return Whether the battery field is set. - */ - @java.lang.Override - public boolean hasBattery() { - return messageCase_ == 5; - } - - /** - * .messages.Battery battery = 5; - * - * @return The battery. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Battery getBattery() { - if (messageCase_ == 5) { - return (dev.slimevr.desktop.platform.ProtobufMessages.Battery) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.Battery.getDefaultInstance(); - } - - /** - * .messages.Battery battery = 5; - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.BatteryOrBuilder getBatteryOrBuilder() { - if (messageCase_ == 5) { - return (dev.slimevr.desktop.platform.ProtobufMessages.Battery) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.Battery.getDefaultInstance(); - } - - public static final int VERSION_FIELD_NUMBER = 6; - - /** - * .messages.Version version = 6; - * - * @return Whether the version field is set. - */ - @java.lang.Override - public boolean hasVersion() { - return messageCase_ == 6; - } - - /** - * .messages.Version version = 6; - * - * @return The version. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Version getVersion() { - if (messageCase_ == 6) { - return (dev.slimevr.desktop.platform.ProtobufMessages.Version) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.Version.getDefaultInstance(); - } - - /** - * .messages.Version version = 6; - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.VersionOrBuilder getVersionOrBuilder() { - if (messageCase_ == 6) { - return (dev.slimevr.desktop.platform.ProtobufMessages.Version) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.Version.getDefaultInstance(); - } - - private byte memoizedIsInitialized = -1; - - @java.lang.Override - public final boolean isInitialized() { - byte isInitialized = memoizedIsInitialized; - if (isInitialized == 1) - return true; - if (isInitialized == 0) - return false; - - memoizedIsInitialized = 1; - return true; - } - - @java.lang.Override - public void writeTo(com.google.protobuf.CodedOutputStream output) - throws java.io.IOException { - if (messageCase_ == 1) { - output - .writeMessage( - 1, - (dev.slimevr.desktop.platform.ProtobufMessages.Position) message_ - ); - } - if (messageCase_ == 2) { - output - .writeMessage( - 2, - (dev.slimevr.desktop.platform.ProtobufMessages.UserAction) message_ - ); - } - if (messageCase_ == 3) { - output - .writeMessage( - 3, - (dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded) message_ - ); - } - if (messageCase_ == 4) { - output - .writeMessage( - 4, - (dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus) message_ - ); - } - if (messageCase_ == 5) { - output - .writeMessage( - 5, - (dev.slimevr.desktop.platform.ProtobufMessages.Battery) message_ - ); - } - if (messageCase_ == 6) { - output - .writeMessage( - 6, - (dev.slimevr.desktop.platform.ProtobufMessages.Version) message_ - ); - } - getUnknownFields().writeTo(output); - } - - @java.lang.Override - public int getSerializedSize() { - int size = memoizedSize; - if (size != -1) - return size; - - size = 0; - if (messageCase_ == 1) { - size += com.google.protobuf.CodedOutputStream - .computeMessageSize( - 1, - (dev.slimevr.desktop.platform.ProtobufMessages.Position) message_ - ); - } - if (messageCase_ == 2) { - size += com.google.protobuf.CodedOutputStream - .computeMessageSize( - 2, - (dev.slimevr.desktop.platform.ProtobufMessages.UserAction) message_ - ); - } - if (messageCase_ == 3) { - size += com.google.protobuf.CodedOutputStream - .computeMessageSize( - 3, - (dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded) message_ - ); - } - if (messageCase_ == 4) { - size += com.google.protobuf.CodedOutputStream - .computeMessageSize( - 4, - (dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus) message_ - ); - } - if (messageCase_ == 5) { - size += com.google.protobuf.CodedOutputStream - .computeMessageSize( - 5, - (dev.slimevr.desktop.platform.ProtobufMessages.Battery) message_ - ); - } - if (messageCase_ == 6) { - size += com.google.protobuf.CodedOutputStream - .computeMessageSize( - 6, - (dev.slimevr.desktop.platform.ProtobufMessages.Version) message_ - ); - } - size += getUnknownFields().getSerializedSize(); - memoizedSize = size; - return size; - } - - @java.lang.Override - public boolean equals(final java.lang.Object obj) { - if (obj == this) { - return true; - } - if (!(obj instanceof dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage)) { - return super.equals(obj); - } - dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage other = (dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage) obj; - - if (!getMessageCase().equals(other.getMessageCase())) - return false; - switch (messageCase_) { - case 1: - if ( - !getPosition() - .equals(other.getPosition()) - ) - return false; - break; - case 2: - if ( - !getUserAction() - .equals(other.getUserAction()) - ) - return false; - break; - case 3: - if ( - !getTrackerAdded() - .equals(other.getTrackerAdded()) - ) - return false; - break; - case 4: - if ( - !getTrackerStatus() - .equals(other.getTrackerStatus()) - ) - return false; - break; - case 5: - if ( - !getBattery() - .equals(other.getBattery()) - ) - return false; - break; - case 6: - if ( - !getVersion() - .equals(other.getVersion()) - ) - return false; - break; - case 0: - default: - } - if (!getUnknownFields().equals(other.getUnknownFields())) - return false; - return true; - } - - @java.lang.Override - public int hashCode() { - if (memoizedHashCode != 0) { - return memoizedHashCode; - } - int hash = 41; - hash = (19 * hash) + getDescriptor().hashCode(); - switch (messageCase_) { - case 1: - hash = (37 * hash) + POSITION_FIELD_NUMBER; - hash = (53 * hash) + getPosition().hashCode(); - break; - case 2: - hash = (37 * hash) + USER_ACTION_FIELD_NUMBER; - hash = (53 * hash) + getUserAction().hashCode(); - break; - case 3: - hash = (37 * hash) + TRACKER_ADDED_FIELD_NUMBER; - hash = (53 * hash) + getTrackerAdded().hashCode(); - break; - case 4: - hash = (37 * hash) + TRACKER_STATUS_FIELD_NUMBER; - hash = (53 * hash) + getTrackerStatus().hashCode(); - break; - case 5: - hash = (37 * hash) + BATTERY_FIELD_NUMBER; - hash = (53 * hash) + getBattery().hashCode(); - break; - case 6: - hash = (37 * hash) + VERSION_FIELD_NUMBER; - hash = (53 * hash) + getVersion().hashCode(); - break; - case 0: - default: - } - hash = (29 * hash) + getUnknownFields().hashCode(); - memoizedHashCode = hash; - return hash; - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage parseFrom( - java.nio.ByteBuffer data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage parseFrom( - java.nio.ByteBuffer data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage parseFrom( - com.google.protobuf.ByteString data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage parseFrom( - com.google.protobuf.ByteString data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage parseFrom( - byte[] data - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage parseFrom( - byte[] data, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - return PARSER.parseFrom(data, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage parseFrom( - java.io.InputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage parseFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage parseDelimitedFrom( - java.io.InputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseDelimitedWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage parseDelimitedFrom( - java.io.InputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseDelimitedWithIOException(PARSER, input, extensionRegistry); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage parseFrom( - com.google.protobuf.CodedInputStream input - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage parseFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - return com.google.protobuf.GeneratedMessage - .parseWithIOException(PARSER, input, extensionRegistry); - } - - @java.lang.Override - public Builder newBuilderForType() { - return newBuilder(); - } - - public static Builder newBuilder() { - return DEFAULT_INSTANCE.toBuilder(); - } - - public static Builder newBuilder( - dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage prototype - ) { - return DEFAULT_INSTANCE.toBuilder().mergeFrom(prototype); - } - - @java.lang.Override - public Builder toBuilder() { - return this == DEFAULT_INSTANCE - ? new Builder() - : new Builder().mergeFrom(this); - } - - @java.lang.Override - protected Builder newBuilderForType( - com.google.protobuf.GeneratedMessage.BuilderParent parent - ) { - Builder builder = new Builder(parent); - return builder; - } - - /** - * Protobuf type {@code messages.ProtobufMessage} - */ - public static final class Builder extends - com.google.protobuf.GeneratedMessage.Builder implements - // @@protoc_insertion_point(builder_implements:messages.ProtobufMessage) - dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessageOrBuilder { - public static final com.google.protobuf.Descriptors.Descriptor getDescriptor() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_ProtobufMessage_descriptor; - } - - @java.lang.Override - protected com.google.protobuf.GeneratedMessage.FieldAccessorTable internalGetFieldAccessorTable() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_ProtobufMessage_fieldAccessorTable - .ensureFieldAccessorsInitialized( - dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage.class, - dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage.Builder.class - ); - } - - // Construct using - // dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage.newBuilder() - private Builder() { - - } - - private Builder( - com.google.protobuf.GeneratedMessage.BuilderParent parent - ) { - super(parent); - - } - - @java.lang.Override - public Builder clear() { - super.clear(); - bitField0_ = 0; - if (positionBuilder_ != null) { - positionBuilder_.clear(); - } - if (userActionBuilder_ != null) { - userActionBuilder_.clear(); - } - if (trackerAddedBuilder_ != null) { - trackerAddedBuilder_.clear(); - } - if (trackerStatusBuilder_ != null) { - trackerStatusBuilder_.clear(); - } - if (batteryBuilder_ != null) { - batteryBuilder_.clear(); - } - if (versionBuilder_ != null) { - versionBuilder_.clear(); - } - messageCase_ = 0; - message_ = null; - return this; - } - - @java.lang.Override - public com.google.protobuf.Descriptors.Descriptor getDescriptorForType() { - return dev.slimevr.desktop.platform.ProtobufMessages.internal_static_messages_ProtobufMessage_descriptor; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage getDefaultInstanceForType() { - return dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage - .getDefaultInstance(); - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage build() { - dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage result = buildPartial(); - if (!result.isInitialized()) { - throw newUninitializedMessageException(result); - } - return result; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage buildPartial() { - dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage result = new dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage( - this - ); - if (bitField0_ != 0) { - buildPartial0(result); - } - buildPartialOneofs(result); - onBuilt(); - return result; - } - - private void buildPartial0( - dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage result - ) { - int from_bitField0_ = bitField0_; - } - - private void buildPartialOneofs( - dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage result - ) { - result.messageCase_ = messageCase_; - result.message_ = this.message_; - if ( - messageCase_ == 1 - && - positionBuilder_ != null - ) { - result.message_ = positionBuilder_.build(); - } - if ( - messageCase_ == 2 - && - userActionBuilder_ != null - ) { - result.message_ = userActionBuilder_.build(); - } - if ( - messageCase_ == 3 - && - trackerAddedBuilder_ != null - ) { - result.message_ = trackerAddedBuilder_.build(); - } - if ( - messageCase_ == 4 - && - trackerStatusBuilder_ != null - ) { - result.message_ = trackerStatusBuilder_.build(); - } - if ( - messageCase_ == 5 - && - batteryBuilder_ != null - ) { - result.message_ = batteryBuilder_.build(); - } - if ( - messageCase_ == 6 - && - versionBuilder_ != null - ) { - result.message_ = versionBuilder_.build(); - } - } - - @java.lang.Override - public Builder mergeFrom(com.google.protobuf.Message other) { - if ( - other instanceof dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage - ) { - return mergeFrom( - (dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage) other - ); - } else { - super.mergeFrom(other); - return this; - } - } - - public Builder mergeFrom( - dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage other - ) { - if ( - other - == dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage - .getDefaultInstance() - ) - return this; - switch (other.getMessageCase()) { - case POSITION: { - mergePosition(other.getPosition()); - break; - } - case USER_ACTION: { - mergeUserAction(other.getUserAction()); - break; - } - case TRACKER_ADDED: { - mergeTrackerAdded(other.getTrackerAdded()); - break; - } - case TRACKER_STATUS: { - mergeTrackerStatus(other.getTrackerStatus()); - break; - } - case BATTERY: { - mergeBattery(other.getBattery()); - break; - } - case VERSION: { - mergeVersion(other.getVersion()); - break; - } - case MESSAGE_NOT_SET: { - break; - } - } - this.mergeUnknownFields(other.getUnknownFields()); - onChanged(); - return this; - } - - @java.lang.Override - public final boolean isInitialized() { - return true; - } - - @java.lang.Override - public Builder mergeFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws java.io.IOException { - if (extensionRegistry == null) { - throw new java.lang.NullPointerException(); - } - try { - boolean done = false; - while (!done) { - int tag = input.readTag(); - switch (tag) { - case 0: - done = true; - break; - case 10: { - input - .readMessage( - internalGetPositionFieldBuilder().getBuilder(), - extensionRegistry - ); - messageCase_ = 1; - break; - } // case 10 - case 18: { - input - .readMessage( - internalGetUserActionFieldBuilder().getBuilder(), - extensionRegistry - ); - messageCase_ = 2; - break; - } // case 18 - case 26: { - input - .readMessage( - internalGetTrackerAddedFieldBuilder().getBuilder(), - extensionRegistry - ); - messageCase_ = 3; - break; - } // case 26 - case 34: { - input - .readMessage( - internalGetTrackerStatusFieldBuilder().getBuilder(), - extensionRegistry - ); - messageCase_ = 4; - break; - } // case 34 - case 42: { - input - .readMessage( - internalGetBatteryFieldBuilder().getBuilder(), - extensionRegistry - ); - messageCase_ = 5; - break; - } // case 42 - case 50: { - input - .readMessage( - internalGetVersionFieldBuilder().getBuilder(), - extensionRegistry - ); - messageCase_ = 6; - break; - } // case 50 - default: { - if (!super.parseUnknownField(input, extensionRegistry, tag)) { - done = true; // was an endgroup tag - } - break; - } // default: - } // switch (tag) - } // while (!done) - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.unwrapIOException(); - } finally { - onChanged(); - } // finally - return this; - } - - private int messageCase_ = 0; - private java.lang.Object message_; - - public MessageCase getMessageCase() { - return MessageCase - .forNumber( - messageCase_ - ); - } - - public Builder clearMessage() { - messageCase_ = 0; - message_ = null; - onChanged(); - return this; - } - - private int bitField0_; - - private com.google.protobuf.SingleFieldBuilder positionBuilder_; - - /** - * .messages.Position position = 1; - * - * @return Whether the position field is set. - */ - @java.lang.Override - public boolean hasPosition() { - return messageCase_ == 1; - } - - /** - * .messages.Position position = 1; - * - * @return The position. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Position getPosition() { - if (positionBuilder_ == null) { - if (messageCase_ == 1) { - return (dev.slimevr.desktop.platform.ProtobufMessages.Position) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.Position - .getDefaultInstance(); - } else { - if (messageCase_ == 1) { - return positionBuilder_.getMessage(); - } - return dev.slimevr.desktop.platform.ProtobufMessages.Position - .getDefaultInstance(); - } - } - - /** - * .messages.Position position = 1; - */ - public Builder setPosition( - dev.slimevr.desktop.platform.ProtobufMessages.Position value - ) { - if (positionBuilder_ == null) { - if (value == null) { - throw new NullPointerException(); - } - message_ = value; - onChanged(); - } else { - positionBuilder_.setMessage(value); - } - messageCase_ = 1; - return this; - } - - /** - * .messages.Position position = 1; - */ - public Builder setPosition( - dev.slimevr.desktop.platform.ProtobufMessages.Position.Builder builderForValue - ) { - if (positionBuilder_ == null) { - message_ = builderForValue.build(); - onChanged(); - } else { - positionBuilder_.setMessage(builderForValue.build()); - } - messageCase_ = 1; - return this; - } - - /** - * .messages.Position position = 1; - */ - public Builder mergePosition( - dev.slimevr.desktop.platform.ProtobufMessages.Position value - ) { - if (positionBuilder_ == null) { - if ( - messageCase_ == 1 - && - message_ - != dev.slimevr.desktop.platform.ProtobufMessages.Position - .getDefaultInstance() - ) { - message_ = dev.slimevr.desktop.platform.ProtobufMessages.Position - .newBuilder( - (dev.slimevr.desktop.platform.ProtobufMessages.Position) message_ - ) - .mergeFrom(value) - .buildPartial(); - } else { - message_ = value; - } - onChanged(); - } else { - if (messageCase_ == 1) { - positionBuilder_.mergeFrom(value); - } else { - positionBuilder_.setMessage(value); - } - } - messageCase_ = 1; - return this; - } - - /** - * .messages.Position position = 1; - */ - public Builder clearPosition() { - if (positionBuilder_ == null) { - if (messageCase_ == 1) { - messageCase_ = 0; - message_ = null; - onChanged(); - } - } else { - if (messageCase_ == 1) { - messageCase_ = 0; - message_ = null; - } - positionBuilder_.clear(); - } - return this; - } - - /** - * .messages.Position position = 1; - */ - public dev.slimevr.desktop.platform.ProtobufMessages.Position.Builder getPositionBuilder() { - return internalGetPositionFieldBuilder().getBuilder(); - } - - /** - * .messages.Position position = 1; - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.PositionOrBuilder getPositionOrBuilder() { - if ((messageCase_ == 1) && (positionBuilder_ != null)) { - return positionBuilder_.getMessageOrBuilder(); - } else { - if (messageCase_ == 1) { - return (dev.slimevr.desktop.platform.ProtobufMessages.Position) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.Position - .getDefaultInstance(); - } - } - - /** - * .messages.Position position = 1; - */ - private com.google.protobuf.SingleFieldBuilder internalGetPositionFieldBuilder() { - if (positionBuilder_ == null) { - if (!(messageCase_ == 1)) { - message_ = dev.slimevr.desktop.platform.ProtobufMessages.Position - .getDefaultInstance(); - } - positionBuilder_ = new com.google.protobuf.SingleFieldBuilder( - (dev.slimevr.desktop.platform.ProtobufMessages.Position) message_, - getParentForChildren(), - isClean() - ); - message_ = null; - } - messageCase_ = 1; - onChanged(); - return positionBuilder_; - } - - private com.google.protobuf.SingleFieldBuilder userActionBuilder_; - - /** - * .messages.UserAction user_action = 2; - * - * @return Whether the userAction field is set. - */ - @java.lang.Override - public boolean hasUserAction() { - return messageCase_ == 2; - } - - /** - * .messages.UserAction user_action = 2; - * - * @return The userAction. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.UserAction getUserAction() { - if (userActionBuilder_ == null) { - if (messageCase_ == 2) { - return (dev.slimevr.desktop.platform.ProtobufMessages.UserAction) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.UserAction - .getDefaultInstance(); - } else { - if (messageCase_ == 2) { - return userActionBuilder_.getMessage(); - } - return dev.slimevr.desktop.platform.ProtobufMessages.UserAction - .getDefaultInstance(); - } - } - - /** - * .messages.UserAction user_action = 2; - */ - public Builder setUserAction( - dev.slimevr.desktop.platform.ProtobufMessages.UserAction value - ) { - if (userActionBuilder_ == null) { - if (value == null) { - throw new NullPointerException(); - } - message_ = value; - onChanged(); - } else { - userActionBuilder_.setMessage(value); - } - messageCase_ = 2; - return this; - } - - /** - * .messages.UserAction user_action = 2; - */ - public Builder setUserAction( - dev.slimevr.desktop.platform.ProtobufMessages.UserAction.Builder builderForValue - ) { - if (userActionBuilder_ == null) { - message_ = builderForValue.build(); - onChanged(); - } else { - userActionBuilder_.setMessage(builderForValue.build()); - } - messageCase_ = 2; - return this; - } - - /** - * .messages.UserAction user_action = 2; - */ - public Builder mergeUserAction( - dev.slimevr.desktop.platform.ProtobufMessages.UserAction value - ) { - if (userActionBuilder_ == null) { - if ( - messageCase_ == 2 - && - message_ - != dev.slimevr.desktop.platform.ProtobufMessages.UserAction - .getDefaultInstance() - ) { - message_ = dev.slimevr.desktop.platform.ProtobufMessages.UserAction - .newBuilder( - (dev.slimevr.desktop.platform.ProtobufMessages.UserAction) message_ - ) - .mergeFrom(value) - .buildPartial(); - } else { - message_ = value; - } - onChanged(); - } else { - if (messageCase_ == 2) { - userActionBuilder_.mergeFrom(value); - } else { - userActionBuilder_.setMessage(value); - } - } - messageCase_ = 2; - return this; - } - - /** - * .messages.UserAction user_action = 2; - */ - public Builder clearUserAction() { - if (userActionBuilder_ == null) { - if (messageCase_ == 2) { - messageCase_ = 0; - message_ = null; - onChanged(); - } - } else { - if (messageCase_ == 2) { - messageCase_ = 0; - message_ = null; - } - userActionBuilder_.clear(); - } - return this; - } - - /** - * .messages.UserAction user_action = 2; - */ - public dev.slimevr.desktop.platform.ProtobufMessages.UserAction.Builder getUserActionBuilder() { - return internalGetUserActionFieldBuilder().getBuilder(); - } - - /** - * .messages.UserAction user_action = 2; - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.UserActionOrBuilder getUserActionOrBuilder() { - if ((messageCase_ == 2) && (userActionBuilder_ != null)) { - return userActionBuilder_.getMessageOrBuilder(); - } else { - if (messageCase_ == 2) { - return (dev.slimevr.desktop.platform.ProtobufMessages.UserAction) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.UserAction - .getDefaultInstance(); - } - } - - /** - * .messages.UserAction user_action = 2; - */ - private com.google.protobuf.SingleFieldBuilder internalGetUserActionFieldBuilder() { - if (userActionBuilder_ == null) { - if (!(messageCase_ == 2)) { - message_ = dev.slimevr.desktop.platform.ProtobufMessages.UserAction - .getDefaultInstance(); - } - userActionBuilder_ = new com.google.protobuf.SingleFieldBuilder( - (dev.slimevr.desktop.platform.ProtobufMessages.UserAction) message_, - getParentForChildren(), - isClean() - ); - message_ = null; - } - messageCase_ = 2; - onChanged(); - return userActionBuilder_; - } - - private com.google.protobuf.SingleFieldBuilder trackerAddedBuilder_; - - /** - * .messages.TrackerAdded tracker_added = 3; - * - * @return Whether the trackerAdded field is set. - */ - @java.lang.Override - public boolean hasTrackerAdded() { - return messageCase_ == 3; - } - - /** - * .messages.TrackerAdded tracker_added = 3; - * - * @return The trackerAdded. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded getTrackerAdded() { - if (trackerAddedBuilder_ == null) { - if (messageCase_ == 3) { - return (dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded - .getDefaultInstance(); - } else { - if (messageCase_ == 3) { - return trackerAddedBuilder_.getMessage(); - } - return dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded - .getDefaultInstance(); - } - } - - /** - * .messages.TrackerAdded tracker_added = 3; - */ - public Builder setTrackerAdded( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded value - ) { - if (trackerAddedBuilder_ == null) { - if (value == null) { - throw new NullPointerException(); - } - message_ = value; - onChanged(); - } else { - trackerAddedBuilder_.setMessage(value); - } - messageCase_ = 3; - return this; - } - - /** - * .messages.TrackerAdded tracker_added = 3; - */ - public Builder setTrackerAdded( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded.Builder builderForValue - ) { - if (trackerAddedBuilder_ == null) { - message_ = builderForValue.build(); - onChanged(); - } else { - trackerAddedBuilder_.setMessage(builderForValue.build()); - } - messageCase_ = 3; - return this; - } - - /** - * .messages.TrackerAdded tracker_added = 3; - */ - public Builder mergeTrackerAdded( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded value - ) { - if (trackerAddedBuilder_ == null) { - if ( - messageCase_ == 3 - && - message_ - != dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded - .getDefaultInstance() - ) { - message_ = dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded - .newBuilder( - (dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded) message_ - ) - .mergeFrom(value) - .buildPartial(); - } else { - message_ = value; - } - onChanged(); - } else { - if (messageCase_ == 3) { - trackerAddedBuilder_.mergeFrom(value); - } else { - trackerAddedBuilder_.setMessage(value); - } - } - messageCase_ = 3; - return this; - } - - /** - * .messages.TrackerAdded tracker_added = 3; - */ - public Builder clearTrackerAdded() { - if (trackerAddedBuilder_ == null) { - if (messageCase_ == 3) { - messageCase_ = 0; - message_ = null; - onChanged(); - } - } else { - if (messageCase_ == 3) { - messageCase_ = 0; - message_ = null; - } - trackerAddedBuilder_.clear(); - } - return this; - } - - /** - * .messages.TrackerAdded tracker_added = 3; - */ - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded.Builder getTrackerAddedBuilder() { - return internalGetTrackerAddedFieldBuilder().getBuilder(); - } - - /** - * .messages.TrackerAdded tracker_added = 3; - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerAddedOrBuilder getTrackerAddedOrBuilder() { - if ((messageCase_ == 3) && (trackerAddedBuilder_ != null)) { - return trackerAddedBuilder_.getMessageOrBuilder(); - } else { - if (messageCase_ == 3) { - return (dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded - .getDefaultInstance(); - } - } - - /** - * .messages.TrackerAdded tracker_added = 3; - */ - private com.google.protobuf.SingleFieldBuilder internalGetTrackerAddedFieldBuilder() { - if (trackerAddedBuilder_ == null) { - if (!(messageCase_ == 3)) { - message_ = dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded - .getDefaultInstance(); - } - trackerAddedBuilder_ = new com.google.protobuf.SingleFieldBuilder( - (dev.slimevr.desktop.platform.ProtobufMessages.TrackerAdded) message_, - getParentForChildren(), - isClean() - ); - message_ = null; - } - messageCase_ = 3; - onChanged(); - return trackerAddedBuilder_; - } - - private com.google.protobuf.SingleFieldBuilder trackerStatusBuilder_; - - /** - * .messages.TrackerStatus tracker_status = 4; - * - * @return Whether the trackerStatus field is set. - */ - @java.lang.Override - public boolean hasTrackerStatus() { - return messageCase_ == 4; - } - - /** - * .messages.TrackerStatus tracker_status = 4; - * - * @return The trackerStatus. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus getTrackerStatus() { - if (trackerStatusBuilder_ == null) { - if (messageCase_ == 4) { - return (dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus - .getDefaultInstance(); - } else { - if (messageCase_ == 4) { - return trackerStatusBuilder_.getMessage(); - } - return dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus - .getDefaultInstance(); - } - } - - /** - * .messages.TrackerStatus tracker_status = 4; - */ - public Builder setTrackerStatus( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus value - ) { - if (trackerStatusBuilder_ == null) { - if (value == null) { - throw new NullPointerException(); - } - message_ = value; - onChanged(); - } else { - trackerStatusBuilder_.setMessage(value); - } - messageCase_ = 4; - return this; - } - - /** - * .messages.TrackerStatus tracker_status = 4; - */ - public Builder setTrackerStatus( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Builder builderForValue - ) { - if (trackerStatusBuilder_ == null) { - message_ = builderForValue.build(); - onChanged(); - } else { - trackerStatusBuilder_.setMessage(builderForValue.build()); - } - messageCase_ = 4; - return this; - } - - /** - * .messages.TrackerStatus tracker_status = 4; - */ - public Builder mergeTrackerStatus( - dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus value - ) { - if (trackerStatusBuilder_ == null) { - if ( - messageCase_ == 4 - && - message_ - != dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus - .getDefaultInstance() - ) { - message_ = dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus - .newBuilder( - (dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus) message_ - ) - .mergeFrom(value) - .buildPartial(); - } else { - message_ = value; - } - onChanged(); - } else { - if (messageCase_ == 4) { - trackerStatusBuilder_.mergeFrom(value); - } else { - trackerStatusBuilder_.setMessage(value); - } - } - messageCase_ = 4; - return this; - } - - /** - * .messages.TrackerStatus tracker_status = 4; - */ - public Builder clearTrackerStatus() { - if (trackerStatusBuilder_ == null) { - if (messageCase_ == 4) { - messageCase_ = 0; - message_ = null; - onChanged(); - } - } else { - if (messageCase_ == 4) { - messageCase_ = 0; - message_ = null; - } - trackerStatusBuilder_.clear(); - } - return this; - } - - /** - * .messages.TrackerStatus tracker_status = 4; - */ - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus.Builder getTrackerStatusBuilder() { - return internalGetTrackerStatusFieldBuilder().getBuilder(); - } - - /** - * .messages.TrackerStatus tracker_status = 4; - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatusOrBuilder getTrackerStatusOrBuilder() { - if ((messageCase_ == 4) && (trackerStatusBuilder_ != null)) { - return trackerStatusBuilder_.getMessageOrBuilder(); - } else { - if (messageCase_ == 4) { - return (dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus - .getDefaultInstance(); - } - } - - /** - * .messages.TrackerStatus tracker_status = 4; - */ - private com.google.protobuf.SingleFieldBuilder internalGetTrackerStatusFieldBuilder() { - if (trackerStatusBuilder_ == null) { - if (!(messageCase_ == 4)) { - message_ = dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus - .getDefaultInstance(); - } - trackerStatusBuilder_ = new com.google.protobuf.SingleFieldBuilder( - (dev.slimevr.desktop.platform.ProtobufMessages.TrackerStatus) message_, - getParentForChildren(), - isClean() - ); - message_ = null; - } - messageCase_ = 4; - onChanged(); - return trackerStatusBuilder_; - } - - private com.google.protobuf.SingleFieldBuilder batteryBuilder_; - - /** - * .messages.Battery battery = 5; - * - * @return Whether the battery field is set. - */ - @java.lang.Override - public boolean hasBattery() { - return messageCase_ == 5; - } - - /** - * .messages.Battery battery = 5; - * - * @return The battery. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Battery getBattery() { - if (batteryBuilder_ == null) { - if (messageCase_ == 5) { - return (dev.slimevr.desktop.platform.ProtobufMessages.Battery) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.Battery - .getDefaultInstance(); - } else { - if (messageCase_ == 5) { - return batteryBuilder_.getMessage(); - } - return dev.slimevr.desktop.platform.ProtobufMessages.Battery - .getDefaultInstance(); - } - } - - /** - * .messages.Battery battery = 5; - */ - public Builder setBattery(dev.slimevr.desktop.platform.ProtobufMessages.Battery value) { - if (batteryBuilder_ == null) { - if (value == null) { - throw new NullPointerException(); - } - message_ = value; - onChanged(); - } else { - batteryBuilder_.setMessage(value); - } - messageCase_ = 5; - return this; - } - - /** - * .messages.Battery battery = 5; - */ - public Builder setBattery( - dev.slimevr.desktop.platform.ProtobufMessages.Battery.Builder builderForValue - ) { - if (batteryBuilder_ == null) { - message_ = builderForValue.build(); - onChanged(); - } else { - batteryBuilder_.setMessage(builderForValue.build()); - } - messageCase_ = 5; - return this; - } - - /** - * .messages.Battery battery = 5; - */ - public Builder mergeBattery( - dev.slimevr.desktop.platform.ProtobufMessages.Battery value - ) { - if (batteryBuilder_ == null) { - if ( - messageCase_ == 5 - && - message_ - != dev.slimevr.desktop.platform.ProtobufMessages.Battery - .getDefaultInstance() - ) { - message_ = dev.slimevr.desktop.platform.ProtobufMessages.Battery - .newBuilder( - (dev.slimevr.desktop.platform.ProtobufMessages.Battery) message_ - ) - .mergeFrom(value) - .buildPartial(); - } else { - message_ = value; - } - onChanged(); - } else { - if (messageCase_ == 5) { - batteryBuilder_.mergeFrom(value); - } else { - batteryBuilder_.setMessage(value); - } - } - messageCase_ = 5; - return this; - } - - /** - * .messages.Battery battery = 5; - */ - public Builder clearBattery() { - if (batteryBuilder_ == null) { - if (messageCase_ == 5) { - messageCase_ = 0; - message_ = null; - onChanged(); - } - } else { - if (messageCase_ == 5) { - messageCase_ = 0; - message_ = null; - } - batteryBuilder_.clear(); - } - return this; - } - - /** - * .messages.Battery battery = 5; - */ - public dev.slimevr.desktop.platform.ProtobufMessages.Battery.Builder getBatteryBuilder() { - return internalGetBatteryFieldBuilder().getBuilder(); - } - - /** - * .messages.Battery battery = 5; - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.BatteryOrBuilder getBatteryOrBuilder() { - if ((messageCase_ == 5) && (batteryBuilder_ != null)) { - return batteryBuilder_.getMessageOrBuilder(); - } else { - if (messageCase_ == 5) { - return (dev.slimevr.desktop.platform.ProtobufMessages.Battery) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.Battery - .getDefaultInstance(); - } - } - - /** - * .messages.Battery battery = 5; - */ - private com.google.protobuf.SingleFieldBuilder internalGetBatteryFieldBuilder() { - if (batteryBuilder_ == null) { - if (!(messageCase_ == 5)) { - message_ = dev.slimevr.desktop.platform.ProtobufMessages.Battery - .getDefaultInstance(); - } - batteryBuilder_ = new com.google.protobuf.SingleFieldBuilder( - (dev.slimevr.desktop.platform.ProtobufMessages.Battery) message_, - getParentForChildren(), - isClean() - ); - message_ = null; - } - messageCase_ = 5; - onChanged(); - return batteryBuilder_; - } - - private com.google.protobuf.SingleFieldBuilder versionBuilder_; - - /** - * .messages.Version version = 6; - * - * @return Whether the version field is set. - */ - @java.lang.Override - public boolean hasVersion() { - return messageCase_ == 6; - } - - /** - * .messages.Version version = 6; - * - * @return The version. - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.Version getVersion() { - if (versionBuilder_ == null) { - if (messageCase_ == 6) { - return (dev.slimevr.desktop.platform.ProtobufMessages.Version) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.Version - .getDefaultInstance(); - } else { - if (messageCase_ == 6) { - return versionBuilder_.getMessage(); - } - return dev.slimevr.desktop.platform.ProtobufMessages.Version - .getDefaultInstance(); - } - } - - /** - * .messages.Version version = 6; - */ - public Builder setVersion(dev.slimevr.desktop.platform.ProtobufMessages.Version value) { - if (versionBuilder_ == null) { - if (value == null) { - throw new NullPointerException(); - } - message_ = value; - onChanged(); - } else { - versionBuilder_.setMessage(value); - } - messageCase_ = 6; - return this; - } - - /** - * .messages.Version version = 6; - */ - public Builder setVersion( - dev.slimevr.desktop.platform.ProtobufMessages.Version.Builder builderForValue - ) { - if (versionBuilder_ == null) { - message_ = builderForValue.build(); - onChanged(); - } else { - versionBuilder_.setMessage(builderForValue.build()); - } - messageCase_ = 6; - return this; - } - - /** - * .messages.Version version = 6; - */ - public Builder mergeVersion( - dev.slimevr.desktop.platform.ProtobufMessages.Version value - ) { - if (versionBuilder_ == null) { - if ( - messageCase_ == 6 - && - message_ - != dev.slimevr.desktop.platform.ProtobufMessages.Version - .getDefaultInstance() - ) { - message_ = dev.slimevr.desktop.platform.ProtobufMessages.Version - .newBuilder( - (dev.slimevr.desktop.platform.ProtobufMessages.Version) message_ - ) - .mergeFrom(value) - .buildPartial(); - } else { - message_ = value; - } - onChanged(); - } else { - if (messageCase_ == 6) { - versionBuilder_.mergeFrom(value); - } else { - versionBuilder_.setMessage(value); - } - } - messageCase_ = 6; - return this; - } - - /** - * .messages.Version version = 6; - */ - public Builder clearVersion() { - if (versionBuilder_ == null) { - if (messageCase_ == 6) { - messageCase_ = 0; - message_ = null; - onChanged(); - } - } else { - if (messageCase_ == 6) { - messageCase_ = 0; - message_ = null; - } - versionBuilder_.clear(); - } - return this; - } - - /** - * .messages.Version version = 6; - */ - public dev.slimevr.desktop.platform.ProtobufMessages.Version.Builder getVersionBuilder() { - return internalGetVersionFieldBuilder().getBuilder(); - } - - /** - * .messages.Version version = 6; - */ - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.VersionOrBuilder getVersionOrBuilder() { - if ((messageCase_ == 6) && (versionBuilder_ != null)) { - return versionBuilder_.getMessageOrBuilder(); - } else { - if (messageCase_ == 6) { - return (dev.slimevr.desktop.platform.ProtobufMessages.Version) message_; - } - return dev.slimevr.desktop.platform.ProtobufMessages.Version - .getDefaultInstance(); - } - } - - /** - * .messages.Version version = 6; - */ - private com.google.protobuf.SingleFieldBuilder internalGetVersionFieldBuilder() { - if (versionBuilder_ == null) { - if (!(messageCase_ == 6)) { - message_ = dev.slimevr.desktop.platform.ProtobufMessages.Version - .getDefaultInstance(); - } - versionBuilder_ = new com.google.protobuf.SingleFieldBuilder( - (dev.slimevr.desktop.platform.ProtobufMessages.Version) message_, - getParentForChildren(), - isClean() - ); - message_ = null; - } - messageCase_ = 6; - onChanged(); - return versionBuilder_; - } - - // @@protoc_insertion_point(builder_scope:messages.ProtobufMessage) - } - - // @@protoc_insertion_point(class_scope:messages.ProtobufMessage) - private static final dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage DEFAULT_INSTANCE; - static { - DEFAULT_INSTANCE = new dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage(); - } - - public static dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage getDefaultInstance() { - return DEFAULT_INSTANCE; - } - - private static final com.google.protobuf.Parser PARSER = new com.google.protobuf.AbstractParser() { - @java.lang.Override - public ProtobufMessage parsePartialFrom( - com.google.protobuf.CodedInputStream input, - com.google.protobuf.ExtensionRegistryLite extensionRegistry - ) - throws com.google.protobuf.InvalidProtocolBufferException { - Builder builder = newBuilder(); - try { - builder.mergeFrom(input, extensionRegistry); - } catch (com.google.protobuf.InvalidProtocolBufferException e) { - throw e.setUnfinishedMessage(builder.buildPartial()); - } catch (com.google.protobuf.UninitializedMessageException e) { - throw e - .asInvalidProtocolBufferException() - .setUnfinishedMessage(builder.buildPartial()); - } catch (java.io.IOException e) { - throw new com.google.protobuf.InvalidProtocolBufferException(e) - .setUnfinishedMessage(builder.buildPartial()); - } - return builder.buildPartial(); - } - }; - - public static com.google.protobuf.Parser parser() { - return PARSER; - } - - @java.lang.Override - public com.google.protobuf.Parser getParserForType() { - return PARSER; - } - - @java.lang.Override - public dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage getDefaultInstanceForType() { - return DEFAULT_INSTANCE; - } - - } - - private static final com.google.protobuf.Descriptors.Descriptor internal_static_messages_PingPong_descriptor; - private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_messages_PingPong_fieldAccessorTable; - private static final com.google.protobuf.Descriptors.Descriptor internal_static_messages_Version_descriptor; - private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_messages_Version_fieldAccessorTable; - private static final com.google.protobuf.Descriptors.Descriptor internal_static_messages_Position_descriptor; - private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_messages_Position_fieldAccessorTable; - private static final com.google.protobuf.Descriptors.Descriptor internal_static_messages_UserAction_descriptor; - private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_messages_UserAction_fieldAccessorTable; - private static final com.google.protobuf.Descriptors.Descriptor internal_static_messages_UserAction_ActionArgumentsEntry_descriptor; - private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_messages_UserAction_ActionArgumentsEntry_fieldAccessorTable; - private static final com.google.protobuf.Descriptors.Descriptor internal_static_messages_TrackerAdded_descriptor; - private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_messages_TrackerAdded_fieldAccessorTable; - private static final com.google.protobuf.Descriptors.Descriptor internal_static_messages_TrackerStatus_descriptor; - private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_messages_TrackerStatus_fieldAccessorTable; - private static final com.google.protobuf.Descriptors.Descriptor internal_static_messages_TrackerStatus_ExtraEntry_descriptor; - private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_messages_TrackerStatus_ExtraEntry_fieldAccessorTable; - private static final com.google.protobuf.Descriptors.Descriptor internal_static_messages_Battery_descriptor; - private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_messages_Battery_fieldAccessorTable; - private static final com.google.protobuf.Descriptors.Descriptor internal_static_messages_ProtobufMessage_descriptor; - private static final com.google.protobuf.GeneratedMessage.FieldAccessorTable internal_static_messages_ProtobufMessage_fieldAccessorTable; - - public static com.google.protobuf.Descriptors.FileDescriptor getDescriptor() { - return descriptor; - } - - private static com.google.protobuf.Descriptors.FileDescriptor descriptor; - static { - java.lang.String[] descriptorData = { - "\n\026ProtobufMessages.proto\022\010messages\"\n\n\010Pi" - + - "ngPong\"#\n\007Version\022\030\n\020protocol_version\030\001 " - + - "\001(\005\"\223\002\n\010Position\022\022\n\ntracker_id\030\001 \001(\005\022\016\n\001" - + - "x\030\002 \001(\002H\000\210\001\001\022\016\n\001y\030\003 \001(\002H\001\210\001\001\022\016\n\001z\030\004 \001(\002H" - + - "\002\210\001\001\022\n\n\002qx\030\005 \001(\002\022\n\n\002qy\030\006 \001(\002\022\n\n\002qz\030\007 \001(\002" - + - "\022\n\n\002qw\030\010 \001(\002\0227\n\013data_source\030\t \001(\0162\035.mess" - + - "ages.Position.DataSourceH\003\210\001\001\"8\n\nDataSou" - + - "rce\022\010\n\004NONE\020\000\022\007\n\003IMU\020\001\022\r\n\tPRECISION\020\002\022\010\n" - + - "\004FULL\020\003B\004\n\002_xB\004\n\002_yB\004\n\002_zB\016\n\014_data_sourc" - + - "e\"\227\001\n\nUserAction\022\014\n\004name\030\001 \001(\t\022C\n\020action" - + - "_arguments\030\002 \003(\0132).messages.UserAction.A" - + - "ctionArgumentsEntry\0326\n\024ActionArgumentsEn" - + - "try\022\013\n\003key\030\001 \001(\t\022\r\n\005value\030\002 \001(\t:\0028\001\"|\n\014T" - + - "rackerAdded\022\022\n\ntracker_id\030\001 \001(\005\022\026\n\016track" - + - "er_serial\030\002 \001(\t\022\024\n\014tracker_name\030\003 \001(\t\022\024\n" - + - "\014tracker_role\030\004 \001(\005\022\024\n\014manufacturer\030\005 \001(" - + - "\t\"\374\002\n\rTrackerStatus\022\022\n\ntracker_id\030\001 \001(\005\022" - + - ".\n\006status\030\002 \001(\0162\036.messages.TrackerStatus" - + - ".Status\0221\n\005extra\030\003 \003(\0132\".messages.Tracke" - + - "rStatus.ExtraEntry\022;\n\nconfidence\030\004 \001(\0162\"" - + - ".messages.TrackerStatus.ConfidenceH\000\210\001\001\032" - + - ",\n\nExtraEntry\022\013\n\003key\030\001 \001(\t\022\r\n\005value\030\002 \001(" - + - "\t:\0028\001\"E\n\006Status\022\020\n\014DISCONNECTED\020\000\022\006\n\002OK\020" - + - "\001\022\010\n\004BUSY\020\002\022\t\n\005ERROR\020\003\022\014\n\010OCCLUDED\020\004\"3\n\n" - + - "Confidence\022\006\n\002NO\020\000\022\007\n\003LOW\020\001\022\n\n\006MEDIUM\020\005\022" - + - "\010\n\004HIGH\020\nB\r\n\013_confidence\"I\n\007Battery\022\022\n\nt" - + - "racker_id\030\001 \001(\005\022\025\n\rbattery_level\030\002 \001(\002\022\023" - + - "\n\013is_charging\030\003 \001(\010\"\241\002\n\017ProtobufMessage\022" - + - "&\n\010position\030\001 \001(\0132\022.messages.PositionH\000\022" - + - "+\n\013user_action\030\002 \001(\0132\024.messages.UserActi" - + - "onH\000\022/\n\rtracker_added\030\003 \001(\0132\026.messages.T" - + - "rackerAddedH\000\0221\n\016tracker_status\030\004 \001(\0132\027." - + - "messages.TrackerStatusH\000\022$\n\007battery\030\005 \001(" - + - "\0132\021.messages.BatteryH\000\022$\n\007version\030\006 \001(\0132" - + - "\021.messages.VersionH\000B\t\n\007messageB2\n\034dev.s" - + - "limevr.desktop.platformB\020ProtobufMessage" - + - "sH\003b\006proto3" - }; - descriptor = com.google.protobuf.Descriptors.FileDescriptor - .internalBuildGeneratedFileFrom( - descriptorData, - new com.google.protobuf.Descriptors.FileDescriptor[] { - } - ); - internal_static_messages_PingPong_descriptor = getDescriptor().getMessageTypes().get(0); - internal_static_messages_PingPong_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_messages_PingPong_descriptor, - new java.lang.String[] {} - ); - internal_static_messages_Version_descriptor = getDescriptor().getMessageTypes().get(1); - internal_static_messages_Version_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_messages_Version_descriptor, - new java.lang.String[] { "ProtocolVersion", } - ); - internal_static_messages_Position_descriptor = getDescriptor().getMessageTypes().get(2); - internal_static_messages_Position_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_messages_Position_descriptor, - new java.lang.String[] { "TrackerId", "X", "Y", "Z", "Qx", "Qy", "Qz", "Qw", - "DataSource", } - ); - internal_static_messages_UserAction_descriptor = getDescriptor().getMessageTypes().get(3); - internal_static_messages_UserAction_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_messages_UserAction_descriptor, - new java.lang.String[] { "Name", "ActionArguments", } - ); - internal_static_messages_UserAction_ActionArgumentsEntry_descriptor = internal_static_messages_UserAction_descriptor - .getNestedTypes() - .get(0); - internal_static_messages_UserAction_ActionArgumentsEntry_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_messages_UserAction_ActionArgumentsEntry_descriptor, - new java.lang.String[] { "Key", "Value", } - ); - internal_static_messages_TrackerAdded_descriptor = getDescriptor().getMessageTypes().get(4); - internal_static_messages_TrackerAdded_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_messages_TrackerAdded_descriptor, - new java.lang.String[] { "TrackerId", "TrackerSerial", "TrackerName", "TrackerRole", - "Manufacturer", } - ); - internal_static_messages_TrackerStatus_descriptor = getDescriptor() - .getMessageTypes() - .get(5); - internal_static_messages_TrackerStatus_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_messages_TrackerStatus_descriptor, - new java.lang.String[] { "TrackerId", "Status", "Extra", "Confidence", } - ); - internal_static_messages_TrackerStatus_ExtraEntry_descriptor = internal_static_messages_TrackerStatus_descriptor - .getNestedTypes() - .get(0); - internal_static_messages_TrackerStatus_ExtraEntry_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_messages_TrackerStatus_ExtraEntry_descriptor, - new java.lang.String[] { "Key", "Value", } - ); - internal_static_messages_Battery_descriptor = getDescriptor().getMessageTypes().get(6); - internal_static_messages_Battery_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_messages_Battery_descriptor, - new java.lang.String[] { "TrackerId", "BatteryLevel", "IsCharging", } - ); - internal_static_messages_ProtobufMessage_descriptor = getDescriptor() - .getMessageTypes() - .get(7); - internal_static_messages_ProtobufMessage_fieldAccessorTable = new com.google.protobuf.GeneratedMessage.FieldAccessorTable( - internal_static_messages_ProtobufMessage_descriptor, - new java.lang.String[] { "Position", "UserAction", "TrackerAdded", "TrackerStatus", - "Battery", "Version", "Message", } - ); - descriptor.resolveAllFeaturesImmutable(); - } - - // @@protoc_insertion_point(outer_class_scope) -} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/platform/SteamVRBridge.kt b/server/desktop/src/main/java/dev/slimevr/desktop/platform/SteamVRBridge.kt deleted file mode 100644 index 581a873849..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/platform/SteamVRBridge.kt +++ /dev/null @@ -1,425 +0,0 @@ -package dev.slimevr.desktop.platform - -import dev.slimevr.VRServer -import dev.slimevr.VRServer.Companion.getNextLocalTrackerId -import dev.slimevr.VRServer.Companion.instance -import dev.slimevr.bridge.BridgeThread -import dev.slimevr.config.BridgeConfig -import dev.slimevr.desktop.platform.ProtobufMessages.* -import dev.slimevr.protocol.rpc.settings.RPCSettingsHandler -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerPosition -import dev.slimevr.tracking.trackers.TrackerPosition.Companion.getByTrackerRole -import dev.slimevr.tracking.trackers.TrackerRole -import dev.slimevr.tracking.trackers.TrackerRole.Companion.getById -import dev.slimevr.tracking.trackers.TrackerUtils.getTrackerForSkeleton -import dev.slimevr.util.ann.VRServerThread -import io.eiren.util.collections.FastList - -abstract class SteamVRBridge( - protected val server: VRServer, - threadName: String, - bridgeName: String, - val bridgeSettingsKey: String, - protected val shareableTrackers: List, -) : ProtobufBridge(bridgeName), - Runnable { - protected val runnerThread: Thread = Thread(this, threadName) - protected val config: BridgeConfig = server.configManager.vrConfig.getBridge(bridgeSettingsKey) - var connected: Boolean = false - - @VRServerThread - override fun startBridge() { - for (tr in shareableTrackers) { - val role = tr.trackerPosition?.trackerRole - changeShareSettings( - role, - config.getBridgeTrackerRole(role, false), - ) - } - runnerThread.start() - } - - @VRServerThread - override fun getShareSetting(role: TrackerRole): Boolean { - for (tr in shareableTrackers) { - if (tr.trackerPosition?.trackerRole == role) { - return sharedTrackers.contains(tr) - } - } - return false - } - - override fun updateShareSettingsAutomatically(): Boolean { - // Return false if automatic trackers is disabled or if tracking is paused - if (!config.automaticSharedTrackersToggling || server.getPauseTracking()) return false - - val skeleton = instance.humanPoseManager.skeleton - val isWaistSteamVr = skeleton.hipTracker?.device?.isOpenVrDevice == true || - skeleton.waistTracker?.device?.isOpenVrDevice == true - // Enable waist if skeleton has an spine tracker - changeShareSettings(TrackerRole.WAIST, skeleton.hasSpineTracker && !isWaistSteamVr) - - // hasChest if waist and/or hip is on, and chest and/or upper chest is also on - val hasChest = skeleton.upperChestTracker != null || skeleton.chestTracker != null - val isChestSteamVr = skeleton.upperChestTracker?.device?.isOpenVrDevice == true || - skeleton.chestTracker?.device?.isOpenVrDevice == true - changeShareSettings( - TrackerRole.CHEST, - hasChest && !isChestSteamVr, - ) - - // hasFeet if lower and/or upper leg tracker is on - val hasLeftFoot = - (skeleton.leftUpperLegTracker != null || skeleton.leftLowerLegTracker != null) - val isLeftFootSteamVr = - skeleton.leftLowerLegTracker?.device?.isOpenVrDevice == true || - skeleton.leftFootTracker?.device?.isOpenVrDevice == true - - val hasRightFoot = - (skeleton.rightUpperLegTracker != null || skeleton.rightLowerLegTracker != null) - val isRightFootSteamVr = - skeleton.rightLowerLegTracker?.device?.isOpenVrDevice == true || - skeleton.rightFootTracker?.device?.isOpenVrDevice == true - changeShareSettings( - TrackerRole.LEFT_FOOT, - hasLeftFoot && !isLeftFootSteamVr, - ) - changeShareSettings( - TrackerRole.RIGHT_FOOT, - hasRightFoot && !isRightFootSteamVr, - ) - - // hasKnees is just hasFeet - val isLeftKneeSteamVr = skeleton.leftUpperLegTracker?.device?.isOpenVrDevice == true - - val isRightKneeSteamVr = skeleton.rightUpperLegTracker?.device?.isOpenVrDevice == true - changeShareSettings(TrackerRole.LEFT_KNEE, hasLeftFoot && !isLeftKneeSteamVr) - changeShareSettings(TrackerRole.RIGHT_KNEE, hasRightFoot && !isRightKneeSteamVr) - - // hasElbows if an upper arm or a lower arm tracker is on - val hasLeftElbow = skeleton.hasLeftArmTracker - val isLeftElbowSteamVr = skeleton.leftUpperArmTracker?.device?.isOpenVrDevice == true || - skeleton.leftLowerArmTracker?.device?.isOpenVrDevice == true - - val hasRightElbow = skeleton.hasRightArmTracker - val isRightElbowSteamVr = skeleton.rightUpperArmTracker?.device?.isOpenVrDevice == true || - skeleton.rightLowerArmTracker?.device?.isOpenVrDevice == true - changeShareSettings(TrackerRole.LEFT_ELBOW, hasLeftElbow && !isLeftElbowSteamVr) - changeShareSettings(TrackerRole.RIGHT_ELBOW, hasRightElbow && !isRightElbowSteamVr) - - // Hands aren't touched as they will override the controller's tracking - // Return true to say that trackers were successfully toggled automatically - return true - } - - override fun getAutomaticSharedTrackers(): Boolean = config.automaticSharedTrackersToggling - - override fun setAutomaticSharedTrackers(value: Boolean) { - if (value == config.automaticSharedTrackersToggling) return - - config.automaticSharedTrackersToggling = value - if (value) { - updateShareSettingsAutomatically() - RPCSettingsHandler.sendSteamVRUpdatedSettings(instance.protocolAPI, instance.protocolAPI.rpcHandler) - } - instance.configManager.saveConfig() - } - - @VRServerThread - override fun changeShareSettings(role: TrackerRole?, share: Boolean) { - if (role == null) return - for (tr in shareableTrackers) { - if (tr.trackerPosition?.trackerRole == role) { - if (share) { - addSharedTracker(tr) - } else { - removeSharedTracker(tr) - } - config.setBridgeTrackerRole(role, share) - instance.configManager.saveConfig() - } - } - } - - @VRServerThread - override fun createNewTracker(trackerAdded: TrackerAdded): Tracker { - val device = instance.deviceManager - .createDevice( - trackerAdded.trackerName, - null, - trackerAdded.manufacturer.ifEmpty { "OpenVR" }, - ) - - // Display name, needsReset and isHmd - val displayName: String = trackerAdded.trackerName - val isHmd = trackerAdded.trackerId == 0 - - // trackerPosition - val role = getById(trackerAdded.trackerRole) - val trackerPosition = if (role != null) { - getByTrackerRole(role) - } else { - null - } - - // Make the tracker - val tracker = Tracker( - device, - getNextLocalTrackerId(), - trackerAdded.trackerSerial, - displayName, - trackerPosition, - trackerAdded.trackerId, - hasPosition = true, - hasRotation = true, - userEditable = true, - isComputed = true, - allowReset = true, - isHmd = isHmd, - ) - - device.trackers[0] = tracker - instance.deviceManager.addDevice(device) - return tracker - } - - // Battery Status - @VRServerThread - override fun writeBatteryUpdate(localTracker: Tracker) { - var lowestLevel = 200f // Arbitrarily higher than expected battery - // percentage - var trackerLevel = 0f // Tracker battery percentage on a scale from 0 - // to 100. SteamVR expects a value from 0 to 1. - var trackerVoltage = 0f // Tracker voltage. This is used to determine - // if the tracker is being charged. owoTrack - // devices do not have a tracker voltage. - var isCharging = false - - val allTrackers = instance.allTrackers - val role = localTracker.trackerPosition?.trackerRole ?: return - - val batteryTrackers: MutableList = FastList() - - // batteryTrackers is filled with trackers that would give battery data - // for the SteamVR tracker according to its role. Warning: trackers - // inside batteryTrackers could be null, so there must be a null check - // when accessing its data. - batteryTrackers - .add( - getTrackerForSkeleton( - allTrackers, - getByTrackerRole(role) ?: return, - ), - ) - when (role) { - TrackerRole.WAIST -> { - // Add waist because the first tracker is hip - batteryTrackers - .add( - getTrackerForSkeleton( - allTrackers, - TrackerPosition.WAIST, - ), - ) - // When the chest SteamVR tracking point is disabled, aggregate - // its battery level alongside waist and hip. - if (!(config.getBridgeTrackerRole(TrackerRole.CHEST, true))) { - batteryTrackers - .add( - getTrackerForSkeleton( - allTrackers, - TrackerPosition.CHEST, - ), - ) - batteryTrackers - .add( - getTrackerForSkeleton( - allTrackers, - TrackerPosition.UPPER_CHEST, - ), - ) - } - } - - TrackerRole.CHEST -> { - // Add chest because the first tracker is upperChest - batteryTrackers - .add( - getTrackerForSkeleton( - allTrackers, - TrackerPosition.CHEST, - ), - ) - // When the waist SteamVR tracking point is disabled, aggregate - // waist and hip battery level with the chest. - if (!(config.getBridgeTrackerRole(TrackerRole.WAIST, true))) { - batteryTrackers - .add( - getTrackerForSkeleton( - allTrackers, - TrackerPosition.WAIST, - ), - ) - batteryTrackers - .add( - getTrackerForSkeleton( - allTrackers, - TrackerPosition.HIP, - ), - ) - } - } - - TrackerRole.LEFT_FOOT -> { - batteryTrackers - .add( - getTrackerForSkeleton( - allTrackers, - TrackerPosition.LEFT_LOWER_LEG, - ), - ) - // When the left knee SteamVR tracking point is disabled, - // aggregate its battery level with left ankle and left foot. - if (!(config.getBridgeTrackerRole(TrackerRole.LEFT_KNEE, true))) { - batteryTrackers - .add( - getTrackerForSkeleton( - allTrackers, - TrackerPosition.LEFT_UPPER_LEG, - ), - ) - } - } - - TrackerRole.RIGHT_FOOT -> { - batteryTrackers - .add( - getTrackerForSkeleton( - allTrackers, - TrackerPosition.RIGHT_LOWER_LEG, - ), - ) - // When the right knee SteamVR tracking point is disabled, - // aggregate its battery level with right ankle and right foot. - if (!(config.getBridgeTrackerRole(TrackerRole.RIGHT_KNEE, true))) { - batteryTrackers - .add( - getTrackerForSkeleton( - allTrackers, - TrackerPosition.RIGHT_UPPER_LEG, - ), - ) - } - } - - TrackerRole.LEFT_ELBOW -> { - batteryTrackers - .add( - getTrackerForSkeleton( - allTrackers, - TrackerPosition.LEFT_LOWER_ARM, - ), - ) - batteryTrackers - .add( - getTrackerForSkeleton( - allTrackers, - TrackerPosition.LEFT_SHOULDER, - ), - ) - } - - TrackerRole.RIGHT_ELBOW -> { - batteryTrackers - .add( - getTrackerForSkeleton( - allTrackers, - TrackerPosition.RIGHT_LOWER_ARM, - ), - ) - batteryTrackers - .add( - getTrackerForSkeleton( - allTrackers, - TrackerPosition.RIGHT_SHOULDER, - ), - ) - } - - else -> {} - } - // If the battery level of the tracker is lower than lowestLevel, then - // the battery level of the tracker position becomes lowestLevel. - // Tracker voltage is set if the tracker position has a battery level - // lower than the lowest level and has a battery voltage (owoTrack - // devices do not). - for (batteryTracker in batteryTrackers) { - if (batteryTracker?.batteryLevel?.let { it < lowestLevel } == true) { - lowestLevel = batteryTracker.batteryLevel!! - - trackerVoltage = if (batteryTracker.batteryVoltage != null) { - batteryTracker.batteryVoltage!! - } else { - 0f - } - } - } - - // Internal battery reporting 5V max, and <= 3.2V when >=50mV - // lower than initial reading (e.g. 3.25V down from 3.3V), and ~0V when - // battery is fine. - // 3.2V is technically 0%, but the last 5% of battery level is ignored, - // which makes 3.36V 0% in practice. Refer to batterymonitor.cpp in - // SlimeVR-Tracker-ESP for exact details. - // External battery reporting anything > 0V. - // The following should catch internal battery reporting and erroneous - // readings. - if ((lowestLevel >= 200 || lowestLevel < 0) || - (trackerVoltage < 3.2 && lowestLevel <= 0) || - (trackerVoltage >= 5 && lowestLevel > 150) - ) { - return - } else { - trackerLevel = lowestLevel / 100 - if (trackerVoltage >= 4.3) { - // TO DO: Add sending whether the tracker is charging from the - // tracker itself rather than checking voltage. - isCharging = true - } - } - - val builder = Battery.newBuilder().setTrackerId(localTracker.trackerNum) - - builder.setBatteryLevel(trackerLevel) - builder.setIsCharging(isCharging) - - sendMessage(ProtobufMessage.newBuilder().setBattery(builder).build()) - } - - @VRServerThread - override fun batteryReceived(batteryMessage: Battery) { - val tracker = getInternalRemoteTrackerById(batteryMessage.trackerId) ?: return - - tracker.batteryLevel = batteryMessage.batteryLevel - - // Purely for cosmetic purposes, SteamVR does not report device - // voltage. - if (batteryMessage.isCharging) { - tracker.batteryVoltage = 4.3f - // TO DO: Add "tracker.setIsCharging()" - } else { - tracker.batteryVoltage = 3.7f - } - } - - @BridgeThread - protected fun reportDisconnected() { - connected = false - } - - @BridgeThread - protected fun reportConnected() { - connected = true - } -} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/SocketUtils.java b/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/SocketUtils.java deleted file mode 100644 index f36af175cb..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/SocketUtils.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.slimevr.desktop.platform.linux; - -import java.io.IOException; -import java.net.StandardProtocolFamily; -import java.net.UnixDomainSocketAddress; -import java.nio.channels.SocketChannel; - - -public class SocketUtils { - - static boolean isSocketInUse(String socketPath) { - try (SocketChannel testChannel = SocketChannel.open(StandardProtocolFamily.UNIX)) { - testChannel.connect(UnixDomainSocketAddress.of(socketPath)); - return true; - } catch (IOException e) { - return false; - } - } - -} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketBridge.java b/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketBridge.java deleted file mode 100644 index 1c29cedd29..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketBridge.java +++ /dev/null @@ -1,263 +0,0 @@ -package dev.slimevr.desktop.platform.linux; - -import com.google.protobuf.InvalidProtocolBufferException; -import dev.slimevr.VRServer; -import dev.slimevr.bridge.BridgeThread; -import dev.slimevr.desktop.platform.ProtobufMessages; -import dev.slimevr.desktop.platform.SteamVRBridge; -import dev.slimevr.tracking.trackers.Tracker; -import io.eiren.util.ann.ThreadSafe; -import io.eiren.util.logging.LogManager; -import org.jetbrains.annotations.NotNull; - -import java.io.File; -import java.io.IOException; -import java.net.StandardProtocolFamily; -import java.net.UnixDomainSocketAddress; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.channels.ServerSocketChannel; -import java.nio.channels.SocketChannel; -import java.nio.channels.Selector; -import java.nio.channels.SelectionKey; -import java.util.List; - - -public class UnixSocketBridge extends SteamVRBridge implements AutoCloseable { - public final String socketPath; - public final UnixDomainSocketAddress socketAddress; - private final String bridgeSettingsKey; - private final ByteBuffer dst = ByteBuffer.allocate(2048).order(ByteOrder.LITTLE_ENDIAN); - private final ByteBuffer src = ByteBuffer.allocate(2048).order(ByteOrder.LITTLE_ENDIAN); - - private ServerSocketChannel server; - private SocketChannel channel; - private Selector selector; - private boolean socketError = false; - - public UnixSocketBridge( - VRServer server, - String bridgeSettingsKey, - String bridgeName, - String socketPath, - List shareableTrackers - ) { - super(server, "Named socket thread", bridgeName, bridgeSettingsKey, shareableTrackers); - this.bridgeSettingsKey = bridgeSettingsKey; - this.socketPath = socketPath; - this.socketAddress = UnixDomainSocketAddress.of(socketPath); - - File socketFile = new File(socketPath); - if (socketFile.exists()) { - if (SocketUtils.isSocketInUse(socketPath)) { - throw new RuntimeException( - socketPath + " socket is already in use by another process." - ); - } else { - LogManager.warning("[" + bridgeName + "] Cleaning up stale socket: " + socketPath); - if (!socketFile.delete()) { - throw new RuntimeException("Failed to delete stale socket: " + socketPath); - } - } - } - socketFile.deleteOnExit(); - } - - @Override - @BridgeThread - public void run() { - try { - this.server = createSocket(); - while (true) { - if (this.channel == null) { - reportDisconnected(); - this.selector = Selector.open(); - this.channel = server.accept(); - this.channel.configureBlocking(false); - this.channel.register(this.selector, SelectionKey.OP_READ); - if (this.channel == null) - continue; - VRServer.Companion.getInstance().queueTask(this::reconnected); - LogManager - .info( - "[" - + bridgeName - + "]" - + " Connected to " - + this.channel.getRemoteAddress().toString() - ); - } else { - if (this.socketError || !this.channel.isConnected()) { - this.resetChannel(); - continue; - } - try { - boolean updated = this.updateSocket(); - updateMessageQueue(); - if (updated) { - reportConnected(); - } else { - this.waitForData(10); - } - } catch (IOException ioError) { - this.resetChannel(); - ioError.printStackTrace(); - try { - Thread.sleep(10); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - } - } - } catch (IOException e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } - } - - @Override - @ThreadSafe - protected void signalSend() { - Selector selector = this.selector; - if (selector == null) { - return; - } - selector.wakeup(); - } - - @BridgeThread - private void waitForData(long timeoutMs) throws IOException { - this.selector.select(timeoutMs); - } - - @Override - @BridgeThread - protected boolean sendMessageReal(ProtobufMessages.ProtobufMessage message) { - if (this.channel != null) { - try { - int size = message.getSerializedSize() + 4; - this.src.putInt(size); - byte[] serialized = message.toByteArray(); - this.src.put(serialized); - this.src.flip(); - - while (this.src.hasRemaining()) { - channel.write(this.src); - } - - this.src.clear(); - return true; - } catch (IOException e) { - e.printStackTrace(); - } - } - return false; - } - - private boolean updateSocket() throws IOException { - int read = channel.read(dst); - if (read == -1) { - LogManager - .info( - "[" - + bridgeName - + "] Reached end-of-stream on connection of " - + this.channel.getRemoteAddress().toString() - ); - socketError = true; - return false; - } else if (read == 0) { - return false; - } - - boolean readAnything = false; - // if buffer has 4 bytes at least, we got the message size! - // processs all messages - while (dst.position() >= 4) { - int messageLength = dst.getInt(0); - if (messageLength > 1024) { // Overflow - LogManager - .severe( - "[" - + bridgeName - + "] Buffer overflow on socket. Message length: " - + messageLength - ); - socketError = true; - break; - } else if (dst.position() >= messageLength) { - // Parse the message (this reads the array directly from the - // dst, so we need to move position ourselves) - try { - var message = parseMessage(dst.array(), 4, messageLength - 4); - this.messageReceived(message); - } catch (InvalidProtocolBufferException e) { - LogManager.severe("Failed to read protocol message", e); - } - int originalpos = dst.position(); - dst.position(messageLength); - dst.compact(); - // move position after compacting - dst.position(originalpos - messageLength); - readAnything = true; - } else { - break; - } - } - return readAnything; - } - - private static ProtobufMessages.ProtobufMessage parseMessage( - byte[] data, - int offset, - int length - ) throws InvalidProtocolBufferException { - return ProtobufMessages.ProtobufMessage - .parser() - .parseFrom(data, offset, length); - } - - private void resetChannel() throws IOException { - LogManager - .info( - "[" - + bridgeName - + "] Disconnected from " - + this.channel.getRemoteAddress().toString() - ); - this.selector.close(); - this.selector = null; - this.channel.close(); - this.channel = null; - this.socketError = false; - this.dst.clear(); - VRServer.Companion.getInstance().queueTask(this::disconnected); - } - - private ServerSocketChannel createSocket() throws IOException { - ServerSocketChannel server = ServerSocketChannel.open(StandardProtocolFamily.UNIX); - server.bind(this.socketAddress); - LogManager.info("[" + bridgeName + "] Socket " + this.socketPath + " created"); - return server; - } - - @Override - public void close() throws Exception { - if (this.server != null) { - this.server.close(); - } - } - - @Override - public boolean isConnected() { - return channel != null && channel.isConnected(); - } - - @NotNull - @Override - public String getBridgeConfigKey() { - return bridgeSettingsKey; - } -} - diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketConnection.java b/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketConnection.java deleted file mode 100644 index 45ccb81b0b..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketConnection.java +++ /dev/null @@ -1,114 +0,0 @@ -package dev.slimevr.desktop.platform.linux; - -import dev.slimevr.protocol.ConnectionContext; -import dev.slimevr.protocol.GenericConnection; -import io.eiren.util.logging.LogManager; - -import java.io.IOException; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.channels.SocketChannel; -import java.util.UUID; - - -public class UnixSocketConnection implements GenericConnection { - public final UUID id; - public final ConnectionContext context; - private final ByteBuffer dst = ByteBuffer.allocate(2048).order(ByteOrder.LITTLE_ENDIAN); - private final SocketChannel channel; - private int remainingBytes; - - public UnixSocketConnection(SocketChannel channel) { - this.id = UUID.randomUUID(); - this.context = new ConnectionContext(); - this.channel = channel; - } - - @Override - public UUID getConnectionId() { - return id; - } - - @Override - public ConnectionContext getContext() { - return this.context; - } - - public boolean isConnected() { - return this.channel.isConnected(); - } - - private void resetChannel() { - try { - this.channel.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } - - @Override - public void send(ByteBuffer bytes) { - if (!this.channel.isConnected()) - return; - try { - ByteBuffer[] src = new ByteBuffer[] { - ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN), - bytes.slice(), - }; - src[0].putInt(src[1].remaining() + 4); - src[0].flip(); - synchronized (this) { - while (src[1].hasRemaining()) { - this.channel.write(src); - } - } - } catch (IOException e) { - e.printStackTrace(); - } - } - - public ByteBuffer read() { - if (dst.position() < 4 || dst.position() < dst.getInt(0)) { - if (!this.channel.isConnected()) - return null; - try { - int result = this.channel.read(dst); - if (result == -1) { - LogManager.info("[SolarXR Bridge] Reached end-of-stream on connection"); - this.resetChannel(); - return null; - } - if (dst.position() < 4) { - return null; - } - } catch (IOException e) { - e.printStackTrace(); - this.resetChannel(); - return null; - } - } - int messageLength = dst.getInt(0); - if (messageLength > 1024) { - LogManager - .severe( - "[SolarXR Bridge] Buffer overflow on socket. Message length: " + messageLength - ); - this.resetChannel(); - return null; - } - if (dst.position() < messageLength) { - return null; - } - remainingBytes = dst.position() - messageLength; - dst.position(4); - dst.limit(messageLength); - return dst; - } - - public void next() { - dst.position(dst.limit()); - dst.limit(dst.limit() + remainingBytes); - dst.compact(); - dst.limit(dst.capacity()); - } -} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketRpcBridge.java b/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketRpcBridge.java deleted file mode 100644 index 42dab98fbb..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/platform/linux/UnixSocketRpcBridge.java +++ /dev/null @@ -1,151 +0,0 @@ -package dev.slimevr.desktop.platform.linux; - -import dev.slimevr.bridge.BridgeThread; -import dev.slimevr.protocol.GenericConnection; -import dev.slimevr.protocol.ProtocolAPI; -import dev.slimevr.tracking.trackers.Tracker; -import dev.slimevr.util.ann.VRServerThread; -import dev.slimevr.VRServer; -import io.eiren.util.logging.LogManager; - -import java.io.File; -import java.io.IOException; -import java.net.StandardProtocolFamily; -import java.net.UnixDomainSocketAddress; -import java.nio.ByteBuffer; -import java.nio.channels.SelectionKey; -import java.nio.channels.Selector; -import java.nio.channels.ServerSocketChannel; -import java.nio.channels.SocketChannel; -import java.util.List; - - -public class UnixSocketRpcBridge implements dev.slimevr.bridge.Bridge, - dev.slimevr.protocol.ProtocolAPIServer, Runnable, AutoCloseable { - private final Thread runnerThread = new Thread(this, "Named socket thread"); - private final String socketPath; - private final ProtocolAPI protocolAPI; - private final ServerSocketChannel socket; - private final Selector selector; - - public UnixSocketRpcBridge( - VRServer server, - String socketPath, - List shareableTrackers - ) { - this.socketPath = socketPath; - this.protocolAPI = server.protocolAPI; - - File socketFile = new File(socketPath); - if (socketFile.exists()) { - if (SocketUtils.isSocketInUse(socketPath)) { - throw new RuntimeException( - socketPath + " socket is already in use by another process." - ); - } else { - LogManager.warning("[SolarXR Bridge] Cleaning up stale socket: " + socketPath); - if (!socketFile.delete()) { - throw new RuntimeException("Failed to delete stale socket: " + socketPath); - } - } - } - socketFile.deleteOnExit(); - - try { - socket = ServerSocketChannel.open(StandardProtocolFamily.UNIX); - selector = Selector.open(); - } catch (IOException e) { - e.printStackTrace(); - throw new RuntimeException("Socket open failed."); - } - - server.protocolAPI.registerAPIServer(this); - } - - @VRServerThread - private void disconnected() { - } - - @Override - @VRServerThread - public void dataRead() { - } - - @Override - @VRServerThread - public void dataWrite() { - } - - @Override - @VRServerThread - public void addSharedTracker(Tracker tracker) { - } - - @Override - @VRServerThread - public void removeSharedTracker(Tracker tracker) { - } - - @Override - @VRServerThread - public void startBridge() { - this.runnerThread.start(); - } - - @Override - @BridgeThread - public void run() { - try { - this.socket.bind(UnixDomainSocketAddress.of(this.socketPath)); - this.socket.configureBlocking(false); - this.socket.register(this.selector, SelectionKey.OP_ACCEPT); - LogManager.info("[SolarXR Bridge] Socket " + this.socketPath + " created"); - while (this.socket.isOpen()) { - this.selector.select(0); - for (SelectionKey key : this.selector.selectedKeys()) { - UnixSocketConnection conn = (UnixSocketConnection) key.attachment(); - if (conn != null) { - for (ByteBuffer message; (message = conn.read()) != null; conn.next()) - this.protocolAPI.onMessage(conn, message); - } else - for (SocketChannel channel; (channel = socket.accept()) != null;) { - channel.configureBlocking(false); - channel - .register( - this.selector, - SelectionKey.OP_READ, - new UnixSocketConnection(channel) - ); - LogManager - .info( - "[SolarXR Bridge] Connected to " - + channel.getRemoteAddress().toString() - ); - } - } - } - } catch (IOException e) { - e.printStackTrace(); - } - } - - @Override - public void close() throws Exception { - this.socket.close(); - this.selector.close(); - } - - @Override - public boolean isConnected() { - return this.selector.keys().stream().anyMatch(key -> key.attachment() != null); - } - - @Override - public java.util.stream.Stream getApiConnections() { - return this.selector - .keys() - .stream() - .map(key -> (GenericConnection) key.attachment()) - .filter(conn -> conn != null); - } -} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/platform/windows/PipeState.java b/server/desktop/src/main/java/dev/slimevr/desktop/platform/windows/PipeState.java deleted file mode 100644 index 81173b42d7..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/platform/windows/PipeState.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.slimevr.desktop.platform.windows; - -public enum PipeState { - CREATED, - OPEN, - ERROR -} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/platform/windows/WindowsNamedPipeBridge.java b/server/desktop/src/main/java/dev/slimevr/desktop/platform/windows/WindowsNamedPipeBridge.java deleted file mode 100644 index 5099fccd8d..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/platform/windows/WindowsNamedPipeBridge.java +++ /dev/null @@ -1,333 +0,0 @@ -package dev.slimevr.desktop.platform.windows; - -import com.google.protobuf.CodedOutputStream; -import com.google.protobuf.InvalidProtocolBufferException; -import com.sun.jna.Native; -import com.sun.jna.platform.win32.*; -import com.sun.jna.platform.win32.Kernel32; -import com.sun.jna.ptr.IntByReference; -import com.sun.jna.win32.W32APIOptions; -import dev.slimevr.VRServer; -import dev.slimevr.bridge.BridgeThread; -import dev.slimevr.desktop.platform.ProtobufMessages.ProtobufMessage; -import dev.slimevr.desktop.platform.SteamVRBridge; -import dev.slimevr.tracking.trackers.Tracker; -import io.eiren.util.ann.ThreadSafe; -import io.eiren.util.logging.LogManager; -import org.jetbrains.annotations.NotNull; - -import java.io.IOException; -import java.util.List; - - -interface Kernel32IO extends Kernel32 { - Kernel32IO INSTANCE = Native.load("kernel32", Kernel32IO.class, W32APIOptions.DEFAULT_OPTIONS); - - boolean GetOverlappedResult( - /* [in] */ HANDLE hFile, - /* [in] */ WinBase.OVERLAPPED lpOverlapped, - /* [out] */ IntByReference lpNumberOfBytesTransferred, - /* [in] */ boolean bWait - ); -} - - -public class WindowsNamedPipeBridge extends SteamVRBridge { - private static final Kernel32 k32 = Kernel32.INSTANCE; - private static final Kernel32IO k32io = Kernel32IO.INSTANCE; - private static final Advapi32 adv32 = Advapi32.INSTANCE; - - protected final String pipeName; - protected final String bridgeSettingsKey; - private final byte[] buffArray = new byte[2048]; - protected WindowsPipe pipe; - protected WinNT.HANDLE openEvent = k32.CreateEvent(null, false, false, null); - protected WinNT.HANDLE readEvent = k32.CreateEvent(null, false, false, null); - protected WinNT.HANDLE writeEvent = k32.CreateEvent(null, false, false, null); - protected WinNT.HANDLE rxEvent = k32.CreateEvent(null, false, false, null); - protected WinNT.HANDLE txEvent = k32.CreateEvent(null, false, false, null); - protected WinNT.HANDLE[] events = new WinNT.HANDLE[] { rxEvent, txEvent }; - private final WinBase.OVERLAPPED overlappedOpen = new WinBase.OVERLAPPED(); - private final WinBase.OVERLAPPED overlappedWrite = new WinBase.OVERLAPPED(); - private final WinBase.OVERLAPPED overlappedRead = new WinBase.OVERLAPPED(); - private final WinBase.OVERLAPPED overlappedWait = new WinBase.OVERLAPPED(); - private final IntByReference bytesWritten = new IntByReference(0); - private final IntByReference bytesAvailable = new IntByReference(0); - private final IntByReference bytesRead = new IntByReference(0); - private boolean pendingWait = false; - - public WindowsNamedPipeBridge( - VRServer server, - String bridgeSettingsKey, - String bridgeName, - String pipeName, - List shareableTrackers - ) { - super(server, "Named pipe thread", bridgeName, bridgeSettingsKey, shareableTrackers); - this.pipeName = pipeName; - this.bridgeSettingsKey = bridgeSettingsKey; - overlappedWait.hEvent = rxEvent; - } - - @Override - @BridgeThread - public void run() { - try { - createPipe(); - while (true) { - boolean pipesUpdated = false; - if (pipe.state == PipeState.CREATED) { - // Report that our pipe is disconnected right now - reportDisconnected(); - tryOpeningPipe(pipe); - } - if (pipe.state == PipeState.OPEN) { - pipesUpdated = updatePipe(); - if (pipesUpdated) { - reportConnected(); - } - updateMessageQueue(); - } - if (pipe.state == PipeState.ERROR) { - resetPipe(); - } - if (!pipesUpdated) { - if (pipe.state == PipeState.OPEN) { - waitForData(10); - } else { - try { - Thread.sleep(10); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - } - } - } catch (IOException e) { - // TODO Auto-generated catch block - e.printStackTrace(); - } - } - - @Override - @ThreadSafe - protected void signalSend() { - k32.SetEvent(txEvent); - } - - @BridgeThread - private void waitForData(int timeoutMs) { - if (pipe.state != PipeState.OPEN) - return; - if (!pendingWait) { - k32.ReadFile(pipe.pipeHandle, null, 0, null, overlappedWait); - pendingWait = true; - } - int evIdx = k32.WaitForMultipleObjects(events.length, events, false, timeoutMs); - if (evIdx == 0) { - // events[0] == overlappedWait.hEvent == rxEvent - pendingWait = false; - } - } - - @Override - @BridgeThread - protected boolean sendMessageReal(ProtobufMessage message) { - if (pipe.state != PipeState.OPEN) { - return false; - } - try { - int size = message.getSerializedSize(); - CodedOutputStream os = CodedOutputStream.newInstance(buffArray, 4, size); - message.writeTo(os); - size += 4; - buffArray[0] = (byte) (size & 0xFF); - buffArray[1] = (byte) ((size >> 8) & 0xFF); - buffArray[2] = (byte) ((size >> 16) & 0xFF); - buffArray[3] = (byte) ((size >> 24) & 0xFF); - - overlappedWrite.clear(); - overlappedWrite.hEvent = writeEvent; - boolean immediate = k32 - .WriteFile(pipe.pipeHandle, buffArray, size, null, overlappedWrite); - int err = k32.GetLastError(); - if (!immediate && err != WinError.ERROR_IO_PENDING) { - setPipeError("WriteFile failed: " + err); - return false; - } - - if (!k32io.GetOverlappedResult(pipe.pipeHandle, overlappedWrite, bytesWritten, true)) { - setPipeError( - "sendMessageReal/GetOverlappedResult failed: " + k32.GetLastError() - ); - return false; - } - - if (bytesWritten.getValue() != size) { - setPipeError("Bytes written " + bytesWritten.getValue() + ", expected " + size); - return false; - } - - return true; - } catch (IOException e) { - e.printStackTrace(); - } - return false; - } - - private boolean updatePipe() throws IOException { - if (pipe.state != PipeState.OPEN) { - return false; - } - boolean readAnything = false; - while (k32.PeekNamedPipe(pipe.pipeHandle, buffArray, 4, null, bytesAvailable, null)) { - if (bytesAvailable.getValue() < 4) { - return readAnything; // Wait for more data - } - int messageLength = (Byte.toUnsignedInt(buffArray[3]) << 24) - | (Byte.toUnsignedInt(buffArray[2]) << 16) - | (Byte.toUnsignedInt(buffArray[1]) << 8) - | Byte.toUnsignedInt(buffArray[0]); - if (messageLength > 1024) { // Overflow - setPipeError("Pipe overflow. Message length: " + messageLength); - return readAnything; - } - if (bytesAvailable.getValue() < messageLength) { - return readAnything; // Wait for more data - } - - overlappedRead.clear(); - overlappedRead.hEvent = readEvent; - boolean immediate = k32 - .ReadFile(pipe.pipeHandle, buffArray, messageLength, null, overlappedRead); - int err = k32.GetLastError(); - if (!immediate && err != WinError.ERROR_IO_PENDING) { - setPipeError("ReadFile failed: " + err); - return readAnything; - } - - if (!k32io.GetOverlappedResult(pipe.pipeHandle, overlappedRead, bytesRead, true)) { - setPipeError( - "updatePipe/GetOverlappedResult failed: " + k32.GetLastError() - ); - return readAnything; - } - - if (bytesRead.getValue() != messageLength) { - setPipeError( - "Bytes read " + bytesRead.getValue() + ", expected " + messageLength - ); - return readAnything; - } - - try { - ProtobufMessage message = ProtobufMessage - .parser() - .parseFrom(buffArray, 4, messageLength - 4); - messageReceived(message); - readAnything = true; - } catch (InvalidProtocolBufferException parseEx) { - parseEx.printStackTrace(); - setPipeError("Failed to parse message: " + parseEx.getMessage()); - return readAnything; - } - } - - int err = k32.GetLastError(); - if (err == WinError.ERROR_BROKEN_PIPE) { - setPipeError("Pipe closed"); - } else { - setPipeError("Pipe error: " + err); - } - return readAnything; - } - - private void setPipeError(String message) { - pipe.state = PipeState.ERROR; - LogManager.severe("[" + bridgeName + "] " + message); - } - - private void resetPipe() { - WindowsPipe.safeDisconnect(pipe); - pipe.state = PipeState.CREATED; - VRServer.Companion.getInstance().queueTask(this::disconnected); - } - - private void createPipe() throws IOException { - try { - WinNT.SECURITY_DESCRIPTOR descriptor = new WinNT.SECURITY_DESCRIPTOR(64 * 1024); - adv32.InitializeSecurityDescriptor(descriptor, WinNT.SECURITY_DESCRIPTOR_REVISION); - adv32.SetSecurityDescriptorDacl(descriptor, true, null, false); - adv32 - .SetSecurityDescriptorControl( - descriptor, - (short) WinNT.SE_DACL_PROTECTED, - (short) WinNT.SE_DACL_PROTECTED - ); - - WinBase.SECURITY_ATTRIBUTES attributes = new WinBase.SECURITY_ATTRIBUTES(); - attributes.lpSecurityDescriptor = descriptor.getPointer(); - attributes.bInheritHandle = false; - - pipe = new WindowsPipe( - k32 - .CreateNamedPipe( - pipeName, - WinBase.PIPE_ACCESS_DUPLEX | WinNT.FILE_FLAG_OVERLAPPED, // dwOpenMode - WinBase.PIPE_TYPE_BYTE | WinBase.PIPE_READMODE_BYTE | WinBase.PIPE_WAIT, // dwPipeMode - 1, // nMaxInstances, - 1024 * 16, // nOutBufferSize, - 1024 * 16, // nInBufferSize, - 0, // nDefaultTimeOut, - attributes // lpSecurityAttributes - ), - pipeName - ); - LogManager.info("[" + bridgeName + "] Pipe " + pipe.name + " created"); - if (WinBase.INVALID_HANDLE_VALUE.equals(pipe.pipeHandle)) { - throw new IOException("Can't open " + pipeName + " pipe: " + k32.GetLastError()); - } - LogManager.info("[" + bridgeName + "] Pipes are created"); - } catch (IOException e) { - WindowsPipe.safeDisconnect(pipe); - throw e; - } - } - - private boolean tryOpeningPipe(WindowsPipe pipe) { - overlappedOpen.clear(); - overlappedOpen.hEvent = openEvent; - - boolean ok = k32.ConnectNamedPipe(pipe.pipeHandle, overlappedOpen); - int err = k32.GetLastError(); - if (!ok && err != WinError.ERROR_PIPE_CONNECTED) { - if (err != WinError.ERROR_IO_PENDING) { - setPipeError("ConnectNamedPipe failed: " + err); - return false; - } - - if (!k32io.GetOverlappedResult(pipe.pipeHandle, overlappedOpen, bytesRead, true)) { - setPipeError( - "tryOpeningPipe/GetOverlappedResult failed: " + k32.GetLastError() - ); - return false; - } - } - - pipe.state = PipeState.OPEN; - LogManager.info("[" + bridgeName + "] Pipe " + pipe.name + " is open"); - VRServer.Companion.getInstance().queueTask(this::reconnected); - return true; - } - - @Override - public boolean isConnected() { - return pipe != null && pipe.state == PipeState.OPEN; - } - - @NotNull - @Override - public String getBridgeConfigKey() { - return this.bridgeSettingsKey; - } -} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/platform/windows/WindowsPipe.java b/server/desktop/src/main/java/dev/slimevr/desktop/platform/windows/WindowsPipe.java deleted file mode 100644 index 6aabd03adc..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/platform/windows/WindowsPipe.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.slimevr.desktop.platform.windows; - -import com.sun.jna.platform.win32.Kernel32; -import com.sun.jna.platform.win32.WinNT.HANDLE; - - -public class WindowsPipe { - - public final String name; - public final HANDLE pipeHandle; - public PipeState state = PipeState.CREATED; - - public WindowsPipe(HANDLE pipeHandle, String name) { - this.pipeHandle = pipeHandle; - this.name = name; - } - - public static void safeDisconnect(WindowsPipe pipe) { - try { - if (pipe != null && pipe.pipeHandle != null) - Kernel32.INSTANCE.DisconnectNamedPipe(pipe.pipeHandle); - } catch (Exception ignored) {} - } -} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/serial/DesktopSerialHandler.kt b/server/desktop/src/main/java/dev/slimevr/desktop/serial/DesktopSerialHandler.kt deleted file mode 100644 index 1b911837be..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/serial/DesktopSerialHandler.kt +++ /dev/null @@ -1,268 +0,0 @@ -package dev.slimevr.desktop.serial - -import com.fazecast.jSerialComm.SerialPort -import com.fazecast.jSerialComm.SerialPortEvent -import com.fazecast.jSerialComm.SerialPortMessageListener -import dev.slimevr.serial.SerialHandler -import dev.slimevr.serial.SerialListener -import io.eiren.util.logging.LogManager -import java.io.IOException -import java.io.OutputStreamWriter -import java.nio.ByteBuffer -import java.nio.charset.StandardCharsets -import java.util.* -import java.util.concurrent.CopyOnWriteArrayList -import java.util.stream.Stream -import kotlin.concurrent.timerTask -import kotlin.streams.asSequence -import kotlin.streams.asStream -import dev.slimevr.serial.SerialPort as SlimeSerialPort - -class SerialPortWrapper(val port: SerialPort) : SlimeSerialPort() { - override val portLocation: String - get() = port.portLocation - override val descriptivePortName: String - get() = port.descriptivePortName - - override val vendorId: Int - get() = port.vendorID - - override val productId: Int - get() = port.productID -} - -class DesktopSerialHandler : - SerialHandler(), - SerialPortMessageListener { - private val listeners: MutableList = CopyOnWriteArrayList() - private val getDevicesTimer = Timer("GetDevicesTimer") - private var currentPort: SerialPort? = null - private var watchingNewDevices = false - private var lastKnownPorts = setOf() - - init { - startWatchingNewDevices() - } - - fun startWatchingNewDevices() { - if (watchingNewDevices) return - watchingNewDevices = true - getDevicesTimer.scheduleAtFixedRate( - timerTask { - try { - detectNewPorts() - } catch (t: Throwable) { - LogManager.severe( - "[SerialHandler] Error while watching for new devices, cancelling the \"getDevicesTimer\".", - t, - ) - getDevicesTimer.cancel() - } - }, - 0, - 3000, - ) - } - - fun stopWatchingNewDevices() { - if (!watchingNewDevices) return - watchingNewDevices = false - getDevicesTimer.cancel() - getDevicesTimer.purge() - } - - private fun onNewDevice(port: SerialPort) { - listeners.forEach { it.onNewSerialDevice(SerialPortWrapper(port)) } - } - - private fun onDeviceDel(port: SerialPort) { - listeners.forEach { it.onSerialDeviceDeleted(SerialPortWrapper(port)) } - } - - override fun addListener(channel: SerialListener) { - listeners.add(channel) - } - - override fun removeListener(channel: SerialListener) { - listeners.removeIf { channel === it } - } - - @Synchronized - override fun openSerial(portLocation: String?, auto: Boolean): Boolean { - LogManager.info("[SerialHandler] Trying to open: $portLocation, auto: $auto") - val ports = SerialPort.getCommPorts() - lastKnownPorts = ports.map { SerialPortWrapper(it) }.toSet() - val newPort: SerialPort? = ports.find { - (!auto && it.portLocation == portLocation) || - (auto && isKnownBoard(SerialPortWrapper(it))) - } - if (newPort == null) { - LogManager.info( - "[SerialHandler] No serial ports found to connect to (${ports.size}) total ports", - ) - return false - } - if (isConnected) { - if (SerialPortWrapper(newPort) != currentPort?.let { SerialPortWrapper(it) }) { - LogManager.info( - "[SerialHandler] Closing current serial port " + - currentPort!!.descriptivePortName, - ) - currentPort!!.removeDataListener() - currentPort!!.closePort() - } else { - LogManager.info("[SerialHandler] Reusing already open port") - listeners.forEach { it.onSerialConnected(SerialPortWrapper(currentPort!!)) } - return true - } - } - currentPort = newPort - LogManager.info( - "[SerialHandler] Trying to connect to new serial port " + - currentPort!!.descriptivePortName, - ) - currentPort?.setBaudRate(115200) - currentPort?.clearRTS() - currentPort?.clearDTR() - if (currentPort?.openPort(1000) == false) { - LogManager.warning( - "[SerialHandler] Can't open serial port ${currentPort?.descriptivePortName}, last error: ${currentPort?.lastErrorCode}", - - ) - currentPort = null - return false - } - currentPort?.addDataListener(this) - listeners.forEach { it.onSerialConnected(SerialPortWrapper(currentPort!!)) } - LogManager.info("[SerialHandler] Serial port ${newPort.descriptivePortName} is open") - return true - } - - override fun rebootRequest() { - writeSerial("REBOOT") - } - - override fun factoryResetRequest() { - writeSerial("FRST") - } - - override fun infoRequest() { - writeSerial("GET INFO") - } - - override fun wifiScanRequest() { - writeSerial("GET WIFISCAN") - } - - override fun customCommandRequest(command: String) { - writeSerial(command) - } - - @Synchronized - override fun closeSerial() { - try { - currentPort?.closePort() - listeners.forEach { it.onSerialDisconnected() } - LogManager.info( - "[SerialHandler] Port ${currentPort?.descriptivePortName} closed okay", - ) - currentPort = null - } catch (e: Exception) { - LogManager.warning( - "[SerialHandler] Error closing port ${currentPort?.descriptivePortName}", - e, - ) - } - } - - @Synchronized - private fun writeSerial(serialText: String) { - val os = currentPort?.outputStream ?: return - val writer = OutputStreamWriter(os) - try { - writer.append(serialText).append("\n") - writer.flush() - addLog("-> $serialText\n") - } catch (e: IOException) { - addLog("[!] Serial error: ${e.message}\n") - LogManager.warning("[SerialHandler] Serial port write error", e) - } - } - - override fun write(buff: ByteArray) { - LogManager.info("[SerialHandler] WRITING $buff") - currentPort?.outputStream?.write(buff) - } - - @Synchronized - override fun setWifi(ssid: String, passwd: String) { - val os = currentPort?.outputStream ?: return - val writer = OutputStreamWriter(os) - try { - writer.append("SET WIFI \"").append(ssid).append("\" \"").append(passwd).append("\"\n") - writer.flush() - addLog("-> SET WIFI \"$ssid\" \"${passwd.replace(".".toRegex(), "*")}\"\n") - } catch (e: IOException) { - addLog("$e\n") - LogManager.warning("[SerialHandler] Serial port write error", e) - } - } - - fun addLog(str: String, server: Boolean = true) { - LogManager.info("[Serial] $str") - listeners.forEach { it.onSerialLog(str, server) } - } - - override fun getListeningEvents(): Int = ( - SerialPort.LISTENING_EVENT_PORT_DISCONNECTED - or SerialPort.LISTENING_EVENT_DATA_RECEIVED - ) - - override fun serialEvent(event: SerialPortEvent) { - when (event.eventType) { - SerialPort.LISTENING_EVENT_DATA_RECEIVED -> { - val newData = event.receivedData - val s = StandardCharsets.UTF_8.decode(ByteBuffer.wrap(newData)).toString() - addLog(s, false) - } - - SerialPort.LISTENING_EVENT_PORT_DISCONNECTED -> { - closeSerial() - } - } - } - - @get:Synchronized - override val isConnected: Boolean - get() = currentPort?.isOpen ?: false - - override fun getMessageDelimiter(): ByteArray = byteArrayOf(0x0A.toByte()) - - override fun delimiterIndicatesEndOfMessage(): Boolean = true - - override val knownPorts: Stream - get() = SerialPort.getCommPorts() - .asSequence() - .map { SerialPortWrapper(it) } - .filter { isKnownBoard(it) } - .asStream() - - private fun detectNewPorts() { - try { - val addDifferences = knownPorts.asSequence() - lastKnownPorts - val delDifferences = lastKnownPorts - knownPorts.asSequence().toSet() - lastKnownPorts = SerialPort.getCommPorts().map { SerialPortWrapper(it) }.toSet() - addDifferences.forEach { onNewDevice(it.port) } - delDifferences.forEach { onDeviceDel(it.port) } - } catch (e: Throwable) { - LogManager - .severe("[SerialHandler] Using serial ports is not supported on this platform", e) - throw RuntimeException("Serial unsupported") - } - } - - override fun getCurrentPort(): dev.slimevr.serial.SerialPort? { - val port = this.currentPort ?: return null - return SerialPortWrapper(port) - } -} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/tracking/trackers/hid/DesktopHIDManager.kt b/server/desktop/src/main/java/dev/slimevr/desktop/tracking/trackers/hid/DesktopHIDManager.kt deleted file mode 100644 index 3f89940a10..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/tracking/trackers/hid/DesktopHIDManager.kt +++ /dev/null @@ -1,289 +0,0 @@ -package dev.slimevr.desktop.tracking.trackers.hid - -import dev.slimevr.VRServer -import dev.slimevr.config.config -import dev.slimevr.tracking.trackers.Device -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerStatus -import dev.slimevr.tracking.trackers.hid.HIDCommon -import dev.slimevr.tracking.trackers.hid.HIDCommon.Companion.HID_TRACKER_PID -import dev.slimevr.tracking.trackers.hid.HIDCommon.Companion.HID_TRACKER_RECEIVER_PID -import dev.slimevr.tracking.trackers.hid.HIDCommon.Companion.HID_TRACKER_RECEIVER_VID -import dev.slimevr.tracking.trackers.hid.HIDCommon.Companion.PACKET_SIZE -import dev.slimevr.tracking.trackers.hid.HIDDevice -import io.eiren.util.logging.LogManager -import org.hid4java.HidDevice -import org.hid4java.HidException -import org.hid4java.HidManager -import org.hid4java.HidServices -import org.hid4java.HidServicesListener -import org.hid4java.HidServicesSpecification -import org.hid4java.event.HidServicesEvent -import org.hid4java.jna.HidApi -import org.hid4java.jna.HidDeviceInfoStructure -import java.nio.ByteBuffer -import java.util.function.Consumer - -/** - * Handles desktop USB HID dongles and receives tracker data from them. - */ -class DesktopHIDManager(name: String, private val trackersConsumer: Consumer) : - Thread(name), - HidServicesListener { - private val devices: MutableList = mutableListOf() - private val devicesBySerial: MutableMap> = HashMap() - private val devicesByHID: MutableMap> = HashMap() - private val lastDataByHID: MutableMap = HashMap() - private val hidServicesSpecification = HidServicesSpecification() - private var hidServices: HidServices? = null - - init { - hidServicesSpecification.setAutoStart(false) - try { - hidServices = HidManager.getHidServices(hidServicesSpecification) - hidServices?.addHidServicesListener(this) - val dataReadThread = Thread(dataReadRunnable) - dataReadThread.isDaemon = true - dataReadThread.name = "hid4java data reader" - dataReadThread.start() - // We use hid4java but actually do not start the service ever, because it will just enumerate everything and cause problems - // Do enumeration ourself - val deviceEnumerateThread = Thread(deviceEnumerateRunnable) - deviceEnumerateThread.isDaemon = true - deviceEnumerateThread.name = "hid4java device enumerator" - deviceEnumerateThread.start() - } catch (e: HidException) { - LogManager.severe("Error initializing HID services: ${e.message}", e) - } - } - - private fun checkConfigureDevice(hidDevice: HidDevice) { - if (hidDevice.vendorId == HID_TRACKER_RECEIVER_VID && (hidDevice.productId == HID_TRACKER_RECEIVER_PID || hidDevice.productId == HID_TRACKER_PID)) { // TODO: Use list of valid ids - val serial = hidDevice.serialNumber ?: "Unknown HID Device" - if (hidDevice.isClosed) { - if (!hidDevice.open()) { - LogManager.warning("[TrackerServer] Unable to open device: $serial") - return - } - } - // TODO: Configure the device here - // val product = hidDevice.product - // val manufacturer = hidDevice.manufacturer - this.devicesBySerial[serial]?.let { - this.devicesByHID[hidDevice] = it - synchronized(this.devices) { - for (id in it) { - val device = this.devices[id] - for (value in device.trackers.values) { - if (value.status == TrackerStatus.DISCONNECTED) value.status = TrackerStatus.OK - } - } - } - LogManager.info("[TrackerServer] Linked HID device reattached: $serial") - return - } - - // TODO: Need to check that the hidDevice is the same or different? - // TODO: Get firmware and manufacturer from the device? - val list: MutableList = mutableListOf() - this.devicesBySerial[serial] = list - this.devicesByHID[hidDevice] = list - this.lastDataByHID[hidDevice] = 0 // initialize last data received - LogManager.info("[TrackerServer] (Probably) Compatible HID device detected: $serial") - } - } - - private fun removeDevice(hidDevice: HidDevice) { - this.devicesByHID[hidDevice]?.let { - synchronized(this.devices) { - for (id in it) { - val device = this.devices[id] - for (value in device.trackers.values) { - if (value.status == TrackerStatus.OK) { - value.status = - TrackerStatus.DISCONNECTED - } - } - } - } - this.devicesByHID.remove(hidDevice) - LogManager.info("[TrackerServer] Linked HID device removed: ${hidDevice.serialNumber}") - } - } - - @get:Synchronized - private val dataReadRunnable: Runnable - get() = Runnable { - while (true) { - try { - sleep(0) // Possible performance impact - } catch (e: InterruptedException) { - currentThread().interrupt() - break - } - dataRead() // not in try catch? - } - } - - @get:Synchronized - private val deviceEnumerateRunnable: Runnable - get() = Runnable { - try { - sleep(100) // Delayed start - } catch (e: InterruptedException) { - currentThread().interrupt() - return@Runnable - } - while (true) { - try { - sleep(1000) - } catch (e: InterruptedException) { - currentThread().interrupt() - break - } - deviceEnumerate() // not in try catch? - } - } - - private fun dataRead() { - synchronized(devicesByHID) { - var devicesPresent = false - var devicesDataReceived = false - val q = intArrayOf(0, 0, 0, 0) - val a = intArrayOf(0, 0, 0) - val m = intArrayOf(0, 0, 0) - for ((hidDevice, deviceList) in devicesByHID) { - val dataReceived: ByteArray = try { - hidDevice.readAll(0) // multiples 64 bytes - } catch (e: NegativeArraySizeException) { - continue // Skip devices with read error (Maybe disconnected) - } - devicesPresent = true // Even if the device has no data - if (dataReceived.isNotEmpty()) { - // Process data - // The data is always received as 64 bytes, this check no longer works - if (dataReceived.size % PACKET_SIZE != 0) { - LogManager.info("[TrackerServer] Malformed HID packet, ignoring") - continue // Don't continue with this data - } - devicesDataReceived = true // Data is received and is valid (not malformed) - lastDataByHID[hidDevice] = 0 // reset last data received - val packetCount = dataReceived.size / PACKET_SIZE - var i = 0 - while (i < packetCount * PACKET_SIZE) { - // Common packet data - val packetType = dataReceived[i].toUByte().toInt() - val id = dataReceived[i + 1].toUByte().toInt() - val deviceId = id - - // Register device - if (packetType == 255) { // device register packet from receiver - val buffer = ByteBuffer.wrap(dataReceived, i + 2, 8) - buffer.order(java.nio.ByteOrder.LITTLE_ENDIAN) - val addr = buffer.getLong() and 0xFFFFFFFFFFFF - val deviceName = String.format("%012X", addr) - HIDCommon.deviceIdLookup(devices, hidDevice.serialNumber, deviceId, deviceName, deviceList) // register device - // server wants tracker to be unique, so use combination of hid serial and full id - i += PACKET_SIZE - continue - } - - val device: HIDDevice? = HIDCommon.deviceIdLookup(devices, hidDevice.serialNumber, deviceId, null, deviceList) - if (device == null) { // not registered yet - i += PACKET_SIZE - continue - } - - HIDCommon.processPacket(dataReceived, i, packetType, device, q, a, m, trackersConsumer) - i += PACKET_SIZE - } - // LogManager.info("[TrackerServer] HID received $packetCount tracker packets") - } else { - lastDataByHID[hidDevice] = lastDataByHID[hidDevice]!! + 1 // increment last data received - } - } - if (!devicesPresent) { - sleep(10) // No hid device, "empty loop" so sleep to save the poor cpu - } else if (!devicesDataReceived) { - sleep(1) // read has no timeout, no data also causes an "empty loop" - } - } - } - - private fun deviceEnumerate() { - var rootReceivers: HidDeviceInfoStructure? = null - var rootTrackers: HidDeviceInfoStructure? = null - val trackersOverHID: Boolean = VRServer.instance.configManager.vrConfig.hidConfig.trackersOverHID - try { - rootReceivers = HidApi.enumerateDevices(HID_TRACKER_RECEIVER_VID, HID_TRACKER_RECEIVER_PID) // TODO: Use list of ids - rootTrackers = if (trackersOverHID) { - HidApi.enumerateDevices(HID_TRACKER_RECEIVER_VID, HID_TRACKER_PID) - } else { - null - } // TODO: Use list of ids - } catch (e: Throwable) { - LogManager.severe("[TrackerServer] Couldn't enumerate HID devices", e) - } - var root: HidDeviceInfoStructure? = rootReceivers - if (root == null) { - root = rootTrackers - } else { - var last: HidDeviceInfoStructure = root - while (last.hasNext()) { - last = last.next() - } - last.next = rootTrackers - } - val hidDeviceList: MutableList = mutableListOf() - if (root != null) { - var hidDeviceInfoStructure: HidDeviceInfoStructure? = root - do { - hidDeviceList.add(HidDevice(hidDeviceInfoStructure, null, hidServicesSpecification)) - hidDeviceInfoStructure = hidDeviceInfoStructure?.next() - } while (hidDeviceInfoStructure != null) - HidApi.freeEnumeration(root) - } - synchronized(devicesByHID) { - // Work on devicesByHid and add/remove as necessary - val removeList: MutableList = devicesByHID.keys.toMutableList() - removeList.removeAll(hidDeviceList) - for (device in removeList) { - removeDevice(device) - } - // Quickly reattaching a device may not be detected, so always try to open existing devices - for (device in devicesByHID.keys) { - // a receiver sends keep-alive data at 10 packets/s - if (lastDataByHID[device]!! > 100) { // try to reopen device if no data was received recently (about >100ms) - LogManager.info("[TrackerServer] Reopening device ${device.serialNumber} after no data received") - device.open() - } - } - hidDeviceList.removeAll(devicesByHID.keys) // addList - for (device in hidDeviceList) { - checkConfigureDevice(device) - } - } - } - - override fun run() { // Doesn't seem to run - } - - fun getDevices(): List = devices - - // We don't use these - override fun hidDeviceAttached(event: HidServicesEvent) { - } - - override fun hidDeviceDetached(event: HidServicesEvent) { - } - - override fun hidFailure(event: HidServicesEvent) { - } - - override fun hidDataReceived(p0: HidServicesEvent?) { - } - - companion object { - private const val resetSourceName = "TrackerServer" - } -} From e060bc7cc52eee33cc444c2b658b037d3cde79d0 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 17 Mar 2026 21:22:56 +0100 Subject: [PATCH 002/350] Trying to get the basic shape of the state system out. Testing it with the udp part of the server to confirm the implementation choices --- flake.nix | 8 +- server/android/build.gradle.kts | 8 +- server/build.gradle.kts | 1 + server/core/build.gradle.kts | 8 +- .../src/main/java/dev/slimevr/VRServer.kt | 4 - .../main/java/dev/slimevr/config/config.kt | 67 +++++++ .../main/java/dev/slimevr/context/context.kt | 44 +++++ .../main/java/dev/slimevr/tracker/tracker.kt | 18 ++ .../dev/slimevr/tracker/udp/connection.kt | 138 +++++++++++++ .../java/dev/slimevr/tracker/udp/packets.kt | 187 ++++++++++++++++++ .../java/dev/slimevr/tracker/udp/server.kt | 86 ++++++++ .../src/main/java/dev/slimevr/vrserver.kt | 54 +++++ server/desktop/build.gradle.kts | 7 +- .../src/main/java/dev/slimevr/desktop/Main.kt | 12 +- 14 files changed, 623 insertions(+), 19 deletions(-) delete mode 100644 server/core/src/main/java/dev/slimevr/VRServer.kt create mode 100644 server/core/src/main/java/dev/slimevr/config/config.kt create mode 100644 server/core/src/main/java/dev/slimevr/context/context.kt create mode 100644 server/core/src/main/java/dev/slimevr/tracker/tracker.kt create mode 100644 server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt create mode 100644 server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt create mode 100644 server/core/src/main/java/dev/slimevr/tracker/udp/server.kt create mode 100644 server/core/src/main/java/dev/slimevr/vrserver.kt diff --git a/flake.nix b/flake.nix index 96e33f387f..372fc5cada 100644 --- a/flake.nix +++ b/flake.nix @@ -12,8 +12,10 @@ perSystem = { pkgs, ... }: let + java = pkgs.jdk24; + runtimeLibs = pkgs: (with pkgs; [ - jdk22 + java alsa-lib at-spi2-atk at-spi2-core cairo cups dbus expat gdk-pixbuf glib gtk3 libdrm libgbm libglvnd libnotify @@ -33,8 +35,8 @@ name = "slimevr-env"; targetPkgs = runtimeLibs; profile = '' - export JAVA_HOME=${pkgs.jdk22} - export PATH="${pkgs.jdk22}/bin:$PATH" + export JAVA_HOME=${java} + export PATH="${java}/bin:$PATH" # Tell electron-builder to use system tools instead of downloading them export USE_SYSTEM_FPM=true diff --git a/server/android/build.gradle.kts b/server/android/build.gradle.kts index 694ff83927..39180108e9 100644 --- a/server/android/build.gradle.kts +++ b/server/android/build.gradle.kts @@ -22,12 +22,12 @@ plugins { kotlin { jvmToolchain { - languageVersion.set(JavaLanguageVersion.of(22)) + languageVersion.set(JavaLanguageVersion.of(24)) } } java { toolchain { - languageVersion.set(JavaLanguageVersion.of(22)) + languageVersion.set(JavaLanguageVersion.of(24)) } } @@ -217,7 +217,7 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_22 - targetCompatibility = JavaVersion.VERSION_22 + sourceCompatibility = JavaVersion.VERSION_24 + targetCompatibility = JavaVersion.VERSION_24 } } diff --git a/server/build.gradle.kts b/server/build.gradle.kts index 6e0662e73b..da4159f54a 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -42,6 +42,7 @@ configure { ",dev.slimevr.tracking.trackers.*,dev.slimevr.desktop.platform.ProtobufMessages.*" + ",solarxr_protocol.rpc.*,kotlinx.coroutines.*,com.illposed.osc.*,android.app.*", "ij_kotlin_allow_trailing_comma" to true, + "ktlint_standard_filename" to "disabled", ) val ktlintVersion = "1.8.0" kotlinGradle { diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index a98a9d9e2c..26cacfa9d3 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -16,17 +16,17 @@ plugins { kotlin { jvmToolchain { - languageVersion.set(JavaLanguageVersion.of(22)) + languageVersion.set(JavaLanguageVersion.of(24)) } } java { toolchain { - languageVersion.set(JavaLanguageVersion.of(22)) + languageVersion.set(JavaLanguageVersion.of(24)) } } tasks.withType { compilerOptions { - jvmTarget.set(JvmTarget.JVM_22) + jvmTarget.set(JvmTarget.JVM_24) freeCompilerArgs.set(listOf("-Xvalue-classes")) } } @@ -67,6 +67,8 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("com.mayakapps.kache:kache:2.1.1") + implementation("io.ktor:ktor-network:3.0.0") + implementation("io.ktor:ktor-utils:3.0.3") api("com.github.loucass003:EspflashKotlin:v0.11.0") diff --git a/server/core/src/main/java/dev/slimevr/VRServer.kt b/server/core/src/main/java/dev/slimevr/VRServer.kt deleted file mode 100644 index b7fd794e3d..0000000000 --- a/server/core/src/main/java/dev/slimevr/VRServer.kt +++ /dev/null @@ -1,4 +0,0 @@ -package dev.slimevr - -class VRServer { -} diff --git a/server/core/src/main/java/dev/slimevr/config/config.kt b/server/core/src/main/java/dev/slimevr/config/config.kt new file mode 100644 index 0000000000..2d7b8f191b --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/config/config.kt @@ -0,0 +1,67 @@ +package dev.slimevr.config + +import dev.slimevr.context.Context +import dev.slimevr.context.BasicModule +import dev.slimevr.context.createContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.Serializable + +@Serializable +data class UserConfigState( + val userHeight: Float = 1.6f, +) + +@Serializable +data class SettingsConfigState( + val trackerPort: Int = 6969, +) + +@Serializable +data class GlobalConfigState( + val selectedUserProfile: String = "default", + val selectedSettingsProfile: String = "default", +) + +data class ConfigState( + val userConfig: UserConfigState = UserConfigState(), + val settingsConfig: SettingsConfigState = SettingsConfigState(), + val globalConfig: GlobalConfigState = GlobalConfigState(), +) + +sealed interface ConfigAction { + data class ChangeProfile(val settingsProfile: String? = null, val userProfile: String? = null) : ConfigAction +} + +typealias ConfigContext = Context +typealias ConfigModule = BasicModule + +val ConfigModuleTest = ConfigModule( + reducer = { s, a -> + when (a) { + is ConfigAction.ChangeProfile -> if (a.settingsProfile != null) { + s.copy(globalConfig = s.globalConfig.copy(selectedSettingsProfile = a.settingsProfile)) + } else if (a.userProfile != null) { + s.copy(globalConfig = s.globalConfig.copy(selectedUserProfile = a.userProfile)) + } else { + s + } + } + }, +) + +suspend fun createConfig(scope: CoroutineScope): ConfigContext { + + val modules = listOf(ConfigModuleTest) + + val context = createContext( + initialState = ConfigState(), + reducers = modules.map { it.reducer }, + scope = scope, + ) + + modules.map { it.observer }.forEach { it?.invoke(context) } + + context.dispatch(ConfigAction.ChangeProfile(settingsProfile = "Test")) + + return context +} diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt new file mode 100644 index 0000000000..d64b322003 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -0,0 +1,44 @@ +package dev.slimevr.context + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class BasicModule( + val reducer: (S, A) -> S, + val observer: ((Context) -> Unit)? = null, +) + +data class Context( + val state: StateFlow, + val dispatch: suspend (A) -> Unit, + val dispatchAll: suspend (List) -> Unit, + val scope: CoroutineScope, +) + +fun createContext( + initialState: S, + scope: CoroutineScope, + reducers: List<(S, A) -> S>, +): Context { + val mutableStateFlow = MutableStateFlow(initialState) + + val applyAction: (S, A) -> S = { currentState, action -> + reducers.fold(currentState) { s, reducer -> reducer(s, action) } + } + + val dispatch: suspend (A) -> Unit = { action -> + mutableStateFlow.update { applyAction(it, action) } + } + + val dispatchAll: suspend (List) -> Unit = { actions -> + mutableStateFlow.update { currentState -> + actions.fold(currentState) { s, action -> applyAction(s, action) } + } + } + val context = Context(mutableStateFlow.asStateFlow(), dispatch, dispatchAll, scope) + + return context +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt new file mode 100644 index 0000000000..ecb695607f --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt @@ -0,0 +1,18 @@ +package dev.slimevr.tracker + +import dev.slimevr.context.Context +import dev.slimevr.context.BasicModule +import io.github.axisangles.ktmath.Quaternion + +data class TrackerState( + val id: Int, + val name: String, + val rawRotation: Quaternion, +) + +sealed interface TrackerActions { + data class UpdateRotation(val rotation: Quaternion) +} + +typealias TrackerContext = Context +typealias TrackerModule = BasicModule diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt new file mode 100644 index 0000000000..ad472c83c0 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt @@ -0,0 +1,138 @@ +package dev.slimevr.tracker.udp + +import dev.slimevr.context.Context +import dev.slimevr.context.createContext +import io.ktor.network.sockets.BoundDatagramSocket +import io.ktor.network.sockets.Datagram +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.utils.io.core.buildPacket +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +data class LastPing( + val id: Int, + val startTime: Long, + val duration: Long, +) + +data class UDPConnectionState( + val id: String, + val lastPacket: Long, + val lastPacketNum: Int, + val lastPing: LastPing, + val didHandshake: Boolean, + val address: String, + val port: Int, +) + +sealed interface UDPConnectionActions { + data class StartPing(val startTime: Long): UDPConnectionActions + data class ReceivedPong(val id: Int, val duration: Long): UDPConnectionActions +} + +typealias UDPConnectionContext = Context + +data class UDPConnection( + val context: UDPConnectionContext, + val packetEvents: PacketDispatcher, + val send: (Packet) -> Unit +) + +data class UDPConnectionModule( + val reducer: (UDPConnectionState, UDPConnectionActions) -> UDPConnectionState, + val observer: ((UDPConnection) -> Unit)? = null, +) + +val PingModule = UDPConnectionModule( + reducer = { s, a -> + when (a) { + is UDPConnectionActions.StartPing -> { + s.copy(lastPing = s.lastPing.copy(startTime = a.startTime)) + } + is UDPConnectionActions.ReceivedPong -> { + s.copy(lastPing = s.lastPing.copy(duration = a.duration, id = a.id)) + } + else -> s + } + }, + observer = { + // Send the ping every 1s + it.context.scope.launch { + while (isActive) { + val state = it.context.state.value + if (state.didHandshake) { + it.context.dispatch(UDPConnectionActions.StartPing(startTime = System.currentTimeMillis())) + it.send(PingPong(state.lastPacketNum + 1)) + } + delay(1000) + } + } + + // listen for the pong + it.packetEvents.on { packet -> + val state = it.context.state.value + if (packet.pingId != state.lastPing.id + 1) { + println("Ping ID does not match, ignoring") + return@on + } + + val ping = System.currentTimeMillis() - state.lastPing.startTime; + + it.context.scope.launch { + it.context.dispatch(UDPConnectionActions.ReceivedPong(id = packet.pingId, duration = ping)) + + // TODO update the device ping delay + } + } + } +) + + + +fun createUDPConnectionContext( + id: String, + socket: BoundDatagramSocket, + remoteAddress: InetSocketAddress, + scope: CoroutineScope +): UDPConnection { + + val modules = listOf(PingModule) + + val context = createContext( + initialState = UDPConnectionState( + id = id, + lastPacket = System.currentTimeMillis(), + lastPacketNum = 0, + lastPing = LastPing(id = 0, duration = 0, startTime = 0), + didHandshake = false, + address = remoteAddress.hostname, + port = remoteAddress.port, + ), + reducers = modules.map { it.reducer }, + scope = scope + ) + + val dispatcher = PacketDispatcher() + + val sendFunc = { packet: Packet -> + scope.launch { + val bytePacket = buildPacket { + writePacket(this, packet) + } + socket.send(Datagram(bytePacket, remoteAddress)) + } + Unit + } + + val conn = UDPConnection( + context, + dispatcher, + send = sendFunc + ) + + modules.map { it.observer }.forEach { it?.invoke(conn) } + + return conn +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt new file mode 100644 index 0000000000..ed44a82e4d --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt @@ -0,0 +1,187 @@ +package dev.slimevr.tracker.udp + +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import io.ktor.utils.io.core.remaining +import kotlinx.io.* +import kotlin.reflect.KClass + +// ── Enums ─────────────────────────────────────────────────────────────────── + +enum class PacketType(val id: Int) { + HEARTBEAT(0), ROTATION(1), HANDSHAKE(3), ACCEL(4), PING_PONG(10), + SERIAL(11), BATTERY_LEVEL(12), TAP(13), ERROR(14), SENSOR_INFO(15), + ROTATION_2(16), ROTATION_DATA(17), MAGNETOMETER_ACCURACY(18), + SIGNAL_STRENGTH(19), TEMPERATURE(20), USER_ACTION(21), PROTOCOL_CHANGE(200); + + companion object { + private val map = entries.associateBy { it.id } + fun fromId(id: Int) = map[id] + } +} + +enum class UserAction(val id: Int) { + RESET_FULL(2), RESET_YAW(3), RESET_MOUNTING(4), PAUSE_TRACKING(5); + companion object { + private val map = entries.associateBy { it.id } + fun fromId(id: Int) = map[id] + } +} + +sealed interface Packet +sealed interface SensorSpecificPacket : Packet { val sensorId: Int } +sealed interface RotationPacket : SensorSpecificPacket { val rotation: Quaternion } + +data object Heartbeat : Packet +data class Handshake(val board: Int = 0, val imu: Int = 0, val mcu: Int = 0, val pVer: Int = 0, val firmware: String? = null, val mac: String? = null) : Packet +data class Rotation(override val sensorId: Int = 0, override val rotation: Quaternion = Quaternion.IDENTITY) : RotationPacket +data class Accel(val acceleration: Vector3 = Vector3.NULL, override val sensorId: Int = 0) : SensorSpecificPacket +data class PingPong(val pingId: Int = 0) : Packet +data class Serial(val serial: String = "") : Packet +data class BatteryLevel(val voltage: Float = 0f, val level: Float = 0f) : Packet +data class Tap(override val sensorId: Int = 0, val tap: Int = 0) : SensorSpecificPacket +data class Error(override val sensorId: Int = 0, val errorNumber: Int = 0) : SensorSpecificPacket +data class SensorInfo(override val sensorId: Int = 0, val status: Int = 0, val type: Int = 0) : SensorSpecificPacket +data class Rotation2(override val sensorId: Int = 1, override val rotation: Quaternion = Quaternion.IDENTITY) : RotationPacket +data class RotationData(override val sensorId: Int = 0, val dataType: Int = 0, val rotation: Quaternion = Quaternion.IDENTITY, val calibration: Int = 0) : SensorSpecificPacket +data class MagnetometerAccuracy(override val sensorId: Int = 0, val accuracy: Float = 0f) : SensorSpecificPacket +data class SignalStrength(override val sensorId: Int = 0, val signal: Int = 0) : SensorSpecificPacket +data class Temperature(override val sensorId: Int = 0, val temp: Float = 0f) : SensorSpecificPacket +data class UserActionPacket(val action: UserAction? = null) : Packet +data class ProtocolChange(val targetProtocol: Int = 0, val targetVersion: Int = 0) : Packet + +private fun Source.readU8() = readByte().toInt() and 0xFF +private fun Sink.writeU8(v: Int) = writeByte(v.toByte()) + +private fun Source.readSafeFloat() = readFloat().let { if (it.isNaN()) 0f else it } +private fun Source.readSafeQuat() = Quaternion(readFloat(), readFloat(), readFloat(), readFloat()).let { + if (it.x.isNaN() || (it.x == 0f && it.y == 0f && it.z == 0f && it.w == 0f)) Quaternion.IDENTITY else it +} + +private fun Sink.writeQuat(q: Quaternion) { + writeFloat(q.x); writeFloat(q.y); writeFloat(q.z); writeFloat(q.w) +} + +private fun Source.readStr(len: Int) = buildString { + repeat(len) { readByte().toInt().takeIf { it != 0 }?.let { append(it.toChar()) } } +} + +object PacketCodec { + fun read(type: PacketType, src: Source): Packet = when (type) { + PacketType.HEARTBEAT -> Heartbeat + PacketType.HANDSHAKE -> with(src) { + if (exhausted()) return Handshake() + val b = if (remaining >= 4) readInt() else 0 + val i = if (remaining >= 4) readInt() else 0 + val m = if (remaining >= 4) readInt() else 0 + if (remaining >= 12) skip(12) + val p = if (remaining >= 4) readInt() else 0 + val f = if (remaining >= 1) readStr(readByte().toInt()) else null + val mac = if (remaining >= 6) readByteArray(6).joinToString(":") { "%02X".format(it) }.takeIf { it != "00:00:00:00:00:00" } else null + Handshake(b, i, m, p, f, mac) + } + PacketType.ROTATION -> Rotation(rotation = src.readSafeQuat()) + PacketType.ACCEL -> Accel(Vector3(src.readSafeFloat(), src.readSafeFloat(), src.readSafeFloat()), if (!src.exhausted()) src.readU8() else 0) + PacketType.PING_PONG -> PingPong(src.readInt()) + PacketType.SERIAL -> Serial(src.readStr(src.readInt())) + PacketType.BATTERY_LEVEL -> src.readSafeFloat().let { f -> + if (src.remaining >= 4) BatteryLevel(f, src.readSafeFloat()) else BatteryLevel(0f, f) + } + PacketType.TAP -> Tap(src.readU8(), src.readU8()) + PacketType.ERROR -> Error(src.readU8(), src.readU8()) + PacketType.SENSOR_INFO -> SensorInfo(src.readU8(), src.readU8(), if (!src.exhausted()) src.readU8() else 0) + PacketType.ROTATION_2 -> Rotation2(rotation = src.readSafeQuat()) + PacketType.ROTATION_DATA -> RotationData(src.readU8(), src.readU8(), src.readSafeQuat(), src.readU8()) + PacketType.MAGNETOMETER_ACCURACY -> MagnetometerAccuracy(src.readU8(), src.readSafeFloat()) + PacketType.SIGNAL_STRENGTH -> SignalStrength(src.readU8(), src.readByte().toInt()) + PacketType.TEMPERATURE -> Temperature(src.readU8(), src.readSafeFloat()) + PacketType.USER_ACTION -> UserActionPacket(UserAction.fromId(src.readU8())) + PacketType.PROTOCOL_CHANGE -> ProtocolChange(src.readU8(), src.readU8()) + } + + fun write(dst: Sink, packet: Packet) = when (packet) { + is Heartbeat -> {} + is Handshake -> { + dst.writeU8(PacketType.HANDSHAKE.id) + dst.write("Hey OVR =D 5".toByteArray(Charsets.US_ASCII)) + } + is Rotation -> dst.writeQuat(packet.rotation) + is Accel -> { dst.writeFloat(packet.acceleration.x); dst.writeFloat(packet.acceleration.y); dst.writeFloat(packet.acceleration.z) } + is PingPong -> dst.writeInt(packet.pingId) + is Serial -> { dst.writeInt(packet.serial.length); packet.serial.forEach { dst.writeU8(it.code) } } + is BatteryLevel -> { dst.writeFloat(packet.voltage); dst.writeFloat(packet.level) } + is Tap -> { dst.writeU8(packet.sensorId); dst.writeU8(packet.tap) } + is Error -> { dst.writeU8(packet.sensorId); dst.writeU8(packet.errorNumber) } + is SensorInfo -> { dst.writeU8(packet.sensorId); dst.writeU8(packet.status); dst.writeU8(packet.type) } + is Rotation2 -> dst.writeQuat(packet.rotation) + is RotationData -> { dst.writeU8(packet.sensorId); dst.writeU8(packet.dataType); dst.writeQuat(packet.rotation); dst.writeU8(packet.calibration) } + is MagnetometerAccuracy -> { dst.writeU8(packet.sensorId); dst.writeFloat(packet.accuracy) } + is SignalStrength -> { dst.writeU8(packet.sensorId); dst.writeByte(packet.signal.toByte()) } + is Temperature -> { dst.writeU8(packet.sensorId); dst.writeFloat(packet.temp) } + is UserActionPacket -> dst.writeU8(packet.action?.id ?: 0) + is ProtocolChange -> { dst.writeU8(packet.targetProtocol); dst.writeU8(packet.targetVersion) } + } +} + +// ── Entry Points ──────────────────────────────────────────────────────────── + +fun readPacket(src: Source): Packet? { + if (src.exhausted()) return null + val type = PacketType.fromId(src.readInt()) ?: return null + if (type != PacketType.HANDSHAKE) src.skip(8) // Skip sequence number + return PacketCodec.read(type, src) +} + +fun writePacket(dst: Sink, packet: Packet) { + val type = when(packet) { + is Heartbeat -> PacketType.HEARTBEAT + is Handshake -> PacketType.HANDSHAKE + is Rotation -> PacketType.ROTATION + is Accel -> PacketType.ACCEL + is PingPong -> PacketType.PING_PONG + is Serial -> PacketType.SERIAL + is BatteryLevel -> PacketType.BATTERY_LEVEL + is Tap -> PacketType.TAP + is Error -> PacketType.ERROR + is SensorInfo -> PacketType.SENSOR_INFO + is Rotation2 -> PacketType.ROTATION_2 + is RotationData -> PacketType.ROTATION_DATA + is MagnetometerAccuracy -> PacketType.MAGNETOMETER_ACCURACY + is SignalStrength -> PacketType.SIGNAL_STRENGTH + is Temperature -> PacketType.TEMPERATURE + is UserActionPacket -> PacketType.USER_ACTION + is ProtocolChange -> PacketType.PROTOCOL_CHANGE + } + + if (type != PacketType.HANDSHAKE) { + dst.writeInt(type.id) + dst.writeLong(type.id.toLong()) // Sequence number placeholder + } + PacketCodec.write(dst, packet) +} + + +class PacketDispatcher { + val listeners = mutableMapOf, MutableList<(Packet) -> Unit>>() + + /** + * Listen for a specific packet type. + * Usage: dispatcher.on { packet -> println(packet.rotation) } + */ + inline fun on(crossinline callback: (T) -> Unit) { + val list = listeners.getOrPut(T::class) { mutableListOf() } + synchronized(list) { + list.add { callback(it as T) } + } + } + + /** + * Broadcasts a packet to all registered listeners for its type. + */ + fun emit(packet: Packet) { + val list = listeners[packet::class] ?: return + synchronized(list) { + list.forEach { it(packet) } + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt new file mode 100644 index 0000000000..c4aea15809 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt @@ -0,0 +1,86 @@ +package dev.slimevr.tracker.udp + +import dev.slimevr.VRServerContext +import dev.slimevr.config.ConfigContext +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.BoundDatagramSocket +import io.ktor.network.sockets.Datagram +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.aSocket +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope + +data class UDPTrackerServerState( + val port: Int, + val connections: MutableMap +) + +suspend fun processPacket( + socket: BoundDatagramSocket, + datagram: Datagram, + workerId: Int +) { + + +} + +const val PACKET_WORKERS = 4 + +suspend fun createUDPTrackerServer( + serverContext: VRServerContext, + configContext: ConfigContext +): UDPTrackerServerState { + val state = UDPTrackerServerState( + port = configContext.state.value.settingsConfig.trackerPort, + connections = mutableMapOf() + ) + + val selectorManager = SelectorManager(Dispatchers.IO) + val serverSocket = + aSocket(selectorManager).udp().bind(InetSocketAddress("0.0.0.0", state.port)) + val packetChannel = Channel(capacity = Channel.BUFFERED) + + supervisorScope { + launch(Dispatchers.IO) { + while (isActive) { + val datagram = serverSocket.receive() + packetChannel.send(datagram) + } + } + + repeat(PACKET_WORKERS) { workerId -> + launch(Dispatchers.Default) { + for (datagram in packetChannel) { + val packet = readPacket(datagram.packet) + if (packet == null) { + println("null packet") + continue + } + + val address = datagram.address as InetSocketAddress + val connContext = state.connections[address.hostname] + + if (connContext !== null) + connContext.packetEvents.emit(packet = packet) + else { + val newContext = createUDPConnectionContext( + id = address.hostname, + remoteAddress = address, + socket = serverSocket, + scope = this + ) + + state.connections[address.hostname] = newContext + newContext.packetEvents.emit(packet = packet) + } + + } + } + } + } + + return state +} diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt new file mode 100644 index 0000000000..d2257ae774 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -0,0 +1,54 @@ +package dev.slimevr + +import dev.slimevr.context.Context +import dev.slimevr.context.BasicModule +import dev.slimevr.context.createContext +import dev.slimevr.tracker.TrackerContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +data class VRServerState( + val trackers: Map, +) + +sealed interface VRServerActions { + data class NewTracker(val trackerId: Int, val context: TrackerContext) : VRServerActions +} + +typealias VRServerContext = Context +typealias VRServerModule = BasicModule + +val TestModule = VRServerModule( + reducer = { s, a -> + when (a) { + is VRServerActions.NewTracker -> s.copy( + trackers = s.trackers + (a.trackerId to a.context), + ) + } + }, + observer = { context -> + context.state.distinctUntilChangedBy { state -> state.trackers.size }.onEach { + println("tracker list size changed") + }.launchIn(context.scope) + }, +) + +fun createVRServer(scope: CoroutineScope): VRServerContext { + val server = VRServerState( + trackers = mapOf(), + ) + + val modules = listOf(TestModule) + + val context = createContext( + initialState = server, + reducers = modules.map { it.reducer }, + scope = scope, + ) + + modules.map { it.observer }.forEach { it?.invoke(context) } + + return context +} diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index c71c9dd3ea..38eea50a4a 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -18,17 +18,17 @@ plugins { kotlin { jvmToolchain { - languageVersion.set(JavaLanguageVersion.of(22)) + languageVersion.set(JavaLanguageVersion.of(24)) } } java { toolchain { - languageVersion.set(JavaLanguageVersion.of(22)) + languageVersion.set(JavaLanguageVersion.of(24)) } } tasks.withType { compilerOptions { - jvmTarget.set(JvmTarget.JVM_22) + jvmTarget.set(JvmTarget.JVM_24) freeCompilerArgs.set(listOf("-Xvalue-classes")) } } @@ -60,6 +60,7 @@ dependencies { implementation("net.java.dev.jna:jna:5.+") implementation("net.java.dev.jna:jna-platform:5.+") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("com.fazecast:jSerialComm:2.11.3") { exclude(group = "com.fazecast", module = "android") } 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 c4c3412fad..6f1024e0ee 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -2,6 +2,14 @@ package dev.slimevr.desktop -fun main(args: Array) { - println("Hello world!") +import dev.slimevr.config.createConfig +import dev.slimevr.createVRServer +import dev.slimevr.tracker.udp.createUDPTrackerServer +import kotlinx.coroutines.runBlocking + +fun main(args: Array) = runBlocking { + val config = createConfig(this) + val server = createVRServer(this) + + val udpServer = createUDPTrackerServer(server, config) } From 01b7b8dbbad3df5d37be2b01686e0d1ba6e9bbb0 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 18 Mar 2026 16:38:20 +0100 Subject: [PATCH 003/350] basic handshake --- .../main/java/dev/slimevr/config/config.kt | 3 +- .../main/java/dev/slimevr/tracker/device.kt | 86 ++++++++ .../main/java/dev/slimevr/tracker/tracker.kt | 2 +- .../dev/slimevr/tracker/udp/connection.kt | 126 +++++++++-- .../java/dev/slimevr/tracker/udp/packets.kt | 202 ++++++++++++++---- .../java/dev/slimevr/tracker/udp/server.kt | 44 ++-- .../src/main/java/dev/slimevr/vrserver.kt | 50 +++-- .../src/main/java/dev/slimevr/desktop/Main.kt | 4 +- 8 files changed, 411 insertions(+), 106 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/tracker/device.kt diff --git a/server/core/src/main/java/dev/slimevr/config/config.kt b/server/core/src/main/java/dev/slimevr/config/config.kt index 2d7b8f191b..cdfdffd871 100644 --- a/server/core/src/main/java/dev/slimevr/config/config.kt +++ b/server/core/src/main/java/dev/slimevr/config/config.kt @@ -1,7 +1,7 @@ package dev.slimevr.config -import dev.slimevr.context.Context import dev.slimevr.context.BasicModule +import dev.slimevr.context.Context import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.serialization.Serializable @@ -50,7 +50,6 @@ val ConfigModuleTest = ConfigModule( ) suspend fun createConfig(scope: CoroutineScope): ConfigContext { - val modules = listOf(ConfigModuleTest) val context = createContext( diff --git a/server/core/src/main/java/dev/slimevr/tracker/device.kt b/server/core/src/main/java/dev/slimevr/tracker/device.kt new file mode 100644 index 0000000000..5698b96374 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/tracker/device.kt @@ -0,0 +1,86 @@ +package dev.slimevr.tracker + +import dev.slimevr.VRServer +import dev.slimevr.context.BasicModule +import dev.slimevr.context.Context +import dev.slimevr.context.createContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +enum class DeviceOrigin { + DRIVER, + FEEDER, + UDP, + HID, +} + +data class DeviceState( + val id: Int, + val name: String, + val address: String, + val batteryLevel: Int, + val batteryVoltage: Int, + val ping: Long?, + val signalStrength: Int?, + val origin: DeviceOrigin, +) + +sealed interface DeviceActions { + data class SetBattery(val level: Int, val voltage: Int) : DeviceActions + data class SetPing(val ping: Long) : DeviceActions + data class SetSignalStrength(val signalStrength: Int) : DeviceActions +} + +typealias DeviceContext = Context +typealias DeviceModule = BasicModule + +data class Device( + val context: DeviceContext, +) + +val PingModule = DeviceModule( + reducer = { s, a -> + when (a) { + is DeviceActions.SetPing -> s.copy(ping = a.ping) + else -> s + } + }, + observer = { + it.state + .distinctUntilChangedBy { device -> device.ping } + .filter { device -> device.ping != null } + .onEach { device -> + println("[${device.name}] ping change to ${device.ping}") + }.launchIn(it.scope) + }, +) + +fun createDevice(scope: CoroutineScope, id: Int, address: String, origin: DeviceOrigin, serverContext: VRServer): Device { + val deviceState = DeviceState( + id = id, + name = "Device $id", + batteryLevel = 0, + batteryVoltage = 0, + origin = origin, + address = address, + ping = null, + signalStrength = null, + ) + + val modules = listOf(PingModule) + + val context = createContext( + initialState = deviceState, + reducers = modules.map { it.reducer }, + scope = scope, + ) + + modules.map { it.observer }.forEach { it?.invoke(context) } + + return Device( + context = context, + ) +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt index ecb695607f..b2360ee958 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt @@ -1,7 +1,7 @@ package dev.slimevr.tracker -import dev.slimevr.context.Context import dev.slimevr.context.BasicModule +import dev.slimevr.context.Context import io.github.axisangles.ktmath.Quaternion data class TrackerState( diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt index ad472c83c0..da703e9fc8 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt @@ -1,7 +1,12 @@ package dev.slimevr.tracker.udp +import dev.slimevr.VRServer +import dev.slimevr.VRServerActions import dev.slimevr.context.Context import dev.slimevr.context.createContext +import dev.slimevr.tracker.DeviceActions +import dev.slimevr.tracker.DeviceOrigin +import dev.slimevr.tracker.createDevice import io.ktor.network.sockets.BoundDatagramSocket import io.ktor.network.sockets.Datagram import io.ktor.network.sockets.InetSocketAddress @@ -20,24 +25,28 @@ data class LastPing( data class UDPConnectionState( val id: String, val lastPacket: Long, - val lastPacketNum: Int, + val lastPacketNum: Long, val lastPing: LastPing, val didHandshake: Boolean, val address: String, val port: Int, + val deviceId: Int?, ) sealed interface UDPConnectionActions { - data class StartPing(val startTime: Long): UDPConnectionActions - data class ReceivedPong(val id: Int, val duration: Long): UDPConnectionActions + data class StartPing(val startTime: Long) : UDPConnectionActions + data class ReceivedPong(val id: Int, val duration: Long) : UDPConnectionActions + data class Handshake(val deviceId: Int) : UDPConnectionActions + data class LastPacket(val packetNum: Long? = null, val time: Long) : UDPConnectionActions } typealias UDPConnectionContext = Context data class UDPConnection( val context: UDPConnectionContext, + val serverContext: VRServer, val packetEvents: PacketDispatcher, - val send: (Packet) -> Unit + val send: (Packet) -> Unit, ) data class UDPConnectionModule( @@ -45,15 +54,55 @@ data class UDPConnectionModule( val observer: ((UDPConnection) -> Unit)? = null, ) +val PacketModule = UDPConnectionModule( + reducer = { s, a -> + when (a) { + is UDPConnectionActions.LastPacket -> { + var newState = s.copy(lastPacket = a.time) + + if (a.packetNum != null) { + newState = newState.copy(lastPacketNum = a.packetNum) + } + + newState + } + + else -> s + } + }, + observer = { + it.packetEvents.onAny { packet -> + val state = it.context.state.value + + val now = System.currentTimeMillis() + if (now - state.lastPacket > 5000 && packet.packetNumber == 0L) { + it.context.scope.launch { + it.context.dispatch(UDPConnectionActions.LastPacket(packetNum = 0, time = now)) + println("Reconnecting") + } + } else if (packet.packetNumber < state.lastPacketNum) { + println("WARN: Received packet with wrong packet number") + return@onAny + } else { + it.context.scope.launch { + it.context.dispatch(UDPConnectionActions.LastPacket(time = now)) + } + } + } + }, +) + val PingModule = UDPConnectionModule( reducer = { s, a -> when (a) { is UDPConnectionActions.StartPing -> { s.copy(lastPing = s.lastPing.copy(startTime = a.startTime)) } + is UDPConnectionActions.ReceivedPong -> { s.copy(lastPing = s.lastPing.copy(duration = a.duration, id = a.id)) } + else -> s } }, @@ -64,41 +113,76 @@ val PingModule = UDPConnectionModule( val state = it.context.state.value if (state.didHandshake) { it.context.dispatch(UDPConnectionActions.StartPing(startTime = System.currentTimeMillis())) - it.send(PingPong(state.lastPacketNum + 1)) + it.send(PingPong(state.lastPing.id + 1)) } delay(1000) } } // listen for the pong - it.packetEvents.on { packet -> + it.packetEvents.on { paket -> val state = it.context.state.value - if (packet.pingId != state.lastPing.id + 1) { + val deviceId = state.deviceId ?: return@on + + if (paket.data.pingId != state.lastPing.id + 1) { println("Ping ID does not match, ignoring") return@on } - val ping = System.currentTimeMillis() - state.lastPing.startTime; + val ping = System.currentTimeMillis() - state.lastPing.startTime - it.context.scope.launch { - it.context.dispatch(UDPConnectionActions.ReceivedPong(id = packet.pingId, duration = ping)) + val device = it.serverContext.getDeviceContext(deviceId) ?: return@on - // TODO update the device ping delay + it.context.scope.launch { + it.context.dispatch(UDPConnectionActions.ReceivedPong(id = paket.data.pingId, duration = ping)) + device.context.dispatch(DeviceActions.SetPing(ping)) } } - } + }, ) +val HandshakeModule = UDPConnectionModule( + reducer = { s, a -> + when (a) { + is UDPConnectionActions.Handshake -> s.copy(didHandshake = true, deviceId = a.deviceId) + else -> s + } + }, + observer = { + it.packetEvents.on { packet -> + val state = it.context.state.value + if (state.deviceId == null) { + val deviceId = it.serverContext.nextHandle() + + val newDevice = createDevice( + id = deviceId, + scope = it.serverContext.context.scope, + address = packet.data.mac ?: error("no mac address?"), + origin = DeviceOrigin.UDP, + serverContext = it.serverContext, + ) + + it.context.scope.launch { + it.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId = deviceId, context = newDevice)) + it.context.dispatch(UDPConnectionActions.Handshake(deviceId)) + it.send(HandshakeResponse()) + } + } else { + it.send(HandshakeResponse()) + } + } + }, +) fun createUDPConnectionContext( id: String, socket: BoundDatagramSocket, remoteAddress: InetSocketAddress, - scope: CoroutineScope + serverContext: VRServer, + scope: CoroutineScope, ): UDPConnection { - - val modules = listOf(PingModule) + val modules = listOf(PacketModule, HandshakeModule, PingModule) val context = createContext( initialState = UDPConnectionState( @@ -109,17 +193,20 @@ fun createUDPConnectionContext( didHandshake = false, address = remoteAddress.hostname, port = remoteAddress.port, + deviceId = null, ), reducers = modules.map { it.reducer }, - scope = scope + scope = scope, ) val dispatcher = PacketDispatcher() val sendFunc = { packet: Packet -> scope.launch { + val packetNum = context.state.value.lastPacketNum + 1 + val bytePacket = buildPacket { - writePacket(this, packet) + writePacket(this, packet, packetNum) } socket.send(Datagram(bytePacket, remoteAddress)) } @@ -127,9 +214,10 @@ fun createUDPConnectionContext( } val conn = UDPConnection( - context, + context = context, + serverContext = serverContext, dispatcher, - send = sendFunc + send = sendFunc, ) modules.map { it.observer }.forEach { it?.invoke(conn) } diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt index ed44a82e4d..237ad8b5ef 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt @@ -3,16 +3,34 @@ package dev.slimevr.tracker.udp import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import io.ktor.utils.io.core.remaining -import kotlinx.io.* +import kotlinx.io.Sink +import kotlinx.io.Source +import kotlinx.io.readByteArray +import kotlinx.io.readFloat +import kotlinx.io.writeFloat import kotlin.reflect.KClass // ── Enums ─────────────────────────────────────────────────────────────────── enum class PacketType(val id: Int) { - HEARTBEAT(0), ROTATION(1), HANDSHAKE(3), ACCEL(4), PING_PONG(10), - SERIAL(11), BATTERY_LEVEL(12), TAP(13), ERROR(14), SENSOR_INFO(15), - ROTATION_2(16), ROTATION_DATA(17), MAGNETOMETER_ACCURACY(18), - SIGNAL_STRENGTH(19), TEMPERATURE(20), USER_ACTION(21), PROTOCOL_CHANGE(200); + HEARTBEAT(0), + ROTATION(1), + HANDSHAKE(3), + ACCEL(4), + PING_PONG(10), + SERIAL(11), + BATTERY_LEVEL(12), + TAP(13), + ERROR(14), + SENSOR_INFO(15), + ROTATION_2(16), + ROTATION_DATA(17), + MAGNETOMETER_ACCURACY(18), + SIGNAL_STRENGTH(19), + TEMPERATURE(20), + USER_ACTION(21), + PROTOCOL_CHANGE(200), + ; companion object { private val map = entries.associateBy { it.id } @@ -21,7 +39,12 @@ enum class PacketType(val id: Int) { } enum class UserAction(val id: Int) { - RESET_FULL(2), RESET_YAW(3), RESET_MOUNTING(4), PAUSE_TRACKING(5); + RESET_FULL(2), + RESET_YAW(3), + RESET_MOUNTING(4), + PAUSE_TRACKING(5), + ; + companion object { private val map = entries.associateBy { it.id } fun fromId(id: Int) = map[id] @@ -29,11 +52,16 @@ enum class UserAction(val id: Int) { } sealed interface Packet -sealed interface SensorSpecificPacket : Packet { val sensorId: Int } -sealed interface RotationPacket : SensorSpecificPacket { val rotation: Quaternion } +sealed interface SensorSpecificPacket : Packet { + val sensorId: Int +} +sealed interface RotationPacket : SensorSpecificPacket { + val rotation: Quaternion +} data object Heartbeat : Packet data class Handshake(val board: Int = 0, val imu: Int = 0, val mcu: Int = 0, val pVer: Int = 0, val firmware: String? = null, val mac: String? = null) : Packet +data class HandshakeResponse(val nothing: Unit = Unit) : Packet data class Rotation(override val sensorId: Int = 0, override val rotation: Quaternion = Quaternion.IDENTITY) : RotationPacket data class Accel(val acceleration: Vector3 = Vector3.NULL, override val sensorId: Int = 0) : SensorSpecificPacket data class PingPong(val pingId: Int = 0) : Packet @@ -59,7 +87,10 @@ private fun Source.readSafeQuat() = Quaternion(readFloat(), readFloat(), readFlo } private fun Sink.writeQuat(q: Quaternion) { - writeFloat(q.x); writeFloat(q.y); writeFloat(q.z); writeFloat(q.w) + writeFloat(q.x) + writeFloat(q.y) + writeFloat(q.z) + writeFloat(q.w) } private fun Source.readStr(len: Int) = buildString { @@ -69,73 +100,144 @@ private fun Source.readStr(len: Int) = buildString { object PacketCodec { fun read(type: PacketType, src: Source): Packet = when (type) { PacketType.HEARTBEAT -> Heartbeat + PacketType.HANDSHAKE -> with(src) { if (exhausted()) return Handshake() val b = if (remaining >= 4) readInt() else 0 val i = if (remaining >= 4) readInt() else 0 val m = if (remaining >= 4) readInt() else 0 - if (remaining >= 12) skip(12) + if (remaining >= 12) { + readInt() + readInt() + readInt() + } val p = if (remaining >= 4) readInt() else 0 val f = if (remaining >= 1) readStr(readByte().toInt()) else null val mac = if (remaining >= 6) readByteArray(6).joinToString(":") { "%02X".format(it) }.takeIf { it != "00:00:00:00:00:00" } else null Handshake(b, i, m, p, f, mac) } + PacketType.ROTATION -> Rotation(rotation = src.readSafeQuat()) - PacketType.ACCEL -> Accel(Vector3(src.readSafeFloat(), src.readSafeFloat(), src.readSafeFloat()), if (!src.exhausted()) src.readU8() else 0) + + PacketType.ACCEL -> Accel( + Vector3(src.readSafeFloat(), src.readSafeFloat(), src.readSafeFloat()), + if (!src.exhausted()) src.readU8() else 0, + ) + PacketType.PING_PONG -> PingPong(src.readInt()) + PacketType.SERIAL -> Serial(src.readStr(src.readInt())) + PacketType.BATTERY_LEVEL -> src.readSafeFloat().let { f -> if (src.remaining >= 4) BatteryLevel(f, src.readSafeFloat()) else BatteryLevel(0f, f) } + PacketType.TAP -> Tap(src.readU8(), src.readU8()) + PacketType.ERROR -> Error(src.readU8(), src.readU8()) + PacketType.SENSOR_INFO -> SensorInfo(src.readU8(), src.readU8(), if (!src.exhausted()) src.readU8() else 0) + PacketType.ROTATION_2 -> Rotation2(rotation = src.readSafeQuat()) + PacketType.ROTATION_DATA -> RotationData(src.readU8(), src.readU8(), src.readSafeQuat(), src.readU8()) + PacketType.MAGNETOMETER_ACCURACY -> MagnetometerAccuracy(src.readU8(), src.readSafeFloat()) + PacketType.SIGNAL_STRENGTH -> SignalStrength(src.readU8(), src.readByte().toInt()) + PacketType.TEMPERATURE -> Temperature(src.readU8(), src.readSafeFloat()) + PacketType.USER_ACTION -> UserActionPacket(UserAction.fromId(src.readU8())) + PacketType.PROTOCOL_CHANGE -> ProtocolChange(src.readU8(), src.readU8()) } fun write(dst: Sink, packet: Packet) = when (packet) { is Heartbeat -> {} - is Handshake -> { + + is HandshakeResponse -> { dst.writeU8(PacketType.HANDSHAKE.id) dst.write("Hey OVR =D 5".toByteArray(Charsets.US_ASCII)) } + is Rotation -> dst.writeQuat(packet.rotation) - is Accel -> { dst.writeFloat(packet.acceleration.x); dst.writeFloat(packet.acceleration.y); dst.writeFloat(packet.acceleration.z) } + + is Accel -> { + dst.writeFloat(packet.acceleration.x) + dst.writeFloat(packet.acceleration.y) + dst.writeFloat(packet.acceleration.z) + } + is PingPong -> dst.writeInt(packet.pingId) - is Serial -> { dst.writeInt(packet.serial.length); packet.serial.forEach { dst.writeU8(it.code) } } - is BatteryLevel -> { dst.writeFloat(packet.voltage); dst.writeFloat(packet.level) } - is Tap -> { dst.writeU8(packet.sensorId); dst.writeU8(packet.tap) } - is Error -> { dst.writeU8(packet.sensorId); dst.writeU8(packet.errorNumber) } - is SensorInfo -> { dst.writeU8(packet.sensorId); dst.writeU8(packet.status); dst.writeU8(packet.type) } + + is Serial -> { + dst.writeInt(packet.serial.length) + packet.serial.forEach { dst.writeU8(it.code) } + } + + is BatteryLevel -> { + dst.writeFloat(packet.voltage) + dst.writeFloat(packet.level) + } + + is Tap -> { + dst.writeU8(packet.sensorId) + dst.writeU8(packet.tap) + } + + is Error -> { + dst.writeU8(packet.sensorId) + dst.writeU8(packet.errorNumber) + } + + is SensorInfo -> { + dst.writeU8(packet.sensorId) + dst.writeU8(packet.status) + dst.writeU8(packet.type) + } + is Rotation2 -> dst.writeQuat(packet.rotation) - is RotationData -> { dst.writeU8(packet.sensorId); dst.writeU8(packet.dataType); dst.writeQuat(packet.rotation); dst.writeU8(packet.calibration) } - is MagnetometerAccuracy -> { dst.writeU8(packet.sensorId); dst.writeFloat(packet.accuracy) } - is SignalStrength -> { dst.writeU8(packet.sensorId); dst.writeByte(packet.signal.toByte()) } - is Temperature -> { dst.writeU8(packet.sensorId); dst.writeFloat(packet.temp) } + + is RotationData -> { + dst.writeU8(packet.sensorId) + dst.writeU8(packet.dataType) + dst.writeQuat(packet.rotation) + dst.writeU8(packet.calibration) + } + + is MagnetometerAccuracy -> { + dst.writeU8(packet.sensorId) + dst.writeFloat(packet.accuracy) + } + + is SignalStrength -> { + dst.writeU8(packet.sensorId) + dst.writeByte(packet.signal.toByte()) + } + + is Temperature -> { + dst.writeU8(packet.sensorId) + dst.writeFloat(packet.temp) + } + is UserActionPacket -> dst.writeU8(packet.action?.id ?: 0) - is ProtocolChange -> { dst.writeU8(packet.targetProtocol); dst.writeU8(packet.targetVersion) } + + is ProtocolChange -> { + dst.writeU8(packet.targetProtocol) + dst.writeU8(packet.targetVersion) + } + + else -> error("unhandled packet") } } // ── Entry Points ──────────────────────────────────────────────────────────── -fun readPacket(src: Source): Packet? { - if (src.exhausted()) return null - val type = PacketType.fromId(src.readInt()) ?: return null - if (type != PacketType.HANDSHAKE) src.skip(8) // Skip sequence number - return PacketCodec.read(type, src) -} - -fun writePacket(dst: Sink, packet: Packet) { - val type = when(packet) { +fun writePacket(dst: Sink, packet: Packet, packetNum: Long) { + val type = when (packet) { is Heartbeat -> PacketType.HEARTBEAT - is Handshake -> PacketType.HANDSHAKE + is HandshakeResponse -> PacketType.HANDSHAKE is Rotation -> PacketType.ROTATION is Accel -> PacketType.ACCEL is PingPong -> PacketType.PING_PONG @@ -151,37 +253,47 @@ fun writePacket(dst: Sink, packet: Packet) { is Temperature -> PacketType.TEMPERATURE is UserActionPacket -> PacketType.USER_ACTION is ProtocolChange -> PacketType.PROTOCOL_CHANGE + else -> error("unhandled packet") } if (type != PacketType.HANDSHAKE) { dst.writeInt(type.id) - dst.writeLong(type.id.toLong()) // Sequence number placeholder + dst.writeLong(packetNum) } PacketCodec.write(dst, packet) } +data class PacketEvent( + val data: T, + val packetNumber: Long, +) class PacketDispatcher { - val listeners = mutableMapOf, MutableList<(Packet) -> Unit>>() + val listeners = mutableMapOf, MutableList<(PacketEvent) -> Unit>>() + private val globalListeners = mutableListOf<(PacketEvent) -> Unit>() /** - * Listen for a specific packet type. - * Usage: dispatcher.on { packet -> println(packet.rotation) } + * Listen for a specific packet type with metadata. */ - inline fun on(crossinline callback: (T) -> Unit) { + @Suppress("UNCHECKED_CAST") + inline fun on(crossinline callback: (PacketEvent) -> Unit) { val list = listeners.getOrPut(T::class) { mutableListOf() } synchronized(list) { - list.add { callback(it as T) } + list.add { callback(it as PacketEvent) } } } - /** - * Broadcasts a packet to all registered listeners for its type. - */ - fun emit(packet: Packet) { - val list = listeners[packet::class] ?: return + fun onAny(callback: (PacketEvent) -> Unit) { + synchronized(globalListeners) { globalListeners.add(callback) } + } + + fun emit(event: PacketEvent) { + synchronized(globalListeners) { + globalListeners.forEach { it(event) } + } + val list = listeners[event.data::class] ?: return synchronized(list) { - list.forEach { it(packet) } + list.forEach { it(event) } } } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt index c4aea15809..f9b032391c 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt @@ -1,5 +1,6 @@ package dev.slimevr.tracker.udp +import dev.slimevr.VRServer import dev.slimevr.VRServerContext import dev.slimevr.config.ConfigContext import io.ktor.network.selector.SelectorManager @@ -15,27 +16,18 @@ import kotlinx.coroutines.supervisorScope data class UDPTrackerServerState( val port: Int, - val connections: MutableMap + val connections: MutableMap, ) -suspend fun processPacket( - socket: BoundDatagramSocket, - datagram: Datagram, - workerId: Int -) { - - -} - const val PACKET_WORKERS = 4 suspend fun createUDPTrackerServer( - serverContext: VRServerContext, - configContext: ConfigContext + serverContext: VRServer, + configContext: ConfigContext, ): UDPTrackerServerState { val state = UDPTrackerServerState( port = configContext.state.value.settingsConfig.trackerPort, - connections = mutableMapOf() + connections = mutableMapOf(), ) val selectorManager = SelectorManager(Dispatchers.IO) @@ -54,29 +46,33 @@ suspend fun createUDPTrackerServer( repeat(PACKET_WORKERS) { workerId -> launch(Dispatchers.Default) { for (datagram in packetChannel) { - val packet = readPacket(datagram.packet) - if (packet == null) { - println("null packet") - continue - } + val packetId = datagram.packet.readInt() + val packetNumber = datagram.packet.readLong() + val type = PacketType.fromId(packetId) ?: continue + val packetData = PacketCodec.read(type, datagram.packet) val address = datagram.address as InetSocketAddress val connContext = state.connections[address.hostname] - if (connContext !== null) - connContext.packetEvents.emit(packet = packet) - else { + val event = PacketEvent( + data = packetData, + packetNumber = packetNumber, + ) + + if (connContext !== null) { + connContext.packetEvents.emit(event = event) + } else { val newContext = createUDPConnectionContext( id = address.hostname, remoteAddress = address, socket = serverSocket, - scope = this + serverContext = serverContext, + scope = this, ) state.connections[address.hostname] = newContext - newContext.packetEvents.emit(packet = packet) + newContext.packetEvents.emit(event = event) } - } } } diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index d2257ae774..6e3b31db26 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -1,8 +1,9 @@ package dev.slimevr -import dev.slimevr.context.Context import dev.slimevr.context.BasicModule +import dev.slimevr.context.Context import dev.slimevr.context.createContext +import dev.slimevr.tracker.Device import dev.slimevr.tracker.TrackerContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChangedBy @@ -10,11 +11,14 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach data class VRServerState( + val handleId: Int, val trackers: Map, + val devices: Map, ) sealed interface VRServerActions { data class NewTracker(val trackerId: Int, val context: TrackerContext) : VRServerActions + data class NewDevice(val deviceId: Int, val context: Device) : VRServerActions } typealias VRServerContext = Context @@ -25,6 +29,12 @@ val TestModule = VRServerModule( when (a) { is VRServerActions.NewTracker -> s.copy( trackers = s.trackers + (a.trackerId to a.context), + handleId = a.trackerId, + ) + + is VRServerActions.NewDevice -> s.copy( + devices = s.devices + (a.deviceId to a.context), + handleId = a.deviceId, ) } }, @@ -35,20 +45,34 @@ val TestModule = VRServerModule( }, ) -fun createVRServer(scope: CoroutineScope): VRServerContext { - val server = VRServerState( - trackers = mapOf(), - ) +data class VRServer( + val context: VRServerContext, +) { + fun nextHandle() = context.state.value.handleId + 1 + fun getTrackerContext(id: Int) = context.state.value.trackers[id] + fun getDeviceContext(id: Int) = context.state.value.devices[id] - val modules = listOf(TestModule) + companion object { + fun create(scope: CoroutineScope): VRServer { + val server = VRServerState( + handleId = 0, + trackers = mapOf(), + devices = mapOf(), + ) - val context = createContext( - initialState = server, - reducers = modules.map { it.reducer }, - scope = scope, - ) + val modules = listOf(TestModule) - modules.map { it.observer }.forEach { it?.invoke(context) } + val context = createContext( + initialState = server, + reducers = modules.map { it.reducer }, + scope = scope, + ) + + modules.map { it.observer }.forEach { it?.invoke(context) } - return context + return VRServer( + context = context, + ) + } + } } 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 6f1024e0ee..22257c4e1f 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -2,14 +2,14 @@ package dev.slimevr.desktop +import dev.slimevr.VRServer import dev.slimevr.config.createConfig -import dev.slimevr.createVRServer import dev.slimevr.tracker.udp.createUDPTrackerServer import kotlinx.coroutines.runBlocking fun main(args: Array) = runBlocking { val config = createConfig(this) - val server = createVRServer(this) + val server = VRServer.create(this) val udpServer = createUDPTrackerServer(server, config) } From 6eb63ce9f8970146d4e6f8d0f3fe35bdd18f436d Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 19 Mar 2026 02:56:32 +0100 Subject: [PATCH 004/350] Tracker data --- .../main/java/dev/slimevr/context/context.kt | 6 +- .../java/dev/slimevr/skeleton/BodyPart.kt | 63 +++ .../main/java/dev/slimevr/tracker/device.kt | 40 +- .../src/main/java/dev/slimevr/tracker/imu.kt | 30 ++ .../main/java/dev/slimevr/tracker/tracker.kt | 80 +++- .../dev/slimevr/tracker/udp/connection.kt | 201 +++++++-- .../java/dev/slimevr/tracker/udp/packets.kt | 390 ++++++++++-------- .../java/dev/slimevr/tracker/udp/server.kt | 53 +-- .../src/main/java/dev/slimevr/vrserver.kt | 14 +- .../src/main/java/dev/slimevr/desktop/Main.kt | 4 +- 10 files changed, 615 insertions(+), 266 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/BodyPart.kt create mode 100644 server/core/src/main/java/dev/slimevr/tracker/imu.kt diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index d64b322003..8002b6e9d8 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -7,7 +7,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update data class BasicModule( - val reducer: (S, A) -> S, + val reducer: ((S, A) -> S)? = null, val observer: ((Context) -> Unit)? = null, ) @@ -21,12 +21,12 @@ data class Context( fun createContext( initialState: S, scope: CoroutineScope, - reducers: List<(S, A) -> S>, + reducers: List<((S, A) -> S)?>, ): Context { val mutableStateFlow = MutableStateFlow(initialState) val applyAction: (S, A) -> S = { currentState, action -> - reducers.fold(currentState) { s, reducer -> reducer(s, action) } + reducers.filterNotNull().fold(currentState) { s, reducer -> reducer(s, action) } } val dispatch: suspend (A) -> Unit = { action -> diff --git a/server/core/src/main/java/dev/slimevr/skeleton/BodyPart.kt b/server/core/src/main/java/dev/slimevr/skeleton/BodyPart.kt new file mode 100644 index 0000000000..92537bee83 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/BodyPart.kt @@ -0,0 +1,63 @@ +package dev.slimevr.skeleton + +enum class BodyPart(val id: UByte) { + HEAD(solarxr_protocol.datatypes.BodyPart.HEAD), + NECK(solarxr_protocol.datatypes.BodyPart.NECK), + UPPER_CHEST(solarxr_protocol.datatypes.BodyPart.UPPERCHEST), + CHEST(solarxr_protocol.datatypes.BodyPart.CHEST), + WAIST(solarxr_protocol.datatypes.BodyPart.WAIST), + HIP(solarxr_protocol.datatypes.BodyPart.HIP), + LEFT_HIP(solarxr_protocol.datatypes.BodyPart.LEFTHIP), + RIGHT_HIP(solarxr_protocol.datatypes.BodyPart.RIGHTHIP), + LEFT_UPPER_LEG(solarxr_protocol.datatypes.BodyPart.LEFTUPPERLEG), + RIGHT_UPPER_LEG(solarxr_protocol.datatypes.BodyPart.RIGHTUPPERLEG), + LEFT_LOWER_LEG(solarxr_protocol.datatypes.BodyPart.LEFTLOWERLEG), + RIGHT_LOWER_LEG(solarxr_protocol.datatypes.BodyPart.RIGHTLOWERLEG), + LEFT_FOOT(solarxr_protocol.datatypes.BodyPart.LEFTFOOT), + RIGHT_FOOT(solarxr_protocol.datatypes.BodyPart.RIGHTFOOT), + LEFT_FOOT_TRACKER(solarxr_protocol.datatypes.BodyPart.LEFTFOOT), + RIGHT_FOOT_TRACKER(solarxr_protocol.datatypes.BodyPart.RIGHTFOOT), + LEFT_LOWER_ARM(solarxr_protocol.datatypes.BodyPart.LEFTLOWERARM), + RIGHT_LOWER_ARM(solarxr_protocol.datatypes.BodyPart.RIGHTLOWERARM), + LEFT_UPPER_ARM(solarxr_protocol.datatypes.BodyPart.LEFTUPPERARM), + RIGHT_UPPER_ARM(solarxr_protocol.datatypes.BodyPart.RIGHTUPPERARM), + LEFT_SHOULDER(solarxr_protocol.datatypes.BodyPart.LEFTSHOULDER), + RIGHT_SHOULDER(solarxr_protocol.datatypes.BodyPart.RIGHTSHOULDER), + LEFT_HAND(solarxr_protocol.datatypes.BodyPart.LEFTHAND), + RIGHT_HAND(solarxr_protocol.datatypes.BodyPart.RIGHTHAND), + LEFT_THUMB_METACARPAL(solarxr_protocol.datatypes.BodyPart.LEFTTHUMBMETACARPAL), + LEFT_THUMB_PROXIMAL(solarxr_protocol.datatypes.BodyPart.LEFTTHUMBPROXIMAL), + LEFT_THUMB_DISTAL(solarxr_protocol.datatypes.BodyPart.LEFTTHUMBDISTAL), + LEFT_INDEX_PROXIMAL(solarxr_protocol.datatypes.BodyPart.LEFTINDEXPROXIMAL), + LEFT_INDEX_INTERMEDIATE(solarxr_protocol.datatypes.BodyPart.LEFTINDEXINTERMEDIATE), + LEFT_INDEX_DISTAL(solarxr_protocol.datatypes.BodyPart.LEFTINDEXDISTAL), + LEFT_MIDDLE_PROXIMAL(solarxr_protocol.datatypes.BodyPart.LEFTMIDDLEPROXIMAL), + LEFT_MIDDLE_INTERMEDIATE(solarxr_protocol.datatypes.BodyPart.LEFTMIDDLEINTERMEDIATE), + LEFT_MIDDLE_DISTAL(solarxr_protocol.datatypes.BodyPart.LEFTMIDDLEDISTAL), + LEFT_RING_PROXIMAL(solarxr_protocol.datatypes.BodyPart.LEFTRINGPROXIMAL), + LEFT_RING_INTERMEDIATE(solarxr_protocol.datatypes.BodyPart.LEFTRINGINTERMEDIATE), + LEFT_RING_DISTAL(solarxr_protocol.datatypes.BodyPart.LEFTRINGDISTAL), + LEFT_LITTLE_PROXIMAL(solarxr_protocol.datatypes.BodyPart.LEFTLITTLEPROXIMAL), + LEFT_LITTLE_INTERMEDIATE(solarxr_protocol.datatypes.BodyPart.LEFTLITTLEINTERMEDIATE), + LEFT_LITTLE_DISTAL(solarxr_protocol.datatypes.BodyPart.LEFTLITTLEDISTAL), + RIGHT_THUMB_METACARPAL(solarxr_protocol.datatypes.BodyPart.RIGHTTHUMBMETACARPAL), + RIGHT_THUMB_PROXIMAL(solarxr_protocol.datatypes.BodyPart.RIGHTTHUMBPROXIMAL), + RIGHT_THUMB_DISTAL(solarxr_protocol.datatypes.BodyPart.RIGHTTHUMBDISTAL), + RIGHT_INDEX_PROXIMAL(solarxr_protocol.datatypes.BodyPart.RIGHTINDEXPROXIMAL), + RIGHT_INDEX_INTERMEDIATE(solarxr_protocol.datatypes.BodyPart.RIGHTINDEXINTERMEDIATE), + RIGHT_INDEX_DISTAL(solarxr_protocol.datatypes.BodyPart.RIGHTINDEXDISTAL), + RIGHT_MIDDLE_PROXIMAL(solarxr_protocol.datatypes.BodyPart.RIGHTMIDDLEPROXIMAL), + RIGHT_MIDDLE_INTERMEDIATE(solarxr_protocol.datatypes.BodyPart.RIGHTMIDDLEINTERMEDIATE), + RIGHT_MIDDLE_DISTAL(solarxr_protocol.datatypes.BodyPart.RIGHTMIDDLEDISTAL), + RIGHT_RING_PROXIMAL(solarxr_protocol.datatypes.BodyPart.RIGHTRINGPROXIMAL), + RIGHT_RING_INTERMEDIATE(solarxr_protocol.datatypes.BodyPart.RIGHTRINGINTERMEDIATE), + RIGHT_RING_DISTAL(solarxr_protocol.datatypes.BodyPart.RIGHTRINGDISTAL), + RIGHT_LITTLE_PROXIMAL(solarxr_protocol.datatypes.BodyPart.RIGHTLITTLEPROXIMAL), + RIGHT_LITTLE_INTERMEDIATE(solarxr_protocol.datatypes.BodyPart.RIGHTLITTLEINTERMEDIATE), + RIGHT_LITTLE_DISTAL(solarxr_protocol.datatypes.BodyPart.RIGHTLITTLEDISTAL); + + companion object { + private val map = entries.associateBy { it.id } + fun fromId(id: UByte) = map[id] + } +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/device.kt b/server/core/src/main/java/dev/slimevr/tracker/device.kt index 5698b96374..6c03be4cdf 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/device.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/device.kt @@ -21,19 +21,26 @@ data class DeviceState( val id: Int, val name: String, val address: String, - val batteryLevel: Int, - val batteryVoltage: Int, + val batteryLevel: Float, + val batteryVoltage: Float, val ping: Long?, val signalStrength: Int?, val origin: DeviceOrigin, ) sealed interface DeviceActions { - data class SetBattery(val level: Int, val voltage: Int) : DeviceActions - data class SetPing(val ping: Long) : DeviceActions - data class SetSignalStrength(val signalStrength: Int) : DeviceActions + data class Update(val transform: DeviceState.() -> DeviceState) : DeviceActions } +val DeviceStatsModule = DeviceModule( + reducer = { s, a -> if (a is DeviceActions.Update) a.transform(s) else s }, + observer = { + it.state.onEach { + println("Device state changed $it") + }.launchIn(it.scope) + } +) + typealias DeviceContext = Context typealias DeviceModule = BasicModule @@ -41,36 +48,19 @@ data class Device( val context: DeviceContext, ) -val PingModule = DeviceModule( - reducer = { s, a -> - when (a) { - is DeviceActions.SetPing -> s.copy(ping = a.ping) - else -> s - } - }, - observer = { - it.state - .distinctUntilChangedBy { device -> device.ping } - .filter { device -> device.ping != null } - .onEach { device -> - println("[${device.name}] ping change to ${device.ping}") - }.launchIn(it.scope) - }, -) - fun createDevice(scope: CoroutineScope, id: Int, address: String, origin: DeviceOrigin, serverContext: VRServer): Device { val deviceState = DeviceState( id = id, name = "Device $id", - batteryLevel = 0, - batteryVoltage = 0, + batteryLevel = 0f, + batteryVoltage = 0f, origin = origin, address = address, ping = null, signalStrength = null, ) - val modules = listOf(PingModule) + val modules = listOf(DeviceStatsModule) val context = createContext( initialState = deviceState, diff --git a/server/core/src/main/java/dev/slimevr/tracker/imu.kt b/server/core/src/main/java/dev/slimevr/tracker/imu.kt new file mode 100644 index 0000000000..7fcd9e57ab --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/tracker/imu.kt @@ -0,0 +1,30 @@ +package dev.slimevr.tracker + +enum class IMUType(val id: UByte) { + UNKNOWN(solarxr_protocol.datatypes.hardware_info.ImuType.Other.toUByte()), + MPU9250(solarxr_protocol.datatypes.hardware_info.ImuType.MPU9250.toUByte()), + MPU6500(solarxr_protocol.datatypes.hardware_info.ImuType.MPU6500.toUByte()), + BNO080(solarxr_protocol.datatypes.hardware_info.ImuType.BNO080.toUByte()), + BNO085(solarxr_protocol.datatypes.hardware_info.ImuType.BNO085.toUByte()), + BNO055(solarxr_protocol.datatypes.hardware_info.ImuType.BNO055.toUByte()), + MPU6050(solarxr_protocol.datatypes.hardware_info.ImuType.MPU6050.toUByte()), + BNO086(solarxr_protocol.datatypes.hardware_info.ImuType.BNO086.toUByte()), + BMI160(solarxr_protocol.datatypes.hardware_info.ImuType.BMI160.toUByte()), + ICM20948(solarxr_protocol.datatypes.hardware_info.ImuType.ICM20948.toUByte()), + ICM42688(solarxr_protocol.datatypes.hardware_info.ImuType.ICM42688.toUByte()), + BMI270(solarxr_protocol.datatypes.hardware_info.ImuType.BMI270.toUByte()), + LSM6DS3TRC(solarxr_protocol.datatypes.hardware_info.ImuType.LSM6DS3TRC.toUByte()), + LSM6DSV(solarxr_protocol.datatypes.hardware_info.ImuType.LSM6DSV.toUByte()), + LSM6DSO(solarxr_protocol.datatypes.hardware_info.ImuType.LSM6DSO.toUByte()), + LSM6DSR(solarxr_protocol.datatypes.hardware_info.ImuType.LSM6DSR.toUByte()), + ICM45686(solarxr_protocol.datatypes.hardware_info.ImuType.ICM45686.toUByte()), + ICM45605(solarxr_protocol.datatypes.hardware_info.ImuType.ICM45605.toUByte()), + ADC_RESISTANCE(solarxr_protocol.datatypes.hardware_info.ImuType.ADCRESISTANCE.toUByte()), + DEV_RESERVED(solarxr_protocol.datatypes.hardware_info.ImuType.DEVRESERVED.toUByte()), + ; + + companion object { + private val map = entries.associateBy { it.id } + fun fromId(id: UByte) = map[id] + } +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt index b2360ee958..aa8963656a 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt @@ -1,18 +1,96 @@ package dev.slimevr.tracker +import dev.slimevr.VRServer import dev.slimevr.context.BasicModule import dev.slimevr.context.Context +import dev.slimevr.context.createContext +import dev.slimevr.skeleton.BodyPart import io.github.axisangles.ktmath.Quaternion +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +enum class TrackerStatus(val id: UByte) { + DISCONNECTED(solarxr_protocol.datatypes.TrackerStatus.DISCONNECTED), + OK(solarxr_protocol.datatypes.TrackerStatus.OK), + BUSY(solarxr_protocol.datatypes.TrackerStatus.BUSY), + ERROR(solarxr_protocol.datatypes.TrackerStatus.ERROR), + OCCLUDED(solarxr_protocol.datatypes.TrackerStatus.OCCLUDED), + TIMEDOUT(solarxr_protocol.datatypes.TrackerStatus.TIMEDOUT); + + companion object { + private val map = entries.associateBy { it.id } + fun fromId(id: UByte) = map[id] + } +} + +data class TrackerIdNum(val id: Int, val trackerNum: Int) data class TrackerState( val id: Int, val name: String, + val hardwareId: String, + val sensorType: IMUType, + val bodyPart: BodyPart?, + val status: TrackerStatus, + val customName: String?, val rawRotation: Quaternion, + val deviceId: Int, + val origin: DeviceOrigin ) sealed interface TrackerActions { - data class UpdateRotation(val rotation: Quaternion) + data class Update(val transform: TrackerState.() -> TrackerState) : TrackerActions } typealias TrackerContext = Context typealias TrackerModule = BasicModule + +data class Tracker( + val context: TrackerContext +) + +val TrackerInfosModule = TrackerModule( + reducer = { s, a -> if (a is TrackerActions.Update) a.transform(s) else s }, + observer = { + it.state.onEach { println("Tracker $it") }.launchIn(it.scope) + } +) + +fun createTracker( + scope: CoroutineScope, + id: Int, + deviceId: Int, + sensorType: IMUType, + hardwareId: String, + origin: DeviceOrigin, + serverContext: VRServer +): Tracker { + val trackerState = TrackerState( + id = id, + hardwareId = hardwareId, + name = "Tracker #$id", + rawRotation = Quaternion.IDENTITY, + status = TrackerStatus.DISCONNECTED, + bodyPart = null, + origin = origin, + deviceId = deviceId, + customName = null, + sensorType = sensorType + ) + + val modules = listOf(TrackerInfosModule) + + val context = createContext( + initialState = trackerState, + reducers = modules.map { it.reducer }, + scope = scope, + ) + + modules.map { it.observer }.forEach { it?.invoke(context) } + + return Tracker( + context = context, + ) +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt index da703e9fc8..8c6298f1ad 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt @@ -4,9 +4,14 @@ import dev.slimevr.VRServer import dev.slimevr.VRServerActions import dev.slimevr.context.Context import dev.slimevr.context.createContext +import dev.slimevr.tracker.Device import dev.slimevr.tracker.DeviceActions import dev.slimevr.tracker.DeviceOrigin +import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerActions +import dev.slimevr.tracker.TrackerIdNum import dev.slimevr.tracker.createDevice +import dev.slimevr.tracker.createTracker import io.ktor.network.sockets.BoundDatagramSocket import io.ktor.network.sockets.Datagram import io.ktor.network.sockets.InetSocketAddress @@ -31,13 +36,16 @@ data class UDPConnectionState( val address: String, val port: Int, val deviceId: Int?, + val trackerIds: List ) sealed interface UDPConnectionActions { data class StartPing(val startTime: Long) : UDPConnectionActions data class ReceivedPong(val id: Int, val duration: Long) : UDPConnectionActions data class Handshake(val deviceId: Int) : UDPConnectionActions - data class LastPacket(val packetNum: Long? = null, val time: Long) : UDPConnectionActions + data class LastPacket(val packetNum: Long? = null, val time: Long) : + UDPConnectionActions + data class AssignTracker(val trackerId: TrackerIdNum) : UDPConnectionActions } typealias UDPConnectionContext = Context @@ -47,10 +55,12 @@ data class UDPConnection( val serverContext: VRServer, val packetEvents: PacketDispatcher, val send: (Packet) -> Unit, + val getDevice: () -> Device?, + val getTracker: (sensorId: Int) -> Tracker?, ) data class UDPConnectionModule( - val reducer: (UDPConnectionState, UDPConnectionActions) -> UDPConnectionState, + val reducer: ((UDPConnectionState, UDPConnectionActions) -> UDPConnectionState)? = null, val observer: ((UDPConnection) -> Unit)? = null, ) @@ -77,7 +87,12 @@ val PacketModule = UDPConnectionModule( val now = System.currentTimeMillis() if (now - state.lastPacket > 5000 && packet.packetNumber == 0L) { it.context.scope.launch { - it.context.dispatch(UDPConnectionActions.LastPacket(packetNum = 0, time = now)) + it.context.dispatch( + UDPConnectionActions.LastPacket( + packetNum = 0, + time = now + ) + ) println("Reconnecting") } } else if (packet.packetNumber < state.lastPacketNum) { @@ -125,17 +140,24 @@ val PingModule = UDPConnectionModule( val deviceId = state.deviceId ?: return@on if (paket.data.pingId != state.lastPing.id + 1) { - println("Ping ID does not match, ignoring") + println("Ping ID does not match, ignoring ${paket.data.pingId} != ${state.lastPing.id + 1}") return@on } val ping = System.currentTimeMillis() - state.lastPing.startTime - val device = it.serverContext.getDeviceContext(deviceId) ?: return@on + val device = it.serverContext.getDevice(deviceId) ?: return@on it.context.scope.launch { - it.context.dispatch(UDPConnectionActions.ReceivedPong(id = paket.data.pingId, duration = ping)) - device.context.dispatch(DeviceActions.SetPing(ping)) + it.context.dispatch( + UDPConnectionActions.ReceivedPong( + id = paket.data.pingId, + duration = ping + ) + ) + device.context.dispatch(DeviceActions.Update { + copy(ping = ping) + }) } } }, @@ -144,7 +166,11 @@ val PingModule = UDPConnectionModule( val HandshakeModule = UDPConnectionModule( reducer = { s, a -> when (a) { - is UDPConnectionActions.Handshake -> s.copy(didHandshake = true, deviceId = a.deviceId) + is UDPConnectionActions.Handshake -> s.copy( + didHandshake = true, + deviceId = a.deviceId + ) + else -> s } }, @@ -158,21 +184,131 @@ val HandshakeModule = UDPConnectionModule( val newDevice = createDevice( id = deviceId, scope = it.serverContext.context.scope, - address = packet.data.mac ?: error("no mac address?"), + address = packet.data.macString ?: error("no mac address?"), origin = DeviceOrigin.UDP, serverContext = it.serverContext, ) it.context.scope.launch { - it.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId = deviceId, context = newDevice)) + it.serverContext.context.dispatch( + VRServerActions.NewDevice( + deviceId = deviceId, + context = newDevice + ) + ) it.context.dispatch(UDPConnectionActions.Handshake(deviceId)) - it.send(HandshakeResponse()) + it.send(Handshake()) } } else { - it.send(HandshakeResponse()) + it.send(Handshake()) + } + } + }, +) + +val DeviceStatsModule = UDPConnectionModule( + observer = { + it.packetEvents.on { event -> + val device = it.getDevice() ?: return@on + + device.context.scope.launch { + device.context.dispatch(DeviceActions.Update { + copy( + batteryLevel = event.data.level, + batteryVoltage = event.data.voltage + ) + }) + } + } + + it.packetEvents.on { event -> + val device = it.getDevice() ?: return@on + + device.context.scope.launch { + device.context.dispatch(DeviceActions.Update { + copy(signalStrength = event.data.signal) + }) + } + } + } +) + +val SensorInfoModule = UDPConnectionModule( + reducer = { s, a -> + when (a) { + is UDPConnectionActions.AssignTracker -> { + s.copy(trackerIds = s.trackerIds + a.trackerId) } + + else -> s } }, + observer = { + it.packetEvents.on { event -> + val tracker = it.getTracker(event.data.sensorId) + + val action = TrackerActions.Update { + copy( + sensorType = event.data.imuType, + status = event.data.status, + ) + } + + if (tracker != null) { + tracker.context.scope.launch { + tracker.context.dispatch(action) + } + } else { + val device = it.getDevice() + ?: error("invalid state - a device should exist at this point") + val deviceState = device.context.state.value + val trackerId = it.serverContext.nextHandle() + val newTracker = createTracker( + id = trackerId, + hardwareId = "${deviceState.address}:${event.data.sensorId}", + sensorType = event.data.imuType, + deviceId = deviceState.id, + origin = DeviceOrigin.UDP, + serverContext = it.serverContext, + scope = it.serverContext.context.scope + ) + + it.serverContext.context.scope.launch { + it.serverContext.context.dispatch( + VRServerActions.NewTracker( + trackerId = trackerId, + context = newTracker + ) + ) + it.context.dispatch( + UDPConnectionActions.AssignTracker( + trackerId = TrackerIdNum( + id = trackerId, + trackerNum = event.data.sensorId + ) + ) + ) + newTracker.context.dispatch(action) + } + } + + } + } +) + +val SensorRotationModule = UDPConnectionModule( + observer = { context -> + context.packetEvents.on { event -> + val tracker = context.getTracker(event.data.sensorId) ?: return@on + tracker.context.scope.launch { + tracker.context.dispatch( + TrackerActions.Update { + copy(rawRotation = event.data.rotation) + } + ) + } + } + } ) fun createUDPConnectionContext( @@ -182,7 +318,14 @@ fun createUDPConnectionContext( serverContext: VRServer, scope: CoroutineScope, ): UDPConnection { - val modules = listOf(PacketModule, HandshakeModule, PingModule) + val modules = listOf( + PacketModule, + HandshakeModule, + PingModule, + DeviceStatsModule, + SensorInfoModule, + SensorRotationModule + ) val context = createContext( initialState = UDPConnectionState( @@ -194,6 +337,7 @@ fun createUDPConnectionContext( address = remoteAddress.hostname, port = remoteAddress.port, deviceId = null, + trackerIds = listOf() ), reducers = modules.map { it.reducer }, scope = scope, @@ -201,23 +345,28 @@ fun createUDPConnectionContext( val dispatcher = PacketDispatcher() - val sendFunc = { packet: Packet -> - scope.launch { - val packetNum = context.state.value.lastPacketNum + 1 - - val bytePacket = buildPacket { - writePacket(this, packet, packetNum) - } - socket.send(Datagram(bytePacket, remoteAddress)) - } - Unit - } - val conn = UDPConnection( context = context, serverContext = serverContext, dispatcher, - send = sendFunc, + send = { packet: Packet -> + scope.launch { + val bytePacket = buildPacket { + writePacket(this, packet) + } + socket.send(Datagram(bytePacket, remoteAddress)) + } + }, + getDevice = { + val deviceId = context.state.value.deviceId + if (deviceId != null) serverContext.getDevice(deviceId) + else null + }, + getTracker = { id -> + val trackerId = context.state.value.trackerIds.find { it.trackerNum == id } + if (trackerId != null) serverContext.getTracker(trackerId.id) + else null + } ) modules.map { it.observer }.forEach { it?.invoke(conn) } diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt index 237ad8b5ef..599f75c217 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt @@ -1,5 +1,7 @@ package dev.slimevr.tracker.udp +import dev.slimevr.tracker.IMUType +import dev.slimevr.tracker.TrackerStatus import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import io.ktor.utils.io.core.remaining @@ -7,10 +9,28 @@ import kotlinx.io.Sink import kotlinx.io.Source import kotlinx.io.readByteArray import kotlinx.io.readFloat +import kotlinx.io.readString +import kotlinx.io.readUByte import kotlinx.io.writeFloat +import kotlinx.io.writeUByte import kotlin.reflect.KClass -// ── Enums ─────────────────────────────────────────────────────────────────── +private fun Source.readU8(): Int = readByte().toInt() and 0xFF + +private fun Source.readSafeFloat(): Float = readFloat().let { if (it.isNaN()) 0f else it } + +private fun Source.readSafeQuat(): Quaternion { + val x = readFloat() + val y = readFloat() + val z = readFloat() + val w = readFloat() + return if (x.isNaN() || y.isNaN() || z.isNaN() || w.isNaN() || (x == 0f && y == 0f && z == 0f && w == 0f)) { + Quaternion.IDENTITY + } else { + Quaternion(w, x, y, z) + } +} + enum class PacketType(val id: Int) { HEARTBEAT(0), @@ -29,8 +49,13 @@ enum class PacketType(val id: Int) { SIGNAL_STRENGTH(19), TEMPERATURE(20), USER_ACTION(21), - PROTOCOL_CHANGE(200), - ; + FEATURE_FLAGS(22), + ROTATION_AND_ACCEL(23), + ACK_CONFIG_CHANGE(24), + SET_CONFIG_FLAG(25), + FLEX_DATA(26), + POSITION(27), + PROTOCOL_CHANGE(200); companion object { private val map = entries.associateBy { it.id } @@ -38,229 +63,255 @@ enum class PacketType(val id: Int) { } } -enum class UserAction(val id: Int) { - RESET_FULL(2), - RESET_YAW(3), - RESET_MOUNTING(4), - PAUSE_TRACKING(5), - ; - - companion object { - private val map = entries.associateBy { it.id } - fun fromId(id: Int) = map[id] - } +sealed interface Packet { + fun write(dst: Sink) {} } -sealed interface Packet sealed interface SensorSpecificPacket : Packet { val sensorId: Int } -sealed interface RotationPacket : SensorSpecificPacket { - val rotation: Quaternion -} data object Heartbeat : Packet -data class Handshake(val board: Int = 0, val imu: Int = 0, val mcu: Int = 0, val pVer: Int = 0, val firmware: String? = null, val mac: String? = null) : Packet -data class HandshakeResponse(val nothing: Unit = Unit) : Packet -data class Rotation(override val sensorId: Int = 0, override val rotation: Quaternion = Quaternion.IDENTITY) : RotationPacket -data class Accel(val acceleration: Vector3 = Vector3.NULL, override val sensorId: Int = 0) : SensorSpecificPacket -data class PingPong(val pingId: Int = 0) : Packet -data class Serial(val serial: String = "") : Packet -data class BatteryLevel(val voltage: Float = 0f, val level: Float = 0f) : Packet -data class Tap(override val sensorId: Int = 0, val tap: Int = 0) : SensorSpecificPacket -data class Error(override val sensorId: Int = 0, val errorNumber: Int = 0) : SensorSpecificPacket -data class SensorInfo(override val sensorId: Int = 0, val status: Int = 0, val type: Int = 0) : SensorSpecificPacket -data class Rotation2(override val sensorId: Int = 1, override val rotation: Quaternion = Quaternion.IDENTITY) : RotationPacket -data class RotationData(override val sensorId: Int = 0, val dataType: Int = 0, val rotation: Quaternion = Quaternion.IDENTITY, val calibration: Int = 0) : SensorSpecificPacket -data class MagnetometerAccuracy(override val sensorId: Int = 0, val accuracy: Float = 0f) : SensorSpecificPacket -data class SignalStrength(override val sensorId: Int = 0, val signal: Int = 0) : SensorSpecificPacket -data class Temperature(override val sensorId: Int = 0, val temp: Float = 0f) : SensorSpecificPacket -data class UserActionPacket(val action: UserAction? = null) : Packet -data class ProtocolChange(val targetProtocol: Int = 0, val targetVersion: Int = 0) : Packet - -private fun Source.readU8() = readByte().toInt() and 0xFF -private fun Sink.writeU8(v: Int) = writeByte(v.toByte()) - -private fun Source.readSafeFloat() = readFloat().let { if (it.isNaN()) 0f else it } -private fun Source.readSafeQuat() = Quaternion(readFloat(), readFloat(), readFloat(), readFloat()).let { - if (it.x.isNaN() || (it.x == 0f && it.y == 0f && it.z == 0f && it.w == 0f)) Quaternion.IDENTITY else it -} - -private fun Sink.writeQuat(q: Quaternion) { - writeFloat(q.x) - writeFloat(q.y) - writeFloat(q.z) - writeFloat(q.w) -} - -private fun Source.readStr(len: Int) = buildString { - repeat(len) { readByte().toInt().takeIf { it != 0 }?.let { append(it.toChar()) } } -} -object PacketCodec { - fun read(type: PacketType, src: Source): Packet = when (type) { - PacketType.HEARTBEAT -> Heartbeat +data class Handshake( + val boardType: Int = 0, + val imuType: Int = 0, + val mcuType: Int = 0, + val protocolVersion: Int = 0, + val firmware: String? = null, + val macString: String? = null +) : Packet { + override fun write(dst: Sink) { + dst.writeByte(PacketType.HANDSHAKE.id.toByte()) + dst.write("Hey OVR =D 5".toByteArray(Charsets.US_ASCII)) + } - PacketType.HANDSHAKE -> with(src) { - if (exhausted()) return Handshake() + companion object { + fun read(src: Source): Handshake = with(src) { + if (remaining == 0L) return Handshake() val b = if (remaining >= 4) readInt() else 0 val i = if (remaining >= 4) readInt() else 0 val m = if (remaining >= 4) readInt() else 0 - if (remaining >= 12) { - readInt() - readInt() - readInt() - } + if (remaining >= 12) { readInt(); readInt(); readInt() } val p = if (remaining >= 4) readInt() else 0 - val f = if (remaining >= 1) readStr(readByte().toInt()) else null - val mac = if (remaining >= 6) readByteArray(6).joinToString(":") { "%02X".format(it) }.takeIf { it != "00:00:00:00:00:00" } else null + val f = if (remaining >= 1) readString(readByte().toLong()) else null + val mac = if (remaining >= 6) { + val bytes = readByteArray(6) + bytes.joinToString(":") { "%02X".format(it) }.takeIf { it != "00:00:00:00:00:00" } + } else null Handshake(b, i, m, p, f, mac) } + } +} - PacketType.ROTATION -> Rotation(rotation = src.readSafeQuat()) +data class Rotation(override val sensorId: Int = 0, val rotation: Quaternion = Quaternion.IDENTITY) : SensorSpecificPacket { + companion object { fun read(src: Source) = Rotation(0, src.readSafeQuat()) } +} - PacketType.ACCEL -> Accel( +data class Accel(val acceleration: Vector3 = Vector3.NULL, override val sensorId: Int = 0) : SensorSpecificPacket { + companion object { + fun read(src: Source) = Accel( Vector3(src.readSafeFloat(), src.readSafeFloat(), src.readSafeFloat()), - if (!src.exhausted()) src.readU8() else 0, + if (src.remaining > 0) src.readU8() else 0 ) + } +} - PacketType.PING_PONG -> PingPong(src.readInt()) +data class PingPong(val pingId: Int = 0) : Packet { + override fun write(dst: Sink) { dst.writeInt(pingId) } + companion object { fun read(src: Source) = PingPong(src.readInt()) } +} - PacketType.SERIAL -> Serial(src.readStr(src.readInt())) +data class Serial(val serial: String = "") : Packet { + companion object { fun read(src: Source) = Serial(src.readString(src.readInt().toLong())) } +} - PacketType.BATTERY_LEVEL -> src.readSafeFloat().let { f -> - if (src.remaining >= 4) BatteryLevel(f, src.readSafeFloat()) else BatteryLevel(0f, f) +data class BatteryLevel(val voltage: Float = 0f, val level: Float = 0f) : Packet { + companion object { + fun read(src: Source): BatteryLevel { + val f = src.readSafeFloat() + return if (src.remaining >= 4) BatteryLevel(f, src.readSafeFloat()) else BatteryLevel(0f, f) } - - PacketType.TAP -> Tap(src.readU8(), src.readU8()) - - PacketType.ERROR -> Error(src.readU8(), src.readU8()) - - PacketType.SENSOR_INFO -> SensorInfo(src.readU8(), src.readU8(), if (!src.exhausted()) src.readU8() else 0) - - PacketType.ROTATION_2 -> Rotation2(rotation = src.readSafeQuat()) - - PacketType.ROTATION_DATA -> RotationData(src.readU8(), src.readU8(), src.readSafeQuat(), src.readU8()) - - PacketType.MAGNETOMETER_ACCURACY -> MagnetometerAccuracy(src.readU8(), src.readSafeFloat()) - - PacketType.SIGNAL_STRENGTH -> SignalStrength(src.readU8(), src.readByte().toInt()) - - PacketType.TEMPERATURE -> Temperature(src.readU8(), src.readSafeFloat()) - - PacketType.USER_ACTION -> UserActionPacket(UserAction.fromId(src.readU8())) - - PacketType.PROTOCOL_CHANGE -> ProtocolChange(src.readU8(), src.readU8()) } +} - fun write(dst: Sink, packet: Packet) = when (packet) { - is Heartbeat -> {} - - is HandshakeResponse -> { - dst.writeU8(PacketType.HANDSHAKE.id) - dst.write("Hey OVR =D 5".toByteArray(Charsets.US_ASCII)) - } +data class Tap(override val sensorId: Int = 0, val tap: Int = 0) : SensorSpecificPacket { + companion object { fun read(src: Source) = Tap(src.readU8(), src.readU8()) } +} - is Rotation -> dst.writeQuat(packet.rotation) +data class ErrorPacket(override val sensorId: Int = 0, val errorNumber: Int = 0) : SensorSpecificPacket { + companion object { fun read(src: Source) = ErrorPacket(src.readU8(), src.readU8()) } +} - is Accel -> { - dst.writeFloat(packet.acceleration.x) - dst.writeFloat(packet.acceleration.y) - dst.writeFloat(packet.acceleration.z) +data class SensorInfo( + override val sensorId: Int = 0, + val status: TrackerStatus = TrackerStatus.DISCONNECTED, + val imuType: IMUType = IMUType.UNKNOWN, + val sensorConfig: UShort? = null, + val hasCompletedRestCalibration: Boolean? = null, + val trackerPosition: Int? = null, + val trackerDataType: Int? = null +) : SensorSpecificPacket { + companion object { + fun read(src: Source) = with(src) { + val id = readU8() + val stat = TrackerStatus.fromId((readUByte() + 1u).toUByte()) ?: TrackerStatus.DISCONNECTED + val imu = if (remaining > 0) IMUType.fromId(readUByte()) ?: IMUType.UNKNOWN else IMUType.UNKNOWN + val conf = if (remaining >= 2) readShort().toUShort() else null + val calib = if (remaining > 0) readU8() != 0 else null + val pos = if (remaining > 0) readU8() else null + val dt = if (remaining > 0) readU8() else null + SensorInfo(id, stat, imu, conf, calib, pos, dt) } + } +} - is PingPong -> dst.writeInt(packet.pingId) - - is Serial -> { - dst.writeInt(packet.serial.length) - packet.serial.forEach { dst.writeU8(it.code) } - } +data class Rotation2(override val sensorId: Int = 1, val rotation: Quaternion = Quaternion.IDENTITY) : SensorSpecificPacket { + companion object { fun read(src: Source) = Rotation2(1, src.readSafeQuat()) } +} - is BatteryLevel -> { - dst.writeFloat(packet.voltage) - dst.writeFloat(packet.level) +data class RotationData( + override val sensorId: Int = 0, + val dataType: Int = 0, + val rotation: Quaternion = Quaternion.IDENTITY, + val calibrationInfo: Int = 0 +) : SensorSpecificPacket { + companion object { + fun read(src: Source): RotationData = with(src) { + val id = readU8() + val type = readU8() + val rot = readSafeQuat() + val calib = if (remaining > 0) readU8() else 0 + return RotationData(id, type, rot, calib) } + } +} - is Tap -> { - dst.writeU8(packet.sensorId) - dst.writeU8(packet.tap) - } +data class MagnetometerAccuracy(override val sensorId: Int = 0, val accuracy: Float = 0f) : SensorSpecificPacket { + companion object { fun read(src: Source) = MagnetometerAccuracy(src.readU8(), src.readSafeFloat()) } +} - is Error -> { - dst.writeU8(packet.sensorId) - dst.writeU8(packet.errorNumber) +data class SignalStrength(override val sensorId: Int = 0, val signal: Int = 0) : SensorSpecificPacket { + companion object { + fun read(src: Source): SignalStrength { + val id = src.readU8() + val sig = src.readByte().toInt() + return SignalStrength(id, sig) } + } +} - is SensorInfo -> { - dst.writeU8(packet.sensorId) - dst.writeU8(packet.status) - dst.writeU8(packet.type) +data class Temperature(override val sensorId: Int = 0, val temp: Float = 0f) : SensorSpecificPacket { + companion object { + fun read(src: Source): Temperature { + val id = src.readU8() + val t = if (src.remaining >= 4) src.readSafeFloat() else 0f + return Temperature(id, t) } + } +} - is Rotation2 -> dst.writeQuat(packet.rotation) +data class UserActionPacket(val type: Int = 0) : Packet { + companion object { fun read(src: Source) = UserActionPacket(src.readU8()) } +} - is RotationData -> { - dst.writeU8(packet.sensorId) - dst.writeU8(packet.dataType) - dst.writeQuat(packet.rotation) - dst.writeU8(packet.calibration) - } +data class FeatureFlags(val firmwareFeatures: ByteArray = byteArrayOf()) : Packet { + companion object { fun read(src: Source) = FeatureFlags(src.readByteArray(src.remaining.toInt())) } +} - is MagnetometerAccuracy -> { - dst.writeU8(packet.sensorId) - dst.writeFloat(packet.accuracy) +data class RotationAndAccel( + override val sensorId: Int = 0, + val rotation: Quaternion = Quaternion.IDENTITY, + val acceleration: Vector3 = Vector3.NULL +) : SensorSpecificPacket { + companion object { + fun read(src: Source): RotationAndAccel { + val id = src.readU8() + val scaleR = 1f / 32768f + val x = src.readShort() * scaleR + val y = src.readShort() * scaleR + val z = src.readShort() * scaleR + val w = src.readShort() * scaleR + val scaleA = 1f / 128f + val accel = Vector3(src.readShort() * scaleA, src.readShort() * scaleA, src.readShort() * scaleA) + return RotationAndAccel(id, Quaternion(w, x, y, z).unit(), accel) } + } +} - is SignalStrength -> { - dst.writeU8(packet.sensorId) - dst.writeByte(packet.signal.toByte()) - } +data class AckConfigChange(override val sensorId: Int = 0, val configType: UShort = 0u) : SensorSpecificPacket { + companion object { fun read(src: Source) = AckConfigChange(src.readU8(), src.readShort().toUShort()) } +} - is Temperature -> { - dst.writeU8(packet.sensorId) - dst.writeFloat(packet.temp) - } +data class SetConfigFlag(override val sensorId: Int = 255, val configType: UShort = 0u, val state: Boolean = false) : SensorSpecificPacket { + override fun write(dst: Sink) { + dst.writeUByte(sensorId.toUByte()) + dst.writeShort(configType.toShort()) + dst.writeUByte(if (state) 1u else 0u) + } +} - is UserActionPacket -> dst.writeU8(packet.action?.id ?: 0) +data class FlexData(override val sensorId: Int = 0, val flexData: Float = 0f) : SensorSpecificPacket { + companion object { fun read(src: Source) = FlexData(src.readU8(), src.readSafeFloat()) } +} - is ProtocolChange -> { - dst.writeU8(packet.targetProtocol) - dst.writeU8(packet.targetVersion) - } +data class PositionPacket(override val sensorId: Int = 0, val position: Vector3 = Vector3.NULL) : SensorSpecificPacket { + companion object { + fun read(src: Source) = PositionPacket(src.readU8(), Vector3(src.readSafeFloat(), src.readSafeFloat(), src.readSafeFloat())) + } +} - else -> error("unhandled packet") +data class ProtocolChange(val targetProtocol: Int = 0, val targetVersion: Int = 0) : Packet { + override fun write(dst: Sink) { + dst.writeUByte(targetProtocol.toUByte()) + dst.writeUByte(targetVersion.toUByte()) } + companion object { fun read(src: Source) = ProtocolChange(src.readU8(), src.readU8()) } } -// ── Entry Points ──────────────────────────────────────────────────────────── +fun readPacket(type: PacketType, src: Source): Packet = when (type) { + PacketType.HEARTBEAT -> Heartbeat + PacketType.HANDSHAKE -> Handshake.read(src) + PacketType.ROTATION -> Rotation.read(src) + PacketType.ACCEL -> Accel.read(src) + PacketType.PING_PONG -> PingPong.read(src) + PacketType.SERIAL -> Serial.read(src) + PacketType.BATTERY_LEVEL -> BatteryLevel.read(src) + PacketType.TAP -> Tap.read(src) + PacketType.ERROR -> ErrorPacket.read(src) + PacketType.SENSOR_INFO -> SensorInfo.read(src) + PacketType.ROTATION_2 -> Rotation2.read(src) + PacketType.ROTATION_DATA -> RotationData.read(src) + PacketType.MAGNETOMETER_ACCURACY -> MagnetometerAccuracy.read(src) + PacketType.SIGNAL_STRENGTH -> SignalStrength.read(src) + PacketType.TEMPERATURE -> Temperature.read(src) + PacketType.USER_ACTION -> UserActionPacket.read(src) + PacketType.FEATURE_FLAGS -> FeatureFlags.read(src) + PacketType.ROTATION_AND_ACCEL -> RotationAndAccel.read(src) + PacketType.ACK_CONFIG_CHANGE -> AckConfigChange.read(src) + PacketType.SET_CONFIG_FLAG -> SetConfigFlag() // Usually outbound + PacketType.FLEX_DATA -> FlexData.read(src) + PacketType.POSITION -> PositionPacket.read(src) + PacketType.PROTOCOL_CHANGE -> ProtocolChange.read(src) +} -fun writePacket(dst: Sink, packet: Packet, packetNum: Long) { +fun writePacket(dst: Sink, packet: Packet) { val type = when (packet) { is Heartbeat -> PacketType.HEARTBEAT - is HandshakeResponse -> PacketType.HANDSHAKE - is Rotation -> PacketType.ROTATION - is Accel -> PacketType.ACCEL + is Handshake -> PacketType.HANDSHAKE is PingPong -> PacketType.PING_PONG - is Serial -> PacketType.SERIAL - is BatteryLevel -> PacketType.BATTERY_LEVEL - is Tap -> PacketType.TAP - is Error -> PacketType.ERROR - is SensorInfo -> PacketType.SENSOR_INFO - is Rotation2 -> PacketType.ROTATION_2 - is RotationData -> PacketType.ROTATION_DATA - is MagnetometerAccuracy -> PacketType.MAGNETOMETER_ACCURACY - is SignalStrength -> PacketType.SIGNAL_STRENGTH - is Temperature -> PacketType.TEMPERATURE - is UserActionPacket -> PacketType.USER_ACTION + is SetConfigFlag -> PacketType.SET_CONFIG_FLAG is ProtocolChange -> PacketType.PROTOCOL_CHANGE - else -> error("unhandled packet") + is FeatureFlags -> PacketType.FEATURE_FLAGS + else -> error("Outbound support not implemented for ${packet::class.simpleName}") } + if (type != PacketType.HANDSHAKE) { dst.writeInt(type.id) - dst.writeLong(packetNum) + dst.writeLong(0) } - PacketCodec.write(dst, packet) + packet.write(dst) } data class PacketEvent( @@ -272,9 +323,6 @@ class PacketDispatcher { val listeners = mutableMapOf, MutableList<(PacketEvent) -> Unit>>() private val globalListeners = mutableListOf<(PacketEvent) -> Unit>() - /** - * Listen for a specific packet type with metadata. - */ @Suppress("UNCHECKED_CAST") inline fun on(crossinline callback: (PacketEvent) -> Unit) { val list = listeners.getOrPut(T::class) { mutableListOf() } diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt index f9b032391c..5c6db82438 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt @@ -19,8 +19,6 @@ data class UDPTrackerServerState( val connections: MutableMap, ) -const val PACKET_WORKERS = 4 - suspend fun createUDPTrackerServer( serverContext: VRServer, configContext: ConfigContext, @@ -33,46 +31,37 @@ suspend fun createUDPTrackerServer( val selectorManager = SelectorManager(Dispatchers.IO) val serverSocket = aSocket(selectorManager).udp().bind(InetSocketAddress("0.0.0.0", state.port)) - val packetChannel = Channel(capacity = Channel.BUFFERED) supervisorScope { launch(Dispatchers.IO) { while (isActive) { val datagram = serverSocket.receive() - packetChannel.send(datagram) - } - } + val packetId = datagram.packet.readInt() + val packetNumber = datagram.packet.readLong() + val type = PacketType.fromId(packetId) ?: continue + val packetData = readPacket(type, datagram.packet) - repeat(PACKET_WORKERS) { workerId -> - launch(Dispatchers.Default) { - for (datagram in packetChannel) { - val packetId = datagram.packet.readInt() - val packetNumber = datagram.packet.readLong() - val type = PacketType.fromId(packetId) ?: continue - val packetData = PacketCodec.read(type, datagram.packet) + val address = datagram.address as InetSocketAddress + val connContext = state.connections[address.hostname] - val address = datagram.address as InetSocketAddress - val connContext = state.connections[address.hostname] + val event = PacketEvent( + data = packetData, + packetNumber = packetNumber, + ) - val event = PacketEvent( - data = packetData, - packetNumber = packetNumber, + if (connContext !== null) { + connContext.packetEvents.emit(event) + } else { + val newContext = createUDPConnectionContext( + id = address.hostname, + remoteAddress = address, + socket = serverSocket, + serverContext = serverContext, + scope = this, ) - if (connContext !== null) { - connContext.packetEvents.emit(event = event) - } else { - val newContext = createUDPConnectionContext( - id = address.hostname, - remoteAddress = address, - socket = serverSocket, - serverContext = serverContext, - scope = this, - ) - - state.connections[address.hostname] = newContext - newContext.packetEvents.emit(event = event) - } + state.connections[address.hostname] = newContext + newContext.packetEvents.emit(event) } } } diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index 6e3b31db26..503b9597b8 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -4,7 +4,7 @@ import dev.slimevr.context.BasicModule import dev.slimevr.context.Context import dev.slimevr.context.createContext import dev.slimevr.tracker.Device -import dev.slimevr.tracker.TrackerContext +import dev.slimevr.tracker.Tracker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.launchIn @@ -12,19 +12,19 @@ import kotlinx.coroutines.flow.onEach data class VRServerState( val handleId: Int, - val trackers: Map, + val trackers: Map, val devices: Map, ) sealed interface VRServerActions { - data class NewTracker(val trackerId: Int, val context: TrackerContext) : VRServerActions + data class NewTracker(val trackerId: Int, val context: Tracker) : VRServerActions data class NewDevice(val deviceId: Int, val context: Device) : VRServerActions } typealias VRServerContext = Context typealias VRServerModule = BasicModule -val TestModule = VRServerModule( +val BaseModule = VRServerModule( reducer = { s, a -> when (a) { is VRServerActions.NewTracker -> s.copy( @@ -49,8 +49,8 @@ data class VRServer( val context: VRServerContext, ) { fun nextHandle() = context.state.value.handleId + 1 - fun getTrackerContext(id: Int) = context.state.value.trackers[id] - fun getDeviceContext(id: Int) = context.state.value.devices[id] + fun getTracker(id: Int) = context.state.value.trackers[id] + fun getDevice(id: Int) = context.state.value.devices[id] companion object { fun create(scope: CoroutineScope): VRServer { @@ -60,7 +60,7 @@ data class VRServer( devices = mapOf(), ) - val modules = listOf(TestModule) + val modules = listOf(BaseModule) val context = createContext( initialState = server, 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 22257c4e1f..e658a32a54 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -11,5 +11,7 @@ fun main(args: Array) = runBlocking { val config = createConfig(this) val server = VRServer.create(this) - val udpServer = createUDPTrackerServer(server, config) + createUDPTrackerServer(server, config) + + Unit } From 4fd4997e608540a9065560eca6f5f4761fe7bf6c Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 19 Mar 2026 03:55:09 +0100 Subject: [PATCH 005/350] Logger test --- server/core/build.gradle.kts | 2 ++ .../core/src/main/java/dev/slimevr/logger.kt | 26 +++++++++++++++++++ .../main/java/dev/slimevr/tracker/device.kt | 9 ++++--- .../main/java/dev/slimevr/tracker/tracker.kt | 5 ++-- .../dev/slimevr/tracker/udp/connection.kt | 12 ++++++--- settings.gradle.kts | 2 +- solarxr-protocol | 2 +- 7 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/logger.kt diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index 26cacfa9d3..fad64e8dc4 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -69,6 +69,8 @@ dependencies { implementation("com.mayakapps.kache:kache:2.1.1") implementation("io.ktor:ktor-network:3.0.0") implementation("io.ktor:ktor-utils:3.0.3") + implementation("io.klogging:klogging:0.11.7") + api("com.github.loucass003:EspflashKotlin:v0.11.0") diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt new file mode 100644 index 0000000000..5f7380a643 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -0,0 +1,26 @@ +package dev.slimevr + +import io.klogging.Level +import io.klogging.config.loggingConfiguration +import io.klogging.logger +import io.klogging.rendering.RENDER_SIMPLE +import io.klogging.sending.STDOUT + +object AppLogger { + val tracker = logger("Tracker") + val device = logger("Device") + val udp = logger("UDPConnection") + + + init { + loggingConfiguration { + sink("stdout", RENDER_SIMPLE, STDOUT) + logging { + fromMinLevel(Level.INFO) { + toSink("stdout") + } + + } + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/device.kt b/server/core/src/main/java/dev/slimevr/tracker/device.kt index 6c03be4cdf..3d146de33e 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/device.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/device.kt @@ -1,12 +1,11 @@ package dev.slimevr.tracker +import dev.slimevr.AppLogger import dev.slimevr.VRServer import dev.slimevr.context.BasicModule import dev.slimevr.context.Context import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -32,11 +31,13 @@ sealed interface DeviceActions { data class Update(val transform: DeviceState.() -> DeviceState) : DeviceActions } + + val DeviceStatsModule = DeviceModule( reducer = { s, a -> if (a is DeviceActions.Update) a.transform(s) else s }, observer = { - it.state.onEach { - println("Device state changed $it") + it.state.onEach { state -> + AppLogger.device.info("Device state changed {State}", state) }.launchIn(it.scope) } ) diff --git a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt index aa8963656a..7b96d20f1b 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt @@ -1,5 +1,6 @@ package dev.slimevr.tracker +import dev.slimevr.AppLogger import dev.slimevr.VRServer import dev.slimevr.context.BasicModule import dev.slimevr.context.Context @@ -8,7 +9,6 @@ import dev.slimevr.skeleton.BodyPart import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach enum class TrackerStatus(val id: UByte) { @@ -51,10 +51,11 @@ data class Tracker( val context: TrackerContext ) + val TrackerInfosModule = TrackerModule( reducer = { s, a -> if (a is TrackerActions.Update) a.transform(s) else s }, observer = { - it.state.onEach { println("Tracker $it") }.launchIn(it.scope) + it.state.onEach { state -> AppLogger.tracker.info("Tracker state changed {State}", state) }.launchIn(it.scope) } ) diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt index 8c6298f1ad..f2bd788034 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt @@ -1,5 +1,6 @@ package dev.slimevr.tracker.udp +import dev.slimevr.AppLogger import dev.slimevr.VRServer import dev.slimevr.VRServerActions import dev.slimevr.context.Context @@ -64,6 +65,7 @@ data class UDPConnectionModule( val observer: ((UDPConnection) -> Unit)? = null, ) + val PacketModule = UDPConnectionModule( reducer = { s, a -> when (a) { @@ -93,10 +95,12 @@ val PacketModule = UDPConnectionModule( time = now ) ) - println("Reconnecting") + AppLogger.udp.info("Reconnecting") } } else if (packet.packetNumber < state.lastPacketNum) { - println("WARN: Received packet with wrong packet number") + it.context.scope.launch { + AppLogger.udp.warn("WARN: Received packet with wrong packet number") + } return@onAny } else { it.context.scope.launch { @@ -140,7 +144,9 @@ val PingModule = UDPConnectionModule( val deviceId = state.deviceId ?: return@on if (paket.data.pingId != state.lastPing.id + 1) { - println("Ping ID does not match, ignoring ${paket.data.pingId} != ${state.lastPing.id + 1}") + it.context.scope.launch { + AppLogger.udp.warn("Ping ID does not match, ignoring ${paket.data.pingId} != ${state.lastPing.id + 1}") + } return@on } diff --git a/settings.gradle.kts b/settings.gradle.kts index e2691d0b82..a87de8acd5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,7 +33,7 @@ pluginManagement { } include(":solarxr-protocol") -project(":solarxr-protocol").projectDir = File("solarxr-protocol/protocol/java") +project(":solarxr-protocol").projectDir = File("solarxr-protocol/protocol/kotlin") include(":server") project(":server").projectDir = File("server") diff --git a/solarxr-protocol b/solarxr-protocol index fa2895b19a..84fc97ff30 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit fa2895b19a53d9b1686de8c2a6efe2b3e9ca4fc6 +Subproject commit 84fc97ff306f2d60e8f4f34ac6d2374d2587bcfc From 4c1e4691be66cae0309fc954caf03e22d1d1d23e Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 19 Mar 2026 17:43:15 +0100 Subject: [PATCH 006/350] Solarxr start --- server/core/build.gradle.kts | 12 +- .../core/src/main/java/dev/slimevr/logger.kt | 1 - .../main/java/dev/slimevr/solarxr/server.kt | 59 ++++++++ .../main/java/dev/slimevr/solarxr/solarxr.kt | 6 + .../main/java/dev/slimevr/tracker/device.kt | 2 +- .../main/java/dev/slimevr/tracker/tracker.kt | 4 +- .../dev/slimevr/tracker/udp/connection.kt | 131 ++++++++---------- .../java/dev/slimevr/tracker/udp/packets.kt | 64 +++++---- .../src/main/java/dev/slimevr/desktop/Main.kt | 10 +- 9 files changed, 178 insertions(+), 111 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/server.kt create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index fad64e8dc4..8a0578c8a9 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -62,15 +62,21 @@ dependencies { implementation("com.google.flatbuffers:flatbuffers-java:22.10.26") implementation("com.illposed.osc:javaosc-core:0.9") - implementation("org.java-websocket:Java-WebSocket:1.+") implementation("com.melloware:jintellitype:1.+") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("com.mayakapps.kache:kache:2.1.1") - implementation("io.ktor:ktor-network:3.0.0") - implementation("io.ktor:ktor-utils:3.0.3") implementation("io.klogging:klogging:0.11.7") + val ktor_version = "3.4.1" + implementation("io.ktor:ktor-server-core-jvm:$ktor_version") + implementation("io.ktor:ktor-server-netty-jvm:$ktor_version") + implementation("io.ktor:ktor-server-websockets-jvm:$ktor_version") + implementation("io.ktor:ktor-server-content-negotiation-jvm:$ktor_version") + implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:$ktor_version") + implementation("io.ktor:ktor-utils:$ktor_version") + + api("com.github.loucass003:EspflashKotlin:v0.11.0") diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index 5f7380a643..c5539dad0f 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -19,7 +19,6 @@ object AppLogger { fromMinLevel(Level.INFO) { toSink("stdout") } - } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/server.kt b/server/core/src/main/java/dev/slimevr/solarxr/server.kt new file mode 100644 index 0000000000..783f258bbf --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/server.kt @@ -0,0 +1,59 @@ +package dev.slimevr.solarxr + +import io.ktor.server.application.* +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import io.ktor.server.routing.routing +import io.ktor.server.websocket.* +import io.ktor.websocket.Frame +import io.ktor.websocket.readBytes +import solarxr_protocol.MessageBundle +import java.nio.ByteBuffer + +const val SOLARXR_PORT = 21110; + +fun onSolarXRMessage(message: ByteBuffer) { + val messageBundle = MessageBundle.getRootAsMessageBundle(message) + + + for (index in 0.. { + val data = frame.readBytes() + onSolarXRMessage(frame.buffer) + println("Received Binary Packet: ${data.size} bytes") + } + else -> {} + } + + } + } + } + }.start(wait = true) +} + diff --git a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt b/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt new file mode 100644 index 0000000000..7f7d097cc5 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt @@ -0,0 +1,6 @@ +package dev.slimevr.solarxr + + +data class SolarXRConnectionState( + val id: Int, +) diff --git a/server/core/src/main/java/dev/slimevr/tracker/device.kt b/server/core/src/main/java/dev/slimevr/tracker/device.kt index 3d146de33e..865ed5f864 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/device.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/device.kt @@ -37,7 +37,7 @@ val DeviceStatsModule = DeviceModule( reducer = { s, a -> if (a is DeviceActions.Update) a.transform(s) else s }, observer = { it.state.onEach { state -> - AppLogger.device.info("Device state changed {State}", state) +// AppLogger.device.info("Device state changed", state) }.launchIn(it.scope) } ) diff --git a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt index 7b96d20f1b..7a53933057 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt @@ -55,7 +55,9 @@ data class Tracker( val TrackerInfosModule = TrackerModule( reducer = { s, a -> if (a is TrackerActions.Update) a.transform(s) else s }, observer = { - it.state.onEach { state -> AppLogger.tracker.info("Tracker state changed {State}", state) }.launchIn(it.scope) + it.state.onEach { state -> +// AppLogger.tracker.info("Tracker state changed {State}", state) + }.launchIn(it.scope) } ) diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt index f2bd788034..8877f3304b 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt @@ -55,7 +55,7 @@ data class UDPConnection( val context: UDPConnectionContext, val serverContext: VRServer, val packetEvents: PacketDispatcher, - val send: (Packet) -> Unit, + val send: (UDPPacket) -> Unit, val getDevice: () -> Device?, val getTracker: (sensorId: Int) -> Tracker?, ) @@ -88,19 +88,15 @@ val PacketModule = UDPConnectionModule( val now = System.currentTimeMillis() if (now - state.lastPacket > 5000 && packet.packetNumber == 0L) { - it.context.scope.launch { - it.context.dispatch( - UDPConnectionActions.LastPacket( - packetNum = 0, - time = now - ) + it.context.dispatch( + UDPConnectionActions.LastPacket( + packetNum = 0, + time = now ) - AppLogger.udp.info("Reconnecting") - } + ) + AppLogger.udp.info("Reconnecting") } else if (packet.packetNumber < state.lastPacketNum) { - it.context.scope.launch { - AppLogger.udp.warn("WARN: Received packet with wrong packet number") - } + AppLogger.udp.warn("WARN: Received packet with wrong packet number") return@onAny } else { it.context.scope.launch { @@ -139,14 +135,12 @@ val PingModule = UDPConnectionModule( } // listen for the pong - it.packetEvents.on { paket -> + it.packetEvents.on { packet -> val state = it.context.state.value val deviceId = state.deviceId ?: return@on - if (paket.data.pingId != state.lastPing.id + 1) { - it.context.scope.launch { - AppLogger.udp.warn("Ping ID does not match, ignoring ${paket.data.pingId} != ${state.lastPing.id + 1}") - } + if (packet.data.pingId != state.lastPing.id + 1) { + AppLogger.udp.warn("Ping ID does not match, ignoring ${packet.data.pingId} != ${state.lastPing.id + 1}") return@on } @@ -154,17 +148,15 @@ val PingModule = UDPConnectionModule( val device = it.serverContext.getDevice(deviceId) ?: return@on - it.context.scope.launch { - it.context.dispatch( - UDPConnectionActions.ReceivedPong( - id = paket.data.pingId, - duration = ping - ) + it.context.dispatch( + UDPConnectionActions.ReceivedPong( + id = packet.data.pingId, + duration = ping ) - device.context.dispatch(DeviceActions.Update { - copy(ping = ping) - }) - } + ) + device.context.dispatch(DeviceActions.Update { + copy(ping = ping) + }) } }, ) @@ -195,16 +187,14 @@ val HandshakeModule = UDPConnectionModule( serverContext = it.serverContext, ) - it.context.scope.launch { - it.serverContext.context.dispatch( - VRServerActions.NewDevice( - deviceId = deviceId, - context = newDevice - ) + it.serverContext.context.dispatch( + VRServerActions.NewDevice( + deviceId = deviceId, + context = newDevice ) - it.context.dispatch(UDPConnectionActions.Handshake(deviceId)) - it.send(Handshake()) - } + ) + it.context.dispatch(UDPConnectionActions.Handshake(deviceId)) + it.send(Handshake()) } else { it.send(Handshake()) } @@ -217,24 +207,20 @@ val DeviceStatsModule = UDPConnectionModule( it.packetEvents.on { event -> val device = it.getDevice() ?: return@on - device.context.scope.launch { - device.context.dispatch(DeviceActions.Update { - copy( - batteryLevel = event.data.level, - batteryVoltage = event.data.voltage - ) - }) - } + device.context.dispatch(DeviceActions.Update { + copy( + batteryLevel = event.data.level, + batteryVoltage = event.data.voltage + ) + }) } it.packetEvents.on { event -> val device = it.getDevice() ?: return@on - device.context.scope.launch { - device.context.dispatch(DeviceActions.Update { - copy(signalStrength = event.data.signal) - }) - } + device.context.dispatch(DeviceActions.Update { + copy(signalStrength = event.data.signal) + }) } } ) @@ -249,9 +235,9 @@ val SensorInfoModule = UDPConnectionModule( else -> s } }, - observer = { - it.packetEvents.on { event -> - val tracker = it.getTracker(event.data.sensorId) + observer = { observerContext -> + observerContext.packetEvents.on { event -> + val tracker = observerContext.getTracker(event.data.sensorId) val action = TrackerActions.Update { copy( @@ -261,41 +247,38 @@ val SensorInfoModule = UDPConnectionModule( } if (tracker != null) { - tracker.context.scope.launch { - tracker.context.dispatch(action) - } + tracker.context.dispatch(action) } else { - val device = it.getDevice() + + val device = observerContext.getDevice() ?: error("invalid state - a device should exist at this point") val deviceState = device.context.state.value - val trackerId = it.serverContext.nextHandle() + val trackerId = observerContext.serverContext.nextHandle() val newTracker = createTracker( id = trackerId, hardwareId = "${deviceState.address}:${event.data.sensorId}", sensorType = event.data.imuType, deviceId = deviceState.id, origin = DeviceOrigin.UDP, - serverContext = it.serverContext, - scope = it.serverContext.context.scope + serverContext = observerContext.serverContext, + scope = observerContext.serverContext.context.scope ) - it.serverContext.context.scope.launch { - it.serverContext.context.dispatch( - VRServerActions.NewTracker( - trackerId = trackerId, - context = newTracker - ) + observerContext.serverContext.context.dispatch( + VRServerActions.NewTracker( + trackerId = trackerId, + context = newTracker ) - it.context.dispatch( - UDPConnectionActions.AssignTracker( - trackerId = TrackerIdNum( - id = trackerId, - trackerNum = event.data.sensorId - ) + ) + observerContext.context.dispatch( + UDPConnectionActions.AssignTracker( + trackerId = TrackerIdNum( + id = trackerId, + trackerNum = event.data.sensorId ) ) - newTracker.context.dispatch(action) - } + ) + newTracker.context.dispatch(action) } } @@ -355,7 +338,7 @@ fun createUDPConnectionContext( context = context, serverContext = serverContext, dispatcher, - send = { packet: Packet -> + send = { packet: UDPPacket -> scope.launch { val bytePacket = buildPacket { writePacket(this, packet) diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt index 599f75c217..262397311a 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt @@ -5,13 +5,15 @@ import dev.slimevr.tracker.TrackerStatus import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import io.ktor.utils.io.core.remaining +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.io.Sink import kotlinx.io.Source import kotlinx.io.readByteArray import kotlinx.io.readFloat import kotlinx.io.readString import kotlinx.io.readUByte -import kotlinx.io.writeFloat import kotlinx.io.writeUByte import kotlin.reflect.KClass @@ -63,15 +65,15 @@ enum class PacketType(val id: Int) { } } -sealed interface Packet { +sealed interface UDPPacket { fun write(dst: Sink) {} } -sealed interface SensorSpecificPacket : Packet { +sealed interface SensorSpecificPacket : UDPPacket { val sensorId: Int } -data object Heartbeat : Packet +data object Heartbeat : UDPPacket data class Handshake( val boardType: Int = 0, @@ -80,7 +82,7 @@ data class Handshake( val protocolVersion: Int = 0, val firmware: String? = null, val macString: String? = null -) : Packet { +) : UDPPacket { override fun write(dst: Sink) { dst.writeByte(PacketType.HANDSHAKE.id.toByte()) dst.write("Hey OVR =D 5".toByteArray(Charsets.US_ASCII)) @@ -117,16 +119,16 @@ data class Accel(val acceleration: Vector3 = Vector3.NULL, override val sensorId } } -data class PingPong(val pingId: Int = 0) : Packet { +data class PingPong(val pingId: Int = 0) : UDPPacket { override fun write(dst: Sink) { dst.writeInt(pingId) } companion object { fun read(src: Source) = PingPong(src.readInt()) } } -data class Serial(val serial: String = "") : Packet { +data class Serial(val serial: String = "") : UDPPacket { companion object { fun read(src: Source) = Serial(src.readString(src.readInt().toLong())) } } -data class BatteryLevel(val voltage: Float = 0f, val level: Float = 0f) : Packet { +data class BatteryLevel(val voltage: Float = 0f, val level: Float = 0f) : UDPPacket { companion object { fun read(src: Source): BatteryLevel { val f = src.readSafeFloat() @@ -211,11 +213,11 @@ data class Temperature(override val sensorId: Int = 0, val temp: Float = 0f) : S } } -data class UserActionPacket(val type: Int = 0) : Packet { +data class UserActionPacket(val type: Int = 0) : UDPPacket { companion object { fun read(src: Source) = UserActionPacket(src.readU8()) } } -data class FeatureFlags(val firmwareFeatures: ByteArray = byteArrayOf()) : Packet { +data class FeatureFlags(val firmwareFeatures: ByteArray = byteArrayOf()) : UDPPacket { companion object { fun read(src: Source) = FeatureFlags(src.readByteArray(src.remaining.toInt())) } } @@ -261,7 +263,7 @@ data class PositionPacket(override val sensorId: Int = 0, val position: Vector3 } } -data class ProtocolChange(val targetProtocol: Int = 0, val targetVersion: Int = 0) : Packet { +data class ProtocolChange(val targetProtocol: Int = 0, val targetVersion: Int = 0) : UDPPacket { override fun write(dst: Sink) { dst.writeUByte(targetProtocol.toUByte()) dst.writeUByte(targetVersion.toUByte()) @@ -269,7 +271,7 @@ data class ProtocolChange(val targetProtocol: Int = 0, val targetVersion: Int = companion object { fun read(src: Source) = ProtocolChange(src.readU8(), src.readU8()) } } -fun readPacket(type: PacketType, src: Source): Packet = when (type) { +fun readPacket(type: PacketType, src: Source): UDPPacket = when (type) { PacketType.HEARTBEAT -> Heartbeat PacketType.HANDSHAKE -> Handshake.read(src) PacketType.ROTATION -> Rotation.read(src) @@ -295,7 +297,7 @@ fun readPacket(type: PacketType, src: Source): Packet = when (type) { PacketType.PROTOCOL_CHANGE -> ProtocolChange.read(src) } -fun writePacket(dst: Sink, packet: Packet) { +fun writePacket(dst: Sink, packet: UDPPacket) { val type = when (packet) { is Heartbeat -> PacketType.HEARTBEAT is Handshake -> PacketType.HANDSHAKE @@ -314,34 +316,38 @@ fun writePacket(dst: Sink, packet: Packet) { packet.write(dst) } -data class PacketEvent( +data class PacketEvent( val data: T, val packetNumber: Long, ) class PacketDispatcher { - val listeners = mutableMapOf, MutableList<(PacketEvent) -> Unit>>() - private val globalListeners = mutableListOf<(PacketEvent) -> Unit>() + val listeners = mutableMapOf, MutableList) -> Unit>>() + val globalListeners = mutableListOf) -> Unit>() + val mutex = Mutex() @Suppress("UNCHECKED_CAST") - inline fun on(crossinline callback: (PacketEvent) -> Unit) { - val list = listeners.getOrPut(T::class) { mutableListOf() } - synchronized(list) { - list.add { callback(it as PacketEvent) } + inline fun on(crossinline callback: suspend (PacketEvent) -> Unit) { + runBlocking { + mutex.withLock { + val list = listeners.getOrPut(T::class) { mutableListOf() } + list.add { callback(it as PacketEvent) } + } } } - fun onAny(callback: (PacketEvent) -> Unit) { - synchronized(globalListeners) { globalListeners.add(callback) } + fun onAny(callback: suspend (PacketEvent) -> Unit) { + runBlocking { + mutex.withLock { globalListeners.add(callback) } + } } - fun emit(event: PacketEvent) { - synchronized(globalListeners) { - globalListeners.forEach { it(event) } - } - val list = listeners[event.data::class] ?: return - synchronized(list) { - list.forEach { it(event) } + suspend fun emit(event: PacketEvent) { + val targets = mutex.withLock { + val specific = listeners[event.data::class]?.toList() ?: emptyList() + val global = globalListeners.toList() + global + specific } + targets.forEach { it(event) } } } 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 e658a32a54..b5546429f5 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -4,14 +4,20 @@ package dev.slimevr.desktop import dev.slimevr.VRServer import dev.slimevr.config.createConfig +import dev.slimevr.solarxr.createSolarXRWebsocketServer import dev.slimevr.tracker.udp.createUDPTrackerServer +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking fun main(args: Array) = runBlocking { val config = createConfig(this) val server = VRServer.create(this) - createUDPTrackerServer(server, config) - + launch { + createUDPTrackerServer(server, config) + } + launch { + createSolarXRWebsocketServer() + } Unit } From 334be5f7cc1728525e9f48aa04ef2f09f5b16c07 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Fri, 20 Mar 2026 03:35:17 +0100 Subject: [PATCH 007/350] Solaxr datafeed out --- .../main/java/dev/slimevr/context/context.kt | 1 - .../core/src/main/java/dev/slimevr/logger.kt | 1 + .../java/dev/slimevr/skeleton/BodyPart.kt | 63 ------ .../main/java/dev/slimevr/solarxr/server.kt | 51 +++-- .../main/java/dev/slimevr/solarxr/solarxr.kt | 198 +++++++++++++++++- .../src/main/java/dev/slimevr/tracker/imu.kt | 30 --- .../main/java/dev/slimevr/tracker/tracker.kt | 23 +- .../dev/slimevr/tracker/udp/connection.kt | 1 - .../java/dev/slimevr/tracker/udp/packets.kt | 10 +- .../src/main/java/dev/slimevr/desktop/Main.kt | 2 +- 10 files changed, 239 insertions(+), 141 deletions(-) delete mode 100644 server/core/src/main/java/dev/slimevr/skeleton/BodyPart.kt delete mode 100644 server/core/src/main/java/dev/slimevr/tracker/imu.kt diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index 8002b6e9d8..73cf82ebd9 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -39,6 +39,5 @@ fun createContext( } } val context = Context(mutableStateFlow.asStateFlow(), dispatch, dispatchAll, scope) - return context } diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index c5539dad0f..d2055d42ba 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -10,6 +10,7 @@ object AppLogger { val tracker = logger("Tracker") val device = logger("Device") val udp = logger("UDPConnection") + val solarxr = logger("SolarXR") init { diff --git a/server/core/src/main/java/dev/slimevr/skeleton/BodyPart.kt b/server/core/src/main/java/dev/slimevr/skeleton/BodyPart.kt deleted file mode 100644 index 92537bee83..0000000000 --- a/server/core/src/main/java/dev/slimevr/skeleton/BodyPart.kt +++ /dev/null @@ -1,63 +0,0 @@ -package dev.slimevr.skeleton - -enum class BodyPart(val id: UByte) { - HEAD(solarxr_protocol.datatypes.BodyPart.HEAD), - NECK(solarxr_protocol.datatypes.BodyPart.NECK), - UPPER_CHEST(solarxr_protocol.datatypes.BodyPart.UPPERCHEST), - CHEST(solarxr_protocol.datatypes.BodyPart.CHEST), - WAIST(solarxr_protocol.datatypes.BodyPart.WAIST), - HIP(solarxr_protocol.datatypes.BodyPart.HIP), - LEFT_HIP(solarxr_protocol.datatypes.BodyPart.LEFTHIP), - RIGHT_HIP(solarxr_protocol.datatypes.BodyPart.RIGHTHIP), - LEFT_UPPER_LEG(solarxr_protocol.datatypes.BodyPart.LEFTUPPERLEG), - RIGHT_UPPER_LEG(solarxr_protocol.datatypes.BodyPart.RIGHTUPPERLEG), - LEFT_LOWER_LEG(solarxr_protocol.datatypes.BodyPart.LEFTLOWERLEG), - RIGHT_LOWER_LEG(solarxr_protocol.datatypes.BodyPart.RIGHTLOWERLEG), - LEFT_FOOT(solarxr_protocol.datatypes.BodyPart.LEFTFOOT), - RIGHT_FOOT(solarxr_protocol.datatypes.BodyPart.RIGHTFOOT), - LEFT_FOOT_TRACKER(solarxr_protocol.datatypes.BodyPart.LEFTFOOT), - RIGHT_FOOT_TRACKER(solarxr_protocol.datatypes.BodyPart.RIGHTFOOT), - LEFT_LOWER_ARM(solarxr_protocol.datatypes.BodyPart.LEFTLOWERARM), - RIGHT_LOWER_ARM(solarxr_protocol.datatypes.BodyPart.RIGHTLOWERARM), - LEFT_UPPER_ARM(solarxr_protocol.datatypes.BodyPart.LEFTUPPERARM), - RIGHT_UPPER_ARM(solarxr_protocol.datatypes.BodyPart.RIGHTUPPERARM), - LEFT_SHOULDER(solarxr_protocol.datatypes.BodyPart.LEFTSHOULDER), - RIGHT_SHOULDER(solarxr_protocol.datatypes.BodyPart.RIGHTSHOULDER), - LEFT_HAND(solarxr_protocol.datatypes.BodyPart.LEFTHAND), - RIGHT_HAND(solarxr_protocol.datatypes.BodyPart.RIGHTHAND), - LEFT_THUMB_METACARPAL(solarxr_protocol.datatypes.BodyPart.LEFTTHUMBMETACARPAL), - LEFT_THUMB_PROXIMAL(solarxr_protocol.datatypes.BodyPart.LEFTTHUMBPROXIMAL), - LEFT_THUMB_DISTAL(solarxr_protocol.datatypes.BodyPart.LEFTTHUMBDISTAL), - LEFT_INDEX_PROXIMAL(solarxr_protocol.datatypes.BodyPart.LEFTINDEXPROXIMAL), - LEFT_INDEX_INTERMEDIATE(solarxr_protocol.datatypes.BodyPart.LEFTINDEXINTERMEDIATE), - LEFT_INDEX_DISTAL(solarxr_protocol.datatypes.BodyPart.LEFTINDEXDISTAL), - LEFT_MIDDLE_PROXIMAL(solarxr_protocol.datatypes.BodyPart.LEFTMIDDLEPROXIMAL), - LEFT_MIDDLE_INTERMEDIATE(solarxr_protocol.datatypes.BodyPart.LEFTMIDDLEINTERMEDIATE), - LEFT_MIDDLE_DISTAL(solarxr_protocol.datatypes.BodyPart.LEFTMIDDLEDISTAL), - LEFT_RING_PROXIMAL(solarxr_protocol.datatypes.BodyPart.LEFTRINGPROXIMAL), - LEFT_RING_INTERMEDIATE(solarxr_protocol.datatypes.BodyPart.LEFTRINGINTERMEDIATE), - LEFT_RING_DISTAL(solarxr_protocol.datatypes.BodyPart.LEFTRINGDISTAL), - LEFT_LITTLE_PROXIMAL(solarxr_protocol.datatypes.BodyPart.LEFTLITTLEPROXIMAL), - LEFT_LITTLE_INTERMEDIATE(solarxr_protocol.datatypes.BodyPart.LEFTLITTLEINTERMEDIATE), - LEFT_LITTLE_DISTAL(solarxr_protocol.datatypes.BodyPart.LEFTLITTLEDISTAL), - RIGHT_THUMB_METACARPAL(solarxr_protocol.datatypes.BodyPart.RIGHTTHUMBMETACARPAL), - RIGHT_THUMB_PROXIMAL(solarxr_protocol.datatypes.BodyPart.RIGHTTHUMBPROXIMAL), - RIGHT_THUMB_DISTAL(solarxr_protocol.datatypes.BodyPart.RIGHTTHUMBDISTAL), - RIGHT_INDEX_PROXIMAL(solarxr_protocol.datatypes.BodyPart.RIGHTINDEXPROXIMAL), - RIGHT_INDEX_INTERMEDIATE(solarxr_protocol.datatypes.BodyPart.RIGHTINDEXINTERMEDIATE), - RIGHT_INDEX_DISTAL(solarxr_protocol.datatypes.BodyPart.RIGHTINDEXDISTAL), - RIGHT_MIDDLE_PROXIMAL(solarxr_protocol.datatypes.BodyPart.RIGHTMIDDLEPROXIMAL), - RIGHT_MIDDLE_INTERMEDIATE(solarxr_protocol.datatypes.BodyPart.RIGHTMIDDLEINTERMEDIATE), - RIGHT_MIDDLE_DISTAL(solarxr_protocol.datatypes.BodyPart.RIGHTMIDDLEDISTAL), - RIGHT_RING_PROXIMAL(solarxr_protocol.datatypes.BodyPart.RIGHTRINGPROXIMAL), - RIGHT_RING_INTERMEDIATE(solarxr_protocol.datatypes.BodyPart.RIGHTRINGINTERMEDIATE), - RIGHT_RING_DISTAL(solarxr_protocol.datatypes.BodyPart.RIGHTRINGDISTAL), - RIGHT_LITTLE_PROXIMAL(solarxr_protocol.datatypes.BodyPart.RIGHTLITTLEPROXIMAL), - RIGHT_LITTLE_INTERMEDIATE(solarxr_protocol.datatypes.BodyPart.RIGHTLITTLEINTERMEDIATE), - RIGHT_LITTLE_DISTAL(solarxr_protocol.datatypes.BodyPart.RIGHTLITTLEDISTAL); - - companion object { - private val map = entries.associateBy { it.id } - fun fromId(id: UByte) = map[id] - } -} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/server.kt b/server/core/src/main/java/dev/slimevr/solarxr/server.kt index 783f258bbf..984cf77427 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/server.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/server.kt @@ -1,5 +1,7 @@ package dev.slimevr.solarxr +import dev.slimevr.AppLogger +import dev.slimevr.VRServer import io.ktor.server.application.* import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty @@ -7,47 +9,54 @@ import io.ktor.server.routing.routing import io.ktor.server.websocket.* import io.ktor.websocket.Frame import io.ktor.websocket.readBytes +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import solarxr_protocol.MessageBundle import java.nio.ByteBuffer +import kotlin.reflect.KClass -const val SOLARXR_PORT = 21110; +const val SOLARXR_PORT = 21110 -fun onSolarXRMessage(message: ByteBuffer) { - val messageBundle = MessageBundle.getRootAsMessageBundle(message) +suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRConnection) { + val messageBundle = MessageBundle.fromByteBuffer(message) - for (index in 0.. { - val data = frame.readBytes() - onSolarXRMessage(frame.buffer) - println("Received Binary Packet: ${data.size} bytes") + is Frame.Binary -> onSolarXRMessage( + frame.buffer, + solarxrConnection + ) + + is Frame.Close -> { + AppLogger.solarxr.info("Connection closed") } + else -> {} } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt b/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt index 7f7d097cc5..42c1fec333 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt @@ -1,6 +1,202 @@ package dev.slimevr.solarxr +import com.google.flatbuffers.FlatBufferBuilder +import dev.slimevr.VRServer +import dev.slimevr.context.Context +import dev.slimevr.context.createContext +import io.ktor.util.moveToByteArray +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import solarxr_protocol.MessageBundle +import solarxr_protocol.data_feed.DataFeedConfig +import solarxr_protocol.data_feed.DataFeedMessage +import solarxr_protocol.data_feed.DataFeedMessageHeader +import solarxr_protocol.data_feed.DataFeedUpdate +import solarxr_protocol.data_feed.StartDataFeed +import solarxr_protocol.data_feed.device_data.DeviceData +import solarxr_protocol.data_feed.tracker.TrackerData +import solarxr_protocol.datatypes.DeviceId +import solarxr_protocol.datatypes.TrackerId +import solarxr_protocol.datatypes.hardware_info.HardwareStatus +import solarxr_protocol.rpc.RpcMessage +import kotlin.reflect.KClass +import kotlin.time.measureTime data class SolarXRConnectionState( - val id: Int, + val dataFeedConfigs: List, + val datafeedTimers: List, ) + +sealed interface SolarXRConnectionActions { + data class SetConfig(val configs: List, val timers: List) : + SolarXRConnectionActions +} + +typealias SolarXRConnectionContext = Context + +class PacketDispatcher { + val listeners = mutableMapOf, MutableList Unit>>() + val globalListeners = mutableListOf Unit>() + val mutex = Mutex() + + @Suppress("UNCHECKED_CAST") + inline fun on(crossinline callback: suspend (P) -> Unit) { + runBlocking { + mutex.withLock { + val list = + listeners.getOrPut(P::class as KClass) { mutableListOf() } + list.add { callback(it as P) } + } + } + } + + fun onAny(callback: suspend (T) -> Unit) { + runBlocking { + mutex.withLock { globalListeners.add(callback) } + } + } + + suspend fun emit(event: T) { + val targets = mutex.withLock { + val specific = listeners[event::class]?.toList() ?: emptyList() + val global = globalListeners.toList() + global + specific + } + targets.forEach { it(event) } + } +} + +data class SolarXRConnection( + val context: SolarXRConnectionContext, + val serverContext: VRServer, + val dataFeedDispatcher: PacketDispatcher, + val rpcDispatcher: PacketDispatcher, + val send: suspend (ByteArray) -> Unit +) + +data class SolarXRConnectionModule( + val reducer: ((SolarXRConnectionState, SolarXRConnectionActions) -> SolarXRConnectionState)? = null, + val observer: ((SolarXRConnection) -> Unit)? = null, +) + + +val DataFeedInitModule = SolarXRConnectionModule( + observer = { context -> + context.dataFeedDispatcher.on { event -> + val datafeeds = event.dataFeeds ?: return@on + val state = context.context.state.value + + state.datafeedTimers.forEach { + it.cancelAndJoin() + } + + val timers = datafeeds.map { config -> + val minTime = config.minimumTimeSinceLast ?: return@map null + + return@map context.context.scope.launch { + val fbb = FlatBufferBuilder(1024) + while (isActive) { + val timeTaken = measureTime { + fbb.clear() + + val serverState = context.serverContext.context.state.value + val trackers = + serverState.trackers.values.map { it.context.state.value } + val devices = + serverState.devices.values.map { it.context.state.value } + .map { device -> + DeviceData( + id = DeviceId(device.id.toUByte()), + hardwareStatus = HardwareStatus( + batteryVoltage = device.batteryVoltage, + batteryPctEstimate = device.batteryLevel.toUInt() + .toUByte(), + ping = device.ping?.toUShort() + ), + trackers = trackers.filter { it.deviceId == device.id } + .map { tracker -> + TrackerData( + trackerId = TrackerId( + trackerNum = tracker.id.toUByte(), + deviceId = DeviceId(device.id.toUByte()) + ), + status = tracker.status + ) + } + ) + } + + + fbb.finish( + MessageBundle( + dataFeedMsgs = listOf( + DataFeedMessageHeader( + message = DataFeedUpdate( + devices = devices + ) + ) + ) + ).encode(fbb) + ) + + context.send(fbb.dataBuffer().moveToByteArray()) + } + val remainingDelay = + (minTime.toLong() - timeTaken.inWholeMilliseconds).coerceAtLeast( + 0 + ) + delay(remainingDelay) + } + } + }.filterNotNull() + + context.context.dispatch( + SolarXRConnectionActions.SetConfig( + datafeeds, + timers = timers + ) + ) + + timers.forEach { it.start() } + } + } +) + +fun createSolarXRConnection( + serverContext: VRServer, + onSend: suspend (ByteArray) -> Unit, + scope: CoroutineScope +): SolarXRConnection { + + val state = SolarXRConnectionState( + dataFeedConfigs = listOf(), + datafeedTimers = listOf() + ) + + val modules = listOf(DataFeedInitModule) + + val context = createContext( + initialState = state, + reducers = modules.map { it.reducer }, + scope = scope, + ) + + val conn = SolarXRConnection( + context = context, + serverContext = serverContext, + dataFeedDispatcher = PacketDispatcher(), + rpcDispatcher = PacketDispatcher(), + onSend, + ) + + modules.map { it.observer }.forEach { it?.invoke(conn) } + + return conn +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/imu.kt b/server/core/src/main/java/dev/slimevr/tracker/imu.kt deleted file mode 100644 index 7fcd9e57ab..0000000000 --- a/server/core/src/main/java/dev/slimevr/tracker/imu.kt +++ /dev/null @@ -1,30 +0,0 @@ -package dev.slimevr.tracker - -enum class IMUType(val id: UByte) { - UNKNOWN(solarxr_protocol.datatypes.hardware_info.ImuType.Other.toUByte()), - MPU9250(solarxr_protocol.datatypes.hardware_info.ImuType.MPU9250.toUByte()), - MPU6500(solarxr_protocol.datatypes.hardware_info.ImuType.MPU6500.toUByte()), - BNO080(solarxr_protocol.datatypes.hardware_info.ImuType.BNO080.toUByte()), - BNO085(solarxr_protocol.datatypes.hardware_info.ImuType.BNO085.toUByte()), - BNO055(solarxr_protocol.datatypes.hardware_info.ImuType.BNO055.toUByte()), - MPU6050(solarxr_protocol.datatypes.hardware_info.ImuType.MPU6050.toUByte()), - BNO086(solarxr_protocol.datatypes.hardware_info.ImuType.BNO086.toUByte()), - BMI160(solarxr_protocol.datatypes.hardware_info.ImuType.BMI160.toUByte()), - ICM20948(solarxr_protocol.datatypes.hardware_info.ImuType.ICM20948.toUByte()), - ICM42688(solarxr_protocol.datatypes.hardware_info.ImuType.ICM42688.toUByte()), - BMI270(solarxr_protocol.datatypes.hardware_info.ImuType.BMI270.toUByte()), - LSM6DS3TRC(solarxr_protocol.datatypes.hardware_info.ImuType.LSM6DS3TRC.toUByte()), - LSM6DSV(solarxr_protocol.datatypes.hardware_info.ImuType.LSM6DSV.toUByte()), - LSM6DSO(solarxr_protocol.datatypes.hardware_info.ImuType.LSM6DSO.toUByte()), - LSM6DSR(solarxr_protocol.datatypes.hardware_info.ImuType.LSM6DSR.toUByte()), - ICM45686(solarxr_protocol.datatypes.hardware_info.ImuType.ICM45686.toUByte()), - ICM45605(solarxr_protocol.datatypes.hardware_info.ImuType.ICM45605.toUByte()), - ADC_RESISTANCE(solarxr_protocol.datatypes.hardware_info.ImuType.ADCRESISTANCE.toUByte()), - DEV_RESERVED(solarxr_protocol.datatypes.hardware_info.ImuType.DEVRESERVED.toUByte()), - ; - - companion object { - private val map = entries.associateBy { it.id } - fun fromId(id: UByte) = map[id] - } -} diff --git a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt index 7a53933057..b4edeb6952 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt @@ -1,29 +1,16 @@ package dev.slimevr.tracker -import dev.slimevr.AppLogger import dev.slimevr.VRServer import dev.slimevr.context.BasicModule import dev.slimevr.context.Context import dev.slimevr.context.createContext -import dev.slimevr.skeleton.BodyPart import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach - -enum class TrackerStatus(val id: UByte) { - DISCONNECTED(solarxr_protocol.datatypes.TrackerStatus.DISCONNECTED), - OK(solarxr_protocol.datatypes.TrackerStatus.OK), - BUSY(solarxr_protocol.datatypes.TrackerStatus.BUSY), - ERROR(solarxr_protocol.datatypes.TrackerStatus.ERROR), - OCCLUDED(solarxr_protocol.datatypes.TrackerStatus.OCCLUDED), - TIMEDOUT(solarxr_protocol.datatypes.TrackerStatus.TIMEDOUT); - - companion object { - private val map = entries.associateBy { it.id } - fun fromId(id: UByte) = map[id] - } -} +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.datatypes.hardware_info.ImuType data class TrackerIdNum(val id: Int, val trackerNum: Int) @@ -31,7 +18,7 @@ data class TrackerState( val id: Int, val name: String, val hardwareId: String, - val sensorType: IMUType, + val sensorType: ImuType, val bodyPart: BodyPart?, val status: TrackerStatus, val customName: String?, @@ -65,7 +52,7 @@ fun createTracker( scope: CoroutineScope, id: Int, deviceId: Int, - sensorType: IMUType, + sensorType: ImuType, hardwareId: String, origin: DeviceOrigin, serverContext: VRServer diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt index 8877f3304b..1bc5896239 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt @@ -168,7 +168,6 @@ val HandshakeModule = UDPConnectionModule( didHandshake = true, deviceId = a.deviceId ) - else -> s } }, diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt index 262397311a..a3eb7d8ae4 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt @@ -1,7 +1,5 @@ package dev.slimevr.tracker.udp -import dev.slimevr.tracker.IMUType -import dev.slimevr.tracker.TrackerStatus import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import io.ktor.utils.io.core.remaining @@ -15,6 +13,8 @@ import kotlinx.io.readFloat import kotlinx.io.readString import kotlinx.io.readUByte import kotlinx.io.writeUByte +import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.datatypes.hardware_info.ImuType import kotlin.reflect.KClass private fun Source.readU8(): Int = readByte().toInt() and 0xFF @@ -148,7 +148,7 @@ data class ErrorPacket(override val sensorId: Int = 0, val errorNumber: Int = 0) data class SensorInfo( override val sensorId: Int = 0, val status: TrackerStatus = TrackerStatus.DISCONNECTED, - val imuType: IMUType = IMUType.UNKNOWN, + val imuType: ImuType = ImuType.Other, val sensorConfig: UShort? = null, val hasCompletedRestCalibration: Boolean? = null, val trackerPosition: Int? = null, @@ -157,8 +157,8 @@ data class SensorInfo( companion object { fun read(src: Source) = with(src) { val id = readU8() - val stat = TrackerStatus.fromId((readUByte() + 1u).toUByte()) ?: TrackerStatus.DISCONNECTED - val imu = if (remaining > 0) IMUType.fromId(readUByte()) ?: IMUType.UNKNOWN else IMUType.UNKNOWN + val stat = TrackerStatus.fromValue((readUByte() + 1u).toUByte()) ?: TrackerStatus.DISCONNECTED + val imu = if (remaining > 0) ImuType.fromValue(readUByte().toUShort()) ?: ImuType.Other else ImuType.Other val conf = if (remaining >= 2) readShort().toUShort() else null val calib = if (remaining > 0) readU8() != 0 else null val pos = if (remaining > 0) readU8() else null 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 b5546429f5..ba14c6999e 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -17,7 +17,7 @@ fun main(args: Array) = runBlocking { createUDPTrackerServer(server, config) } launch { - createSolarXRWebsocketServer() + createSolarXRWebsocketServer(server) } Unit } From a42ed79003472451f85389e5059b09b7d41df516 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 23 Mar 2026 04:37:43 +0100 Subject: [PATCH 008/350] Basic config system --- .../main/java/dev/slimevr/config/config.kt | 102 ++++++++---- .../main/java/dev/slimevr/config/configio.kt | 92 +++++++++++ .../main/java/dev/slimevr/config/settings.kt | 101 ++++++++++++ .../src/main/java/dev/slimevr/config/user.kt | 103 +++++++++++++ .../main/java/dev/slimevr/context/context.kt | 16 +- .../src/main/java/dev/slimevr/platform.kt | 90 +++++++++++ .../java/dev/slimevr/skeleton/skeleton.kt | 20 +++ .../main/java/dev/slimevr/solarxr/datafeed.kt | 145 ++++++++++++++++++ .../main/java/dev/slimevr/solarxr/solarxr.kt | 107 +------------ .../dev/slimevr/tracker/udp/connection.kt | 12 +- .../java/dev/slimevr/tracker/udp/server.kt | 10 +- server/desktop/.gitignore | 2 + .../src/main/java/dev/slimevr/desktop/Main.kt | 6 +- solarxr-protocol | 2 +- 14 files changed, 652 insertions(+), 156 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/config/configio.kt create mode 100644 server/core/src/main/java/dev/slimevr/config/settings.kt create mode 100644 server/core/src/main/java/dev/slimevr/config/user.kt create mode 100644 server/core/src/main/java/dev/slimevr/platform.kt create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/skeleton.kt create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt diff --git a/server/core/src/main/java/dev/slimevr/config/config.kt b/server/core/src/main/java/dev/slimevr/config/config.kt index cdfdffd871..5a971cc66f 100644 --- a/server/core/src/main/java/dev/slimevr/config/config.kt +++ b/server/core/src/main/java/dev/slimevr/config/config.kt @@ -5,62 +5,98 @@ import dev.slimevr.context.Context import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File -@Serializable -data class UserConfigState( - val userHeight: Float = 1.6f, -) -@Serializable -data class SettingsConfigState( - val trackerPort: Int = 6969, -) +private const val GLOBAL_CONFIG_VERSION = 1 @Serializable data class GlobalConfigState( val selectedUserProfile: String = "default", val selectedSettingsProfile: String = "default", + val version: Int = GLOBAL_CONFIG_VERSION, ) -data class ConfigState( - val userConfig: UserConfigState = UserConfigState(), - val settingsConfig: SettingsConfigState = SettingsConfigState(), - val globalConfig: GlobalConfigState = GlobalConfigState(), -) +sealed interface GlobalConfigActions { + data class SetUserProfile(val name: String) : GlobalConfigActions + data class SetSettingsProfile(val name: String) : GlobalConfigActions +} -sealed interface ConfigAction { - data class ChangeProfile(val settingsProfile: String? = null, val userProfile: String? = null) : ConfigAction +typealias GlobalConfigContext = Context +typealias GlobalConfigModule = BasicModule + +private fun migrateGlobalConfig(json: JsonObject): JsonObject { + val version = json["version"]?.jsonPrimitive?.intOrNull ?: 0 + return when { + // add migration branches here as: version < N -> migrateGlobalConfig(...) + else -> json + } } -typealias ConfigContext = Context -typealias ConfigModule = BasicModule +private fun parseAndMigrateGlobalConfig(raw: String): GlobalConfigState { + val json = jsonConfig.parseToJsonElement(raw).jsonObject + return jsonConfig.decodeFromJsonElement(migrateGlobalConfig(json)) +} -val ConfigModuleTest = ConfigModule( +val DefaultGlobalConfigModule = GlobalConfigModule( reducer = { s, a -> when (a) { - is ConfigAction.ChangeProfile -> if (a.settingsProfile != null) { - s.copy(globalConfig = s.globalConfig.copy(selectedSettingsProfile = a.settingsProfile)) - } else if (a.userProfile != null) { - s.copy(globalConfig = s.globalConfig.copy(selectedUserProfile = a.userProfile)) - } else { - s - } + is GlobalConfigActions.SetUserProfile -> s.copy(selectedUserProfile = a.name) + is GlobalConfigActions.SetSettingsProfile -> s.copy(selectedSettingsProfile = a.name) } }, ) -suspend fun createConfig(scope: CoroutineScope): ConfigContext { - val modules = listOf(ConfigModuleTest) +data class AppConfig( + val globalContext: GlobalConfigContext, + val userConfig: UserConfig, + val settings: Settings, + val switchUserProfile: suspend (String) -> Unit, + val switchSettingsProfile: suspend (String) -> Unit, +) + +suspend fun createAppConfig(scope: CoroutineScope, configFolder: File): AppConfig { + val initialGlobal = loadFileWithBackup(File(configFolder, "global.json"), GlobalConfigState()) { + parseAndMigrateGlobalConfig(it) + } - val context = createContext( - initialState = ConfigState(), - reducers = modules.map { it.reducer }, + val globalContext = createContext( + initialState = initialGlobal, + reducers = listOf(DefaultGlobalConfigModule.reducer), scope = scope, ) - modules.map { it.observer }.forEach { it?.invoke(context) } + launchAutosave( + scope = scope, + state = globalContext.state, + toFile = { File(configFolder, "global.json") }, + serialize = { jsonConfig.encodeToString(it) }, + ) - context.dispatch(ConfigAction.ChangeProfile(settingsProfile = "Test")) + val userConfig = createUserConfig(scope, configFolder, initialGlobal.selectedUserProfile) + val settings = createSettings(scope, configFolder, initialGlobal.selectedSettingsProfile) - return context + val switchUserProfile: suspend (String) -> Unit = { name -> + globalContext.dispatch(GlobalConfigActions.SetUserProfile(name)) + userConfig.swap(name) + } + + val switchSettingsProfile: suspend (String) -> Unit = { name -> + globalContext.dispatch(GlobalConfigActions.SetSettingsProfile(name)) + settings.swap(name) + } + + return AppConfig( + globalContext = globalContext, + userConfig = userConfig, + settings = settings, + switchUserProfile = switchUserProfile, + switchSettingsProfile = switchSettingsProfile, + ) } diff --git a/server/core/src/main/java/dev/slimevr/config/configio.kt b/server/core/src/main/java/dev/slimevr/config/configio.kt new file mode 100644 index 0000000000..f2aba7607b --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/config/configio.kt @@ -0,0 +1,92 @@ +package dev.slimevr.config + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +val jsonConfig = Json { + prettyPrint = true + ignoreUnknownKeys = true + encodeDefaults = true +} + +suspend fun atomicWriteFile(file: File, content: String) = withContext(Dispatchers.IO) { + file.parentFile?.mkdirs() + val tmp = File(file.parent, "${file.name}.tmp") + tmp.writeText(content) + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + Unit +} + +suspend inline fun loadFileWithBackup(file: File, default: T, crossinline deserialize: (String) -> T): T = + withContext(Dispatchers.IO) { + if (!file.exists()) { + atomicWriteFile(file, jsonConfig.encodeToString(default)) + return@withContext default + } + + try { + deserialize(file.readText()) + } catch (e: Exception) { + System.err.println("Failed to load ${file.absolutePath}: ${e.message}") + if (file.exists()) { + try { + val bakTmp = File(file.parent, "${file.name}.bak.tmp") + file.copyTo(bakTmp, overwrite = true) + Files.move( + bakTmp.toPath(), + File(file.parent, "${file.name}.bak").toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + } catch (e2: Exception) { + System.err.println("Failed to back up corrupted file: ${e2.message}") + } + } + default + } + } + +/** + * Launches a debounced autosave coroutine. Skips the initial state (already on + * disk at start time) and any state that was already successfully persisted. + * Cancel and restart to switch profiles. the new job treats the current state + * as already saved. + */ +@OptIn(FlowPreview::class) +fun launchAutosave( + scope: CoroutineScope, + state: StateFlow, + toFile: (S) -> File, + serialize: (S) -> String, +): Job { + var lastSaved = state.value + return merge(state.debounce(500L), state.sample(2000L)) + .distinctUntilChanged() + .filter { it != lastSaved } + .onEach { s -> + try { + val file = toFile(s) + atomicWriteFile(file, serialize(s)) + lastSaved = s + println("Saved ${file.absolutePath}") + } catch (e: Exception) { + System.err.println("Failed to save: ${e.message}") + } + } + .launchIn(scope) +} diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt new file mode 100644 index 0000000000..a85f5533bc --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -0,0 +1,101 @@ +package dev.slimevr.config + +import dev.slimevr.context.Context +import dev.slimevr.context.CustomModule +import dev.slimevr.context.createContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File + +private const val SETTINGS_CONFIG_VERSION = 1 + +@Serializable +data class SettingsConfigState( + val trackerPort: Int = 6969, + val version: Int = SETTINGS_CONFIG_VERSION, +) + +private fun migrateSettingsConfig(json: JsonObject): JsonObject { + val version = json["version"]?.jsonPrimitive?.intOrNull ?: 0 + return when { + // add migration branches here as: version < N -> migrateSettingsConfig(...) + else -> json + } +} + +private fun parseAndMigrateSettingsConfig(raw: String): SettingsConfigState { + val json = jsonConfig.parseToJsonElement(raw).jsonObject + return jsonConfig.decodeFromJsonElement(migrateSettingsConfig(json)) +} + +data class SettingsState( + val data: SettingsConfigState, + val name: String, +) + +sealed interface SettingsActions { + data class LoadProfile(val newState: SettingsState) : SettingsActions +} + +typealias SettingsContext = Context +typealias SettingsModule = CustomModule + +data class Settings( + val context: SettingsContext, + val configDir: String, + val swap: suspend (String) -> Unit, +) + +val DefaultSettingsModule = SettingsModule( + reducer = { s, a -> + when (a) { + is SettingsActions.LoadProfile -> a.newState + } + }, +) + +suspend fun createSettings(scope: CoroutineScope, configDir: File, name: String): Settings { + val settingsDir = File(configDir, "settings") + + val initialData = loadFileWithBackup(File(settingsDir, "$name.json"), SettingsConfigState()) { + parseAndMigrateSettingsConfig(it) + } + val initialState = SettingsState(name = name, data = initialData) + + val modules = listOf(DefaultSettingsModule) + val context = createContext( + initialState = initialState, + reducers = modules.map { it.reducer }, + scope = scope + ) + + fun startAutosave() = launchAutosave( + scope = scope, + state = context.state, + toFile = { state -> File(settingsDir, "${state.name}.json") }, + serialize = { state -> jsonConfig.encodeToString(state.data) }, + ) + + var autosaveJob: Job = startAutosave() + + val swap: suspend (String) -> Unit = { newName -> + autosaveJob.cancelAndJoin() + + val newData = loadFileWithBackup(File(settingsDir, "$newName.json"), SettingsConfigState()) { + parseAndMigrateSettingsConfig(it) + } + val newState = SettingsState(name = newName, data = newData) + context.dispatch(SettingsActions.LoadProfile(newState)) + + autosaveJob = startAutosave() + } + + return Settings(context, configDir = settingsDir.toString(), swap) +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/config/user.kt b/server/core/src/main/java/dev/slimevr/config/user.kt new file mode 100644 index 0000000000..ead6936adb --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/config/user.kt @@ -0,0 +1,103 @@ +package dev.slimevr.config + +import dev.slimevr.context.Context +import dev.slimevr.context.CustomModule +import dev.slimevr.context.createContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File + +private const val USER_CONFIG_VERSION = 1 + +@Serializable +data class UserConfigData( + val userHeight: Float = 1.6f, + val version: Int = USER_CONFIG_VERSION, +) + +private fun migrateUserConfig(json: JsonObject): JsonObject { + val version = json["version"]?.jsonPrimitive?.intOrNull ?: 0 + return when { + // add migration branches here as: version < N -> migrateUserConfig(...) + else -> json + } +} + +private fun parseAndMigrateUserConfig(raw: String): UserConfigData { + val json = jsonConfig.parseToJsonElement(raw).jsonObject + return jsonConfig.decodeFromJsonElement(migrateUserConfig(json)) +} + +data class UserConfigState( + val data: UserConfigData, + val name: String, +) + +sealed interface UserConfigActions { + data class Update(val transform: UserConfigState.() -> UserConfigState) : UserConfigActions + data class LoadProfile(val newState: UserConfigState) : UserConfigActions +} + +typealias UserConfigContext = Context +typealias UserConfigModule = CustomModule + +data class UserConfig( + val context: UserConfigContext, + val configDir: String, + val swap: suspend (String) -> Unit, +) + +val DefaultUserModule = UserConfigModule( + reducer = { s, a -> + when (a) { + is UserConfigActions.LoadProfile -> a.newState + else -> s + } + }, +) + +suspend fun createUserConfig(scope: CoroutineScope, configDir: File, name: String): UserConfig { + val userConfigDir = File(configDir, "user") + + val initialData = loadFileWithBackup(File(userConfigDir, "$name.json"), UserConfigData()) { + parseAndMigrateUserConfig(it) + } + val initialState = UserConfigState(name = name, data = initialData) + + val modules = listOf(DefaultUserModule) + val context = createContext( + initialState = initialState, + reducers = modules.map { it.reducer }, + scope = scope + ) + + fun startAutosave() = launchAutosave( + scope = scope, + state = context.state, + toFile = { state -> File(userConfigDir, "${state.name}.json") }, + serialize = { state -> jsonConfig.encodeToString(state.data) }, + ) + + var autosaveJob: Job = startAutosave() + + val swap: suspend (String) -> Unit = { newName -> + autosaveJob.cancelAndJoin() + + val newData = loadFileWithBackup(File(userConfigDir, "$newName.json"), UserConfigData()) { + parseAndMigrateUserConfig(it) + } + val newState = UserConfigState(name = newName, data = newData) + context.dispatch(UserConfigActions.LoadProfile(newState)) + + autosaveJob = startAutosave() + } + + return UserConfig(context, userConfigDir.toString(), swap) +} diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index 73cf82ebd9..3a208beab6 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -6,10 +6,20 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +interface Module { + val reducer: ((S, A) -> S)? + val observer: ((C) -> Unit)? +} + data class BasicModule( - val reducer: ((S, A) -> S)? = null, - val observer: ((Context) -> Unit)? = null, -) + override val reducer: ((S, A) -> S)? = null, + override val observer: ((Context) -> Unit)? = null, +) : Module> + +data class CustomModule( + override val reducer: ((S, A) -> S)? = null, + override val observer: ((C) -> Unit)? = null, +) : Module data class Context( val state: StateFlow, diff --git a/server/core/src/main/java/dev/slimevr/platform.kt b/server/core/src/main/java/dev/slimevr/platform.kt new file mode 100644 index 0000000000..67ba9f0b79 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/platform.kt @@ -0,0 +1,90 @@ +package io.eiren.util + +import java.io.File +import java.nio.file.Path +import java.util.* +import kotlin.io.path.Path +import kotlin.io.path.exists + +const val SLIMEVR_IDENTIFIER = "dev.slimevr.SlimeVR" + + +enum class Platform { + LINUX, WINDOWS, OSX, UNKNOWN +} + +val CURRENT_PLATFORM: Platform = detectPlatform() + +private fun detectPlatform(): Platform { + val os = System.getProperty("os.name").lowercase(Locale.getDefault()) + if (os.contains("win")) return Platform.WINDOWS + if (os.contains("mac") || os.contains("darwin")) return Platform.OSX + if (os.contains("linux") || os.contains("unix")) return Platform.LINUX + return Platform.UNKNOWN +} + +fun getJavaExecutable(forceConsole: Boolean): String { + val bin = System.getProperty("java.home") + File.separator + "bin" + File.separator + + if (CURRENT_PLATFORM == Platform.WINDOWS && !forceConsole) { + val javaw = bin + "javaw.exe" + if (File(javaw).isFile) return javaw + } + + if (CURRENT_PLATFORM == Platform.WINDOWS) return bin + "java.exe" + return bin + "java" +} + +fun getSocketDirectory(): String { + val envDir = System.getenv("SLIMEVR_SOCKET_DIR") + if (envDir != null) return envDir + + if (CURRENT_PLATFORM == Platform.LINUX) { + val xdg = System.getenv("XDG_RUNTIME_DIR") + if (xdg != null) return xdg + } + + return System.getProperty("java.io.tmpdir") +} + +fun resolveConfigDirectory(): Path? { + if (Path("config/").exists()) { // this is only for dev + return Path("config/") + } + + val home = System.getenv("HOME") + + return when (CURRENT_PLATFORM) { + Platform.WINDOWS -> { + val appData = System.getenv("AppData") + if (appData != null) Path(appData, SLIMEVR_IDENTIFIER) else null + } + Platform.LINUX -> { + val xdg = System.getenv("XDG_CONFIG_HOME") + if (xdg != null) Path(xdg, SLIMEVR_IDENTIFIER) + else if (home != null) Path(home, ".config", SLIMEVR_IDENTIFIER) + else null + } + Platform.OSX -> { + if (home != null) Path(home, "Library", "Application Support", SLIMEVR_IDENTIFIER) else null + } + else -> null + } +} + +fun resolveLogDirectory(): Path? { + val home = System.getenv("HOME") + val appData = System.getenv("AppData") + + return when (CURRENT_PLATFORM) { + Platform.WINDOWS -> if (appData != null) Path(appData, SLIMEVR_IDENTIFIER, "logs") else null + Platform.OSX -> if (home != null) Path(home, "Library", "Logs", SLIMEVR_IDENTIFIER) else null + Platform.LINUX -> { + val xdg = System.getenv("XDG_DATA_HOME") + if (xdg != null) Path(xdg, SLIMEVR_IDENTIFIER, "logs") + else if (home != null) Path(home, ".local", "share", SLIMEVR_IDENTIFIER, "logs") + else null + } + else -> null + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/skeleton.kt b/server/core/src/main/java/dev/slimevr/skeleton/skeleton.kt new file mode 100644 index 0000000000..5a01f49f6c --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/skeleton.kt @@ -0,0 +1,20 @@ +package dev.slimevr.skeleton + +import io.github.axisangles.ktmath.Quaternion +import solarxr_protocol.datatypes.BodyPart + + +data class Bone( + val bodyPart: BodyPart, + val localRotation: Quaternion, + val parentBone: Bone?, + val childBone: List +) { + val globalRotation: Quaternion + get() = localRotation //FIXME: do maths LMAO +} + +data class SkeletonState( + val bones: Map, + val rootBone: Bone, +) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt new file mode 100644 index 0000000000..59246f0316 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -0,0 +1,145 @@ +package dev.slimevr.solarxr + +import com.google.flatbuffers.FlatBufferBuilder +import dev.slimevr.VRServer +import dev.slimevr.tracker.DeviceState +import dev.slimevr.tracker.TrackerState +import io.ktor.util.moveToByteArray +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import solarxr_protocol.MessageBundle +import solarxr_protocol.data_feed.DataFeedConfig +import solarxr_protocol.data_feed.DataFeedMessageHeader +import solarxr_protocol.data_feed.DataFeedUpdate +import solarxr_protocol.data_feed.PollDataFeed +import solarxr_protocol.data_feed.StartDataFeed +import solarxr_protocol.data_feed.device_data.DeviceData +import solarxr_protocol.data_feed.tracker.TrackerData +import solarxr_protocol.data_feed.tracker.TrackerDataMask +import solarxr_protocol.data_feed.tracker.TrackerInfo +import solarxr_protocol.datatypes.DeviceId +import solarxr_protocol.datatypes.TrackerId +import solarxr_protocol.datatypes.hardware_info.HardwareStatus +import solarxr_protocol.datatypes.math.Quat +import kotlin.time.measureTime + +private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMask: TrackerDataMask): TrackerData = + TrackerData( + trackerId = TrackerId( + trackerNum = tracker.id.toUByte(), + deviceId = DeviceId(device.id.toUByte()) + ), + status = if (trackerMask.status == true) tracker.status else null, + rotation = if (trackerMask.rotation == true) tracker.rawRotation.let { Quat(it.x, it.y, it.z, it.w) } else null, + info = if (trackerMask.info == true) TrackerInfo( + imuType = tracker.sensorType, + bodyPart = tracker.bodyPart, + displayName = tracker.name, + ) else null + ) + +private fun createDevice( + device: DeviceState, + trackers: List, + datafeedConfig: DataFeedConfig +): DeviceData { + val trackerMask = datafeedConfig.dataMask?.trackerData; + + return DeviceData( + id = DeviceId(device.id.toUByte()), + hardwareStatus = HardwareStatus( + batteryVoltage = device.batteryVoltage, + batteryPctEstimate = device.batteryLevel.toUInt() + .toUByte(), + ping = device.ping?.toUShort() + ), + trackers = if (trackerMask != null) + trackers.filter { it.deviceId == device.id } + .map { tracker -> createTracker(device, tracker, trackerMask) } + else null + ) +} + +fun createDatafeedFrame( + serverContext: VRServer, + datafeedConfig: DataFeedConfig +): DataFeedMessageHeader { + val serverState = serverContext.context.state.value + val trackers = + serverState.trackers.values.map { it.context.state.value } + val devices = + serverState.devices.values.map { it.context.state.value } + .map { device -> createDevice(device, trackers, datafeedConfig) } + return DataFeedMessageHeader( + message = DataFeedUpdate( + devices = if (datafeedConfig.dataMask?.deviceData != null) devices else null + ) + ) +} + + +val DataFeedInitModule = SolarXRConnectionModule( + observer = { context -> + context.dataFeedDispatcher.on { event -> + val datafeeds = event.dataFeeds ?: return@on + val state = context.context.state.value + + state.datafeedTimers.forEach { + it.cancelAndJoin() + } + + val timers = datafeeds.map { config -> + val minTime = config.minimumTimeSinceLast ?: return@map null + + return@map context.context.scope.launch(start = CoroutineStart.LAZY) { + val fbb = FlatBufferBuilder(1024) + while (isActive) { + val timeTaken = measureTime { + fbb.clear() + + fbb.finish( + MessageBundle( + dataFeedMsgs = listOf( + createDatafeedFrame(serverContext = context.serverContext, datafeedConfig = config) + ) + ).encode(fbb) + ) + + context.send(fbb.dataBuffer().moveToByteArray()) + } + val remainingDelay = (minTime.toLong() - timeTaken.inWholeMilliseconds) + assert(remainingDelay > 0) + delay(remainingDelay) + } + } + }.filterNotNull() + + context.context.dispatch( + SolarXRConnectionActions.SetConfig( + datafeeds, + timers = timers + ) + ) + + timers.joinAll() + } + + context.dataFeedDispatcher.on { event -> + val config = event.config ?: return@on + + val fbb = FlatBufferBuilder(1024) + fbb.finish( + MessageBundle( + dataFeedMsgs = listOf( + createDatafeedFrame(serverContext = context.serverContext, datafeedConfig = config) + ) + ).encode(fbb) + ) + context.send(fbb.dataBuffer().moveToByteArray()) + } + } +) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt b/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt index 42c1fec333..b8817736ab 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt @@ -1,33 +1,18 @@ package dev.slimevr.solarxr -import com.google.flatbuffers.FlatBufferBuilder import dev.slimevr.VRServer import dev.slimevr.context.Context +import dev.slimevr.context.CustomModule import dev.slimevr.context.createContext -import io.ktor.util.moveToByteArray import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import solarxr_protocol.MessageBundle import solarxr_protocol.data_feed.DataFeedConfig import solarxr_protocol.data_feed.DataFeedMessage -import solarxr_protocol.data_feed.DataFeedMessageHeader -import solarxr_protocol.data_feed.DataFeedUpdate -import solarxr_protocol.data_feed.StartDataFeed -import solarxr_protocol.data_feed.device_data.DeviceData -import solarxr_protocol.data_feed.tracker.TrackerData -import solarxr_protocol.datatypes.DeviceId -import solarxr_protocol.datatypes.TrackerId -import solarxr_protocol.datatypes.hardware_info.HardwareStatus import solarxr_protocol.rpc.RpcMessage import kotlin.reflect.KClass -import kotlin.time.measureTime data class SolarXRConnectionState( val dataFeedConfigs: List, @@ -40,6 +25,8 @@ sealed interface SolarXRConnectionActions { } typealias SolarXRConnectionContext = Context +typealias SolarXRConnectionModule = CustomModule + class PacketDispatcher { val listeners = mutableMapOf, MutableList Unit>>() @@ -81,94 +68,6 @@ data class SolarXRConnection( val send: suspend (ByteArray) -> Unit ) -data class SolarXRConnectionModule( - val reducer: ((SolarXRConnectionState, SolarXRConnectionActions) -> SolarXRConnectionState)? = null, - val observer: ((SolarXRConnection) -> Unit)? = null, -) - - -val DataFeedInitModule = SolarXRConnectionModule( - observer = { context -> - context.dataFeedDispatcher.on { event -> - val datafeeds = event.dataFeeds ?: return@on - val state = context.context.state.value - - state.datafeedTimers.forEach { - it.cancelAndJoin() - } - - val timers = datafeeds.map { config -> - val minTime = config.minimumTimeSinceLast ?: return@map null - - return@map context.context.scope.launch { - val fbb = FlatBufferBuilder(1024) - while (isActive) { - val timeTaken = measureTime { - fbb.clear() - - val serverState = context.serverContext.context.state.value - val trackers = - serverState.trackers.values.map { it.context.state.value } - val devices = - serverState.devices.values.map { it.context.state.value } - .map { device -> - DeviceData( - id = DeviceId(device.id.toUByte()), - hardwareStatus = HardwareStatus( - batteryVoltage = device.batteryVoltage, - batteryPctEstimate = device.batteryLevel.toUInt() - .toUByte(), - ping = device.ping?.toUShort() - ), - trackers = trackers.filter { it.deviceId == device.id } - .map { tracker -> - TrackerData( - trackerId = TrackerId( - trackerNum = tracker.id.toUByte(), - deviceId = DeviceId(device.id.toUByte()) - ), - status = tracker.status - ) - } - ) - } - - - fbb.finish( - MessageBundle( - dataFeedMsgs = listOf( - DataFeedMessageHeader( - message = DataFeedUpdate( - devices = devices - ) - ) - ) - ).encode(fbb) - ) - - context.send(fbb.dataBuffer().moveToByteArray()) - } - val remainingDelay = - (minTime.toLong() - timeTaken.inWholeMilliseconds).coerceAtLeast( - 0 - ) - delay(remainingDelay) - } - } - }.filterNotNull() - - context.context.dispatch( - SolarXRConnectionActions.SetConfig( - datafeeds, - timers = timers - ) - ) - - timers.forEach { it.start() } - } - } -) - fun createSolarXRConnection( serverContext: VRServer, onSend: suspend (ByteArray) -> Unit, diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt index 1bc5896239..94dc62573f 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt @@ -4,7 +4,11 @@ import dev.slimevr.AppLogger import dev.slimevr.VRServer import dev.slimevr.VRServerActions import dev.slimevr.context.Context +import dev.slimevr.context.CustomModule import dev.slimevr.context.createContext +import dev.slimevr.solarxr.SolarXRConnection +import dev.slimevr.solarxr.SolarXRConnectionActions +import dev.slimevr.solarxr.SolarXRConnectionState import dev.slimevr.tracker.Device import dev.slimevr.tracker.DeviceActions import dev.slimevr.tracker.DeviceOrigin @@ -50,6 +54,8 @@ sealed interface UDPConnectionActions { } typealias UDPConnectionContext = Context +typealias UDPConnectionModule = CustomModule + data class UDPConnection( val context: UDPConnectionContext, @@ -60,12 +66,6 @@ data class UDPConnection( val getTracker: (sensorId: Int) -> Tracker?, ) -data class UDPConnectionModule( - val reducer: ((UDPConnectionState, UDPConnectionActions) -> UDPConnectionState)? = null, - val observer: ((UDPConnection) -> Unit)? = null, -) - - val PacketModule = UDPConnectionModule( reducer = { s, a -> when (a) { diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt index 5c6db82438..49fd00783a 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt @@ -1,15 +1,11 @@ package dev.slimevr.tracker.udp import dev.slimevr.VRServer -import dev.slimevr.VRServerContext -import dev.slimevr.config.ConfigContext +import dev.slimevr.config.AppConfig import io.ktor.network.selector.SelectorManager -import io.ktor.network.sockets.BoundDatagramSocket -import io.ktor.network.sockets.Datagram import io.ktor.network.sockets.InetSocketAddress import io.ktor.network.sockets.aSocket import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope @@ -21,10 +17,10 @@ data class UDPTrackerServerState( suspend fun createUDPTrackerServer( serverContext: VRServer, - configContext: ConfigContext, + configContext: AppConfig, ): UDPTrackerServerState { val state = UDPTrackerServerState( - port = configContext.state.value.settingsConfig.trackerPort, + port = configContext.settings.context.state.value.data.trackerPort, connections = mutableMapOf(), ) diff --git a/server/desktop/.gitignore b/server/desktop/.gitignore index 796b96d1c4..d18cf6eed8 100644 --- a/server/desktop/.gitignore +++ b/server/desktop/.gitignore @@ -1 +1,3 @@ /build +config/* +!config/README.md 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 ba14c6999e..573491a368 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -3,14 +3,16 @@ package dev.slimevr.desktop import dev.slimevr.VRServer -import dev.slimevr.config.createConfig +import dev.slimevr.config.createAppConfig import dev.slimevr.solarxr.createSolarXRWebsocketServer import dev.slimevr.tracker.udp.createUDPTrackerServer +import io.eiren.util.resolveConfigDirectory import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking fun main(args: Array) = runBlocking { - val config = createConfig(this) + val configFolder = resolveConfigDirectory() ?: error("Unable to resolve config folder") + val config = createAppConfig(this, configFolder = configFolder.toFile()) val server = VRServer.create(this) launch { diff --git a/solarxr-protocol b/solarxr-protocol index 84fc97ff30..f784b4bb03 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 84fc97ff306f2d60e8f4f34ac6d2374d2587bcfc +Subproject commit f784b4bb034b1c588e9e0cefdd909c7ee5844dfc From af4665e7c943cd567e57e92dab144124605fd4f8 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 24 Mar 2026 01:21:47 +0100 Subject: [PATCH 009/350] IPC interface for linux (tested) and windows (untested) - Solarxr / driver --- gradle.properties | 1 + .../main/java/dev/slimevr/config/settings.kt | 5 +- .../src/main/java/dev/slimevr/config/user.kt | 2 + .../src/main/java/dev/slimevr/platform.kt | 2 +- .../solarxr/{server.kt => ws-server.kt} | 12 +- server/desktop/build.gradle.kts | 18 ++ .../src/main/java/dev/slimevr/desktop/Main.kt | 4 +- .../main/java/dev/slimevr/desktop/ipc/ipc.kt | 31 ++++ .../java/dev/slimevr/desktop/ipc/linux.kt | 95 +++++++++++ .../java/dev/slimevr/desktop/ipc/protocol.kt | 156 ++++++++++++++++++ .../java/dev/slimevr/desktop/ipc/windows.kt | 126 ++++++++++++++ settings.gradle.kts | 2 + 12 files changed, 446 insertions(+), 8 deletions(-) rename server/core/src/main/java/dev/slimevr/solarxr/{server.kt => ws-server.kt} (88%) create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt diff --git a/gradle.properties b/gradle.properties index 5d83448471..9be6eddd32 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,3 +20,4 @@ buildconfigVersion=6.0.7 # We should probably stop using grgit, see: # https://andrewoberstar.com/posts/2024-04-02-dont-commit-to-grgit/ grgitVersion=5.3.3 +wireVersion=5.3.1 diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index a85f5533bc..e20796da9b 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -41,6 +41,7 @@ data class SettingsState( ) sealed interface SettingsActions { + data class Update(val transform: SettingsState.() -> SettingsState) : SettingsActions data class LoadProfile(val newState: SettingsState) : SettingsActions } @@ -56,7 +57,9 @@ data class Settings( val DefaultSettingsModule = SettingsModule( reducer = { s, a -> when (a) { + is SettingsActions.Update -> a.transform(s) is SettingsActions.LoadProfile -> a.newState + else -> s } }, ) @@ -98,4 +101,4 @@ suspend fun createSettings(scope: CoroutineScope, configDir: File, name: String) } return Settings(context, configDir = settingsDir.toString(), swap) -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/config/user.kt b/server/core/src/main/java/dev/slimevr/config/user.kt index ead6936adb..e9a4c41d90 100644 --- a/server/core/src/main/java/dev/slimevr/config/user.kt +++ b/server/core/src/main/java/dev/slimevr/config/user.kt @@ -3,6 +3,7 @@ package dev.slimevr.config import dev.slimevr.context.Context import dev.slimevr.context.CustomModule import dev.slimevr.context.createContext +import dev.slimevr.tracker.DeviceActions import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -57,6 +58,7 @@ data class UserConfig( val DefaultUserModule = UserConfigModule( reducer = { s, a -> when (a) { + is UserConfigActions.Update -> a.transform(s) is UserConfigActions.LoadProfile -> a.newState else -> s } diff --git a/server/core/src/main/java/dev/slimevr/platform.kt b/server/core/src/main/java/dev/slimevr/platform.kt index 67ba9f0b79..b741df3567 100644 --- a/server/core/src/main/java/dev/slimevr/platform.kt +++ b/server/core/src/main/java/dev/slimevr/platform.kt @@ -1,4 +1,4 @@ -package io.eiren.util +package dev.slimevr import java.io.File import java.nio.file.Path diff --git a/server/core/src/main/java/dev/slimevr/solarxr/server.kt b/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt similarity index 88% rename from server/core/src/main/java/dev/slimevr/solarxr/server.kt rename to server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt index 984cf77427..c83d146d78 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/server.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt @@ -8,13 +8,9 @@ import io.ktor.server.netty.Netty import io.ktor.server.routing.routing import io.ktor.server.websocket.* import io.ktor.websocket.Frame -import io.ktor.websocket.readBytes -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import solarxr_protocol.MessageBundle +import solarxr_protocol.rpc.ResetRequest import java.nio.ByteBuffer -import kotlin.reflect.KClass const val SOLARXR_PORT = 21110 @@ -27,10 +23,16 @@ suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRConnection) { context.dataFeedDispatcher.emit(msg) } + // FIXME: temporary test + context.rpcDispatcher.on { + println("RESET $it") + } + messageBundle.rpcMsgs?.forEach { val msg = it.message ?: return; context.rpcDispatcher.emit(msg) } + } diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index 38eea50a4a..db22322f92 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -14,6 +14,7 @@ plugins { id("com.gradleup.shadow") id("com.github.gmazzo.buildconfig") id("org.ajoberstar.grgit") + id("com.squareup.wire") } kotlin { @@ -54,6 +55,23 @@ allprojects { } } +val downloadDriverProto by tasks.registering { + val protoFile = layout.buildDirectory.file("proto/ProtobufMessages.proto") + outputs.file(protoFile) + doLast { + val url = "https://raw.githubusercontent.com/SlimeVR/SlimeVR-OpenVR-Driver/main/src/bridge/ProtobufMessages.proto" + protoFile.get().asFile.parentFile.mkdirs() + uri(url).toURL().openStream().use { it.copyTo(protoFile.get().asFile.outputStream()) } + } +} + +wire { + sourcePath { + srcDir(downloadDriverProto.map { layout.buildDirectory.dir("proto") }) + } + kotlin { } +} + dependencies { implementation(project(":server:core")) implementation(project(":solarxr-protocol")) 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 573491a368..e08970964f 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -4,9 +4,10 @@ package dev.slimevr.desktop import dev.slimevr.VRServer import dev.slimevr.config.createAppConfig +import dev.slimevr.desktop.ipc.createIpcServers import dev.slimevr.solarxr.createSolarXRWebsocketServer import dev.slimevr.tracker.udp.createUDPTrackerServer -import io.eiren.util.resolveConfigDirectory +import dev.slimevr.resolveConfigDirectory import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -21,5 +22,6 @@ fun main(args: Array) = runBlocking { launch { createSolarXRWebsocketServer(server) } + createIpcServers(server) Unit } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt new file mode 100644 index 0000000000..81649da94b --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt @@ -0,0 +1,31 @@ +package dev.slimevr.desktop.ipc + +import dev.slimevr.VRServer +import dev.slimevr.CURRENT_PLATFORM +import dev.slimevr.Platform +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +const val DRIVER_SOCKET_NAME = "SlimeVRDriver" +const val FEEDER_SOCKET_NAME = "SlimeVRInput" +const val SOLARXR_SOCKET_NAME = "SlimeVRRpc" + +const val DRIVER_PIPE = "\\\\.\\pipe\\SlimeVRDriver" +const val FEEDER_PIPE = "\\\\.\\pipe\\SlimeVRInput" +const val SOLARXR_PIPE = "\\\\.\\pipe\\SlimeVRRpc" + +suspend fun createIpcServers(server: VRServer) = coroutineScope { + when (CURRENT_PLATFORM) { + Platform.LINUX, Platform.OSX -> { + launch { createUnixDriverSocket(server) } + launch { createUnixFeederSocket(server) } + launch { createUnixSolarXRSocket(server) } + } + Platform.WINDOWS -> { + launch { createWindowsDriverPipe(server) } + launch { createWindowsFeederPipe(server) } + launch { createWindowsSolarXRPipe(server) } + } + else -> Unit + } +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt new file mode 100644 index 0000000000..73467db7ce --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -0,0 +1,95 @@ +package dev.slimevr.desktop.ipc + +import dev.slimevr.VRServer +import dev.slimevr.getSocketDirectory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.StandardProtocolFamily +import java.net.UnixDomainSocketAddress +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.channels.ServerSocketChannel +import java.nio.channels.SocketChannel +import kotlin.io.path.Path + +suspend fun createUnixDriverSocket(server: VRServer) = + acceptUnixClients(DRIVER_SOCKET_NAME) { channel -> + handleDriverConnection( + server = server, + messages = readFramedMessages(channel), + send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, + ) + } + +suspend fun createUnixFeederSocket(server: VRServer) = + acceptUnixClients(FEEDER_SOCKET_NAME) { channel -> + handleFeederConnection( + server = server, + messages = readFramedMessages(channel), + send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, + ) + } + +suspend fun createUnixSolarXRSocket(server: VRServer) = + acceptUnixClients(SOLARXR_SOCKET_NAME) { channel -> + handleSolarXRConnection( + server = server, + messages = readFramedMessages(channel), + send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, + ) + } + +private fun isSocketInUse(socketPath: String): Boolean = try { + SocketChannel.open(StandardProtocolFamily.UNIX).use { + it.connect(UnixDomainSocketAddress.of(socketPath)) + true + } +} catch (_: Exception) { false } + +// Length field is LE u32 and includes the 4-byte header itself +private fun readFramedMessages(channel: SocketChannel) = flow { + val lenBuf = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN) + while (true) { + lenBuf.clear() + if (channel.read(lenBuf) == -1) break + lenBuf.flip() + + val dataBuf = ByteBuffer.allocate(lenBuf.int - 4) + while (dataBuf.hasRemaining()) { + if (channel.read(dataBuf) == -1) break + } + emit(dataBuf.array()) + } +}.flowOn(Dispatchers.IO) + +private fun writeFramed(channel: SocketChannel, bytes: ByteArray) { + val header = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(bytes.size + 4).flip() + channel.write(arrayOf(header, ByteBuffer.wrap(bytes))) +} + +private suspend fun acceptUnixClients( + name: String, + handle: suspend (SocketChannel) -> Unit, +) = withContext(Dispatchers.IO) { + val path = Path(getSocketDirectory(), name) + val file = path.toFile() + if (file.exists()) { + check(!isSocketInUse(path.toString())) { + "$name socket is already in use by another process" + } + file.delete() + } + file.deleteOnExit() + + ServerSocketChannel.open(StandardProtocolFamily.UNIX).use { server -> + server.bind(UnixDomainSocketAddress.of(path)) + while (isActive) { + val client = server.accept() + launch { handle(client) } + } + } +} \ No newline at end of file diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt new file mode 100644 index 0000000000..04e6c1c576 --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -0,0 +1,156 @@ +package dev.slimevr.desktop.ipc + +import dev.slimevr.VRServer +import dev.slimevr.VRServerActions +import dev.slimevr.desktop.platform.Position +import dev.slimevr.desktop.platform.ProtobufMessage +import dev.slimevr.desktop.platform.TrackerAdded +import dev.slimevr.desktop.platform.Version +import dev.slimevr.solarxr.createSolarXRConnection +import dev.slimevr.solarxr.onSolarXRMessage +import dev.slimevr.tracker.DeviceOrigin +import dev.slimevr.tracker.TrackerActions +import dev.slimevr.tracker.createDevice +import dev.slimevr.tracker.createTracker +import io.github.axisangles.ktmath.Quaternion +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import solarxr_protocol.datatypes.hardware_info.ImuType +import java.nio.ByteBuffer + +const val PROTOCOL_VERSION = 5 + +suspend fun handleDriverConnection( + server: VRServer, + messages: Flow, + send: suspend (ByteArray) -> Unit, +) = coroutineScope { + val sendMutex = Mutex() + + suspend fun sendMsg(msg: ProtobufMessage) = sendMutex.withLock { + send(ProtobufMessage.ADAPTER.encode(msg)) + } + + sendMsg(ProtobufMessage(version = Version(protocol_version = PROTOCOL_VERSION))) + + val subscribedTrackers = mutableSetOf() + + val observerJob = launch { + server.context.state.collect { state -> + state.trackers.values.forEach { tracker -> + val trackerState = tracker.context.state.value + if (trackerState.origin == DeviceOrigin.DRIVER) return@forEach + if (subscribedTrackers.add(trackerState.id)) { + sendMsg( + ProtobufMessage( + tracker_added = TrackerAdded( + tracker_id = trackerState.id, + tracker_serial = trackerState.hardwareId, + tracker_name = trackerState.customName ?: trackerState.name, + ), + ), + ) + launch { + tracker.context.state.collect { ts -> + sendMsg( + ProtobufMessage( + position = Position( + tracker_id = ts.id, + qx = ts.rawRotation.x, + qy = ts.rawRotation.y, + qz = ts.rawRotation.z, + qw = ts.rawRotation.w, + ), + ), + ) + } + } + } + } + } + } + + try { + messages.collect { bytes -> + val msg = ProtobufMessage.ADAPTER.decode(bytes) + msg.user_action?.let { + // TODO: dispatch user actions (reset, etc.) to VRServer + } + msg.version?.let { + // TODO: store remote protocol version if needed + } + } + } finally { + observerJob.cancel() + } +} + +suspend fun handleFeederConnection( + server: VRServer, + messages: Flow, + send: suspend (ByteArray) -> Unit, +) = coroutineScope { + send(ProtobufMessage.ADAPTER.encode(ProtobufMessage(version = Version(protocol_version = PROTOCOL_VERSION)))) + + messages.collect { bytes -> + val msg = ProtobufMessage.ADAPTER.decode(bytes) + + if (msg.tracker_added != null) { + val deviceId = server.nextHandle() + val device = createDevice( + scope = this, + id = deviceId, + address = msg.tracker_added.tracker_serial, + origin = DeviceOrigin.FEEDER, + serverContext = server, + ) + server.context.dispatch(VRServerActions.NewDevice(deviceId, device)) + + val trackerId = server.nextHandle() + val tracker = createTracker( + scope = this, + id = trackerId, + deviceId = deviceId, + sensorType = ImuType.MPU9250, + hardwareId = msg.tracker_added.tracker_serial, + origin = DeviceOrigin.FEEDER, + serverContext = server, + ) + server.context.dispatch(VRServerActions.NewTracker(trackerId, tracker)) + } + + if (msg.position != null) { + server.getTracker(msg.position.tracker_id)?.context?.dispatch( + TrackerActions.Update { + copy( + rawRotation = Quaternion( + w = msg.position.qw, + x = msg.position.qx, + y = msg.position.qy, + z = msg.position.qz, + ), + ) + }, + ) + } + } +} + +suspend fun handleSolarXRConnection( + server: VRServer, + messages: Flow, + send: suspend (ByteArray) -> Unit, +) = coroutineScope { + val connection = createSolarXRConnection( + serverContext = server, + scope = this, + onSend = send, + ) + + messages.collect { bytes -> + onSolarXRMessage(ByteBuffer.wrap(bytes), connection) + } +} \ No newline at end of file diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt new file mode 100644 index 0000000000..5622cd6e94 --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt @@ -0,0 +1,126 @@ +package dev.slimevr.desktop.ipc + +import com.sun.jna.platform.win32.Advapi32 +import com.sun.jna.platform.win32.Kernel32 +import com.sun.jna.platform.win32.WinBase +import com.sun.jna.platform.win32.WinError +import com.sun.jna.platform.win32.WinNT +import com.sun.jna.ptr.IntByReference +import dev.slimevr.VRServer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.nio.ByteBuffer +import java.nio.ByteOrder + +private val k32 = Kernel32.INSTANCE +private val adv32 = Advapi32.INSTANCE + +suspend fun createWindowsDriverPipe(server: VRServer) = + acceptWindowsClients(DRIVER_PIPE) { handle -> + handleDriverConnection( + server = server, + messages = readFramedMessages(handle), + send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, + ) + } + +suspend fun createWindowsFeederPipe(server: VRServer) = + acceptWindowsClients(FEEDER_PIPE) { handle -> + handleFeederConnection( + server = server, + messages = readFramedMessages(handle), + send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, + ) + } + +suspend fun createWindowsSolarXRPipe(server: VRServer) = + acceptWindowsClients(SOLARXR_PIPE) { handle -> + handleSolarXRConnection( + server = server, + messages = readFramedMessages(handle), + send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, + ) + } + +// Length field is LE u32 and includes the 4-byte header itself +private fun readFramedMessages(handle: WinNT.HANDLE) = flow { + val lenBuf = ByteArray(4) + while (true) { + if (!readExact(handle, lenBuf, 4)) break + val totalLen = ByteBuffer.wrap(lenBuf).order(ByteOrder.LITTLE_ENDIAN).int + + val dataBuf = ByteArray(totalLen - 4) + if (!readExact(handle, dataBuf, totalLen - 4)) break + emit(dataBuf) + } +}.flowOn(Dispatchers.IO) + +private fun readExact(handle: WinNT.HANDLE, buf: ByteArray, len: Int): Boolean { + var offset = 0 + val bytesRead = IntByReference() + while (offset < len) { + val ok = k32.ReadFile(handle, buf, len - offset, bytesRead, null) + if (!ok || bytesRead.value == 0) return false + offset += bytesRead.value + } + return true +} + +private fun writeFramedPipe(handle: WinNT.HANDLE, bytes: ByteArray) { + val buf = ByteArray(bytes.size + 4) + ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN).putInt(bytes.size + 4) + bytes.copyInto(buf, destinationOffset = 4) + k32.WriteFile(handle, buf, buf.size, IntByReference(), null) +} + +private fun createSecurePipe(pipeName: String): WinNT.HANDLE { + // Null DACL allows any process (including SteamVR driver) to connect + val descriptor = WinNT.SECURITY_DESCRIPTOR(64 * 1024) + adv32.InitializeSecurityDescriptor(descriptor, WinNT.SECURITY_DESCRIPTOR_REVISION) + adv32.SetSecurityDescriptorDacl(descriptor, true, null, false) + + val attributes = WinBase.SECURITY_ATTRIBUTES() + attributes.lpSecurityDescriptor = descriptor.pointer + attributes.bInheritHandle = false + + val pipe = k32.CreateNamedPipe( + pipeName, + WinBase.PIPE_ACCESS_DUPLEX, + WinBase.PIPE_TYPE_BYTE or WinBase.PIPE_READMODE_BYTE or WinBase.PIPE_WAIT, + WinBase.PIPE_UNLIMITED_INSTANCES, + 65536, 65536, 0, attributes, + ) + check(pipe != WinNT.INVALID_HANDLE_VALUE) { + "CreateNamedPipe failed for $pipeName: ${k32.GetLastError()}" + } + return pipe +} + +private suspend fun acceptWindowsClients( + pipeName: String, + handle: suspend (WinNT.HANDLE) -> Unit, +) = withContext(Dispatchers.IO) { + while (isActive) { + val pipe = createSecurePipe(pipeName) + + val ok = k32.ConnectNamedPipe(pipe, null) + val err = k32.GetLastError() + if (!ok && err != WinError.ERROR_PIPE_CONNECTED) { + k32.CloseHandle(pipe) + continue + } + + launch { + try { + handle(pipe) + } finally { + k32.DisconnectNamedPipe(pipe) + k32.CloseHandle(pipe) + } + } + } +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index a87de8acd5..7c7f9abf25 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -21,6 +21,7 @@ pluginManagement { val shadowJarVersion: String by settings val buildconfigVersion: String by settings val grgitVersion: String by settings + val wireVersion: String by settings plugins { kotlin("plugin.serialization") version kotlinVersion kotlin("jvm") version kotlinVersion @@ -29,6 +30,7 @@ pluginManagement { id("com.gradleup.shadow") version shadowJarVersion id("com.github.gmazzo.buildconfig") version buildconfigVersion id("org.ajoberstar.grgit") version grgitVersion + id("com.squareup.wire") version wireVersion } } From e37a37bcf9fec3bf404b4bb6a1a5334dc4043347 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 24 Mar 2026 02:17:22 +0100 Subject: [PATCH 010/350] Small optimisations / fixes to make sure couroutines do well and prevent dataraces --- .../main/java/dev/slimevr/solarxr/solarxr.kt | 14 ++++---------- .../src/main/java/dev/slimevr/vrserver.kt | 19 +++++++------------ .../java/dev/slimevr/desktop/ipc/protocol.kt | 10 ++++++++-- 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt b/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt index b8817736ab..857d7a3a9c 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt @@ -6,7 +6,6 @@ import dev.slimevr.context.CustomModule import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import solarxr_protocol.data_feed.DataFeedConfig @@ -35,19 +34,14 @@ class PacketDispatcher { @Suppress("UNCHECKED_CAST") inline fun on(crossinline callback: suspend (P) -> Unit) { - runBlocking { - mutex.withLock { - val list = - listeners.getOrPut(P::class as KClass) { mutableListOf() } - list.add { callback(it as P) } - } + synchronized(this) { + listeners.getOrPut(P::class as KClass) { mutableListOf() } + .add { callback(it as P) } } } fun onAny(callback: suspend (T) -> Unit) { - runBlocking { - mutex.withLock { globalListeners.add(callback) } - } + synchronized(this) { globalListeners.add(callback) } } suspend fun emit(event: T) { diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index 503b9597b8..18e98301d7 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -9,9 +9,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import java.util.concurrent.atomic.AtomicInteger data class VRServerState( - val handleId: Int, val trackers: Map, val devices: Map, ) @@ -27,15 +27,8 @@ typealias VRServerModule = BasicModule val BaseModule = VRServerModule( reducer = { s, a -> when (a) { - is VRServerActions.NewTracker -> s.copy( - trackers = s.trackers + (a.trackerId to a.context), - handleId = a.trackerId, - ) - - is VRServerActions.NewDevice -> s.copy( - devices = s.devices + (a.deviceId to a.context), - handleId = a.deviceId, - ) + is VRServerActions.NewTracker -> s.copy(trackers = s.trackers + (a.trackerId to a.context)) + is VRServerActions.NewDevice -> s.copy(devices = s.devices + (a.deviceId to a.context)) } }, observer = { context -> @@ -47,15 +40,16 @@ val BaseModule = VRServerModule( data class VRServer( val context: VRServerContext, + // Moved this outside of the context to make this faster and safer to use + private val handleCounter: AtomicInteger, ) { - fun nextHandle() = context.state.value.handleId + 1 + fun nextHandle() = handleCounter.incrementAndGet() fun getTracker(id: Int) = context.state.value.trackers[id] fun getDevice(id: Int) = context.state.value.devices[id] companion object { fun create(scope: CoroutineScope): VRServer { val server = VRServerState( - handleId = 0, trackers = mapOf(), devices = mapOf(), ) @@ -72,6 +66,7 @@ data class VRServer( return VRServer( context = context, + handleCounter = AtomicInteger(0), ) } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 04e6c1c576..fedb11bb0a 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -15,6 +15,9 @@ import dev.slimevr.tracker.createTracker import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -36,6 +39,8 @@ suspend fun handleDriverConnection( sendMsg(ProtobufMessage(version = Version(protocol_version = PROTOCOL_VERSION))) + // Should be safe, only accessed inside state.collect below. StateFlow never delivers + // two emissions concurrently to the same collector. val subscribedTrackers = mutableSetOf() val observerJob = launch { @@ -114,7 +119,7 @@ suspend fun handleFeederConnection( scope = this, id = trackerId, deviceId = deviceId, - sensorType = ImuType.MPU9250, + sensorType = ImuType.MPU9250, // TODO: prob need to make sensor type optional hardwareId = msg.tracker_added.tracker_serial, origin = DeviceOrigin.FEEDER, serverContext = server, @@ -132,6 +137,7 @@ suspend fun handleFeederConnection( y = msg.position.qy, z = msg.position.qz, ), + // TODO: add position data ) }, ) @@ -153,4 +159,4 @@ suspend fun handleSolarXRConnection( messages.collect { bytes -> onSolarXRMessage(ByteBuffer.wrap(bytes), connection) } -} \ No newline at end of file +} From 2235b8178bc525fd48605e2407e6c924449f6563 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Tue, 24 Mar 2026 15:40:54 -0500 Subject: [PATCH 011/350] Join datafeed timers on separate coroutine --- server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 59246f0316..acf2e71782 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -125,7 +125,9 @@ val DataFeedInitModule = SolarXRConnectionModule( ) ) - timers.joinAll() + context.context.scope.launch { + timers.joinAll() + } } context.dataFeedDispatcher.on { event -> From ad9bc911b62b518ac1f5b34d544d6294a1dbfec2 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 25 Mar 2026 00:30:34 +0100 Subject: [PATCH 012/350] Basic codestyle doc + rename stuff to match it --- CONTRIBUTING.md | 2 + README.md | 2 + server/README.md | 257 ++++++++++++++++++ .../main/java/dev/slimevr/context/context.kt | 10 +- .../main/java/dev/slimevr/solarxr/datafeed.kt | 2 +- .../main/java/dev/slimevr/solarxr/solarxr.kt | 10 +- .../main/java/dev/slimevr/tracker/device.kt | 12 +- .../main/java/dev/slimevr/tracker/tracker.kt | 12 +- .../dev/slimevr/tracker/udp/connection.kt | 34 +-- .../src/main/java/dev/slimevr/vrserver.kt | 12 +- .../src/main/java/dev/slimevr/desktop/Main.kt | 4 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 24 +- 12 files changed, 321 insertions(+), 60 deletions(-) create mode 100644 server/README.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64c75649dc..67c79f01f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,8 @@ Now you can open the codebase in [IDEA](https://www.jetbrains.com/idea/download/ ### Java (server) +Before contributing to the server, read [server/README.md](server/README.md) for an overview of its architecture and design guidelines. + The Java code is built with `gradle`, a CLI tool that manages java projects and their dependencies. - You can run the server by running `./gradlew run` in your IDE's terminal. diff --git a/README.md b/README.md index 409b21e1b3..fd5f0655ea 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Latest setup instructions are [in our docs](https://docs.slimevr.dev/server/inde ## Building & Contributing For information on building and contributing to the codebase, see [CONTRIBUTING.md](CONTRIBUTING.md). +For an overview of the server architecture and design guidelines, see [server/README.md](server/README.md). + ## Translating Translation is done via Pontoon at [i18n.slimevr.dev](https://i18n.slimevr.dev/). Please join our [Discord translation forum](https://discord.com/channels/817184208525983775/1050413434249949235) to coordinate. diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000000..5c9b347c68 --- /dev/null +++ b/server/README.md @@ -0,0 +1,257 @@ +# SlimeVR Server — Design Guidelines + +This document explains the architectural choices made in the server rewrite and how to extend the system correctly. + +--- + +## Core Principle: Reducers and State + +Every major part of this server — a tracker, a device, a UDP connection, a SolarXR session — manages state the same way: immutable data, typed actions, and pure reducer functions that transform one into the other. + +This is not accidental. It gives us: +- **Predictability**: state only changes through known, enumerated actions +- **Observability**: any code can `collect` the `StateFlow` and react to changes +- **Concurrency safety**: `StateFlow.update` is atomic; two concurrent dispatches never corrupt state + +--- + +## The Context System + +The `Context` type (`context/context.kt`) is the building block of every module: + +```kotlin +data class Context( + val state: StateFlow, // current state, readable by anyone + val dispatch: suspend (A) -> Unit, + val dispatchAll: suspend (List) -> Unit, + val scope: CoroutineScope, // lifetime of this module +) +``` + +`createContext` wires everything together: +1. Takes an `initialState` and a list of **reducers** (`(S, A) -> S`) +2. On each `dispatch`, folds all reducers over the current state +3. Publishes the new state on the `StateFlow` + +**Never mutate state directly.** Always go through `dispatch`. + +--- + +## Behaviours: Splitting Concerns + +A `Behaviour` bundles two optional pieces: + +```kotlin +data class BasicBehaviour( + val reducer: ((S, A) -> S)? = null, + val observer: ((Context) -> Unit)? = null, +) +``` + +- **`reducer`**: Pure function. Handles the actions it cares about, returns the rest unchanged. +- **`observer`**: Called once at construction. Launches coroutines, registers event listeners, subscribes to other state flows. + +Use `CustomBehaviour` when the observer needs access to more than just the context — for example, a `UDPConnection` behaviour needs the connection's `send` function and `PacketDispatcher`, not just its `Context`. + +Every module follows the same construction pattern: + +```kotlin +val behaviours = listOf(BehaviourA, BehaviourB, BehaviourC) + +val context = createContext( + initialState = ..., + reducers = behaviours.map { it.reducer }, + scope = scope, +) + +behaviours.map { it.observer }.forEach { it?.invoke(context) } +``` + +This is where observers are started. Order matters for reducers (applied top-to-bottom), but rarely matters for observers. + +--- + +## Actions + +Actions are `sealed interface`s with `data class` variants. This means: + +- The compiler enforces exhaustive `when` expressions in reducers +- No stringly-typed dispatch +- Refactors are caught at compile time + +Use `data class Update(val transform: State.() -> State)` when you need a flexible "update anything" action (see `TrackerActions`, `DeviceActions`). Use specific named actions when the action has semantic meaning that other behaviours need to pattern-match on (see `UDPConnectionActions.Handshake`). + +--- + +## The PacketDispatcher Pattern + +`PacketDispatcher` (`solarxr/solarxr.kt`) routes incoming messages to typed listeners without a giant `when` block: + +```kotlin +dispatcher.on { packet -> /* only called for SensorInfo */ } +dispatcher.onAny { packet -> /* called for everything */ } +dispatcher.emit(packet) // routes to correct listeners +``` + +Use this wherever you have a stream of heterogeneous messages (UDP packets, SolarXR messages). Each behaviour registers its own listener in its `observer` — the dispatcher is passed as part of the module struct. + +--- + +## Coroutines and Lifetime + +- Every module is given a `CoroutineScope` at creation. Cancelling that scope tears down all coroutines the module launched. +- Observers should use `it.context.scope.launch { ... }` so their work is scoped to the module. +- Blocking I/O goes on `Dispatchers.IO`. State updates and logic stay on the default dispatcher. +- **Avoid `runBlocking`** inside observers or handlers — it blocks the coroutine thread. The one acceptable use is synchronous listener registration before a scope is started. + +--- + +## State vs. Out-of-Band Data + +Not everything belongs in `StateFlow`. Two good examples: + +- `VRServer.handleCounter` is an `AtomicInteger` — not in state — because nothing needs to react to it changing, and `incrementAndGet()` is faster and simpler than a dispatch round-trip. +- `UDPTrackerServer` has no `Context` at all. Its connection map is a plain `MutableMap` internal to the server loop. Nothing outside the loop reads it, so there is no reason to wrap it in a state machine. + +Rule of thumb: put data in state if **any other code needs to react to it changing**. If it's purely an implementation detail owned by one place, keep it plain. + +--- + +## Adding a New Module + +To add a new major section of the server (say, a HID device connection): + +1. **Define the state**: + ```kotlin + data class HIDConnectionState( + val deviceId: Int?, + val connected: Boolean, + ) + ``` + +2. **Define sealed actions**: + ```kotlin + sealed interface HIDConnectionActions { + data class Connected(val deviceId: Int) : HIDConnectionActions + data object Disconnected : HIDConnectionActions + } + ``` + +3. **Create type aliases** (keeps signatures readable): + ```kotlin + typealias HIDConnectionContext = Context + typealias HIDConnectionBehaviour = CustomBehaviour + ``` + +4. **Define the module struct** (holds context + extra runtime state): + ```kotlin + data class HIDConnection( + val context: HIDConnectionContext, + val serverContext: VRServer, + val send: suspend (ByteArray) -> Unit, + ) + ``` + +5. **Write behaviours** as top-level `val`s (stateless, reusable): + ```kotlin + val HIDHandshakeBehaviour = HIDConnectionBehaviour( + reducer = { s, a -> when (a) { + is HIDConnectionActions.Connected -> s.copy(deviceId = a.deviceId, connected = true) + is HIDConnectionActions.Disconnected -> s.copy(connected = false) + }}, + observer = { conn -> + // launch coroutines, subscribe to events, etc. + } + ) + ``` + +6. **Write a factory function**: + ```kotlin + fun createHIDConnection( + serverContext: VRServer, + scope: CoroutineScope, + send: suspend (ByteArray) -> Unit, + ): HIDConnection { + val behaviours = listOf(HIDHandshakeBehaviour, ...) + val context = createContext(initialState = ..., reducers = behaviours.map { it.reducer }, scope = scope) + val conn = HIDConnection(context, serverContext, send) + behaviours.map { it.observer }.forEach { it?.invoke(conn) } + return conn + } + ``` + +--- + +## Adding a New Behaviour to an Existing Module + +Find the existing `createX` function, add your behaviour to the `behaviours` list. That's it. The behaviour's reducer and observer are automatically picked up. + +Example: adding battery tracking to a HID connection requires only adding a `HIDBatteryBehaviour` to the list — nothing else changes. + +--- + +## Adding a New UDP Packet Type + +1. Add the packet class and its `read` function in `tracker/udp/packets.kt` +2. In the relevant behaviour's observer, register a listener: + ```kotlin + connection.packetEvents.on { event -> + // handle it + } + ``` +3. In `tracker/udp/server.kt`, route the new packet type to `emit`. + +--- + +## IPC + +There are three IPC sockets, each serving a distinct client: + +| Socket | Client | Payload encoding | +|---|---|---| +| `SlimeVRDriver` | OpenVR driver | Protobuf (Wire) | +| `SlimeVRInput` | External feeder | Protobuf (Wire) | +| `SlimeVRRpc` | SolarXR RPC | FlatBuffers (solarxr-protocol) | + +### Wire framing + +All three sockets share the same framing: a **LE u32 length** prefix (which includes the 4-byte header itself) followed by the raw payload bytes. + +### Transport / protocol split + +Platform files (`linux.kt`, `windows.kt`) own the transport layer — accepting connections, reading frames, and producing a `Flow` + a `send` function. The protocol handlers in `protocol.kt` are plain `suspend fun`s that consume those two abstractions and know nothing about Unix sockets or named pipes. + +This means the same handler runs on Linux (Unix domain sockets) and Windows (named pipes) without any changes. + +### Connection lifetime + +Each client runs in its own `launch` block. When the socket disconnects, the coroutine scope is cancelled and everything inside cleans up automatically. + +### What each handler does + +- **Driver** (`handleDriverConnection`): on connect, sends the protocol version and streams `TrackerAdded` + `Position` messages for every non-driver tracker. Receives user actions from the driver (resets, etc.). +- **Feeder** (`handleFeederConnection`): receives `TrackerAdded` messages to create new devices and trackers, then `Position` updates to drive their rotation. +- **SolarXR** (`handleSolarXRConnection`): creates a `SolarXRConnection` and forwards all incoming FlatBuffers messages to it. + +--- + +## What Goes Where + +| Location | Purpose | +|---|---| +| `server/core` | Protocol-agnostic business logic (trackers, devices, config, SolarXR) | +| `server/desktop` | Platform-specific entry point, IPC socket wiring, platform abstractions | +| `context/context.kt` | The `Context` / `Behaviour` / `createContext` primitives — do not add domain logic here | +| `tracker/udp/` | Everything specific to the SlimeVR UDP wire protocol | +| `solarxr/` | SolarXR WebSocket server + FlatBuffers message handling | +| `config/` | JSON config read/write with autosave; no business logic | + +--- + +## Style Conventions + +- **Prefer plain functions over extension functions.** Only use extension functions when the receiver type is genuinely the primary subject and the function would be confusing without it. +- Behaviours are top-level `val`s (or `object`s if they have no type parameters), not inner classes. +- Factory functions are named `createX`, not `XBuilder` or `X.create` (though `companion object { fun create() }` is acceptable when scoping makes it clearer, as in `VRServer.create`). +- State data classes use `copy(...)` inside reducers and `Update { copy(...) }` actions — never expose a `MutableStateFlow` directly. +- Use `sealed interface` for action types, not `sealed class`, to avoid the extra constructor overhead. \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index 3a208beab6..dd71f9accc 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -6,20 +6,20 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -interface Module { +interface Behaviour { val reducer: ((S, A) -> S)? val observer: ((C) -> Unit)? } -data class BasicModule( +data class BasicBehaviour( override val reducer: ((S, A) -> S)? = null, override val observer: ((Context) -> Unit)? = null, -) : Module> +) : Behaviour> -data class CustomModule( +data class CustomBehaviour( override val reducer: ((S, A) -> S)? = null, override val observer: ((C) -> Unit)? = null, -) : Module +) : Behaviour data class Context( val state: StateFlow, diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index acf2e71782..5b4084f78a 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -82,7 +82,7 @@ fun createDatafeedFrame( } -val DataFeedInitModule = SolarXRConnectionModule( +val DataFeedInitBehaviour = SolarXRConnectionBehaviour( observer = { context -> context.dataFeedDispatcher.on { event -> val datafeeds = event.dataFeeds ?: return@on diff --git a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt b/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt index 857d7a3a9c..b3c8d3b45f 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt @@ -2,7 +2,7 @@ package dev.slimevr.solarxr import dev.slimevr.VRServer import dev.slimevr.context.Context -import dev.slimevr.context.CustomModule +import dev.slimevr.context.CustomBehaviour import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -24,7 +24,7 @@ sealed interface SolarXRConnectionActions { } typealias SolarXRConnectionContext = Context -typealias SolarXRConnectionModule = CustomModule +typealias SolarXRConnectionBehaviour = CustomBehaviour class PacketDispatcher { @@ -73,11 +73,11 @@ fun createSolarXRConnection( datafeedTimers = listOf() ) - val modules = listOf(DataFeedInitModule) + val behaviours = listOf(DataFeedInitBehaviour) val context = createContext( initialState = state, - reducers = modules.map { it.reducer }, + reducers = behaviours.map { it.reducer }, scope = scope, ) @@ -89,7 +89,7 @@ fun createSolarXRConnection( onSend, ) - modules.map { it.observer }.forEach { it?.invoke(conn) } + behaviours.map { it.observer }.forEach { it?.invoke(conn) } return conn } diff --git a/server/core/src/main/java/dev/slimevr/tracker/device.kt b/server/core/src/main/java/dev/slimevr/tracker/device.kt index 865ed5f864..11575d07c0 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/device.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/device.kt @@ -2,7 +2,7 @@ package dev.slimevr.tracker import dev.slimevr.AppLogger import dev.slimevr.VRServer -import dev.slimevr.context.BasicModule +import dev.slimevr.context.BasicBehaviour import dev.slimevr.context.Context import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope @@ -33,7 +33,7 @@ sealed interface DeviceActions { -val DeviceStatsModule = DeviceModule( +val DeviceStatsBehaviour = DeviceBehaviour( reducer = { s, a -> if (a is DeviceActions.Update) a.transform(s) else s }, observer = { it.state.onEach { state -> @@ -43,7 +43,7 @@ val DeviceStatsModule = DeviceModule( ) typealias DeviceContext = Context -typealias DeviceModule = BasicModule +typealias DeviceBehaviour = BasicBehaviour data class Device( val context: DeviceContext, @@ -61,15 +61,15 @@ fun createDevice(scope: CoroutineScope, id: Int, address: String, origin: Device signalStrength = null, ) - val modules = listOf(DeviceStatsModule) + val behaviours = listOf(DeviceStatsBehaviour) val context = createContext( initialState = deviceState, - reducers = modules.map { it.reducer }, + reducers = behaviours.map { it.reducer }, scope = scope, ) - modules.map { it.observer }.forEach { it?.invoke(context) } + behaviours.map { it.observer }.forEach { it?.invoke(context) } return Device( context = context, diff --git a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt index b4edeb6952..f1042ce93a 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt @@ -1,7 +1,7 @@ package dev.slimevr.tracker import dev.slimevr.VRServer -import dev.slimevr.context.BasicModule +import dev.slimevr.context.BasicBehaviour import dev.slimevr.context.Context import dev.slimevr.context.createContext import io.github.axisangles.ktmath.Quaternion @@ -32,14 +32,14 @@ sealed interface TrackerActions { } typealias TrackerContext = Context -typealias TrackerModule = BasicModule +typealias TrackerBehaviour = BasicBehaviour data class Tracker( val context: TrackerContext ) -val TrackerInfosModule = TrackerModule( +val TrackerInfosBehaviour = TrackerBehaviour( reducer = { s, a -> if (a is TrackerActions.Update) a.transform(s) else s }, observer = { it.state.onEach { state -> @@ -70,15 +70,15 @@ fun createTracker( sensorType = sensorType ) - val modules = listOf(TrackerInfosModule) + val behaviours = listOf(TrackerInfosBehaviour) val context = createContext( initialState = trackerState, - reducers = modules.map { it.reducer }, + reducers = behaviours.map { it.reducer }, scope = scope, ) - modules.map { it.observer }.forEach { it?.invoke(context) } + behaviours.map { it.observer }.forEach { it?.invoke(context) } return Tracker( context = context, diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt index 94dc62573f..a8981bda4d 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt @@ -4,7 +4,7 @@ import dev.slimevr.AppLogger import dev.slimevr.VRServer import dev.slimevr.VRServerActions import dev.slimevr.context.Context -import dev.slimevr.context.CustomModule +import dev.slimevr.context.CustomBehaviour import dev.slimevr.context.createContext import dev.slimevr.solarxr.SolarXRConnection import dev.slimevr.solarxr.SolarXRConnectionActions @@ -54,7 +54,7 @@ sealed interface UDPConnectionActions { } typealias UDPConnectionContext = Context -typealias UDPConnectionModule = CustomModule +typealias UDPConnectionBehaviour = CustomBehaviour data class UDPConnection( @@ -66,7 +66,7 @@ data class UDPConnection( val getTracker: (sensorId: Int) -> Tracker?, ) -val PacketModule = UDPConnectionModule( +val PacketBehaviour = UDPConnectionBehaviour( reducer = { s, a -> when (a) { is UDPConnectionActions.LastPacket -> { @@ -107,7 +107,7 @@ val PacketModule = UDPConnectionModule( }, ) -val PingModule = UDPConnectionModule( +val PingBehaviour = UDPConnectionBehaviour( reducer = { s, a -> when (a) { is UDPConnectionActions.StartPing -> { @@ -161,7 +161,7 @@ val PingModule = UDPConnectionModule( }, ) -val HandshakeModule = UDPConnectionModule( +val HandshakeBehaviour = UDPConnectionBehaviour( reducer = { s, a -> when (a) { is UDPConnectionActions.Handshake -> s.copy( @@ -201,7 +201,7 @@ val HandshakeModule = UDPConnectionModule( }, ) -val DeviceStatsModule = UDPConnectionModule( +val DeviceStatsBehaviour = UDPConnectionBehaviour( observer = { it.packetEvents.on { event -> val device = it.getDevice() ?: return@on @@ -224,7 +224,7 @@ val DeviceStatsModule = UDPConnectionModule( } ) -val SensorInfoModule = UDPConnectionModule( +val SensorInfoBehaviour = UDPConnectionBehaviour( reducer = { s, a -> when (a) { is UDPConnectionActions.AssignTracker -> { @@ -284,7 +284,7 @@ val SensorInfoModule = UDPConnectionModule( } ) -val SensorRotationModule = UDPConnectionModule( +val SensorRotationBehaviour = UDPConnectionBehaviour( observer = { context -> context.packetEvents.on { event -> val tracker = context.getTracker(event.data.sensorId) ?: return@on @@ -306,13 +306,13 @@ fun createUDPConnectionContext( serverContext: VRServer, scope: CoroutineScope, ): UDPConnection { - val modules = listOf( - PacketModule, - HandshakeModule, - PingModule, - DeviceStatsModule, - SensorInfoModule, - SensorRotationModule + val behaviours = listOf( + PacketBehaviour, + HandshakeBehaviour, + PingBehaviour, + DeviceStatsBehaviour, + SensorInfoBehaviour, + SensorRotationBehaviour ) val context = createContext( @@ -327,7 +327,7 @@ fun createUDPConnectionContext( deviceId = null, trackerIds = listOf() ), - reducers = modules.map { it.reducer }, + reducers = behaviours.map { it.reducer }, scope = scope, ) @@ -357,7 +357,7 @@ fun createUDPConnectionContext( } ) - modules.map { it.observer }.forEach { it?.invoke(conn) } + behaviours.map { it.observer }.forEach { it?.invoke(conn) } return conn } diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index 18e98301d7..98cc98a957 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -1,6 +1,6 @@ package dev.slimevr -import dev.slimevr.context.BasicModule +import dev.slimevr.context.BasicBehaviour import dev.slimevr.context.Context import dev.slimevr.context.createContext import dev.slimevr.tracker.Device @@ -22,9 +22,9 @@ sealed interface VRServerActions { } typealias VRServerContext = Context -typealias VRServerModule = BasicModule +typealias VRServerBehaviour = BasicBehaviour -val BaseModule = VRServerModule( +val BaseBehaviour = VRServerBehaviour( reducer = { s, a -> when (a) { is VRServerActions.NewTracker -> s.copy(trackers = s.trackers + (a.trackerId to a.context)) @@ -54,15 +54,15 @@ data class VRServer( devices = mapOf(), ) - val modules = listOf(BaseModule) + val behaviours = listOf(BaseBehaviour) val context = createContext( initialState = server, - reducers = modules.map { it.reducer }, + reducers = behaviours.map { it.reducer }, scope = scope, ) - modules.map { it.observer }.forEach { it?.invoke(context) } + behaviours.map { it.observer }.forEach { it?.invoke(context) } return VRServer( context = context, 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 e08970964f..4b5a6a886c 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -22,6 +22,8 @@ fun main(args: Array) = runBlocking { launch { createSolarXRWebsocketServer(server) } - createIpcServers(server) + launch { + createIpcServers(server) + } Unit } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index fedb11bb0a..39ba6f13d6 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -58,20 +58,18 @@ suspend fun handleDriverConnection( ), ), ) - launch { - tracker.context.state.collect { ts -> - sendMsg( - ProtobufMessage( - position = Position( - tracker_id = ts.id, - qx = ts.rawRotation.x, - qy = ts.rawRotation.y, - qz = ts.rawRotation.z, - qw = ts.rawRotation.w, - ), + tracker.context.state.collect { ts -> + sendMsg( + ProtobufMessage( + position = Position( + tracker_id = ts.id, + qx = ts.rawRotation.x, + qy = ts.rawRotation.y, + qz = ts.rawRotation.z, + qw = ts.rawRotation.w, ), - ) - } + ), + ) } } } From 853155600acf738a402c85a1523adf7c61cca21d Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 25 Mar 2026 01:05:51 +0100 Subject: [PATCH 013/350] Small design guidelines update --- server/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/server/README.md b/server/README.md index 5c9b347c68..647c2566b8 100644 --- a/server/README.md +++ b/server/README.md @@ -254,4 +254,5 @@ Each client runs in its own `launch` block. When the socket disconnects, the cor - Behaviours are top-level `val`s (or `object`s if they have no type parameters), not inner classes. - Factory functions are named `createX`, not `XBuilder` or `X.create` (though `companion object { fun create() }` is acceptable when scoping makes it clearer, as in `VRServer.create`). - State data classes use `copy(...)` inside reducers and `Update { copy(...) }` actions — never expose a `MutableStateFlow` directly. +- **Never use `var` in a state data class** — state must be immutable, all fields `val`. Using `var` in any data class is almost certainly a design mistake; if you need mutable fields, prefer a plain class or rethink the structure. - Use `sealed interface` for action types, not `sealed class`, to avoid the extra constructor overhead. \ No newline at end of file From 8ffd00eb479489c672cbeed28458c9a313763299 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 25 Mar 2026 15:11:07 +0100 Subject: [PATCH 014/350] Serial + Serial Flasher --- server/README.md | 125 ++++----- server/core/build.gradle.kts | 2 - .../main/java/dev/slimevr/config/config.kt | 12 +- .../main/java/dev/slimevr/config/configio.kt | 47 ++-- .../main/java/dev/slimevr/config/settings.kt | 13 +- .../src/main/java/dev/slimevr/config/user.kt | 14 +- .../java/dev/slimevr/firmware/download.kt | 48 ++++ .../main/java/dev/slimevr/firmware/server.kt | 263 ++++++++++++++++++ .../core/src/main/java/dev/slimevr/logger.kt | 2 +- .../src/main/java/dev/slimevr/platform.kt | 32 ++- .../java/dev/slimevr/serial/connection.kt | 76 +++++ .../main/java/dev/slimevr/serial/server.kt | 152 ++++++++++ .../java/dev/slimevr/skeleton/skeleton.kt | 5 +- .../main/java/dev/slimevr/solarxr/datafeed.kt | 62 +++-- .../main/java/dev/slimevr/solarxr/firmware.kt | 58 ++++ .../main/java/dev/slimevr/solarxr/serial.kt | 134 +++++++++ .../main/java/dev/slimevr/solarxr/solarxr.kt | 29 +- .../java/dev/slimevr/solarxr/ws-server.kt | 35 ++- .../main/java/dev/slimevr/tracker/device.kt | 7 +- .../main/java/dev/slimevr/tracker/tracker.kt | 13 +- .../dev/slimevr/tracker/udp/connection.kt | 97 ++++--- .../java/dev/slimevr/tracker/udp/packets.kt | 102 +++++-- .../java/dev/slimevr/tracker/udp/server.kt | 2 +- .../src/main/java/dev/slimevr/vrserver.kt | 44 ++- .../src/main/java/dev/slimevr/desktop/Main.kt | 8 +- .../main/java/dev/slimevr/desktop/ipc/ipc.kt | 4 +- .../java/dev/slimevr/desktop/ipc/linux.kt | 51 ++-- .../java/dev/slimevr/desktop/ipc/windows.kt | 52 ++-- .../java/dev/slimevr/desktop/serial/flash.kt | 68 +++++ .../java/dev/slimevr/desktop/serial/serial.kt | 110 ++++++++ 30 files changed, 1341 insertions(+), 326 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/firmware/download.kt create mode 100644 server/core/src/main/java/dev/slimevr/firmware/server.kt create mode 100644 server/core/src/main/java/dev/slimevr/serial/connection.kt create mode 100644 server/core/src/main/java/dev/slimevr/serial/server.kt create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/firmware.kt create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/serial.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/serial/flash.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt diff --git a/server/README.md b/server/README.md index 647c2566b8..4c08ab0cba 100644 --- a/server/README.md +++ b/server/README.md @@ -21,10 +21,10 @@ The `Context` type (`context/context.kt`) is the building block of every m ```kotlin data class Context( - val state: StateFlow, // current state, readable by anyone - val dispatch: suspend (A) -> Unit, - val dispatchAll: suspend (List) -> Unit, - val scope: CoroutineScope, // lifetime of this module + val state: StateFlow, // current state, readable by anyone + val dispatch: suspend (A) -> Unit, + val dispatchAll: suspend (List) -> Unit, + val scope: CoroutineScope, // lifetime of this module ) ``` @@ -43,8 +43,8 @@ A `Behaviour` bundles two optional pieces: ```kotlin data class BasicBehaviour( - val reducer: ((S, A) -> S)? = null, - val observer: ((Context) -> Unit)? = null, + val reducer: ((S, A) -> S)? = null, + val observer: ((Context) -> Unit)? = null, ) ``` @@ -59,9 +59,9 @@ Every module follows the same construction pattern: val behaviours = listOf(BehaviourA, BehaviourB, BehaviourC) val context = createContext( - initialState = ..., - reducers = behaviours.map { it.reducer }, - scope = scope, + initialState = ..., + reducers = behaviours.map { it.reducer }, + scope = scope, ) behaviours.map { it.observer }.forEach { it?.invoke(context) } @@ -122,63 +122,63 @@ Rule of thumb: put data in state if **any other code needs to react to it changi To add a new major section of the server (say, a HID device connection): 1. **Define the state**: - ```kotlin - data class HIDConnectionState( - val deviceId: Int?, - val connected: Boolean, - ) - ``` +```kotlin +data class HIDConnectionState( + val deviceId: Int?, + val connected: Boolean, +) +``` 2. **Define sealed actions**: - ```kotlin - sealed interface HIDConnectionActions { - data class Connected(val deviceId: Int) : HIDConnectionActions - data object Disconnected : HIDConnectionActions - } - ``` +```kotlin +sealed interface HIDConnectionActions { + data class Connected(val deviceId: Int) : HIDConnectionActions + data object Disconnected : HIDConnectionActions +} +``` 3. **Create type aliases** (keeps signatures readable): - ```kotlin - typealias HIDConnectionContext = Context - typealias HIDConnectionBehaviour = CustomBehaviour - ``` +```kotlin +typealias HIDConnectionContext = Context +typealias HIDConnectionBehaviour = CustomBehaviour +``` 4. **Define the module struct** (holds context + extra runtime state): - ```kotlin - data class HIDConnection( - val context: HIDConnectionContext, - val serverContext: VRServer, - val send: suspend (ByteArray) -> Unit, - ) - ``` +```kotlin +data class HIDConnection( + val context: HIDConnectionContext, + val serverContext: VRServer, + val send: suspend (ByteArray) -> Unit, +) +``` 5. **Write behaviours** as top-level `val`s (stateless, reusable): - ```kotlin - val HIDHandshakeBehaviour = HIDConnectionBehaviour( - reducer = { s, a -> when (a) { - is HIDConnectionActions.Connected -> s.copy(deviceId = a.deviceId, connected = true) - is HIDConnectionActions.Disconnected -> s.copy(connected = false) - }}, - observer = { conn -> - // launch coroutines, subscribe to events, etc. - } - ) - ``` +```kotlin +val HIDHandshakeBehaviour = HIDConnectionBehaviour( + reducer = { s, a -> when (a) { + is HIDConnectionActions.Connected -> s.copy(deviceId = a.deviceId, connected = true) + is HIDConnectionActions.Disconnected -> s.copy(connected = false) + }}, + observer = { conn -> + // launch coroutines, subscribe to events, etc. + } +) +``` 6. **Write a factory function**: - ```kotlin - fun createHIDConnection( - serverContext: VRServer, - scope: CoroutineScope, - send: suspend (ByteArray) -> Unit, - ): HIDConnection { - val behaviours = listOf(HIDHandshakeBehaviour, ...) - val context = createContext(initialState = ..., reducers = behaviours.map { it.reducer }, scope = scope) - val conn = HIDConnection(context, serverContext, send) - behaviours.map { it.observer }.forEach { it?.invoke(conn) } - return conn - } - ``` +```kotlin +fun createHIDConnection( + serverContext: VRServer, + scope: CoroutineScope, + send: suspend (ByteArray) -> Unit, +): HIDConnection { + val behaviours = listOf(HIDHandshakeBehaviour, ...) + val context = createContext(initialState = ..., reducers = behaviours.map { it.reducer }, scope = scope) + val conn = HIDConnection(context, serverContext, send) + behaviours.map { it.observer }.forEach { it?.invoke(conn) } + return conn +} +``` --- @@ -194,11 +194,11 @@ Example: adding battery tracking to a HID connection requires only adding a `HID 1. Add the packet class and its `read` function in `tracker/udp/packets.kt` 2. In the relevant behaviour's observer, register a listener: - ```kotlin - connection.packetEvents.on { event -> - // handle it - } - ``` +```kotlin +connection.packetEvents.on { event -> + // handle it +} +``` 3. In `tracker/udp/server.kt`, route the new packet type to `emit`. --- @@ -250,9 +250,10 @@ Each client runs in its own `launch` block. When the socket disconnects, the cor ## Style Conventions +- **Limit OOP to strictly necessary cases.** Prefer plain functions, function types, and data classes. Avoid classes and inheritance unless there is a genuine need for encapsulated mutable state or polymorphism. A single-method interface should almost always be a function type instead (`() -> Unit`, `suspend (String) -> Unit`, etc.). When in doubt, write a function. - **Prefer plain functions over extension functions.** Only use extension functions when the receiver type is genuinely the primary subject and the function would be confusing without it. - Behaviours are top-level `val`s (or `object`s if they have no type parameters), not inner classes. - Factory functions are named `createX`, not `XBuilder` or `X.create` (though `companion object { fun create() }` is acceptable when scoping makes it clearer, as in `VRServer.create`). - State data classes use `copy(...)` inside reducers and `Update { copy(...) }` actions — never expose a `MutableStateFlow` directly. - **Never use `var` in a state data class** — state must be immutable, all fields `val`. Using `var` in any data class is almost certainly a design mistake; if you need mutable fields, prefer a plain class or rethink the structure. -- Use `sealed interface` for action types, not `sealed class`, to avoid the extra constructor overhead. \ No newline at end of file +- Use `sealed interface` for action types, not `sealed class`, to avoid the extra constructor overhead. diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index 8a0578c8a9..700aa3ab62 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -76,8 +76,6 @@ dependencies { implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:$ktor_version") implementation("io.ktor:ktor-utils:$ktor_version") - - api("com.github.loucass003:EspflashKotlin:v0.11.0") // Allow the use of reflection diff --git a/server/core/src/main/java/dev/slimevr/config/config.kt b/server/core/src/main/java/dev/slimevr/config/config.kt index 5a971cc66f..260bf4ea0b 100644 --- a/server/core/src/main/java/dev/slimevr/config/config.kt +++ b/server/core/src/main/java/dev/slimevr/config/config.kt @@ -1,11 +1,10 @@ package dev.slimevr.config -import dev.slimevr.context.BasicModule +import dev.slimevr.context.BasicBehaviour import dev.slimevr.context.Context import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.intOrNull @@ -13,7 +12,6 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import java.io.File - private const val GLOBAL_CONFIG_VERSION = 1 @Serializable @@ -29,7 +27,7 @@ sealed interface GlobalConfigActions { } typealias GlobalConfigContext = Context -typealias GlobalConfigModule = BasicModule +typealias GlobalConfigBehaviour = BasicBehaviour private fun migrateGlobalConfig(json: JsonObject): JsonObject { val version = json["version"]?.jsonPrimitive?.intOrNull ?: 0 @@ -44,7 +42,7 @@ private fun parseAndMigrateGlobalConfig(raw: String): GlobalConfigState { return jsonConfig.decodeFromJsonElement(migrateGlobalConfig(json)) } -val DefaultGlobalConfigModule = GlobalConfigModule( +val DefaultGlobalConfigBehaviour = GlobalConfigBehaviour( reducer = { s, a -> when (a) { is GlobalConfigActions.SetUserProfile -> s.copy(selectedUserProfile = a.name) @@ -66,9 +64,11 @@ suspend fun createAppConfig(scope: CoroutineScope, configFolder: File): AppConfi parseAndMigrateGlobalConfig(it) } + val behaviours = listOf(DefaultGlobalConfigBehaviour) + val globalContext = createContext( initialState = initialGlobal, - reducers = listOf(DefaultGlobalConfigModule.reducer), + reducers = behaviours.map { it.reducer }, scope = scope, ) diff --git a/server/core/src/main/java/dev/slimevr/config/configio.kt b/server/core/src/main/java/dev/slimevr/config/configio.kt index f2aba7607b..fad6525972 100644 --- a/server/core/src/main/java/dev/slimevr/config/configio.kt +++ b/server/core/src/main/java/dev/slimevr/config/configio.kt @@ -32,34 +32,33 @@ suspend fun atomicWriteFile(file: File, content: String) = withContext(Dispatche Unit } -suspend inline fun loadFileWithBackup(file: File, default: T, crossinline deserialize: (String) -> T): T = - withContext(Dispatchers.IO) { - if (!file.exists()) { - atomicWriteFile(file, jsonConfig.encodeToString(default)) - return@withContext default - } +suspend inline fun loadFileWithBackup(file: File, default: T, crossinline deserialize: (String) -> T): T = withContext(Dispatchers.IO) { + if (!file.exists()) { + atomicWriteFile(file, jsonConfig.encodeToString(default)) + return@withContext default + } - try { - deserialize(file.readText()) - } catch (e: Exception) { - System.err.println("Failed to load ${file.absolutePath}: ${e.message}") - if (file.exists()) { - try { - val bakTmp = File(file.parent, "${file.name}.bak.tmp") - file.copyTo(bakTmp, overwrite = true) - Files.move( - bakTmp.toPath(), - File(file.parent, "${file.name}.bak").toPath(), - StandardCopyOption.ATOMIC_MOVE, - StandardCopyOption.REPLACE_EXISTING - ) - } catch (e2: Exception) { - System.err.println("Failed to back up corrupted file: ${e2.message}") - } + try { + deserialize(file.readText()) + } catch (e: Exception) { + System.err.println("Failed to load ${file.absolutePath}: ${e.message}") + if (file.exists()) { + try { + val bakTmp = File(file.parent, "${file.name}.bak.tmp") + file.copyTo(bakTmp, overwrite = true) + Files.move( + bakTmp.toPath(), + File(file.parent, "${file.name}.bak").toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (e2: Exception) { + System.err.println("Failed to back up corrupted file: ${e2.message}") } - default } + default } +} /** * Launches a debounced autosave coroutine. Skips the initial state (already on diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index e20796da9b..4a5aeea0f5 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -1,7 +1,7 @@ package dev.slimevr.config import dev.slimevr.context.Context -import dev.slimevr.context.CustomModule +import dev.slimevr.context.CustomBehaviour import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -46,7 +46,7 @@ sealed interface SettingsActions { } typealias SettingsContext = Context -typealias SettingsModule = CustomModule +typealias SettingsBehaviour = CustomBehaviour data class Settings( val context: SettingsContext, @@ -54,7 +54,7 @@ data class Settings( val swap: suspend (String) -> Unit, ) -val DefaultSettingsModule = SettingsModule( +val DefaultSettingsBehaviour = SettingsBehaviour( reducer = { s, a -> when (a) { is SettingsActions.Update -> a.transform(s) @@ -72,11 +72,12 @@ suspend fun createSettings(scope: CoroutineScope, configDir: File, name: String) } val initialState = SettingsState(name = name, data = initialData) - val modules = listOf(DefaultSettingsModule) + val behaviours = listOf(DefaultSettingsBehaviour) + val context = createContext( initialState = initialState, - reducers = modules.map { it.reducer }, - scope = scope + reducers = behaviours.map { it.reducer }, + scope = scope, ) fun startAutosave() = launchAutosave( diff --git a/server/core/src/main/java/dev/slimevr/config/user.kt b/server/core/src/main/java/dev/slimevr/config/user.kt index e9a4c41d90..0d9553c01a 100644 --- a/server/core/src/main/java/dev/slimevr/config/user.kt +++ b/server/core/src/main/java/dev/slimevr/config/user.kt @@ -1,9 +1,8 @@ package dev.slimevr.config import dev.slimevr.context.Context -import dev.slimevr.context.CustomModule +import dev.slimevr.context.CustomBehaviour import dev.slimevr.context.createContext -import dev.slimevr.tracker.DeviceActions import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -47,7 +46,7 @@ sealed interface UserConfigActions { } typealias UserConfigContext = Context -typealias UserConfigModule = CustomModule +typealias UserConfigBehaviour = CustomBehaviour data class UserConfig( val context: UserConfigContext, @@ -55,7 +54,7 @@ data class UserConfig( val swap: suspend (String) -> Unit, ) -val DefaultUserModule = UserConfigModule( +val DefaultUserBehaviour = UserConfigBehaviour( reducer = { s, a -> when (a) { is UserConfigActions.Update -> a.transform(s) @@ -73,11 +72,12 @@ suspend fun createUserConfig(scope: CoroutineScope, configDir: File, name: Strin } val initialState = UserConfigState(name = name, data = initialData) - val modules = listOf(DefaultUserModule) + val behaviours = listOf(DefaultUserBehaviour) + val context = createContext( initialState = initialState, - reducers = modules.map { it.reducer }, - scope = scope + reducers = behaviours.map { it.reducer }, + scope = scope, ) fun startAutosave() = launchAutosave( diff --git a/server/core/src/main/java/dev/slimevr/firmware/download.kt b/server/core/src/main/java/dev/slimevr/firmware/download.kt new file mode 100644 index 0000000000..ebc27e5ce8 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/firmware/download.kt @@ -0,0 +1,48 @@ +package dev.slimevr.firmware + +import java.io.ByteArrayOutputStream +import java.net.URL +import java.security.MessageDigest + +data class DownloadedFirmwarePart( + val data: ByteArray, + val offset: Int, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as DownloadedFirmwarePart + if (!data.contentEquals(other.data)) return false + return offset == other.offset + } + + override fun hashCode(): Int { + var result = data.contentHashCode() + result = 31 * result + offset + return result + } +} + +fun downloadFirmware(url: String, digest: String): ByteArray { + val output = ByteArrayOutputStream() + val chunk = ByteArray(4096) + URL(url).openStream().use { stream -> + while (true) { + val read = stream.read(chunk) + if (read <= 0) break + output.write(chunk, 0, read) + } + } + val data = output.toByteArray() + check(verifyChecksum(data, digest)) { "Checksum verification failed for $url" } + return data +} + +fun verifyChecksum(data: ByteArray, expectedDigest: String): Boolean { + val parts = expectedDigest.split(":", limit = 2) + check(parts.size == 2) { "Invalid digest format '$expectedDigest', expected 'algorithm:hash'" } + val algorithm = parts[0].uppercase().replace("-", "") + val expectedHash = parts[1].lowercase() + val actualHash = MessageDigest.getInstance(algorithm).digest(data).joinToString("") { "%02x".format(it) } + return actualHash == expectedHash +} diff --git a/server/core/src/main/java/dev/slimevr/firmware/server.kt b/server/core/src/main/java/dev/slimevr/firmware/server.kt new file mode 100644 index 0000000000..bec07afb29 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/firmware/server.kt @@ -0,0 +1,263 @@ +package dev.slimevr.firmware + +import dev.llelievr.espflashkotlin.Flasher +import dev.llelievr.espflashkotlin.FlashingProgressListener +import dev.slimevr.VRServer +import dev.slimevr.context.BasicBehaviour +import dev.slimevr.context.Context +import dev.slimevr.context.createContext +import dev.slimevr.serial.SerialConnection +import dev.slimevr.serial.SerialServer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import solarxr_protocol.rpc.FirmwarePart +import solarxr_protocol.rpc.FirmwareUpdateStatus + +private val MAC_REGEX = Regex("mac: (([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2})", RegexOption.IGNORE_CASE) + +data class FirmwareJobStatus( + val portLocation: String, + val status: FirmwareUpdateStatus, + val progress: Int = 0, +) + +data class FirmwareManagerState( + val jobs: Map, +) + +sealed interface FirmwareManagerActions { + data class UpdateJob(val portLocation: String, val status: FirmwareUpdateStatus, val progress: Int = 0) : FirmwareManagerActions + + data class RemoveJob(val portLocation: String) : FirmwareManagerActions +} + +typealias FirmwareManagerContext = Context +typealias FirmwareManagerBehaviour = BasicBehaviour + +val FirmwareManagerBaseBehaviour = FirmwareManagerBehaviour( + reducer = { s, a -> + when (a) { + is FirmwareManagerActions.UpdateJob -> s.copy( + jobs = s.jobs + (a.portLocation to FirmwareJobStatus(a.portLocation, a.status, a.progress)), + ) + + is FirmwareManagerActions.RemoveJob -> s.copy(jobs = s.jobs - a.portLocation) + } + }, + observer = null, +) + +data class FirmwareManager( + val context: FirmwareManagerContext, + val flash: suspend (portLocation: String, parts: List, needManualReboot: Boolean, ssid: String?, password: String?, server: VRServer) -> Unit, + val cancelAll: suspend () -> Unit, +) + +@OptIn(ExperimentalCoroutinesApi::class) +suspend fun doSerialFlash( + portLocation: String, + parts: List, + needManualReboot: Boolean, + ssid: String?, + password: String?, + serialServer: SerialServer, + server: VRServer, + onStatus: suspend (FirmwareUpdateStatus, Int) -> Unit, + scope: CoroutineScope, +) { + onStatus(FirmwareUpdateStatus.DOWNLOADING, 0) + + val downloadedParts = try { + withContext(Dispatchers.IO) { + parts.map { part -> + val url = part.url ?: error("missing url") + val digest = part.digest ?: error("missing digest") + DownloadedFirmwarePart( + data = downloadFirmware(url, digest), + offset = part.offset.toInt(), + ) + } + } + } catch (_: Exception) { + onStatus(FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED, 0) + return + } + + onStatus(FirmwareUpdateStatus.SYNCING_WITH_MCU, 0) + + val handler = serialServer.openForFlashing(portLocation) ?: run { + onStatus(FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND, 0) + return + } + + val flasher = Flasher(handler) + for (part in downloadedParts) { + flasher.addBin(part.data, part.offset) + } + flasher.addProgressListener( + object : FlashingProgressListener { + override fun progress(progress: Float) { + scope.launch { onStatus(FirmwareUpdateStatus.UPLOADING, (progress * 100).toInt()) } + } + }, + ) + + val runFlasher = runCatching { + withContext(Dispatchers.IO) { flasher.flash(portLocation) } + } + + if (runFlasher.isFailure) { + onStatus(FirmwareUpdateStatus.ERROR_UPLOAD_FAILED, 0) + return + } + + onStatus( + if (needManualReboot) { + FirmwareUpdateStatus.NEED_MANUAL_REBOOT + } else { + FirmwareUpdateStatus.REBOOTING + }, + 0, + ) + + serialServer.openConnection(portLocation) + val serialConn = serialServer.context.state.value.connections[portLocation] + if (serialConn == null) { + onStatus(FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND, 0) + return + } + if (serialConn !is SerialConnection.Console) { + onStatus(FirmwareUpdateStatus.ERROR_UNKNOWN, 0) + return + } + + if (needManualReboot) { + // wait for the device to reboot + val rebooted = withTimeoutOrNull(60_000) { + serialConn.context.state.map { it.logLines } + .filter { logLines -> logLines.any { "starting up" in it.lowercase() } } + .first() + } + + if (rebooted == null) { + onStatus(FirmwareUpdateStatus.ERROR_TIMEOUT, 0) + return + } + } + + // get MAC address by sending GET INFO and parsing the response + serialConn.handle.writeCommand("GET INFO") + + val macAddress = withTimeoutOrNull(10_000) { + serialConn.context.state.map { it.logLines }.mapNotNull { logLines -> + logLines.firstNotNullOfOrNull { line -> + MAC_REGEX.find(line)?.groupValues?.get(1)?.uppercase() + } + }.first() + } + + if (macAddress == null) { + onStatus(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED, 0) + return + } + + // provision with Wi-Fi credentials + if (ssid == null || password == null) { + onStatus(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED, 0) + return + } + + onStatus(FirmwareUpdateStatus.PROVISIONING, 0) + serialConn.handle.writeCommand("SET WIFI \"$ssid\" \"$password\"\n") + + // Wait for Wi-Fi to connect ("looking for the server") + val provisioned = withTimeoutOrNull(30_000) { + serialConn.context.state.map { it.logLines }.filter { logLines -> + logLines.any { + "looking for the server" in it.lowercase() || "searching for the server" in it.lowercase() + } + }.first() + } + + if (provisioned == null) { + onStatus(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED, 0) + return + } + + // wait for the tracker with that MAC to connect to the server via UDP + val connected = withTimeoutOrNull(60_000) { + server.context.state + .map { state -> state.devices.values.any { it.context.state.value.address.uppercase() == macAddress } } + .filter { it } + .first() + } + + if (connected == null) { + onStatus(FirmwareUpdateStatus.ERROR_TIMEOUT, 0) + return + } + + onStatus(FirmwareUpdateStatus.DONE, 0) +} + +fun createFirmwareManager( + serialServer: SerialServer, + scope: CoroutineScope, +): FirmwareManager { + val behaviours = listOf(FirmwareManagerBaseBehaviour) + + val context = createContext( + initialState = FirmwareManagerState(jobs = mapOf()), + reducers = behaviours.map { it.reducer }, + scope = scope, + ) + + val runningJobs = mutableMapOf() + + val flash: suspend (String, List, Boolean, String?, String?, VRServer) -> Unit = { portLocation, parts, needManualReboot, ssid, password, server -> + runningJobs[portLocation]?.cancelAndJoin() + runningJobs[portLocation] = scope.launch { + doSerialFlash( + portLocation = portLocation, + parts = parts, + needManualReboot = needManualReboot, + ssid = ssid, + password = password, + serialServer = serialServer, + server = server, + onStatus = { status, progress -> + context.dispatch(FirmwareManagerActions.UpdateJob(portLocation, status, progress)) + }, + scope = scope, + ) + } + } + + val cancelAll: suspend () -> Unit = { + runningJobs.values.forEach { it.cancelAndJoin() } + runningJobs.clear() + } + + val manager = FirmwareManager( + context = context, + flash = flash, + cancelAll = cancelAll, + ) + + behaviours.map { it.observer }.forEach { it?.invoke(context) } + return manager +} diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index d2055d42ba..aa0042704a 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -11,7 +11,7 @@ object AppLogger { val device = logger("Device") val udp = logger("UDPConnection") val solarxr = logger("SolarXR") - + val serial = logger("Serial") init { loggingConfiguration { diff --git a/server/core/src/main/java/dev/slimevr/platform.kt b/server/core/src/main/java/dev/slimevr/platform.kt index b741df3567..ffcd82f89a 100644 --- a/server/core/src/main/java/dev/slimevr/platform.kt +++ b/server/core/src/main/java/dev/slimevr/platform.kt @@ -8,9 +8,11 @@ import kotlin.io.path.exists const val SLIMEVR_IDENTIFIER = "dev.slimevr.SlimeVR" - enum class Platform { - LINUX, WINDOWS, OSX, UNKNOWN + LINUX, + WINDOWS, + OSX, + UNKNOWN, } val CURRENT_PLATFORM: Platform = detectPlatform() @@ -59,15 +61,22 @@ fun resolveConfigDirectory(): Path? { val appData = System.getenv("AppData") if (appData != null) Path(appData, SLIMEVR_IDENTIFIER) else null } + Platform.LINUX -> { val xdg = System.getenv("XDG_CONFIG_HOME") - if (xdg != null) Path(xdg, SLIMEVR_IDENTIFIER) - else if (home != null) Path(home, ".config", SLIMEVR_IDENTIFIER) - else null + if (xdg != null) { + Path(xdg, SLIMEVR_IDENTIFIER) + } else if (home != null) { + Path(home, ".config", SLIMEVR_IDENTIFIER) + } else { + null + } } + Platform.OSX -> { if (home != null) Path(home, "Library", "Application Support", SLIMEVR_IDENTIFIER) else null } + else -> null } } @@ -78,13 +87,20 @@ fun resolveLogDirectory(): Path? { return when (CURRENT_PLATFORM) { Platform.WINDOWS -> if (appData != null) Path(appData, SLIMEVR_IDENTIFIER, "logs") else null + Platform.OSX -> if (home != null) Path(home, "Library", "Logs", SLIMEVR_IDENTIFIER) else null + Platform.LINUX -> { val xdg = System.getenv("XDG_DATA_HOME") - if (xdg != null) Path(xdg, SLIMEVR_IDENTIFIER, "logs") - else if (home != null) Path(home, ".local", "share", SLIMEVR_IDENTIFIER, "logs") - else null + if (xdg != null) { + Path(xdg, SLIMEVR_IDENTIFIER, "logs") + } else if (home != null) { + Path(home, ".local", "share", SLIMEVR_IDENTIFIER, "logs") + } else { + null + } } + else -> null } } diff --git a/server/core/src/main/java/dev/slimevr/serial/connection.kt b/server/core/src/main/java/dev/slimevr/serial/connection.kt new file mode 100644 index 0000000000..31999de3ab --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/serial/connection.kt @@ -0,0 +1,76 @@ +package dev.slimevr.serial + +import dev.slimevr.context.Context +import dev.slimevr.context.CustomBehaviour +import dev.slimevr.context.createContext +import kotlinx.coroutines.CoroutineScope + +private const val MAX_LOG_LINES = 500 + +data class SerialPortHandle( + val portLocation: String, + val descriptivePortName: String, + val writeCommand: (String) -> Unit, + val close: () -> Unit, +) + +data class SerialConnectionState( + val portLocation: String, + val descriptivePortName: String, + val connected: Boolean, + val logLines: List, +) + +sealed interface SerialConnectionActions { + data class LogLine(val line: String) : SerialConnectionActions + data object Disconnected : SerialConnectionActions +} + +typealias SerialConnectionContext = Context +typealias SerialConnectionBehaviour = + CustomBehaviour + +sealed interface SerialConnection { + data class Console( + val context: SerialConnectionContext, + val handle: SerialPortHandle, + ) : SerialConnection + + data object Flashing : SerialConnection +} + +val SerialLogBehaviour = SerialConnectionBehaviour( + reducer = { s, a -> + when (a) { + is SerialConnectionActions.LogLine -> { + val lines = if (s.logLines.size >= MAX_LOG_LINES) s.logLines.drop(1) else s.logLines + s.copy(logLines = lines + a.line) + } + + SerialConnectionActions.Disconnected -> s.copy(connected = false) + } + }, + observer = null, +) + +fun createSerialConnection( + handle: SerialPortHandle, + scope: CoroutineScope, +): SerialConnection.Console { + val behaviours = listOf(SerialLogBehaviour) + + val context = createContext( + initialState = SerialConnectionState( + portLocation = handle.portLocation, + descriptivePortName = handle.descriptivePortName, + connected = true, + logLines = listOf(), + ), + reducers = behaviours.map { it.reducer }, + scope = scope, + ) + + val conn = SerialConnection.Console(context = context, handle = handle) + behaviours.map { it.observer }.forEach { it?.invoke(conn) } + return conn +} diff --git a/server/core/src/main/java/dev/slimevr/serial/server.kt b/server/core/src/main/java/dev/slimevr/serial/server.kt new file mode 100644 index 0000000000..348061f1c0 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/serial/server.kt @@ -0,0 +1,152 @@ +package dev.slimevr.serial + +import dev.llelievr.espflashkotlin.FlasherSerialInterface +import dev.slimevr.context.BasicBehaviour +import dev.slimevr.context.Context +import dev.slimevr.context.createContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import solarxr_protocol.rpc.SerialDevice + +typealias FlashingHandler = FlasherSerialInterface + +data class SerialPortInfo( + val portLocation: String, + val descriptivePortName: String, + val vendorId: Int, + val productId: Int, +) { + fun toSerialDevice() = SerialDevice( + port = portLocation, + name = descriptivePortName, + ) +} + +data class SerialServerState( + val availablePorts: Map, + val connections: Map, +) + +sealed interface SerialServerActions { + data class PortDetected(val info: SerialPortInfo) : SerialServerActions + data class PortLost(val portLocation: String) : SerialServerActions + data class RegisterConnection(val portLocation: String, val connection: SerialConnection) : SerialServerActions + data class RemoveConnection(val portLocation: String) : SerialServerActions +} + +typealias SerialServerContext = Context +typealias SerialServerBehaviour = BasicBehaviour + +val SerialServerBaseBehaviour = SerialServerBehaviour( + reducer = { s, a -> + when (a) { + is SerialServerActions.PortDetected -> + s.copy(availablePorts = s.availablePorts + (a.info.portLocation to a.info)) + + is SerialServerActions.PortLost -> + s.copy(availablePorts = s.availablePorts - a.portLocation) + + is SerialServerActions.RegisterConnection -> + s.copy(connections = s.connections + (a.portLocation to a.connection)) + + is SerialServerActions.RemoveConnection -> + s.copy(connections = s.connections - a.portLocation) + } + }, + observer = null, +) + +class SerialServer( + val context: SerialServerContext, + private val scope: CoroutineScope, + private val openPortFactory: ( + portLocation: String, + scope: CoroutineScope, + onDataReceived: suspend (portLocation: String, line: String) -> Unit, + onPortDisconnected: suspend (portLocation: String) -> Unit, + ) -> SerialPortHandle?, + private val openFlashingPortFactory: () -> FlashingHandler, +) { + suspend fun onPortDetected(info: SerialPortInfo) { + context.dispatch(SerialServerActions.PortDetected(info)) + } + + suspend fun onPortLost(portLocation: String) { + val conn = context.state.value.connections[portLocation] + if (conn is SerialConnection.Console) { + conn.handle.close() + } + context.dispatch(SerialServerActions.RemoveConnection(portLocation)) + context.dispatch(SerialServerActions.PortLost(portLocation)) + } + + suspend fun onDataReceived(portLocation: String, line: String) { + val conn = context.state.value.connections[portLocation] + if (conn is SerialConnection.Console) conn.context.dispatch(SerialConnectionActions.LogLine(line)) + } + + suspend fun onPortDisconnected(portLocation: String) { + val conn = context.state.value.connections[portLocation] + if (conn !is SerialConnection.Console) return + conn.context.dispatch(SerialConnectionActions.Disconnected) + conn.handle.close() + context.dispatch(SerialServerActions.RemoveConnection(portLocation)) + } + + suspend fun openConnection(portLocation: String) { + val state = context.state.value + if (!state.availablePorts.containsKey(portLocation) || state.connections.containsKey(portLocation)) return + val handle = openPortFactory(portLocation, scope, ::onDataReceived, ::onPortDisconnected) ?: return + context.dispatch(SerialServerActions.RegisterConnection(portLocation, createSerialConnection(handle, scope))) + } + + suspend fun closeConnection(portLocation: String) { + val conn = context.state.value.connections[portLocation] + if (conn !is SerialConnection.Console) return + conn.context.dispatch(SerialConnectionActions.Disconnected) + conn.handle.close() + context.dispatch(SerialServerActions.RemoveConnection(portLocation)) + } + + suspend fun openForFlashing(portLocation: String): FlashingHandler? { + val state = context.state.value + if (!state.availablePorts.containsKey(portLocation) || state.connections.containsKey(portLocation)) return null + closeConnection(portLocation) + val handler = openFlashingPortFactory() + context.dispatch(SerialServerActions.RegisterConnection(portLocation, SerialConnection.Flashing)) + return object : FlashingHandler by handler { + override fun closeSerial() { + handler.closeSerial() + scope.launch { context.dispatch(SerialServerActions.RemoveConnection(portLocation)) } + } + } + } + + companion object { + fun create( + openPort: ( + portLocation: String, + scope: CoroutineScope, + onDataReceived: suspend (portLocation: String, line: String) -> Unit, + onPortDisconnected: suspend (portLocation: String) -> Unit, + ) -> SerialPortHandle?, + openFlashingPort: () -> FlashingHandler, + scope: CoroutineScope, + ): SerialServer { + val behaviours = listOf(SerialServerBaseBehaviour) + val context = createContext( + initialState = SerialServerState(availablePorts = mapOf(), connections = mapOf()), + reducers = behaviours.map { it.reducer }, + scope = scope, + ) + val server = SerialServer( + context = context, + scope = scope, + openPortFactory = openPort, + openFlashingPortFactory = openFlashingPort, + ) + behaviours.map { it.observer }.forEach { it?.invoke(context) } + return server + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/skeleton.kt b/server/core/src/main/java/dev/slimevr/skeleton/skeleton.kt index 5a01f49f6c..a318db88d7 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/skeleton.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/skeleton.kt @@ -3,15 +3,14 @@ package dev.slimevr.skeleton import io.github.axisangles.ktmath.Quaternion import solarxr_protocol.datatypes.BodyPart - data class Bone( val bodyPart: BodyPart, val localRotation: Quaternion, val parentBone: Bone?, - val childBone: List + val childBone: List, ) { val globalRotation: Quaternion - get() = localRotation //FIXME: do maths LMAO + get() = localRotation // FIXME: do maths LMAO } data class SkeletonState( diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 5b4084f78a..914f7155f7 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -27,27 +27,30 @@ import solarxr_protocol.datatypes.hardware_info.HardwareStatus import solarxr_protocol.datatypes.math.Quat import kotlin.time.measureTime -private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMask: TrackerDataMask): TrackerData = - TrackerData( - trackerId = TrackerId( - trackerNum = tracker.id.toUByte(), - deviceId = DeviceId(device.id.toUByte()) - ), - status = if (trackerMask.status == true) tracker.status else null, - rotation = if (trackerMask.rotation == true) tracker.rawRotation.let { Quat(it.x, it.y, it.z, it.w) } else null, - info = if (trackerMask.info == true) TrackerInfo( +private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMask: TrackerDataMask): TrackerData = TrackerData( + trackerId = TrackerId( + trackerNum = tracker.id.toUByte(), + deviceId = DeviceId(device.id.toUByte()), + ), + status = if (trackerMask.status == true) tracker.status else null, + rotation = if (trackerMask.rotation == true) tracker.rawRotation.let { Quat(it.x, it.y, it.z, it.w) } else null, + info = if (trackerMask.info == true) { + TrackerInfo( imuType = tracker.sensorType, bodyPart = tracker.bodyPart, displayName = tracker.name, - ) else null - ) + ) + } else { + null + }, +) private fun createDevice( device: DeviceState, trackers: List, - datafeedConfig: DataFeedConfig + datafeedConfig: DataFeedConfig, ): DeviceData { - val trackerMask = datafeedConfig.dataMask?.trackerData; + val trackerMask = datafeedConfig.dataMask?.trackerData return DeviceData( id = DeviceId(device.id.toUByte()), @@ -55,18 +58,20 @@ private fun createDevice( batteryVoltage = device.batteryVoltage, batteryPctEstimate = device.batteryLevel.toUInt() .toUByte(), - ping = device.ping?.toUShort() + ping = device.ping?.toUShort(), ), - trackers = if (trackerMask != null) + trackers = if (trackerMask != null) { trackers.filter { it.deviceId == device.id } .map { tracker -> createTracker(device, tracker, trackerMask) } - else null + } else { + null + }, ) } fun createDatafeedFrame( serverContext: VRServer, - datafeedConfig: DataFeedConfig + datafeedConfig: DataFeedConfig, ): DataFeedMessageHeader { val serverState = serverContext.context.state.value val trackers = @@ -76,12 +81,11 @@ fun createDatafeedFrame( .map { device -> createDevice(device, trackers, datafeedConfig) } return DataFeedMessageHeader( message = DataFeedUpdate( - devices = if (datafeedConfig.dataMask?.deviceData != null) devices else null - ) + devices = if (datafeedConfig.dataMask?.deviceData != null) devices else null, + ), ) } - val DataFeedInitBehaviour = SolarXRConnectionBehaviour( observer = { context -> context.dataFeedDispatcher.on { event -> @@ -104,9 +108,9 @@ val DataFeedInitBehaviour = SolarXRConnectionBehaviour( fbb.finish( MessageBundle( dataFeedMsgs = listOf( - createDatafeedFrame(serverContext = context.serverContext, datafeedConfig = config) - ) - ).encode(fbb) + createDatafeedFrame(serverContext = context.serverContext, datafeedConfig = config), + ), + ).encode(fbb), ) context.send(fbb.dataBuffer().moveToByteArray()) @@ -121,8 +125,8 @@ val DataFeedInitBehaviour = SolarXRConnectionBehaviour( context.context.dispatch( SolarXRConnectionActions.SetConfig( datafeeds, - timers = timers - ) + timers = timers, + ), ) context.context.scope.launch { @@ -137,11 +141,11 @@ val DataFeedInitBehaviour = SolarXRConnectionBehaviour( fbb.finish( MessageBundle( dataFeedMsgs = listOf( - createDatafeedFrame(serverContext = context.serverContext, datafeedConfig = config) - ) - ).encode(fbb) + createDatafeedFrame(serverContext = context.serverContext, datafeedConfig = config), + ), + ).encode(fbb), ) context.send(fbb.dataBuffer().moveToByteArray()) } - } + }, ) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt new file mode 100644 index 0000000000..012c7b8908 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt @@ -0,0 +1,58 @@ +package dev.slimevr.solarxr + +import dev.slimevr.firmware.FirmwareJobStatus +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import solarxr_protocol.rpc.FirmwareUpdateRequest +import solarxr_protocol.rpc.FirmwareUpdateStatusResponse +import solarxr_protocol.rpc.FirmwareUpdateStopQueuesRequest +import solarxr_protocol.rpc.SerialDevicePort +import solarxr_protocol.rpc.SerialFirmwareUpdate + +val FirmwareBehaviour = SolarXRConnectionBehaviour( + observer = { conn -> + val scope = conn.context.scope + val firmwareManager = conn.serverContext.firmwareManager + + var prevJobs: Map = firmwareManager.context.state.value.jobs + + firmwareManager.context.state + .map { it.jobs } + .distinctUntilChanged() + .onEach { jobs -> + jobs.forEach { (portLocation, jobStatus) -> + if (prevJobs[portLocation] != jobStatus) { + conn.sendRpc( + FirmwareUpdateStatusResponse( + deviceId = SerialDevicePort(port = portLocation), + status = jobStatus.status, + progress = jobStatus.progress.toByte(), + ), + ) + } + } + prevJobs = jobs + } + .launchIn(scope) + + conn.rpcDispatcher.on { req -> + val method = req.method as? SerialFirmwareUpdate ?: return@on + val portLocation = method.deviceId?.port ?: return@on + val parts = method.firmwarePart ?: return@on + firmwareManager.flash( + portLocation, + parts, + method.needmanualreboot, + method.ssid, + method.password, + conn.serverContext + ) + } + + conn.rpcDispatcher.on { + firmwareManager.cancelAll() + } + }, +) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt new file mode 100644 index 0000000000..4ce3564900 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt @@ -0,0 +1,134 @@ +package dev.slimevr.solarxr + +import dev.slimevr.serial.SerialConnection +import dev.slimevr.serial.SerialPortInfo +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import solarxr_protocol.rpc.CloseSerialRequest +import solarxr_protocol.rpc.NewSerialDeviceResponse +import solarxr_protocol.rpc.OpenSerialRequest +import solarxr_protocol.rpc.SerialDevice +import solarxr_protocol.rpc.SerialDevicesRequest +import solarxr_protocol.rpc.SerialDevicesResponse +import solarxr_protocol.rpc.SerialTrackerCustomCommandRequest +import solarxr_protocol.rpc.SerialTrackerFactoryResetRequest +import solarxr_protocol.rpc.SerialTrackerGetInfoRequest +import solarxr_protocol.rpc.SerialTrackerGetWifiScanRequest +import solarxr_protocol.rpc.SerialTrackerRebootRequest +import solarxr_protocol.rpc.SerialUpdateResponse + + +val SerialConsoleBehaviour = SolarXRConnectionBehaviour( + observer = { conn -> + val scope = conn.context.scope + val serialServer = conn.serverContext.serialServer + + // We assume that you can only subscribe to one serial console + // at a time + var logSubscription: Job? = null + var activePortLocation: String? = null + + // Notify client of new serial devices as they are detected. + // Existing devices at connection time are not sent here. the client + // should send SerialDevicesRequest to get the current list. + var prevPortKeys = serialServer.context.state.value.availablePorts.keys.toSet() + serialServer.context.state + .map { it.availablePorts } + .distinctUntilChanged() + .onEach { ports -> + (ports.keys - prevPortKeys).forEach { key -> + conn.sendRpc(NewSerialDeviceResponse(device = ports[key]!!.toSerialDevice())) + } + prevPortKeys = ports.keys.toSet() + } + .launchIn(scope) + + conn.rpcDispatcher.on { + conn.sendRpc( + SerialDevicesResponse( + devices = serialServer.context.state.value.availablePorts.values + .map { it.toSerialDevice() }, + ), + ) + } + + conn.rpcDispatcher.on { req -> + val portLocation = if (req.auto == true) { + serialServer.context.state.value.availablePorts.keys.firstOrNull() + } else { + req.port + } ?: return@on + + logSubscription?.cancel() + logSubscription = null + activePortLocation = null + + serialServer.openConnection(portLocation) + + val connection = serialServer.context.state.value.connections[portLocation] + if (connection !is SerialConnection.Console) return@on + + activePortLocation = portLocation + var lastSentCount = 0 + + logSubscription = scope.launch { + var disconnected = false + connection.context.state.collect { connState -> + if (disconnected) return@collect + + connState.logLines.drop(lastSentCount).forEach { line -> + conn.sendRpc(SerialUpdateResponse(log = line + "\n")) + } + lastSentCount = connState.logLines.size + + if (!connState.connected) { + disconnected = true + activePortLocation = null + conn.sendRpc(SerialUpdateResponse(closed = true)) + } + } + } + } + + conn.rpcDispatcher.on { + logSubscription?.cancel() + logSubscription = null + activePortLocation = null + } + + conn.rpcDispatcher.on { + val portLocation = activePortLocation ?: return@on + val c = serialServer.context.state.value.connections[portLocation] + if (c is SerialConnection.Console) c.handle.writeCommand("REBOOT") + } + + conn.rpcDispatcher.on { + val portLocation = activePortLocation ?: return@on + val c = serialServer.context.state.value.connections[portLocation] + if (c is SerialConnection.Console) c.handle.writeCommand("GET INFO") + } + + conn.rpcDispatcher.on { + val portLocation = activePortLocation ?: return@on + val c = serialServer.context.state.value.connections[portLocation] + if (c is SerialConnection.Console) c.handle.writeCommand("FRST") + } + + conn.rpcDispatcher.on { + val portLocation = activePortLocation ?: return@on + val c = serialServer.context.state.value.connections[portLocation] + if (c is SerialConnection.Console) c.handle.writeCommand("GET WIFISCAN") + } + + conn.rpcDispatcher.on { req -> + val portLocation = activePortLocation ?: return@on + val command = req.command ?: return@on + val c = serialServer.context.state.value.connections[portLocation] + if (c is SerialConnection.Console) c.handle.writeCommand(command) + } + }, +) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt b/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt index b3c8d3b45f..497f33084e 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt @@ -1,16 +1,20 @@ package dev.slimevr.solarxr +import com.google.flatbuffers.FlatBufferBuilder import dev.slimevr.VRServer import dev.slimevr.context.Context import dev.slimevr.context.CustomBehaviour import dev.slimevr.context.createContext +import io.ktor.util.moveToByteArray import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import solarxr_protocol.MessageBundle import solarxr_protocol.data_feed.DataFeedConfig import solarxr_protocol.data_feed.DataFeedMessage import solarxr_protocol.rpc.RpcMessage +import solarxr_protocol.rpc.RpcMessageHeader import kotlin.reflect.KClass data class SolarXRConnectionState( @@ -19,14 +23,12 @@ data class SolarXRConnectionState( ) sealed interface SolarXRConnectionActions { - data class SetConfig(val configs: List, val timers: List) : - SolarXRConnectionActions + data class SetConfig(val configs: List, val timers: List) : SolarXRConnectionActions } typealias SolarXRConnectionContext = Context typealias SolarXRConnectionBehaviour = CustomBehaviour - class PacketDispatcher { val listeners = mutableMapOf, MutableList Unit>>() val globalListeners = mutableListOf Unit>() @@ -59,21 +61,21 @@ data class SolarXRConnection( val serverContext: VRServer, val dataFeedDispatcher: PacketDispatcher, val rpcDispatcher: PacketDispatcher, - val send: suspend (ByteArray) -> Unit + val send: suspend (ByteArray) -> Unit, + val sendRpc: suspend (RpcMessage) -> Unit, ) fun createSolarXRConnection( serverContext: VRServer, onSend: suspend (ByteArray) -> Unit, - scope: CoroutineScope + scope: CoroutineScope, ): SolarXRConnection { - val state = SolarXRConnectionState( dataFeedConfigs = listOf(), - datafeedTimers = listOf() + datafeedTimers = listOf(), ) - val behaviours = listOf(DataFeedInitBehaviour) + val behaviours = listOf(DataFeedInitBehaviour, SerialConsoleBehaviour, FirmwareBehaviour) val context = createContext( initialState = state, @@ -81,12 +83,21 @@ fun createSolarXRConnection( scope = scope, ) + val sendRpc: suspend (RpcMessage) -> Unit = { message -> + val fbb = FlatBufferBuilder(256) + fbb.finish( + MessageBundle(rpcMsgs = listOf(RpcMessageHeader(message = message))).encode(fbb), + ) + onSend(fbb.dataBuffer().moveToByteArray()) + } + val conn = SolarXRConnection( context = context, serverContext = serverContext, dataFeedDispatcher = PacketDispatcher(), rpcDispatcher = PacketDispatcher(), - onSend, + send = onSend, + sendRpc = sendRpc, ) behaviours.map { it.observer }.forEach { it?.invoke(conn) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt b/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt index c83d146d78..40439beb77 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt @@ -2,47 +2,40 @@ package dev.slimevr.solarxr import dev.slimevr.AppLogger import dev.slimevr.VRServer -import io.ktor.server.application.* +import io.ktor.server.application.install import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty import io.ktor.server.routing.routing -import io.ktor.server.websocket.* +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.webSocket import io.ktor.websocket.Frame +import kotlinx.coroutines.awaitCancellation import solarxr_protocol.MessageBundle import solarxr_protocol.rpc.ResetRequest import java.nio.ByteBuffer const val SOLARXR_PORT = 21110 - suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRConnection) { val messageBundle = MessageBundle.fromByteBuffer(message) messageBundle.dataFeedMsgs?.forEach { - val msg = it.message ?: return; + val msg = it.message ?: return context.dataFeedDispatcher.emit(msg) } - // FIXME: temporary test - context.rpcDispatcher.on { - println("RESET $it") - } - messageBundle.rpcMsgs?.forEach { - val msg = it.message ?: return; + val msg = it.message ?: return context.rpcDispatcher.emit(msg) } - } - -fun createSolarXRWebsocketServer(serverContext: VRServer) { - embeddedServer(Netty, port = SOLARXR_PORT) { +suspend fun createSolarXRWebsocketServer(serverContext: VRServer) { + val engine = embeddedServer(Netty, port = SOLARXR_PORT) { install(WebSockets) routing { webSocket { - val solarxrConnection = createSolarXRConnection(serverContext, scope = this, onSend = { send(Frame.Binary(fin = true, data = it)) @@ -52,7 +45,7 @@ fun createSolarXRWebsocketServer(serverContext: VRServer) { when (frame) { is Frame.Binary -> onSolarXRMessage( frame.buffer, - solarxrConnection + solarxrConnection, ) is Frame.Close -> { @@ -61,10 +54,14 @@ fun createSolarXRWebsocketServer(serverContext: VRServer) { else -> {} } - } } } - }.start(wait = true) + } + engine.start(wait = false) + try { + awaitCancellation() + } finally { + engine.stop() + } } - diff --git a/server/core/src/main/java/dev/slimevr/tracker/device.kt b/server/core/src/main/java/dev/slimevr/tracker/device.kt index 11575d07c0..d8cc19b050 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/device.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/device.kt @@ -14,6 +14,7 @@ enum class DeviceOrigin { FEEDER, UDP, HID, + SERIAL, } data class DeviceState( @@ -31,15 +32,13 @@ sealed interface DeviceActions { data class Update(val transform: DeviceState.() -> DeviceState) : DeviceActions } - - val DeviceStatsBehaviour = DeviceBehaviour( reducer = { s, a -> if (a is DeviceActions.Update) a.transform(s) else s }, observer = { it.state.onEach { state -> -// AppLogger.device.info("Device state changed", state) +// AppLogger.device.info("Device state changed", state) }.launchIn(it.scope) - } + }, ) typealias DeviceContext = Context diff --git a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt index f1042ce93a..616af42633 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt @@ -24,7 +24,7 @@ data class TrackerState( val customName: String?, val rawRotation: Quaternion, val deviceId: Int, - val origin: DeviceOrigin + val origin: DeviceOrigin, ) sealed interface TrackerActions { @@ -35,17 +35,16 @@ typealias TrackerContext = Context typealias TrackerBehaviour = BasicBehaviour data class Tracker( - val context: TrackerContext + val context: TrackerContext, ) - val TrackerInfosBehaviour = TrackerBehaviour( reducer = { s, a -> if (a is TrackerActions.Update) a.transform(s) else s }, observer = { it.state.onEach { state -> -// AppLogger.tracker.info("Tracker state changed {State}", state) +// AppLogger.tracker.info("Tracker state changed {State}", state) }.launchIn(it.scope) - } + }, ) fun createTracker( @@ -55,7 +54,7 @@ fun createTracker( sensorType: ImuType, hardwareId: String, origin: DeviceOrigin, - serverContext: VRServer + serverContext: VRServer, ): Tracker { val trackerState = TrackerState( id = id, @@ -67,7 +66,7 @@ fun createTracker( origin = origin, deviceId = deviceId, customName = null, - sensorType = sensorType + sensorType = sensorType, ) val behaviours = listOf(TrackerInfosBehaviour) diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt index a8981bda4d..0fd84fe99a 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt @@ -6,9 +6,6 @@ import dev.slimevr.VRServerActions import dev.slimevr.context.Context import dev.slimevr.context.CustomBehaviour import dev.slimevr.context.createContext -import dev.slimevr.solarxr.SolarXRConnection -import dev.slimevr.solarxr.SolarXRConnectionActions -import dev.slimevr.solarxr.SolarXRConnectionState import dev.slimevr.tracker.Device import dev.slimevr.tracker.DeviceActions import dev.slimevr.tracker.DeviceOrigin @@ -41,22 +38,20 @@ data class UDPConnectionState( val address: String, val port: Int, val deviceId: Int?, - val trackerIds: List + val trackerIds: List, ) sealed interface UDPConnectionActions { data class StartPing(val startTime: Long) : UDPConnectionActions data class ReceivedPong(val id: Int, val duration: Long) : UDPConnectionActions data class Handshake(val deviceId: Int) : UDPConnectionActions - data class LastPacket(val packetNum: Long? = null, val time: Long) : - UDPConnectionActions + data class LastPacket(val packetNum: Long? = null, val time: Long) : UDPConnectionActions data class AssignTracker(val trackerId: TrackerIdNum) : UDPConnectionActions } typealias UDPConnectionContext = Context typealias UDPConnectionBehaviour = CustomBehaviour - data class UDPConnection( val context: UDPConnectionContext, val serverContext: VRServer, @@ -91,8 +86,8 @@ val PacketBehaviour = UDPConnectionBehaviour( it.context.dispatch( UDPConnectionActions.LastPacket( packetNum = 0, - time = now - ) + time = now, + ), ) AppLogger.udp.info("Reconnecting") } else if (packet.packetNumber < state.lastPacketNum) { @@ -151,12 +146,14 @@ val PingBehaviour = UDPConnectionBehaviour( it.context.dispatch( UDPConnectionActions.ReceivedPong( id = packet.data.pingId, - duration = ping - ) + duration = ping, + ), + ) + device.context.dispatch( + DeviceActions.Update { + copy(ping = ping) + }, ) - device.context.dispatch(DeviceActions.Update { - copy(ping = ping) - }) } }, ) @@ -166,8 +163,9 @@ val HandshakeBehaviour = UDPConnectionBehaviour( when (a) { is UDPConnectionActions.Handshake -> s.copy( didHandshake = true, - deviceId = a.deviceId + deviceId = a.deviceId, ) + else -> s } }, @@ -189,8 +187,8 @@ val HandshakeBehaviour = UDPConnectionBehaviour( it.serverContext.context.dispatch( VRServerActions.NewDevice( deviceId = deviceId, - context = newDevice - ) + context = newDevice, + ), ) it.context.dispatch(UDPConnectionActions.Handshake(deviceId)) it.send(Handshake()) @@ -206,22 +204,26 @@ val DeviceStatsBehaviour = UDPConnectionBehaviour( it.packetEvents.on { event -> val device = it.getDevice() ?: return@on - device.context.dispatch(DeviceActions.Update { - copy( - batteryLevel = event.data.level, - batteryVoltage = event.data.voltage - ) - }) + device.context.dispatch( + DeviceActions.Update { + copy( + batteryLevel = event.data.level, + batteryVoltage = event.data.voltage, + ) + }, + ) } it.packetEvents.on { event -> val device = it.getDevice() ?: return@on - device.context.dispatch(DeviceActions.Update { - copy(signalStrength = event.data.signal) - }) + device.context.dispatch( + DeviceActions.Update { + copy(signalStrength = event.data.signal) + }, + ) } - } + }, ) val SensorInfoBehaviour = UDPConnectionBehaviour( @@ -260,28 +262,27 @@ val SensorInfoBehaviour = UDPConnectionBehaviour( deviceId = deviceState.id, origin = DeviceOrigin.UDP, serverContext = observerContext.serverContext, - scope = observerContext.serverContext.context.scope + scope = observerContext.serverContext.context.scope, ) observerContext.serverContext.context.dispatch( VRServerActions.NewTracker( trackerId = trackerId, - context = newTracker - ) + context = newTracker, + ), ) observerContext.context.dispatch( UDPConnectionActions.AssignTracker( trackerId = TrackerIdNum( id = trackerId, - trackerNum = event.data.sensorId - ) - ) + trackerNum = event.data.sensorId, + ), + ), ) newTracker.context.dispatch(action) } - } - } + }, ) val SensorRotationBehaviour = UDPConnectionBehaviour( @@ -292,14 +293,14 @@ val SensorRotationBehaviour = UDPConnectionBehaviour( tracker.context.dispatch( TrackerActions.Update { copy(rawRotation = event.data.rotation) - } + }, ) } } - } + }, ) -fun createUDPConnectionContext( +fun createUDPConnection( id: String, socket: BoundDatagramSocket, remoteAddress: InetSocketAddress, @@ -312,7 +313,7 @@ fun createUDPConnectionContext( PingBehaviour, DeviceStatsBehaviour, SensorInfoBehaviour, - SensorRotationBehaviour + SensorRotationBehaviour, ) val context = createContext( @@ -325,7 +326,7 @@ fun createUDPConnectionContext( address = remoteAddress.hostname, port = remoteAddress.port, deviceId = null, - trackerIds = listOf() + trackerIds = listOf(), ), reducers = behaviours.map { it.reducer }, scope = scope, @@ -347,14 +348,20 @@ fun createUDPConnectionContext( }, getDevice = { val deviceId = context.state.value.deviceId - if (deviceId != null) serverContext.getDevice(deviceId) - else null + if (deviceId != null) { + serverContext.getDevice(deviceId) + } else { + null + } }, getTracker = { id -> val trackerId = context.state.value.trackerIds.find { it.trackerNum == id } - if (trackerId != null) serverContext.getTracker(trackerId.id) - else null - } + if (trackerId != null) { + serverContext.getTracker(trackerId.id) + } else { + null + } + }, ) behaviours.map { it.observer }.forEach { it?.invoke(conn) } diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt index a3eb7d8ae4..7aa0941bb6 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt @@ -33,7 +33,6 @@ private fun Source.readSafeQuat(): Quaternion { } } - enum class PacketType(val id: Int) { HEARTBEAT(0), ROTATION(1), @@ -57,7 +56,8 @@ enum class PacketType(val id: Int) { SET_CONFIG_FLAG(25), FLEX_DATA(26), POSITION(27), - PROTOCOL_CHANGE(200); + PROTOCOL_CHANGE(200), + ; companion object { private val map = entries.associateBy { it.id } @@ -81,7 +81,7 @@ data class Handshake( val mcuType: Int = 0, val protocolVersion: Int = 0, val firmware: String? = null, - val macString: String? = null + val macString: String? = null, ) : UDPPacket { override fun write(dst: Sink) { dst.writeByte(PacketType.HANDSHAKE.id.toByte()) @@ -94,38 +94,52 @@ data class Handshake( val b = if (remaining >= 4) readInt() else 0 val i = if (remaining >= 4) readInt() else 0 val m = if (remaining >= 4) readInt() else 0 - if (remaining >= 12) { readInt(); readInt(); readInt() } + if (remaining >= 12) { + readInt() + readInt() + readInt() + } val p = if (remaining >= 4) readInt() else 0 val f = if (remaining >= 1) readString(readByte().toLong()) else null val mac = if (remaining >= 6) { val bytes = readByteArray(6) bytes.joinToString(":") { "%02X".format(it) }.takeIf { it != "00:00:00:00:00:00" } - } else null + } else { + null + } Handshake(b, i, m, p, f, mac) } } } data class Rotation(override val sensorId: Int = 0, val rotation: Quaternion = Quaternion.IDENTITY) : SensorSpecificPacket { - companion object { fun read(src: Source) = Rotation(0, src.readSafeQuat()) } + companion object { + fun read(src: Source) = Rotation(0, src.readSafeQuat()) + } } data class Accel(val acceleration: Vector3 = Vector3.NULL, override val sensorId: Int = 0) : SensorSpecificPacket { companion object { fun read(src: Source) = Accel( Vector3(src.readSafeFloat(), src.readSafeFloat(), src.readSafeFloat()), - if (src.remaining > 0) src.readU8() else 0 + if (src.remaining > 0) src.readU8() else 0, ) } } data class PingPong(val pingId: Int = 0) : UDPPacket { - override fun write(dst: Sink) { dst.writeInt(pingId) } - companion object { fun read(src: Source) = PingPong(src.readInt()) } + override fun write(dst: Sink) { + dst.writeInt(pingId) + } + companion object { + fun read(src: Source) = PingPong(src.readInt()) + } } data class Serial(val serial: String = "") : UDPPacket { - companion object { fun read(src: Source) = Serial(src.readString(src.readInt().toLong())) } + companion object { + fun read(src: Source) = Serial(src.readString(src.readInt().toLong())) + } } data class BatteryLevel(val voltage: Float = 0f, val level: Float = 0f) : UDPPacket { @@ -138,11 +152,15 @@ data class BatteryLevel(val voltage: Float = 0f, val level: Float = 0f) : UDPPac } data class Tap(override val sensorId: Int = 0, val tap: Int = 0) : SensorSpecificPacket { - companion object { fun read(src: Source) = Tap(src.readU8(), src.readU8()) } + companion object { + fun read(src: Source) = Tap(src.readU8(), src.readU8()) + } } data class ErrorPacket(override val sensorId: Int = 0, val errorNumber: Int = 0) : SensorSpecificPacket { - companion object { fun read(src: Source) = ErrorPacket(src.readU8(), src.readU8()) } + companion object { + fun read(src: Source) = ErrorPacket(src.readU8(), src.readU8()) + } } data class SensorInfo( @@ -152,7 +170,7 @@ data class SensorInfo( val sensorConfig: UShort? = null, val hasCompletedRestCalibration: Boolean? = null, val trackerPosition: Int? = null, - val trackerDataType: Int? = null + val trackerDataType: Int? = null, ) : SensorSpecificPacket { companion object { fun read(src: Source) = with(src) { @@ -169,14 +187,16 @@ data class SensorInfo( } data class Rotation2(override val sensorId: Int = 1, val rotation: Quaternion = Quaternion.IDENTITY) : SensorSpecificPacket { - companion object { fun read(src: Source) = Rotation2(1, src.readSafeQuat()) } + companion object { + fun read(src: Source) = Rotation2(1, src.readSafeQuat()) + } } data class RotationData( override val sensorId: Int = 0, val dataType: Int = 0, val rotation: Quaternion = Quaternion.IDENTITY, - val calibrationInfo: Int = 0 + val calibrationInfo: Int = 0, ) : SensorSpecificPacket { companion object { fun read(src: Source): RotationData = with(src) { @@ -190,7 +210,9 @@ data class RotationData( } data class MagnetometerAccuracy(override val sensorId: Int = 0, val accuracy: Float = 0f) : SensorSpecificPacket { - companion object { fun read(src: Source) = MagnetometerAccuracy(src.readU8(), src.readSafeFloat()) } + companion object { + fun read(src: Source) = MagnetometerAccuracy(src.readU8(), src.readSafeFloat()) + } } data class SignalStrength(override val sensorId: Int = 0, val signal: Int = 0) : SensorSpecificPacket { @@ -214,17 +236,21 @@ data class Temperature(override val sensorId: Int = 0, val temp: Float = 0f) : S } data class UserActionPacket(val type: Int = 0) : UDPPacket { - companion object { fun read(src: Source) = UserActionPacket(src.readU8()) } + companion object { + fun read(src: Source) = UserActionPacket(src.readU8()) + } } data class FeatureFlags(val firmwareFeatures: ByteArray = byteArrayOf()) : UDPPacket { - companion object { fun read(src: Source) = FeatureFlags(src.readByteArray(src.remaining.toInt())) } + companion object { + fun read(src: Source) = FeatureFlags(src.readByteArray(src.remaining.toInt())) + } } data class RotationAndAccel( override val sensorId: Int = 0, val rotation: Quaternion = Quaternion.IDENTITY, - val acceleration: Vector3 = Vector3.NULL + val acceleration: Vector3 = Vector3.NULL, ) : SensorSpecificPacket { companion object { fun read(src: Source): RotationAndAccel { @@ -242,7 +268,9 @@ data class RotationAndAccel( } data class AckConfigChange(override val sensorId: Int = 0, val configType: UShort = 0u) : SensorSpecificPacket { - companion object { fun read(src: Source) = AckConfigChange(src.readU8(), src.readShort().toUShort()) } + companion object { + fun read(src: Source) = AckConfigChange(src.readU8(), src.readShort().toUShort()) + } } data class SetConfigFlag(override val sensorId: Int = 255, val configType: UShort = 0u, val state: Boolean = false) : SensorSpecificPacket { @@ -254,7 +282,9 @@ data class SetConfigFlag(override val sensorId: Int = 255, val configType: UShor } data class FlexData(override val sensorId: Int = 0, val flexData: Float = 0f) : SensorSpecificPacket { - companion object { fun read(src: Source) = FlexData(src.readU8(), src.readSafeFloat()) } + companion object { + fun read(src: Source) = FlexData(src.readU8(), src.readSafeFloat()) + } } data class PositionPacket(override val sensorId: Int = 0, val position: Vector3 = Vector3.NULL) : SensorSpecificPacket { @@ -268,32 +298,57 @@ data class ProtocolChange(val targetProtocol: Int = 0, val targetVersion: Int = dst.writeUByte(targetProtocol.toUByte()) dst.writeUByte(targetVersion.toUByte()) } - companion object { fun read(src: Source) = ProtocolChange(src.readU8(), src.readU8()) } + companion object { + fun read(src: Source) = ProtocolChange(src.readU8(), src.readU8()) + } } fun readPacket(type: PacketType, src: Source): UDPPacket = when (type) { PacketType.HEARTBEAT -> Heartbeat + PacketType.HANDSHAKE -> Handshake.read(src) + PacketType.ROTATION -> Rotation.read(src) + PacketType.ACCEL -> Accel.read(src) + PacketType.PING_PONG -> PingPong.read(src) + PacketType.SERIAL -> Serial.read(src) + PacketType.BATTERY_LEVEL -> BatteryLevel.read(src) + PacketType.TAP -> Tap.read(src) + PacketType.ERROR -> ErrorPacket.read(src) + PacketType.SENSOR_INFO -> SensorInfo.read(src) + PacketType.ROTATION_2 -> Rotation2.read(src) + PacketType.ROTATION_DATA -> RotationData.read(src) + PacketType.MAGNETOMETER_ACCURACY -> MagnetometerAccuracy.read(src) + PacketType.SIGNAL_STRENGTH -> SignalStrength.read(src) + PacketType.TEMPERATURE -> Temperature.read(src) + PacketType.USER_ACTION -> UserActionPacket.read(src) + PacketType.FEATURE_FLAGS -> FeatureFlags.read(src) + PacketType.ROTATION_AND_ACCEL -> RotationAndAccel.read(src) + PacketType.ACK_CONFIG_CHANGE -> AckConfigChange.read(src) - PacketType.SET_CONFIG_FLAG -> SetConfigFlag() // Usually outbound + + PacketType.SET_CONFIG_FLAG -> SetConfigFlag() + + // Usually outbound PacketType.FLEX_DATA -> FlexData.read(src) + PacketType.POSITION -> PositionPacket.read(src) + PacketType.PROTOCOL_CHANGE -> ProtocolChange.read(src) } @@ -308,7 +363,6 @@ fun writePacket(dst: Sink, packet: UDPPacket) { else -> error("Outbound support not implemented for ${packet::class.simpleName}") } - if (type != PacketType.HANDSHAKE) { dst.writeInt(type.id) dst.writeLong(0) diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt index 49fd00783a..6c48d31c68 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt @@ -48,7 +48,7 @@ suspend fun createUDPTrackerServer( if (connContext !== null) { connContext.packetEvents.emit(event) } else { - val newContext = createUDPConnectionContext( + val newContext = createUDPConnection( id = address.hostname, remoteAddress = address, socket = serverSocket, diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index 98cc98a957..2a50b6741c 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -1,15 +1,19 @@ package dev.slimevr -import dev.slimevr.context.BasicBehaviour import dev.slimevr.context.Context +import dev.slimevr.context.CustomBehaviour import dev.slimevr.context.createContext +import dev.slimevr.firmware.FirmwareManager +import dev.slimevr.serial.SerialServer import dev.slimevr.tracker.Device import dev.slimevr.tracker.Tracker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.incrementAndFetch data class VRServerState( val trackers: Map, @@ -22,7 +26,7 @@ sealed interface VRServerActions { } typealias VRServerContext = Context -typealias VRServerBehaviour = BasicBehaviour +typealias VRServerBehaviour = CustomBehaviour val BaseBehaviour = VRServerBehaviour( reducer = { s, a -> @@ -32,24 +36,32 @@ val BaseBehaviour = VRServerBehaviour( } }, observer = { context -> - context.state.distinctUntilChangedBy { state -> state.trackers.size }.onEach { + context.context.state.distinctUntilChangedBy { state -> state.trackers.size }.onEach { println("tracker list size changed") - }.launchIn(context.scope) + }.launchIn(context.context.scope) + + context.serialServer.context.state.distinctUntilChangedBy { state -> state.availablePorts.size }.onEach { + println("Avalable ports $it") + }.launchIn(context.context.scope) }, ) +@OptIn(ExperimentalAtomicApi::class) data class VRServer( val context: VRServerContext, + val serialServer: SerialServer, + val firmwareManager: FirmwareManager, + // Moved this outside of the context to make this faster and safer to use - private val handleCounter: AtomicInteger, + private val handleCounter: AtomicInt, ) { - fun nextHandle() = handleCounter.incrementAndGet() + fun nextHandle() = handleCounter.incrementAndFetch() fun getTracker(id: Int) = context.state.value.trackers[id] fun getDevice(id: Int) = context.state.value.devices[id] companion object { - fun create(scope: CoroutineScope): VRServer { - val server = VRServerState( + fun create(scope: CoroutineScope, serialServer: SerialServer, firmwareManager: FirmwareManager): VRServer { + val state = VRServerState( trackers = mapOf(), devices = mapOf(), ) @@ -57,17 +69,21 @@ data class VRServer( val behaviours = listOf(BaseBehaviour) val context = createContext( - initialState = server, + initialState = state, reducers = behaviours.map { it.reducer }, scope = scope, ) - behaviours.map { it.observer }.forEach { it?.invoke(context) } - - return VRServer( + val server = VRServer( context = context, - handleCounter = AtomicInteger(0), + serialServer = serialServer, + firmwareManager = firmwareManager, + handleCounter = AtomicInt(0), ) + + behaviours.map { it.observer }.forEach { it?.invoke(server) } + + return server } } } 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 4b5a6a886c..42e388aadf 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -5,16 +5,20 @@ package dev.slimevr.desktop import dev.slimevr.VRServer import dev.slimevr.config.createAppConfig import dev.slimevr.desktop.ipc.createIpcServers +import dev.slimevr.desktop.serial.createDesktopSerialServer +import dev.slimevr.firmware.createFirmwareManager +import dev.slimevr.resolveConfigDirectory import dev.slimevr.solarxr.createSolarXRWebsocketServer import dev.slimevr.tracker.udp.createUDPTrackerServer -import dev.slimevr.resolveConfigDirectory import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking fun main(args: Array) = runBlocking { val configFolder = resolveConfigDirectory() ?: error("Unable to resolve config folder") val config = createAppConfig(this, configFolder = configFolder.toFile()) - val server = VRServer.create(this) + val serialServer = createDesktopSerialServer(this) + val firmwareManager = createFirmwareManager(serialServer = serialServer, scope = this) + val server = VRServer.create(this, serialServer, firmwareManager) launch { createUDPTrackerServer(server, config) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt index 81649da94b..14d8e1bf95 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt @@ -1,8 +1,8 @@ package dev.slimevr.desktop.ipc -import dev.slimevr.VRServer import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform +import dev.slimevr.VRServer import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -21,11 +21,13 @@ suspend fun createIpcServers(server: VRServer) = coroutineScope { launch { createUnixFeederSocket(server) } launch { createUnixSolarXRSocket(server) } } + Platform.WINDOWS -> { launch { createWindowsDriverPipe(server) } launch { createWindowsFeederPipe(server) } launch { createWindowsSolarXRPipe(server) } } + else -> Unit } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index 73467db7ce..05fd369ee2 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -16,39 +16,38 @@ import java.nio.channels.ServerSocketChannel import java.nio.channels.SocketChannel import kotlin.io.path.Path -suspend fun createUnixDriverSocket(server: VRServer) = - acceptUnixClients(DRIVER_SOCKET_NAME) { channel -> - handleDriverConnection( - server = server, - messages = readFramedMessages(channel), - send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, - ) - } +suspend fun createUnixDriverSocket(server: VRServer) = acceptUnixClients(DRIVER_SOCKET_NAME) { channel -> + handleDriverConnection( + server = server, + messages = readFramedMessages(channel), + send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, + ) +} -suspend fun createUnixFeederSocket(server: VRServer) = - acceptUnixClients(FEEDER_SOCKET_NAME) { channel -> - handleFeederConnection( - server = server, - messages = readFramedMessages(channel), - send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, - ) - } +suspend fun createUnixFeederSocket(server: VRServer) = acceptUnixClients(FEEDER_SOCKET_NAME) { channel -> + handleFeederConnection( + server = server, + messages = readFramedMessages(channel), + send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, + ) +} -suspend fun createUnixSolarXRSocket(server: VRServer) = - acceptUnixClients(SOLARXR_SOCKET_NAME) { channel -> - handleSolarXRConnection( - server = server, - messages = readFramedMessages(channel), - send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, - ) - } +suspend fun createUnixSolarXRSocket(server: VRServer) = acceptUnixClients(SOLARXR_SOCKET_NAME) { channel -> + handleSolarXRConnection( + server = server, + messages = readFramedMessages(channel), + send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, + ) +} private fun isSocketInUse(socketPath: String): Boolean = try { SocketChannel.open(StandardProtocolFamily.UNIX).use { it.connect(UnixDomainSocketAddress.of(socketPath)) true } -} catch (_: Exception) { false } +} catch (_: Exception) { + false +} // Length field is LE u32 and includes the 4-byte header itself private fun readFramedMessages(channel: SocketChannel) = flow { @@ -92,4 +91,4 @@ private suspend fun acceptUnixClients( launch { handle(client) } } } -} \ No newline at end of file +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt index 5622cd6e94..4c89afb747 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt @@ -19,32 +19,29 @@ import java.nio.ByteOrder private val k32 = Kernel32.INSTANCE private val adv32 = Advapi32.INSTANCE -suspend fun createWindowsDriverPipe(server: VRServer) = - acceptWindowsClients(DRIVER_PIPE) { handle -> - handleDriverConnection( - server = server, - messages = readFramedMessages(handle), - send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, - ) - } +suspend fun createWindowsDriverPipe(server: VRServer) = acceptWindowsClients(DRIVER_PIPE) { handle -> + handleDriverConnection( + server = server, + messages = readFramedMessages(handle), + send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, + ) +} -suspend fun createWindowsFeederPipe(server: VRServer) = - acceptWindowsClients(FEEDER_PIPE) { handle -> - handleFeederConnection( - server = server, - messages = readFramedMessages(handle), - send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, - ) - } +suspend fun createWindowsFeederPipe(server: VRServer) = acceptWindowsClients(FEEDER_PIPE) { handle -> + handleFeederConnection( + server = server, + messages = readFramedMessages(handle), + send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, + ) +} -suspend fun createWindowsSolarXRPipe(server: VRServer) = - acceptWindowsClients(SOLARXR_PIPE) { handle -> - handleSolarXRConnection( - server = server, - messages = readFramedMessages(handle), - send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, - ) - } +suspend fun createWindowsSolarXRPipe(server: VRServer) = acceptWindowsClients(SOLARXR_PIPE) { handle -> + handleSolarXRConnection( + server = server, + messages = readFramedMessages(handle), + send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, + ) +} // Length field is LE u32 and includes the 4-byte header itself private fun readFramedMessages(handle: WinNT.HANDLE) = flow { @@ -92,7 +89,10 @@ private fun createSecurePipe(pipeName: String): WinNT.HANDLE { WinBase.PIPE_ACCESS_DUPLEX, WinBase.PIPE_TYPE_BYTE or WinBase.PIPE_READMODE_BYTE or WinBase.PIPE_WAIT, WinBase.PIPE_UNLIMITED_INSTANCES, - 65536, 65536, 0, attributes, + 65536, + 65536, + 0, + attributes, ) check(pipe != WinNT.INVALID_HANDLE_VALUE) { "CreateNamedPipe failed for $pipeName: ${k32.GetLastError()}" @@ -123,4 +123,4 @@ private suspend fun acceptWindowsClients( } } } -} \ No newline at end of file +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/serial/flash.kt b/server/desktop/src/main/java/dev/slimevr/desktop/serial/flash.kt new file mode 100644 index 0000000000..5fd74dd2ab --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/serial/flash.kt @@ -0,0 +1,68 @@ +package dev.slimevr.desktop.serial + +import dev.slimevr.serial.FlashingHandler +import com.fazecast.jSerialComm.SerialPort as JSerialPort + +class DesktopFlashingHandler : FlashingHandler { + private var port: JSerialPort? = null + + override fun openSerial(portObj: Any) { + val portLocation = portObj as? String ?: error("expected port location string") + val comPort = JSerialPort.getCommPorts().find { it.portLocation == portLocation } + ?: error("Unable to find port $portLocation") + if (comPort.isOpen) { + comPort.closePort() + } + if (!comPort.openPort(1000)) { + error("Unable to open port $portLocation") + } + this.port = comPort + } + + override fun closeSerial() { + port?.closePort() + port = null + } + + override fun setDTR(value: Boolean) { + val p = port ?: error("no port to set DTR") + if (value) p.setDTR() else p.clearDTR() + } + + override fun setRTS(value: Boolean) { + val p = port ?: error("no port to set RTS") + if (value) p.setRTS() else p.clearRTS() + } + + override fun write(data: ByteArray) { + val p = port ?: error("no port to write") + p.writeBytes(data, data.size) + } + + override fun read(length: Int): ByteArray { + val p = port ?: error("no port to read") + val data = ByteArray(length) + p.readBytes(data, length) + return data + } + + override fun changeBaud(baud: Int) { + val p = port ?: error("no port to set baud") + if (!p.setBaudRate(baud)) error("Unable to change baudrate to $baud") + } + + override fun setReadTimeout(timeout: Long) { + val p = port ?: error("no port to set timeout") + p.setComPortTimeouts(JSerialPort.TIMEOUT_READ_BLOCKING, timeout.toInt(), 0) + } + + override fun availableBytes(): Int { + val p = port ?: error("no port to check available bytes") + return p.bytesAvailable() + } + + override fun flushIOBuffers() { + val p = port ?: error("no port to flush") + p.flushIOBuffers() + } +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt b/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt new file mode 100644 index 0000000000..552e2357b3 --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt @@ -0,0 +1,110 @@ +package dev.slimevr.desktop.serial + +import com.fazecast.jSerialComm.SerialPortEvent +import com.fazecast.jSerialComm.SerialPortMessageListener +import dev.slimevr.AppLogger +import dev.slimevr.serial.FlashingHandler +import dev.slimevr.serial.SerialPortHandle +import dev.slimevr.serial.SerialPortInfo +import dev.slimevr.serial.SerialServer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.OutputStreamWriter +import com.fazecast.jSerialComm.SerialPort as JSerialPort + +private val SUPPORTED_BOARDS: Set> = setOf( + Pair(0x1A86, 0x7522), // CH340 + Pair(0x1A86, 0x7523), // CH340 + Pair(0x1A86, 0x5523), // CH341 + Pair(0x1A86, 0x55D3), // CH343 + Pair(0x1A86, 0x55D4), // CH9102x + Pair(0x10C4, 0xEA60), // CP210x + Pair(0x303A, 0x1001), // ESP32-S3 + Pair(0x303A, 0x0002), // ESP32 + Pair(0x0403, 0x6001), // FTDI FT232 +) + +private fun isKnownBoard(vid: Int, pid: Int) = SUPPORTED_BOARDS.contains(vid to pid) + +private fun openPort( + portLocation: String, + scope: CoroutineScope, + onDataReceived: suspend (String, String) -> Unit, + onPortDisconnected: suspend (String) -> Unit, +): SerialPortHandle? { + val port = JSerialPort.getCommPorts().find { it.portLocation == portLocation } ?: return null + port.baudRate = 115200 + port.clearRTS() + port.clearDTR() + if (!port.openPort(1000)) return null + + // Anonymous object is required by the jSerialComm API + port.addDataListener(object : SerialPortMessageListener { + override fun getListeningEvents() = JSerialPort.LISTENING_EVENT_DATA_RECEIVED or JSerialPort.LISTENING_EVENT_PORT_DISCONNECTED + + override fun getMessageDelimiter() = byteArrayOf(0x0A) + override fun delimiterIndicatesEndOfMessage() = true + + override fun serialEvent(event: SerialPortEvent) { + when (event.eventType) { + JSerialPort.LISTENING_EVENT_DATA_RECEIVED -> { + val line = event.receivedData.toString(Charsets.UTF_8).trimEnd() + scope.launch { onDataReceived(portLocation, line) } + } + + JSerialPort.LISTENING_EVENT_PORT_DISCONNECTED -> + scope.launch { onPortDisconnected(portLocation) } + } + } + }) + + return SerialPortHandle( + portLocation = portLocation, + descriptivePortName = port.descriptivePortName, + writeCommand = { text -> + OutputStreamWriter(port.outputStream).append(text).append("\n").flush() + }, + close = { + port.removeDataListener() + port.closePort() + }, + ) +} + +private suspend fun runSerialPoller(server: SerialServer) { + var lastKnown: Set = emptySet() + + while (true) { + val current = withContext(Dispatchers.IO) { + JSerialPort.getCommPorts() + .filter { isKnownBoard(it.vendorID, it.productID) } + .associate { port -> + port.portLocation to SerialPortInfo( + portLocation = port.portLocation, + descriptivePortName = port.descriptivePortName, + vendorId = port.vendorID, + productId = port.productID, + ) + } + } + + val added = current.keys - lastKnown + val removed = lastKnown - current.keys + + added.forEach { loc -> server.onPortDetected(current.getValue(loc)) } + removed.forEach { loc -> server.onPortLost(loc) } + + lastKnown = current.keys + delay(3000) + } +} + +fun createDesktopSerialServer(scope: CoroutineScope): SerialServer { + val server = SerialServer.create(openPort = ::openPort, openFlashingPort = { DesktopFlashingHandler() }, scope = scope) + scope.launch { runSerialPoller(server) } + return server +} From d46fb013ac2fca80d40f5b3c8d356c31c8d0a710 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 26 Mar 2026 09:19:58 +0100 Subject: [PATCH 015/350] Unit tests + Fix start datafeed behaviour --- server/core/build.gradle.kts | 1 + .../main/java/dev/slimevr/firmware/server.kt | 28 ++ .../main/java/dev/slimevr/solarxr/datafeed.kt | 57 ++-- .../main/java/dev/slimevr/solarxr/firmware.kt | 2 +- .../main/java/dev/slimevr/solarxr/serial.kt | 1 - .../main/java/dev/slimevr/tracker/device.kt | 1 - .../src/test/java/dev/slimevr/TestServer.kt | 31 ++ .../dev/slimevr/firmware/DoSerialFlashTest.kt | 315 ++++++++++++++++++ .../reducers/FirmwareManagerReducerTest.kt | 80 +++++ .../dev/slimevr/serial/SerialServerTest.kt | 199 +++++++++++ .../reducers/SerialConnectionReducerTest.kt | 54 +++ .../java/dev/slimevr/solarxr/DataFeedTest.kt | 87 +++++ solarxr-protocol | 2 +- 13 files changed, 822 insertions(+), 36 deletions(-) create mode 100644 server/core/src/test/java/dev/slimevr/TestServer.kt create mode 100644 server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt create mode 100644 server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt create mode 100644 server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt create mode 100644 server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt create mode 100644 server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index 700aa3ab62..d51cfae270 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -89,6 +89,7 @@ dependencies { testImplementation(platform("org.junit:junit-bom:6.0.2")) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.platform:junit-platform-launcher") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") } tasks.test { diff --git a/server/core/src/main/java/dev/slimevr/firmware/server.kt b/server/core/src/main/java/dev/slimevr/firmware/server.kt index bec07afb29..cf5a8cbe43 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/server.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/server.kt @@ -125,6 +125,34 @@ suspend fun doSerialFlash( return } + doSerialFlashPostFlash( + portLocation = portLocation, + needManualReboot = needManualReboot, + ssid = ssid, + password = password, + serialServer = serialServer, + server = server, + onStatus = onStatus, + ) +} + +/** + * Handles the post-flash provisioning phase: reconnects the serial console, + * reads the device MAC address, sends Wi-Fi credentials, and waits for the + * tracker to appear on the network. + * + * Separated from [doSerialFlash] so it can also be exercised independently for + * unit tests + */ +internal suspend fun doSerialFlashPostFlash( + portLocation: String, + needManualReboot: Boolean, + ssid: String?, + password: String?, + serialServer: SerialServer, + server: VRServer, + onStatus: suspend (FirmwareUpdateStatus, Int) -> Unit, +) { onStatus( if (needManualReboot) { FirmwareUpdateStatus.NEED_MANUAL_REBOOT diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 914f7155f7..9636735d93 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -5,11 +5,9 @@ import dev.slimevr.VRServer import dev.slimevr.tracker.DeviceState import dev.slimevr.tracker.TrackerState import io.ktor.util.moveToByteArray -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.isActive -import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import solarxr_protocol.MessageBundle import solarxr_protocol.data_feed.DataFeedConfig @@ -25,7 +23,6 @@ import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.TrackerId import solarxr_protocol.datatypes.hardware_info.HardwareStatus import solarxr_protocol.datatypes.math.Quat -import kotlin.time.measureTime private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMask: TrackerDataMask): TrackerData = TrackerData( trackerId = TrackerId( @@ -72,6 +69,7 @@ private fun createDevice( fun createDatafeedFrame( serverContext: VRServer, datafeedConfig: DataFeedConfig, + index: Int = 0, ): DataFeedMessageHeader { val serverState = serverContext.context.state.value val trackers = @@ -82,45 +80,44 @@ fun createDatafeedFrame( return DataFeedMessageHeader( message = DataFeedUpdate( devices = if (datafeedConfig.dataMask?.deviceData != null) devices else null, + index = index.toUByte(), ), ) } val DataFeedInitBehaviour = SolarXRConnectionBehaviour( + reducer = { s, a -> + when (a) { + is SolarXRConnectionActions.SetConfig -> s.copy( + dataFeedConfigs = a.configs, + datafeedTimers = a.timers, + ) + } + }, observer = { context -> context.dataFeedDispatcher.on { event -> val datafeeds = event.dataFeeds ?: return@on - val state = context.context.state.value - - state.datafeedTimers.forEach { - it.cancelAndJoin() - } - val timers = datafeeds.map { config -> - val minTime = config.minimumTimeSinceLast ?: return@map null + context.context.state.value.datafeedTimers.forEach { it.cancelAndJoin() } - return@map context.context.scope.launch(start = CoroutineStart.LAZY) { + val timers = datafeeds.mapIndexed { index, config -> + context.context.scope.launch { val fbb = FlatBufferBuilder(1024) + val minTime = config.minimumTimeSinceLast.toLong() while (isActive) { - val timeTaken = measureTime { - fbb.clear() - - fbb.finish( - MessageBundle( - dataFeedMsgs = listOf( - createDatafeedFrame(serverContext = context.serverContext, datafeedConfig = config), - ), - ).encode(fbb), - ) - - context.send(fbb.dataBuffer().moveToByteArray()) - } - val remainingDelay = (minTime.toLong() - timeTaken.inWholeMilliseconds) - assert(remainingDelay > 0) - delay(remainingDelay) + fbb.clear() + fbb.finish( + MessageBundle( + dataFeedMsgs = listOf( + createDatafeedFrame(context.serverContext, config, index), + ), + ).encode(fbb), + ) + context.send(fbb.dataBuffer().moveToByteArray()) + delay(minTime) } } - }.filterNotNull() + } context.context.dispatch( SolarXRConnectionActions.SetConfig( @@ -128,10 +125,6 @@ val DataFeedInitBehaviour = SolarXRConnectionBehaviour( timers = timers, ), ) - - context.context.scope.launch { - timers.joinAll() - } } context.dataFeedDispatcher.on { event -> diff --git a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt index 012c7b8908..215006d4eb 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt @@ -47,7 +47,7 @@ val FirmwareBehaviour = SolarXRConnectionBehaviour( method.needmanualreboot, method.ssid, method.password, - conn.serverContext + conn.serverContext, ) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt index 4ce3564900..a666b68033 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt @@ -21,7 +21,6 @@ import solarxr_protocol.rpc.SerialTrackerGetWifiScanRequest import solarxr_protocol.rpc.SerialTrackerRebootRequest import solarxr_protocol.rpc.SerialUpdateResponse - val SerialConsoleBehaviour = SolarXRConnectionBehaviour( observer = { conn -> val scope = conn.context.scope diff --git a/server/core/src/main/java/dev/slimevr/tracker/device.kt b/server/core/src/main/java/dev/slimevr/tracker/device.kt index d8cc19b050..615487abf1 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/device.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/device.kt @@ -14,7 +14,6 @@ enum class DeviceOrigin { FEEDER, UDP, HID, - SERIAL, } data class DeviceState( diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt new file mode 100644 index 0000000000..757fd7f299 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -0,0 +1,31 @@ +package dev.slimevr + +import dev.llelievr.espflashkotlin.FlasherSerialInterface +import dev.slimevr.firmware.createFirmwareManager +import dev.slimevr.serial.SerialPortHandle +import dev.slimevr.serial.SerialServer +import kotlinx.coroutines.CoroutineScope + +fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create( + openPort = { loc, _, _, _ -> SerialPortHandle(loc, "Fake $loc", {}, {}) }, + openFlashingPort = { + object : FlasherSerialInterface { + override fun openSerial(port: Any) = Unit + override fun closeSerial() = Unit + override fun write(data: ByteArray) = Unit + override fun read(length: Int) = ByteArray(length) + override fun setDTR(value: Boolean) = Unit + override fun setRTS(value: Boolean) = Unit + override fun changeBaud(baud: Int) = Unit + override fun setReadTimeout(timeout: Long) = Unit + override fun availableBytes() = 0 + override fun flushIOBuffers() = Unit + } + }, + scope = scope, +) + +fun buildTestVrServer(scope: CoroutineScope): VRServer { + val serialServer = buildTestSerialServer(scope) + return VRServer.create(scope, serialServer, createFirmwareManager(serialServer, scope)) +} \ No newline at end of file diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt new file mode 100644 index 0000000000..231802e830 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -0,0 +1,315 @@ +package dev.slimevr.firmware + +import dev.llelievr.espflashkotlin.FlasherSerialInterface +import dev.slimevr.VRServer +import dev.slimevr.VRServerActions +import dev.slimevr.serial.SerialPortHandle +import dev.slimevr.serial.SerialPortInfo +import dev.slimevr.serial.SerialServer +import dev.slimevr.tracker.DeviceOrigin +import dev.slimevr.tracker.createDevice +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.rpc.FirmwareUpdateStatus +import kotlin.test.Test +import kotlin.test.assertEquals + +private fun fakePortHandle(loc: String) = SerialPortHandle( + portLocation = loc, + descriptivePortName = "Fake $loc", + writeCommand = {}, + close = {}, +) + +private fun fakePort(loc: String = "COM1") = SerialPortInfo(loc, "Fake $loc", 0x1A86, 0x7523) + +/** Fails immediately at openSerial so the Flasher throws with no IO delays */ +private fun failingFlashHandler() = object : FlasherSerialInterface { + override fun openSerial(port: Any) = error("simulated flash failure") + override fun closeSerial() {} + override fun write(data: ByteArray) {} + override fun read(length: Int) = ByteArray(length) + override fun setDTR(value: Boolean) {} + override fun setRTS(value: Boolean) {} + override fun changeBaud(baud: Int) {} + override fun setReadTimeout(timeout: Long) {} + override fun availableBytes() = 0 + override fun flushIOBuffers() {} +} + +private fun buildSerialServer( + scope: kotlinx.coroutines.CoroutineScope, + flashHandler: () -> FlasherSerialInterface = ::failingFlashHandler, +) = SerialServer.create( + openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openFlashingPort = flashHandler, + scope = scope, +) + +// VRServer's BaseBehaviour sets up infinite StateFlow collectors via launchIn(scope). +// backgroundScope lets those run on the test scheduler but doesn't cause +// UncompletedCoroutinesError when the test ends. +private fun buildVrServer( + mainScope: kotlinx.coroutines.CoroutineScope, + backgroundScope: kotlinx.coroutines.CoroutineScope, + serialServer: SerialServer, +): VRServer { + val firmwareManager = createFirmwareManager(serialServer, mainScope) + return VRServer.create(backgroundScope, serialServer, firmwareManager) +} + +class DoSerialFlashTest { + + @Test + fun `emits ERROR_DEVICE_NOT_FOUND when port is not available`() = runTest { + val server = buildSerialServer(this) + val statuses = mutableListOf() + + doSerialFlash( + portLocation = "COM1", + parts = emptyList(), + needManualReboot = false, + ssid = null, + password = null, + serialServer = server, + server = buildVrServer(this, backgroundScope, server), + onStatus = { s, _ -> statuses += s }, + scope = this, + ) + + assertEquals(FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND, statuses.last()) + } + + @Test + fun `emits ERROR_DEVICE_NOT_FOUND when port already has a connection`() = runTest { + val server = buildSerialServer(this) + server.onPortDetected(fakePort()) + server.openConnection("COM1") + val statuses = mutableListOf() + + doSerialFlash( + portLocation = "COM1", + parts = emptyList(), + needManualReboot = false, + ssid = null, + password = null, + serialServer = server, + server = buildVrServer(this, backgroundScope, server), + onStatus = { s, _ -> statuses += s }, + scope = this, + ) + + assertEquals(FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND, statuses.last()) + } + + @Test + fun `emits ERROR_UPLOAD_FAILED when flash throws`() = runTest { + val server = buildSerialServer(this, ::failingFlashHandler) + server.onPortDetected(fakePort()) + val statuses = mutableListOf() + + doSerialFlash( + portLocation = "COM1", + parts = emptyList(), + needManualReboot = false, + ssid = null, + password = null, + serialServer = server, + server = buildVrServer(this, backgroundScope, server), + onStatus = { s, _ -> statuses += s }, + scope = this, + ) + + assertEquals(FirmwareUpdateStatus.ERROR_UPLOAD_FAILED, statuses.last()) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `emits ERROR_DEVICE_NOT_FOUND when device has not reconnected after flash`() = runTest { + // Port not back in availablePorts yet, openConnection inside doSerialFlashPostFlash is a no-op + val server = buildSerialServer(this) + val statuses = mutableListOf() + + doSerialFlashPostFlash( + portLocation = "COM1", + needManualReboot = false, + ssid = "wifi", + password = "pass", + serialServer = server, + server = buildVrServer(this, backgroundScope, server), + onStatus = { s, _ -> statuses += s }, + ) + + assertEquals(FirmwareUpdateStatus.ERROR_DEVICE_NOT_FOUND, statuses.last()) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `emits ERROR_PROVISIONING_FAILED when MAC not received within timeout`() = runTest { + val server = buildSerialServer(this) + server.onPortDetected(fakePort()) + server.openConnection("COM1") + val statuses = mutableListOf() + + val job = launch { + doSerialFlashPostFlash( + portLocation = "COM1", + needManualReboot = false, + ssid = "wifi", + password = "pass", + serialServer = server, + server = buildVrServer(this, backgroundScope, server), + onStatus = { s, _ -> statuses += s }, + ) + } + + advanceTimeBy(10_001) + job.join() + + assertEquals(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED, statuses.last()) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `emits ERROR_PROVISIONING_FAILED when ssid or password is null`() = runTest { + val server = buildSerialServer(this) + server.onPortDetected(fakePort()) + server.openConnection("COM1") + val statuses = mutableListOf() + + launch { + doSerialFlashPostFlash( + portLocation = "COM1", + needManualReboot = false, + ssid = null, + password = null, + serialServer = server, + server = buildVrServer(this, backgroundScope, server), + onStatus = { s, _ -> statuses += s }, + ) + } + + launch { + delay(100) + server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") + } + + advanceUntilIdle() + + assertEquals(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED, statuses.last()) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `emits ERROR_PROVISIONING_FAILED when wifi does not connect within timeout`() = runTest { + val server = buildSerialServer(this) + server.onPortDetected(fakePort()) + server.openConnection("COM1") + val statuses = mutableListOf() + + val job = launch { + doSerialFlashPostFlash( + portLocation = "COM1", + needManualReboot = false, + ssid = "wifi", + password = "pass", + serialServer = server, + server = buildVrServer(this, backgroundScope, server), + onStatus = { s, _ -> statuses += s }, + ) + } + + launch { + delay(100) + server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") + } + + // MAC arrives at 100ms; wifi timeout fires 30s later + advanceTimeBy(30_101) + job.join() + + assertEquals(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED, statuses.last()) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `emits ERROR_TIMEOUT when tracker does not appear within timeout`() = runTest { + val server = buildSerialServer(this) + server.onPortDetected(fakePort()) + server.openConnection("COM1") + val statuses = mutableListOf() + + val job = launch { + doSerialFlashPostFlash( + portLocation = "COM1", + needManualReboot = false, + ssid = "wifi", + password = "pass", + serialServer = server, + server = buildVrServer(this, backgroundScope, server), + onStatus = { s, _ -> statuses += s }, + ) + } + + launch { + delay(100) + server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") + } + launch { + delay(200) + server.onDataReceived("COM1", "looking for the server") + } + + // MAC at 100ms, wifi log at 200ms; tracker timeout fires 60s after wifi confirmed + advanceTimeBy(60_201) + job.join() + + assertEquals(FirmwareUpdateStatus.ERROR_TIMEOUT, statuses.last()) + } + + // ── Full success path ───────────────────────────────────────────────── + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `emits DONE when everything succeeds`() = runTest { + val server = buildSerialServer(this) + server.onPortDetected(fakePort()) + server.openConnection("COM1") + val vrServer = buildVrServer(this, backgroundScope, server) + val statuses = mutableListOf() + + launch { + doSerialFlashPostFlash( + portLocation = "COM1", + needManualReboot = false, + ssid = "wifi", + password = "pass", + serialServer = server, + server = vrServer, + onStatus = { s, _ -> statuses += s }, + ) + } + + launch { + delay(100) + server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") + } + launch { + delay(200) + server.onDataReceived("COM1", "looking for the server") + } + launch { + delay(300) + val device = createDevice(backgroundScope, vrServer.nextHandle(), "AA:BB:CC:DD:EE:FF", DeviceOrigin.UDP, vrServer) + vrServer.context.dispatch(VRServerActions.NewDevice(device.context.state.value.id, device)) + } + + advanceUntilIdle() + + assertEquals(FirmwareUpdateStatus.DONE, statuses.last()) + } +} diff --git a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt new file mode 100644 index 0000000000..f6a3639734 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt @@ -0,0 +1,80 @@ +package dev.slimevr.firmware.reducers + +import dev.slimevr.context.createContext +import dev.slimevr.firmware.FirmwareManagerActions +import dev.slimevr.firmware.FirmwareManagerBaseBehaviour +import dev.slimevr.firmware.FirmwareManagerState +import kotlinx.coroutines.test.runTest +import solarxr_protocol.rpc.FirmwareUpdateStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FirmwareManagerReducerTest { + private fun makeContext(scope: kotlinx.coroutines.CoroutineScope) = createContext( + initialState = FirmwareManagerState(jobs = mapOf()), + reducers = listOf(FirmwareManagerBaseBehaviour.reducer), + scope = scope, + ) + + @Test + fun `UpdateJob adds a new job`() = runTest { + val context = makeContext(this) + + context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.UPLOADING, 42)) + + val job = context.state.value.jobs["COM1"] + assertNotNull(job) + assertEquals(FirmwareUpdateStatus.UPLOADING, job.status) + assertEquals(42, job.progress) + } + + @Test + fun `UpdateJob replaces an existing job`() = runTest { + val context = makeContext(this) + + context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.DOWNLOADING, 0)) + context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.UPLOADING, 75)) + + val job = context.state.value.jobs["COM1"] + assertNotNull(job) + assertEquals(FirmwareUpdateStatus.UPLOADING, job.status) + assertEquals(75, job.progress) + assertEquals(1, context.state.value.jobs.size) + } + + @Test + fun `UpdateJob tracks multiple ports independently`() = runTest { + val context = makeContext(this) + + context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.UPLOADING, 10)) + context.dispatch(FirmwareManagerActions.UpdateJob("COM2", FirmwareUpdateStatus.DOWNLOADING, 0)) + + assertEquals(2, context.state.value.jobs.size) + assertEquals(FirmwareUpdateStatus.UPLOADING, context.state.value.jobs["COM1"]?.status) + assertEquals(FirmwareUpdateStatus.DOWNLOADING, context.state.value.jobs["COM2"]?.status) + } + + @Test + fun `RemoveJob removes an existing job`() = runTest { + val context = makeContext(this) + + context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.UPLOADING, 50)) + context.dispatch(FirmwareManagerActions.RemoveJob("COM1")) + + assertNull(context.state.value.jobs["COM1"]) + assertTrue(context.state.value.jobs.isEmpty()) + } + + @Test + fun `RemoveJob on unknown port is a no-op`() = runTest { + val context = makeContext(this) + + context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.UPLOADING, 50)) + context.dispatch(FirmwareManagerActions.RemoveJob("COM2")) + + assertEquals(1, context.state.value.jobs.size) + } +} \ No newline at end of file diff --git a/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt b/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt new file mode 100644 index 0000000000..705a30dab1 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt @@ -0,0 +1,199 @@ +package dev.slimevr.serial + +import dev.llelievr.espflashkotlin.FlasherSerialInterface +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +private fun fakePortHandle(portLocation: String) = SerialPortHandle( + portLocation = portLocation, + descriptivePortName = "Fake $portLocation", + writeCommand = {}, + close = {}, +) + +private fun fakeFlashingHandler() = object : FlasherSerialInterface { + override fun openSerial(port: Any) {} + override fun closeSerial() {} + override fun write(data: ByteArray) {} + override fun read(length: Int) = ByteArray(length) + override fun setDTR(value: Boolean) {} + override fun setRTS(value: Boolean) {} + override fun changeBaud(baud: Int) {} + override fun setReadTimeout(timeout: Long) {} + override fun availableBytes() = 0 + override fun flushIOBuffers() {} +} + +private fun fakePort() = SerialPortInfo("COM1", "Fake COM1", 0x1A86, 0x7523) + +class SerialServerTest { + @Test + fun `openForFlashing registers Flashing connection`() = runTest { + val server = SerialServer.create( + openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openFlashingPort = ::fakeFlashingHandler, + scope = this, + ) + server.onPortDetected(fakePort()) + + val handler = server.openForFlashing("COM1") + + assertNotNull(handler) + assertIs(server.context.state.value.connections["COM1"]) + } + + @Test + fun `openForFlashing returns null when port has an existing connection`() = runTest { + val server = SerialServer.create( + openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openFlashingPort = ::fakeFlashingHandler, + scope = this, + ) + server.onPortDetected(fakePort()) + server.openConnection("COM1") + + val handler = server.openForFlashing("COM1") + + assertNull(handler) + } + + @Test + fun `openForFlashing returns null for unknown port`() = runTest { + val server = SerialServer.create( + openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openFlashingPort = ::fakeFlashingHandler, + scope = this, + ) + + // No onPortDetected call, port is not in availablePorts + val handler = server.openForFlashing("COM1") + + assertNull(handler) + } + + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + @Test + fun `closeSerial removes Flashing connection asynchronously`() = runTest { + val server = SerialServer.create( + openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openFlashingPort = ::fakeFlashingHandler, + scope = this, + ) + server.onPortDetected(fakePort()) + val handler = server.openForFlashing("COM1")!! + + handler.closeSerial() + + // The scope.launch inside closeSerial has not run yet + assertIs(server.context.state.value.connections["COM1"]) + + advanceUntilIdle() + + // Now the dispatched RemoveConnection has run + assertNull(server.context.state.value.connections["COM1"]) + } + + @Test + fun `openConnection registers Console connection`() = runTest { + val server = SerialServer.create( + openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openFlashingPort = ::fakeFlashingHandler, + scope = this, + ) + server.onPortDetected(fakePort()) + + server.openConnection("COM1") + + assertIs(server.context.state.value.connections["COM1"]) + } + + @Test + fun `onPortLost closes Console and removes connection`() = runTest { + val server = SerialServer.create( + openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openFlashingPort = ::fakeFlashingHandler, + scope = this, + ) + server.onPortDetected(fakePort()) + server.openConnection("COM1") + + server.onPortLost("COM1") + + assertNull(server.context.state.value.connections["COM1"]) + assertNull(server.context.state.value.availablePorts["COM1"]) + } + + @Test + fun `openConnection while flashing is a no-op`() = runTest { + val server = SerialServer.create( + openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openFlashingPort = ::fakeFlashingHandler, + scope = this, + ) + server.onPortDetected(fakePort()) + server.openForFlashing("COM1") + + server.openConnection("COM1") + + // Still Flashing, openConnection must not have replaced it + assertIs(server.context.state.value.connections["COM1"]) + } + + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + @Test + fun `port can be flashed again after previous flash completes`() = runTest { + val server = SerialServer.create( + openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openFlashingPort = ::fakeFlashingHandler, + scope = this, + ) + server.onPortDetected(fakePort()) + val firstHandler = server.openForFlashing("COM1")!! + firstHandler.closeSerial() + advanceUntilIdle() + + // Connection is gone, port is still available, can flash again + val secondHandler = server.openForFlashing("COM1") + + assertNotNull(secondHandler) + assertIs(server.context.state.value.connections["COM1"]) + } + + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + @Test + fun `openConnection succeeds after flash completes`() = runTest { + val server = SerialServer.create( + openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openFlashingPort = ::fakeFlashingHandler, + scope = this, + ) + server.onPortDetected(fakePort()) + val handler = server.openForFlashing("COM1")!! + handler.closeSerial() + advanceUntilIdle() + + server.openConnection("COM1") + + assertIs(server.context.state.value.connections["COM1"]) + } + + @Test + fun `onPortLost during flash removes Flashing connection`() = runTest { + val server = SerialServer.create( + openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openFlashingPort = ::fakeFlashingHandler, + scope = this, + ) + server.onPortDetected(fakePort()) + server.openForFlashing("COM1") + + server.onPortLost("COM1") + + assertNull(server.context.state.value.connections["COM1"]) + assertNull(server.context.state.value.availablePorts["COM1"]) + } +} diff --git a/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt b/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt new file mode 100644 index 0000000000..15198bec9c --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt @@ -0,0 +1,54 @@ +package dev.slimevr.serial.reducers + +import dev.slimevr.serial.SerialConnectionActions +import dev.slimevr.serial.SerialConnectionState +import dev.slimevr.serial.SerialLogBehaviour +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class SerialConnectionReducerTest { + private val reducer = SerialLogBehaviour.reducer!! + + private fun state(lines: List = emptyList(), connected: Boolean = true) = SerialConnectionState( + portLocation = "COM1", + descriptivePortName = "Test Port", + connected = connected, + logLines = lines, + ) + + @Test + fun `LogLine appends to empty log`() { + val result = reducer(state(), SerialConnectionActions.LogLine("hello")) + assertEquals(listOf("hello"), result.logLines) + } + + @Test + fun `LogLine appends to existing log`() { + val result = reducer(state(listOf("a", "b")), SerialConnectionActions.LogLine("c")) + assertEquals(listOf("a", "b", "c"), result.logLines) + } + + @Test + fun `LogLine drops oldest line when at capacity`() { + val full = state(lines = List(500) { "line $it" }) + val result = reducer(full, SerialConnectionActions.LogLine("new")) + assertEquals(500, result.logLines.size) + assertEquals("line 1", result.logLines.first()) + assertEquals("new", result.logLines.last()) + } + + @Test + fun `LogLine does not drop below capacity`() { + val almostFull = state(lines = List(499) { "line $it" }) + val result = reducer(almostFull, SerialConnectionActions.LogLine("new")) + assertEquals(500, result.logLines.size) + assertEquals("line 0", result.logLines.first()) + } + + @Test + fun `Disconnected sets connected to false`() { + val result = reducer(state(connected = true), SerialConnectionActions.Disconnected) + assertFalse(result.connected) + } +} \ No newline at end of file diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt new file mode 100644 index 0000000000..994b32ccc7 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -0,0 +1,87 @@ +package dev.slimevr.solarxr + +import dev.slimevr.buildTestVrServer +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import solarxr_protocol.data_feed.DataFeedConfig +import solarxr_protocol.data_feed.PollDataFeed +import solarxr_protocol.data_feed.StartDataFeed +import kotlin.test.Test +import kotlin.test.assertEquals + +private fun config(intervalMs: Int) = DataFeedConfig(minimumTimeSinceLast = intervalMs.toUShort()) + +@OptIn(ExperimentalCoroutinesApi::class) +class DataFeedTest { + + @Test + fun `StartDataFeed sends frames at the configured interval`() = runTest { + var sendCount = 0 + val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope) + + conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100)))) + + // fires at t=0, t=100, t=200 + advanceTimeBy(250) + assertEquals(3, sendCount) + } + + @Test + fun `StartDataFeed with multiple configs runs each at its own frequency`() = runTest { + var sendCount = 0 + val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope) + + conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100), config(200)))) + + // 100ms feed: t=0, t=100, t=200 -> 3 sends + // 200ms feed: t=0, t=200 -> 2 sends + advanceTimeBy(250) + assertEquals(5, sendCount) + } + + @Test + fun `PollDataFeed sends exactly one frame without starting a repeating timer`() = runTest { + var sendCount = 0 + val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope) + + conn.dataFeedDispatcher.emit(PollDataFeed(config = config(100))) + + advanceTimeBy(500) + assertEquals(1, sendCount) + } + + @Test + fun `StartDataFeed cancels old timers when called a second time`() = runTest { + var sendCount = 0 + val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope) + + conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100)))) + advanceTimeBy(250) + assertEquals(3, sendCount) + + conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100)))) + sendCount = 0 + + advanceTimeBy(250) + assertEquals(3, sendCount) + } + + @Test + fun `StartDataFeed with empty list stops all existing timers`() = runTest { + var sendCount = 0 + val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope) + + conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100)))) + advanceTimeBy(250) + assertEquals(3, sendCount) + + conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = emptyList())) + sendCount = 0 + + advanceTimeBy(500) + assertEquals(0, sendCount) + } + + //TODO: need more tests for the content of a datafeed + check if the masks work +} diff --git a/solarxr-protocol b/solarxr-protocol index f784b4bb03..6ef8a144ff 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit f784b4bb034b1c588e9e0cefdd909c7ee5844dfc +Subproject commit 6ef8a144ffc57bcadf5168740aa0b49c50faa823 From 86dbdbddf8a748d45b0daaff0dbf496caf7397be Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 26 Mar 2026 14:44:39 +0100 Subject: [PATCH 016/350] OTA updates + Packet processing optimisations + code cleanup --- .../main/java/dev/slimevr/event-dispatcher.kt | 32 +++ .../src/main/java/dev/slimevr/firmware/ota.kt | 172 ++++++++++++++ .../main/java/dev/slimevr/firmware/server.kt | 51 +++- .../core/src/main/java/dev/slimevr/logger.kt | 1 + .../main/java/dev/slimevr/solarxr/datafeed.kt | 13 ++ .../main/java/dev/slimevr/solarxr/firmware.kt | 40 ++-- .../main/java/dev/slimevr/solarxr/solarxr.kt | 38 +-- .../main/java/dev/slimevr/tracker/device.kt | 27 ++- .../dev/slimevr/tracker/udp/connection.kt | 221 ++++++++++-------- .../java/dev/slimevr/tracker/udp/packets.kt | 48 +--- .../java/dev/slimevr/tracker/udp/server.kt | 70 +++--- .../dev/slimevr/firmware/DoSerialFlashTest.kt | 10 +- .../reducers/FirmwareManagerReducerTest.kt | 23 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 7 + 14 files changed, 523 insertions(+), 230 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/event-dispatcher.kt create mode 100644 server/core/src/main/java/dev/slimevr/firmware/ota.kt diff --git a/server/core/src/main/java/dev/slimevr/event-dispatcher.kt b/server/core/src/main/java/dev/slimevr/event-dispatcher.kt new file mode 100644 index 0000000000..5e9d12f39d --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/event-dispatcher.kt @@ -0,0 +1,32 @@ +package dev.slimevr + +import kotlin.reflect.KClass + +class EventDispatcher(private val keyOf: (T) -> KClass<*> = { it::class }) { + @Volatile var listeners: Map, List Unit>> = emptyMap() + @Volatile private var globalListeners: List Unit> = emptyList() + + fun register(key: KClass<*>, callback: suspend (T) -> Unit) { + synchronized(this) { + val updated = listeners.toMutableMap() + updated[key] = (updated[key] ?: emptyList()) + callback + listeners = updated + } + } + + @Suppress("UNCHECKED_CAST") + inline fun on(crossinline callback: suspend (P) -> Unit) { + register(P::class) { callback(it as P) } + } + + fun onAny(callback: suspend (T) -> Unit) { + synchronized(this) { + globalListeners = globalListeners + callback + } + } + + suspend fun emit(event: T) { + globalListeners.forEach { it(event) } + listeners[keyOf(event)]?.forEach { it(event) } + } +} diff --git a/server/core/src/main/java/dev/slimevr/firmware/ota.kt b/server/core/src/main/java/dev/slimevr/firmware/ota.kt new file mode 100644 index 0000000000..a66b8ec461 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/firmware/ota.kt @@ -0,0 +1,172 @@ +package dev.slimevr.firmware + +import dev.slimevr.VRServer +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.BoundDatagramSocket +import io.ktor.network.sockets.Datagram +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.aSocket +import io.ktor.utils.io.core.buildPacket +import io.ktor.utils.io.core.readText +import io.ktor.utils.io.core.writeFully +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import solarxr_protocol.datatypes.DeviceId +import solarxr_protocol.rpc.FirmwarePart +import solarxr_protocol.rpc.FirmwareUpdateStatus +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.IOException +import java.net.ServerSocket +import java.security.MessageDigest +import java.util.UUID +import kotlin.math.min + +private const val OTA_PORT = 8266 +private const val OTA_PASSWORD = "SlimeVR-OTA" +private const val OTA_CHUNK_SIZE = 2048 + +private fun bytesToMd5(bytes: ByteArray): String = + MessageDigest.getInstance("MD5").digest(bytes).joinToString("") { "%02x".format(it) } + +private suspend fun sendDatagram(socket: BoundDatagramSocket, message: String, target: InetSocketAddress) = + socket.send(Datagram(buildPacket { writeFully(message.toByteArray()) }, target)) + +/** + * Sends the OTA invitation over UDP and performs the optional AUTH challenge-response. + * Returns true if authentication succeeded (or was not required). + */ +private suspend fun otaAuthenticate( + selectorManager: SelectorManager, + deviceIp: String, + localPort: Int, + firmware: ByteArray, +): Boolean { + val fileMd5 = bytesToMd5(firmware) + val target = InetSocketAddress(deviceIp, OTA_PORT) + + aSocket(selectorManager).udp().bind(InetSocketAddress("0.0.0.0", 0)).use { socket -> + sendDatagram(socket, "0 $localPort ${firmware.size} $fileMd5\n", target) + + val responseData = withTimeout(10_000) { socket.receive() }.packet.readText() + if (responseData == "OK") return true + + val args = responseData.split(" ") + if (args.size != 2 || args[0] != "AUTH") return false + + val authToken = args[1] + val signature = bytesToMd5(UUID.randomUUID().toString().toByteArray()) + val hashedPassword = bytesToMd5(OTA_PASSWORD.toByteArray()) + val payload = bytesToMd5("$hashedPassword:$authToken:$signature".toByteArray()) + + sendDatagram(socket, "200 $signature $payload\n", target) + + val authResponseData = withTimeout(10_000) { socket.receive() }.packet.readText() + return authResponseData == "OK" + } +} + +/** + * Accepts a TCP connection from the device and streams the firmware in chunks. + * Returns true if the device confirmed a successful flash with "OK". + */ +private suspend fun otaUpload( + tcpServer: ServerSocket, + firmware: ByteArray, + onProgress: suspend (Int) -> Unit, +): Boolean { + val socket = withContext(Dispatchers.IO) { tcpServer.accept() } + return socket.use { + socket.soTimeout = 1_000 + val dos = DataOutputStream(socket.getOutputStream()) + val dis = DataInputStream(socket.getInputStream()) + + var offset = 0 + while (offset < firmware.size) { + onProgress(((offset.toDouble() / firmware.size) * 100).toInt()) + + val chunkLen = min(OTA_CHUNK_SIZE, firmware.size - offset) + withContext(Dispatchers.IO) { + dos.write(firmware, offset, chunkLen) + dos.flush() + } + offset += chunkLen + + val bytesSkipped = withContext(Dispatchers.IO) { dis.skipBytes(4) } + if (bytesSkipped != 4) throw IOException("Unexpected bytes skipped: $bytesSkipped") + } + + socket.soTimeout = 10_000 + val response = withContext(Dispatchers.IO) { dis.readBytes().decodeToString() } + response.contains("OK") + } +} + +suspend fun doOtaFlash( + deviceIp: String, + deviceId: DeviceId, + part: FirmwarePart, + server: VRServer, + onStatus: suspend (FirmwareUpdateStatus, Int) -> Unit, +) { + onStatus(FirmwareUpdateStatus.DOWNLOADING, 0) + + val firmware = try { + withContext(Dispatchers.IO) { + val url = part.url ?: error("missing url") + val digest = part.digest ?: error("missing digest") + downloadFirmware(url, digest) + } + } catch (_: Exception) { + onStatus(FirmwareUpdateStatus.ERROR_DOWNLOAD_FAILED, 0) + return + } + + onStatus(FirmwareUpdateStatus.AUTHENTICATING, 0) + + SelectorManager(Dispatchers.IO).use { selectorManager -> + // Bind TCP server first so we know which port to advertise in the invitation + ServerSocket(0).use { tcpServer -> + tcpServer.soTimeout = 30_000 + val localPort = tcpServer.localPort + + if (!otaAuthenticate(selectorManager, deviceIp, localPort, firmware)) { + onStatus(FirmwareUpdateStatus.ERROR_AUTHENTICATION_FAILED, 0) + return + } + + val uploaded = runCatching { + otaUpload(tcpServer, firmware) { progress -> + onStatus(FirmwareUpdateStatus.UPLOADING, progress) + } + } + + if (uploaded.isFailure) { + onStatus(FirmwareUpdateStatus.ERROR_UPLOAD_FAILED, 0) + return + } + } + } + + onStatus(FirmwareUpdateStatus.REBOOTING, 0) + + // wait for the tracker with that MAC to connect to the server via UDP + val connected = withTimeoutOrNull(60_000) { + server.context.state + .map { state -> state.devices.values.any { it.context.state.value.id.toUByte() == deviceId.id } } + .filter { it } + .first() + } + + if (connected == null) { + onStatus(FirmwareUpdateStatus.ERROR_TIMEOUT, 0) + return + } + + onStatus(FirmwareUpdateStatus.DONE, 0) +} diff --git a/server/core/src/main/java/dev/slimevr/firmware/server.kt b/server/core/src/main/java/dev/slimevr/firmware/server.kt index cf5a8cbe43..10aa07108f 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/server.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/server.kt @@ -24,13 +24,17 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import solarxr_protocol.datatypes.DeviceIdTable import solarxr_protocol.rpc.FirmwarePart +import solarxr_protocol.rpc.FirmwareUpdateDeviceId import solarxr_protocol.rpc.FirmwareUpdateStatus +import solarxr_protocol.rpc.SerialDevicePort private val MAC_REGEX = Regex("mac: (([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2})", RegexOption.IGNORE_CASE) data class FirmwareJobStatus( val portLocation: String, + val firmwareDeviceId: FirmwareUpdateDeviceId, val status: FirmwareUpdateStatus, val progress: Int = 0, ) @@ -40,7 +44,12 @@ data class FirmwareManagerState( ) sealed interface FirmwareManagerActions { - data class UpdateJob(val portLocation: String, val status: FirmwareUpdateStatus, val progress: Int = 0) : FirmwareManagerActions + data class UpdateJob( + val portLocation: String, + val firmwareDeviceId: FirmwareUpdateDeviceId, + val status: FirmwareUpdateStatus, + val progress: Int = 0, + ) : FirmwareManagerActions data class RemoveJob(val portLocation: String) : FirmwareManagerActions } @@ -52,7 +61,12 @@ val FirmwareManagerBaseBehaviour = FirmwareManagerBehaviour( reducer = { s, a -> when (a) { is FirmwareManagerActions.UpdateJob -> s.copy( - jobs = s.jobs + (a.portLocation to FirmwareJobStatus(a.portLocation, a.status, a.progress)), + jobs = s.jobs + (a.portLocation to FirmwareJobStatus( + portLocation = a.portLocation, + firmwareDeviceId = a.firmwareDeviceId, + status = a.status, + progress = a.progress, + )), ) is FirmwareManagerActions.RemoveJob -> s.copy(jobs = s.jobs - a.portLocation) @@ -64,6 +78,7 @@ val FirmwareManagerBaseBehaviour = FirmwareManagerBehaviour( data class FirmwareManager( val context: FirmwareManagerContext, val flash: suspend (portLocation: String, parts: List, needManualReboot: Boolean, ssid: String?, password: String?, server: VRServer) -> Unit, + val otaFlash: suspend (deviceIp: String, firmwareDeviceId: FirmwareUpdateDeviceId, part: FirmwarePart, VRServer) -> Unit, val cancelAll: suspend () -> Unit, ) @@ -229,7 +244,7 @@ internal suspend fun doSerialFlashPostFlash( // wait for the tracker with that MAC to connect to the server via UDP val connected = withTimeoutOrNull(60_000) { server.context.state - .map { state -> state.devices.values.any { it.context.state.value.address.uppercase() == macAddress } } + .map { state -> state.devices.values.any { it.context.state.value.macAddress?.uppercase() == macAddress } } .filter { it } .first() } @@ -268,13 +283,40 @@ fun createFirmwareManager( serialServer = serialServer, server = server, onStatus = { status, progress -> - context.dispatch(FirmwareManagerActions.UpdateJob(portLocation, status, progress)) + context.dispatch(FirmwareManagerActions.UpdateJob( + portLocation = portLocation, + firmwareDeviceId = SerialDevicePort(port = portLocation), + status = status, + progress = progress, + )) }, scope = scope, ) } } + val otaFlash: suspend (String, FirmwareUpdateDeviceId, FirmwarePart, VRServer) -> Unit = { deviceIp, firmwareDeviceId, part, server -> + runningJobs[deviceIp]?.cancelAndJoin() + runningJobs[deviceIp] = scope.launch { + doOtaFlash( + deviceIp = deviceIp, + deviceId = (firmwareDeviceId as? DeviceIdTable)?.id ?: error("device id should exist"), + part = part, + server = server, + onStatus = { status, progress -> + context.dispatch( + FirmwareManagerActions.UpdateJob( + portLocation = deviceIp, + firmwareDeviceId = firmwareDeviceId, + status = status, + progress = progress, + ), + ) + }, + ) + } + } + val cancelAll: suspend () -> Unit = { runningJobs.values.forEach { it.cancelAndJoin() } runningJobs.clear() @@ -283,6 +325,7 @@ fun createFirmwareManager( val manager = FirmwareManager( context = context, flash = flash, + otaFlash = otaFlash, cancelAll = cancelAll, ) diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index aa0042704a..b04523cea0 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -12,6 +12,7 @@ object AppLogger { val udp = logger("UDPConnection") val solarxr = logger("SolarXR") val serial = logger("Serial") + val firmware = logger("Firmware") init { loggingConfiguration { diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 9636735d93..fbd6485fb3 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -20,9 +20,13 @@ import solarxr_protocol.data_feed.tracker.TrackerData import solarxr_protocol.data_feed.tracker.TrackerDataMask import solarxr_protocol.data_feed.tracker.TrackerInfo import solarxr_protocol.datatypes.DeviceId +import solarxr_protocol.datatypes.Ipv4Address import solarxr_protocol.datatypes.TrackerId +import solarxr_protocol.datatypes.hardware_info.HardwareAddress +import solarxr_protocol.datatypes.hardware_info.HardwareInfo import solarxr_protocol.datatypes.hardware_info.HardwareStatus import solarxr_protocol.datatypes.math.Quat +import java.nio.ByteBuffer private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMask: TrackerDataMask): TrackerData = TrackerData( trackerId = TrackerId( @@ -57,6 +61,15 @@ private fun createDevice( .toUByte(), ping = device.ping?.toUShort(), ), + hardwareInfo = HardwareInfo( + mcuId = device.mcuType, + manufacturer = "SlimeVR", + boardType = device.boardType.toString(), + officialBoardType = device.boardType, + model = device.mcuType.toString(), + firmwareVersion = device.firmware, + ipAddress = Ipv4Address(ByteBuffer.wrap(device.address.toByteArray()).getLong().toUInt()), + ), trackers = if (trackerMask != null) { trackers.filter { it.deviceId == device.id } .map { tracker -> createTracker(device, tracker, trackerMask) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt index 215006d4eb..a0f8b38e84 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt @@ -5,10 +5,11 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import solarxr_protocol.datatypes.DeviceIdTable import solarxr_protocol.rpc.FirmwareUpdateRequest import solarxr_protocol.rpc.FirmwareUpdateStatusResponse import solarxr_protocol.rpc.FirmwareUpdateStopQueuesRequest -import solarxr_protocol.rpc.SerialDevicePort +import solarxr_protocol.rpc.OTAFirmwareUpdate import solarxr_protocol.rpc.SerialFirmwareUpdate val FirmwareBehaviour = SolarXRConnectionBehaviour( @@ -26,7 +27,7 @@ val FirmwareBehaviour = SolarXRConnectionBehaviour( if (prevJobs[portLocation] != jobStatus) { conn.sendRpc( FirmwareUpdateStatusResponse( - deviceId = SerialDevicePort(port = portLocation), + deviceId = jobStatus.firmwareDeviceId, status = jobStatus.status, progress = jobStatus.progress.toByte(), ), @@ -38,17 +39,30 @@ val FirmwareBehaviour = SolarXRConnectionBehaviour( .launchIn(scope) conn.rpcDispatcher.on { req -> - val method = req.method as? SerialFirmwareUpdate ?: return@on - val portLocation = method.deviceId?.port ?: return@on - val parts = method.firmwarePart ?: return@on - firmwareManager.flash( - portLocation, - parts, - method.needmanualreboot, - method.ssid, - method.password, - conn.serverContext, - ) + when (val method = req.method) { + is SerialFirmwareUpdate -> { + val portLocation = method.deviceId?.port ?: return@on + val parts = method.firmwarePart ?: return@on + firmwareManager.flash( + portLocation, + parts, + method.needmanualreboot, + method.ssid, + method.password, + conn.serverContext, + ) + } + + is OTAFirmwareUpdate -> { + val deviceId = method.deviceId ?: return@on + val part = method.firmwarePart ?: return@on + val device = conn.serverContext.getDevice(deviceId.id.toInt()) ?: return@on + val deviceIp = device.context.state.value.address + firmwareManager.otaFlash(deviceIp, DeviceIdTable(id = deviceId), part, conn.serverContext) + } + + else -> return@on + } } conn.rpcDispatcher.on { diff --git a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt b/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt index 497f33084e..1e2d029f81 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt @@ -5,17 +5,15 @@ import dev.slimevr.VRServer import dev.slimevr.context.Context import dev.slimevr.context.CustomBehaviour import dev.slimevr.context.createContext +import dev.slimevr.EventDispatcher import io.ktor.util.moveToByteArray import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import solarxr_protocol.MessageBundle import solarxr_protocol.data_feed.DataFeedConfig import solarxr_protocol.data_feed.DataFeedMessage import solarxr_protocol.rpc.RpcMessage import solarxr_protocol.rpc.RpcMessageHeader -import kotlin.reflect.KClass data class SolarXRConnectionState( val dataFeedConfigs: List, @@ -29,38 +27,12 @@ sealed interface SolarXRConnectionActions { typealias SolarXRConnectionContext = Context typealias SolarXRConnectionBehaviour = CustomBehaviour -class PacketDispatcher { - val listeners = mutableMapOf, MutableList Unit>>() - val globalListeners = mutableListOf Unit>() - val mutex = Mutex() - - @Suppress("UNCHECKED_CAST") - inline fun on(crossinline callback: suspend (P) -> Unit) { - synchronized(this) { - listeners.getOrPut(P::class as KClass) { mutableListOf() } - .add { callback(it as P) } - } - } - - fun onAny(callback: suspend (T) -> Unit) { - synchronized(this) { globalListeners.add(callback) } - } - - suspend fun emit(event: T) { - val targets = mutex.withLock { - val specific = listeners[event::class]?.toList() ?: emptyList() - val global = globalListeners.toList() - global + specific - } - targets.forEach { it(event) } - } -} data class SolarXRConnection( val context: SolarXRConnectionContext, val serverContext: VRServer, - val dataFeedDispatcher: PacketDispatcher, - val rpcDispatcher: PacketDispatcher, + val dataFeedDispatcher: EventDispatcher, + val rpcDispatcher: EventDispatcher, val send: suspend (ByteArray) -> Unit, val sendRpc: suspend (RpcMessage) -> Unit, ) @@ -94,8 +66,8 @@ fun createSolarXRConnection( val conn = SolarXRConnection( context = context, serverContext = serverContext, - dataFeedDispatcher = PacketDispatcher(), - rpcDispatcher = PacketDispatcher(), + dataFeedDispatcher = EventDispatcher(), + rpcDispatcher = EventDispatcher(), send = onSend, sendRpc = sendRpc, ) diff --git a/server/core/src/main/java/dev/slimevr/tracker/device.kt b/server/core/src/main/java/dev/slimevr/tracker/device.kt index 615487abf1..06d10a6db5 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/device.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/device.kt @@ -5,9 +5,13 @@ import dev.slimevr.VRServer import dev.slimevr.context.BasicBehaviour import dev.slimevr.context.Context import dev.slimevr.context.createContext +import io.ktor.http.HttpProtocolVersion import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import solarxr_protocol.datatypes.hardware_info.BoardType +import solarxr_protocol.datatypes.hardware_info.ImuType +import solarxr_protocol.datatypes.hardware_info.McuType enum class DeviceOrigin { DRIVER, @@ -20,10 +24,15 @@ data class DeviceState( val id: Int, val name: String, val address: String, + val macAddress: String?, val batteryLevel: Float, val batteryVoltage: Float, val ping: Long?, val signalStrength: Int?, + val firmware: String?, + val boardType: BoardType, + val mcuType: McuType, + val protocolVersion: Int, val origin: DeviceOrigin, ) @@ -47,7 +56,18 @@ data class Device( val context: DeviceContext, ) -fun createDevice(scope: CoroutineScope, id: Int, address: String, origin: DeviceOrigin, serverContext: VRServer): Device { +fun createDevice( + scope: CoroutineScope, + id: Int, + address: String, + macAddress: String? = null, + origin: DeviceOrigin, + boardType: BoardType, + mcuType: McuType, + firmware: String? = null, + protocolVersion: Int, + serverContext: VRServer +): Device { val deviceState = DeviceState( id = id, name = "Device $id", @@ -55,6 +75,11 @@ fun createDevice(scope: CoroutineScope, id: Int, address: String, origin: Device batteryVoltage = 0f, origin = origin, address = address, + macAddress = macAddress, + boardType = boardType, + firmware = firmware, + mcuType = mcuType, + protocolVersion = protocolVersion, ping = null, signalStrength = null, ) diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt index 0fd84fe99a..9a155f9ad2 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt @@ -1,6 +1,8 @@ package dev.slimevr.tracker.udp +import dev.llelievr.espflashkotlin.Packet import dev.slimevr.AppLogger +import dev.slimevr.EventDispatcher import dev.slimevr.VRServer import dev.slimevr.VRServerActions import dev.slimevr.context.Context @@ -14,14 +16,17 @@ import dev.slimevr.tracker.TrackerActions import dev.slimevr.tracker.TrackerIdNum import dev.slimevr.tracker.createDevice import dev.slimevr.tracker.createTracker -import io.ktor.network.sockets.BoundDatagramSocket -import io.ktor.network.sockets.Datagram -import io.ktor.network.sockets.InetSocketAddress -import io.ktor.utils.io.core.buildPacket import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.io.Buffer +import kotlinx.io.readByteArray +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress data class LastPing( val id: Int, @@ -52,14 +57,7 @@ sealed interface UDPConnectionActions { typealias UDPConnectionContext = Context typealias UDPConnectionBehaviour = CustomBehaviour -data class UDPConnection( - val context: UDPConnectionContext, - val serverContext: VRServer, - val packetEvents: PacketDispatcher, - val send: (UDPPacket) -> Unit, - val getDevice: () -> Device?, - val getTracker: (sensorId: Int) -> Tracker?, -) + val PacketBehaviour = UDPConnectionBehaviour( reducer = { s, a -> @@ -94,9 +92,7 @@ val PacketBehaviour = UDPConnectionBehaviour( AppLogger.udp.warn("WARN: Received packet with wrong packet number") return@onAny } else { - it.context.scope.launch { - it.context.dispatch(UDPConnectionActions.LastPacket(time = now)) - } + it.context.dispatch(UDPConnectionActions.LastPacket(time = now)) } } }, @@ -130,18 +126,18 @@ val PingBehaviour = UDPConnectionBehaviour( } // listen for the pong - it.packetEvents.on { packet -> + it.packetEvents.onPacket { packet -> val state = it.context.state.value - val deviceId = state.deviceId ?: return@on + val deviceId = state.deviceId ?: return@onPacket if (packet.data.pingId != state.lastPing.id + 1) { AppLogger.udp.warn("Ping ID does not match, ignoring ${packet.data.pingId} != ${state.lastPing.id + 1}") - return@on + return@onPacket } val ping = System.currentTimeMillis() - state.lastPing.startTime - val device = it.serverContext.getDevice(deviceId) ?: return@on + val device = it.serverContext.getDevice(deviceId) ?: return@onPacket it.context.dispatch( UDPConnectionActions.ReceivedPong( @@ -170,7 +166,7 @@ val HandshakeBehaviour = UDPConnectionBehaviour( } }, observer = { - it.packetEvents.on { packet -> + it.packetEvents.onPacket { packet -> val state = it.context.state.value if (state.deviceId == null) { @@ -179,7 +175,12 @@ val HandshakeBehaviour = UDPConnectionBehaviour( val newDevice = createDevice( id = deviceId, scope = it.serverContext.context.scope, - address = packet.data.macString ?: error("no mac address?"), + address = it.context.state.value.address, + macAddress = packet.data.macString, + boardType = packet.data.boardType, + protocolVersion = packet.data.protocolVersion, + mcuType = packet.data.mcuType, + firmware = packet.data.firmware, origin = DeviceOrigin.UDP, serverContext = it.serverContext, ) @@ -201,8 +202,8 @@ val HandshakeBehaviour = UDPConnectionBehaviour( val DeviceStatsBehaviour = UDPConnectionBehaviour( observer = { - it.packetEvents.on { event -> - val device = it.getDevice() ?: return@on + it.packetEvents.onPacket { event -> + val device = it.getDevice() ?: return@onPacket device.context.dispatch( DeviceActions.Update { @@ -214,8 +215,8 @@ val DeviceStatsBehaviour = UDPConnectionBehaviour( ) } - it.packetEvents.on { event -> - val device = it.getDevice() ?: return@on + it.packetEvents.onPacket { event -> + val device = it.getDevice() ?: return@onPacket device.context.dispatch( DeviceActions.Update { @@ -237,7 +238,7 @@ val SensorInfoBehaviour = UDPConnectionBehaviour( } }, observer = { observerContext -> - observerContext.packetEvents.on { event -> + observerContext.packetEvents.onPacket { event -> val tracker = observerContext.getTracker(event.data.sensorId) val action = TrackerActions.Update { @@ -287,84 +288,108 @@ val SensorInfoBehaviour = UDPConnectionBehaviour( val SensorRotationBehaviour = UDPConnectionBehaviour( observer = { context -> - context.packetEvents.on { event -> - val tracker = context.getTracker(event.data.sensorId) ?: return@on - tracker.context.scope.launch { - tracker.context.dispatch( - TrackerActions.Update { - copy(rawRotation = event.data.rotation) - }, - ) - } + context.packetEvents.onPacket { event -> + val tracker = context.getTracker(event.data.sensorId) ?: return@onPacket + tracker.context.dispatch( + TrackerActions.Update { + copy(rawRotation = event.data.rotation) + }, + ) } }, ) -fun createUDPConnection( - id: String, - socket: BoundDatagramSocket, - remoteAddress: InetSocketAddress, - serverContext: VRServer, - scope: CoroutineScope, -): UDPConnection { - val behaviours = listOf( - PacketBehaviour, - HandshakeBehaviour, - PingBehaviour, - DeviceStatsBehaviour, - SensorInfoBehaviour, - SensorRotationBehaviour, - ) - - val context = createContext( - initialState = UDPConnectionState( - id = id, - lastPacket = System.currentTimeMillis(), - lastPacketNum = 0, - lastPing = LastPing(id = 0, duration = 0, startTime = 0), - didHandshake = false, - address = remoteAddress.hostname, - port = remoteAddress.port, - deviceId = null, - trackerIds = listOf(), - ), - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - val dispatcher = PacketDispatcher() - - val conn = UDPConnection( - context = context, - serverContext = serverContext, - dispatcher, - send = { packet: UDPPacket -> + +data class UDPConnection( + val context: UDPConnectionContext, + val serverContext: VRServer, + val packetEvents: UDPPacketDispatcher, + val packetChannel: Channel>, + val send: (UDPPacket) -> Unit +) { + fun getDevice(): Device? { + val deviceId = context.state.value.deviceId + return if (deviceId != null) { + serverContext.getDevice(deviceId) + } else { + null + } + } + + fun getTracker(id: Int): Tracker? { + val trackerId = context.state.value.trackerIds.find { it.trackerNum == id } + return if (trackerId != null) { + serverContext.getTracker(trackerId.id) + } else { + null + } + } + + companion object { + fun create( + id: String, + socket: DatagramSocket, + remoteIp: String, + remotePort: Int, + serverContext: VRServer, + scope: CoroutineScope, + ): UDPConnection { + val behaviours = listOf( + PacketBehaviour, + HandshakeBehaviour, + PingBehaviour, + DeviceStatsBehaviour, + SensorInfoBehaviour, + SensorRotationBehaviour, + ) + + val context = createContext( + initialState = UDPConnectionState( + id = id, + lastPacket = System.currentTimeMillis(), + lastPacketNum = 0, + lastPing = LastPing(id = 0, duration = 0, startTime = 0), + didHandshake = false, + address = remoteIp, + port = remotePort, + deviceId = null, + trackerIds = listOf(), + ), + reducers = behaviours.map { it.reducer }, + scope = scope, + ) + + val dispatcher = EventDispatcher> { it.data::class } + val packetChannel = Channel>(capacity = 256) + val remoteInetAddress = InetAddress.getByName(remoteIp) + + val conn = UDPConnection( + context = context, + serverContext = serverContext, + dispatcher, + packetChannel = packetChannel, + send = { packet: UDPPacket -> + scope.launch(Dispatchers.IO) { + val buf = Buffer() + writePacket(buf, packet) + val bytes = buf.readByteArray() + socket.send(DatagramPacket(bytes, bytes.size, remoteInetAddress, remotePort)) + } + }, + ) + + behaviours.map { it.observer }.forEach { it?.invoke(conn) } + + // Dedicated coroutine per connection so the receive loop is never blocked by packet processing scope.launch { - val bytePacket = buildPacket { - writePacket(this, packet) + for (event in packetChannel) { + dispatcher.emit(event) } - socket.send(Datagram(bytePacket, remoteAddress)) } - }, - getDevice = { - val deviceId = context.state.value.deviceId - if (deviceId != null) { - serverContext.getDevice(deviceId) - } else { - null - } - }, - getTracker = { id -> - val trackerId = context.state.value.trackerIds.find { it.trackerNum == id } - if (trackerId != null) { - serverContext.getTracker(trackerId.id) - } else { - null - } - }, - ) - behaviours.map { it.observer }.forEach { it?.invoke(conn) } - - return conn + return conn + } + } } + + diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt index 7aa0941bb6..cf4bb899a2 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt @@ -1,11 +1,9 @@ package dev.slimevr.tracker.udp +import dev.slimevr.EventDispatcher import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import io.ktor.utils.io.core.remaining -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.io.Sink import kotlinx.io.Source import kotlinx.io.readByteArray @@ -14,8 +12,9 @@ import kotlinx.io.readString import kotlinx.io.readUByte import kotlinx.io.writeUByte import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.datatypes.hardware_info.BoardType import solarxr_protocol.datatypes.hardware_info.ImuType -import kotlin.reflect.KClass +import solarxr_protocol.datatypes.hardware_info.McuType private fun Source.readU8(): Int = readByte().toInt() and 0xFF @@ -76,9 +75,9 @@ sealed interface SensorSpecificPacket : UDPPacket { data object Heartbeat : UDPPacket data class Handshake( - val boardType: Int = 0, + val boardType: BoardType = BoardType.UNKNOWN, val imuType: Int = 0, - val mcuType: Int = 0, + val mcuType: McuType = McuType.Other, val protocolVersion: Int = 0, val firmware: String? = null, val macString: String? = null, @@ -91,9 +90,9 @@ data class Handshake( companion object { fun read(src: Source): Handshake = with(src) { if (remaining == 0L) return Handshake() - val b = if (remaining >= 4) readInt() else 0 + val b = if (remaining >= 4) BoardType.fromValue(readInt().toUShort()) ?: BoardType.UNKNOWN else BoardType.UNKNOWN val i = if (remaining >= 4) readInt() else 0 - val m = if (remaining >= 4) readInt() else 0 + val m = if (remaining >= 4) McuType.fromValue(readInt().toUShort()) ?: McuType.Other else McuType.Other if (remaining >= 12) { readInt() readInt() @@ -344,7 +343,6 @@ fun readPacket(type: PacketType, src: Source): UDPPacket = when (type) { PacketType.SET_CONFIG_FLAG -> SetConfigFlag() - // Usually outbound PacketType.FLEX_DATA -> FlexData.read(src) PacketType.POSITION -> PositionPacket.read(src) @@ -375,33 +373,9 @@ data class PacketEvent( val packetNumber: Long, ) -class PacketDispatcher { - val listeners = mutableMapOf, MutableList) -> Unit>>() - val globalListeners = mutableListOf) -> Unit>() - val mutex = Mutex() - - @Suppress("UNCHECKED_CAST") - inline fun on(crossinline callback: suspend (PacketEvent) -> Unit) { - runBlocking { - mutex.withLock { - val list = listeners.getOrPut(T::class) { mutableListOf() } - list.add { callback(it as PacketEvent) } - } - } - } - - fun onAny(callback: suspend (PacketEvent) -> Unit) { - runBlocking { - mutex.withLock { globalListeners.add(callback) } - } - } +typealias UDPPacketDispatcher = EventDispatcher> - suspend fun emit(event: PacketEvent) { - val targets = mutex.withLock { - val specific = listeners[event.data::class]?.toList() ?: emptyList() - val global = globalListeners.toList() - global + specific - } - targets.forEach { it(event) } - } +@Suppress("UNCHECKED_CAST") +inline fun UDPPacketDispatcher.onPacket(crossinline callback: suspend (PacketEvent) -> Unit) { + register(T::class) { callback(it as PacketEvent) } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt index 6c48d31c68..00a93c9abe 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt @@ -1,14 +1,17 @@ package dev.slimevr.tracker.udp +import dev.slimevr.AppLogger import dev.slimevr.VRServer import dev.slimevr.config.AppConfig -import io.ktor.network.selector.SelectorManager -import io.ktor.network.sockets.InetSocketAddress -import io.ktor.network.sockets.aSocket import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext +import kotlinx.io.Buffer +import java.net.DatagramPacket +import java.net.DatagramSocket +import kotlin.time.measureTime data class UDPTrackerServerState( val port: Int, @@ -24,41 +27,52 @@ suspend fun createUDPTrackerServer( connections = mutableMapOf(), ) - val selectorManager = SelectorManager(Dispatchers.IO) - val serverSocket = - aSocket(selectorManager).udp().bind(InetSocketAddress("0.0.0.0", state.port)) + val socket = withContext(Dispatchers.IO) { + DatagramSocket(state.port) + } + val recvBuffer = ByteArray(2048) + val recvPacket = DatagramPacket(recvBuffer, recvBuffer.size) supervisorScope { launch(Dispatchers.IO) { while (isActive) { - val datagram = serverSocket.receive() - val packetId = datagram.packet.readInt() - val packetNumber = datagram.packet.readLong() - val type = PacketType.fromId(packetId) ?: continue - val packetData = readPacket(type, datagram.packet) + socket.receive(recvPacket) + val took = measureTime { + val src = Buffer() + src.write(recvBuffer, 0, recvPacket.length) - val address = datagram.address as InetSocketAddress - val connContext = state.connections[address.hostname] + val packetId = src.readInt() + val packetNumber = src.readLong() + val type = PacketType.fromId(packetId) ?: continue + val packetData = readPacket(type, src) - val event = PacketEvent( - data = packetData, - packetNumber = packetNumber, - ) + val ip = recvPacket.address.hostAddress + val port = recvPacket.port + val connContext = state.connections[ip] - if (connContext !== null) { - connContext.packetEvents.emit(event) - } else { - val newContext = createUDPConnection( - id = address.hostname, - remoteAddress = address, - socket = serverSocket, - serverContext = serverContext, - scope = this, + val event = PacketEvent( + data = packetData, + packetNumber = packetNumber, ) - state.connections[address.hostname] = newContext - newContext.packetEvents.emit(event) + if (connContext !== null) { + connContext.packetChannel.trySend(event) + } else { + val newContext = UDPConnection.create( + id = ip, + remoteIp = ip, + remotePort = port, + socket = socket, + serverContext = serverContext, + scope = this, + ) + + state.connections[ip] = newContext + newContext.packetChannel.trySend(event) + } } + if (took.inWholeMilliseconds > 2) + AppLogger.udp.warn("Packet processing took too long ${took.inWholeMilliseconds}") } } } diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt index 231802e830..fac5f1ff08 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -258,8 +258,6 @@ class DoSerialFlashTest { launch { delay(100) server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") - } - launch { delay(200) server.onDataReceived("COM1", "looking for the server") } @@ -271,8 +269,6 @@ class DoSerialFlashTest { assertEquals(FirmwareUpdateStatus.ERROR_TIMEOUT, statuses.last()) } - // ── Full success path ───────────────────────────────────────────────── - @OptIn(ExperimentalCoroutinesApi::class) @Test fun `emits DONE when everything succeeds`() = runTest { @@ -297,14 +293,10 @@ class DoSerialFlashTest { launch { delay(100) server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") - } - launch { delay(200) server.onDataReceived("COM1", "looking for the server") - } - launch { delay(300) - val device = createDevice(backgroundScope, vrServer.nextHandle(), "AA:BB:CC:DD:EE:FF", DeviceOrigin.UDP, vrServer) + val device = createDevice(backgroundScope, vrServer.nextHandle(), address = "192.168.1.100", macAddress = "AA:BB:CC:DD:EE:FF", DeviceOrigin.UDP, vrServer) vrServer.context.dispatch(VRServerActions.NewDevice(device.context.state.value.id, device)) } diff --git a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt index f6a3639734..755afbc7f7 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt @@ -6,12 +6,21 @@ import dev.slimevr.firmware.FirmwareManagerBaseBehaviour import dev.slimevr.firmware.FirmwareManagerState import kotlinx.coroutines.test.runTest import solarxr_protocol.rpc.FirmwareUpdateStatus +import solarxr_protocol.rpc.SerialDevicePort import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +private fun serialJob(port: String, status: FirmwareUpdateStatus, progress: Int = 0) = + FirmwareManagerActions.UpdateJob( + portLocation = port, + firmwareDeviceId = SerialDevicePort(port = port), + status = status, + progress = progress, + ) + class FirmwareManagerReducerTest { private fun makeContext(scope: kotlinx.coroutines.CoroutineScope) = createContext( initialState = FirmwareManagerState(jobs = mapOf()), @@ -23,7 +32,7 @@ class FirmwareManagerReducerTest { fun `UpdateJob adds a new job`() = runTest { val context = makeContext(this) - context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.UPLOADING, 42)) + context.dispatch(serialJob("COM1", FirmwareUpdateStatus.UPLOADING, 42)) val job = context.state.value.jobs["COM1"] assertNotNull(job) @@ -35,8 +44,8 @@ class FirmwareManagerReducerTest { fun `UpdateJob replaces an existing job`() = runTest { val context = makeContext(this) - context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.DOWNLOADING, 0)) - context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.UPLOADING, 75)) + context.dispatch(serialJob("COM1", FirmwareUpdateStatus.DOWNLOADING)) + context.dispatch(serialJob("COM1", FirmwareUpdateStatus.UPLOADING, 75)) val job = context.state.value.jobs["COM1"] assertNotNull(job) @@ -49,8 +58,8 @@ class FirmwareManagerReducerTest { fun `UpdateJob tracks multiple ports independently`() = runTest { val context = makeContext(this) - context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.UPLOADING, 10)) - context.dispatch(FirmwareManagerActions.UpdateJob("COM2", FirmwareUpdateStatus.DOWNLOADING, 0)) + context.dispatch(serialJob("COM1", FirmwareUpdateStatus.UPLOADING, 10)) + context.dispatch(serialJob("COM2", FirmwareUpdateStatus.DOWNLOADING)) assertEquals(2, context.state.value.jobs.size) assertEquals(FirmwareUpdateStatus.UPLOADING, context.state.value.jobs["COM1"]?.status) @@ -61,7 +70,7 @@ class FirmwareManagerReducerTest { fun `RemoveJob removes an existing job`() = runTest { val context = makeContext(this) - context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.UPLOADING, 50)) + context.dispatch(serialJob("COM1", FirmwareUpdateStatus.UPLOADING, 50)) context.dispatch(FirmwareManagerActions.RemoveJob("COM1")) assertNull(context.state.value.jobs["COM1"]) @@ -72,7 +81,7 @@ class FirmwareManagerReducerTest { fun `RemoveJob on unknown port is a no-op`() = runTest { val context = makeContext(this) - context.dispatch(FirmwareManagerActions.UpdateJob("COM1", FirmwareUpdateStatus.UPLOADING, 50)) + context.dispatch(serialJob("COM1", FirmwareUpdateStatus.UPLOADING, 50)) context.dispatch(FirmwareManagerActions.RemoveJob("COM2")) assertEquals(1, context.state.value.jobs.size) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 39ba6f13d6..dd987e968d 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -21,7 +21,9 @@ import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import solarxr_protocol.datatypes.hardware_info.BoardType import solarxr_protocol.datatypes.hardware_info.ImuType +import solarxr_protocol.datatypes.hardware_info.McuType import java.nio.ByteBuffer const val PROTOCOL_VERSION = 5 @@ -108,6 +110,11 @@ suspend fun handleFeederConnection( id = deviceId, address = msg.tracker_added.tracker_serial, origin = DeviceOrigin.FEEDER, + boardType = BoardType.UNKNOWN, + firmware = msg.version.toString(), + protocolVersion = 0, + mcuType = McuType.Other, + macAddress = msg.tracker_added.tracker_serial, // FIXME: prob not correct serverContext = server, ) server.context.dispatch(VRServerActions.NewDevice(deviceId, device)) From 1001b7f887fdefcd7cc0e2f0ac1e72da6cf1fe66 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 26 Mar 2026 17:34:49 +0100 Subject: [PATCH 017/350] Cleanup --- .../dev/slimevr/config/{configio.kt => io.kt} | 0 .../slimevr/config/{config.kt => module.kt} | 0 .../main/java/dev/slimevr/event-dispatcher.kt | 1 + .../main/java/dev/slimevr/firmware/module.kt | 147 ++++++++++++++++++ .../src/main/java/dev/slimevr/firmware/ota.kt | 8 +- .../slimevr/firmware/{server.kt => serial.kt} | 138 ---------------- .../slimevr/serial/{server.kt => module.kt} | 0 .../skeleton/{skeleton.kt => module.kt} | 0 .../slimevr/solarxr/{solarxr.kt => module.kt} | 3 +- .../java/dev/slimevr/solarxr/ws-server.kt | 12 +- .../main/java/dev/slimevr/tracker/device.kt | 2 +- .../dev/slimevr/tracker/udp/connection.kt | 9 +- .../java/dev/slimevr/tracker/udp/packets.kt | 24 +-- .../java/dev/slimevr/tracker/udp/server.kt | 3 +- .../src/test/java/dev/slimevr/TestServer.kt | 2 +- .../dev/slimevr/firmware/DoSerialFlashTest.kt | 14 +- .../reducers/FirmwareManagerReducerTest.kt | 15 +- .../reducers/SerialConnectionReducerTest.kt | 2 +- .../java/dev/slimevr/solarxr/DataFeedTest.kt | 2 +- 19 files changed, 189 insertions(+), 193 deletions(-) rename server/core/src/main/java/dev/slimevr/config/{configio.kt => io.kt} (100%) rename server/core/src/main/java/dev/slimevr/config/{config.kt => module.kt} (100%) create mode 100644 server/core/src/main/java/dev/slimevr/firmware/module.kt rename server/core/src/main/java/dev/slimevr/firmware/{server.kt => serial.kt} (56%) rename server/core/src/main/java/dev/slimevr/serial/{server.kt => module.kt} (100%) rename server/core/src/main/java/dev/slimevr/skeleton/{skeleton.kt => module.kt} (100%) rename server/core/src/main/java/dev/slimevr/solarxr/{solarxr.kt => module.kt} (99%) diff --git a/server/core/src/main/java/dev/slimevr/config/configio.kt b/server/core/src/main/java/dev/slimevr/config/io.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/config/configio.kt rename to server/core/src/main/java/dev/slimevr/config/io.kt diff --git a/server/core/src/main/java/dev/slimevr/config/config.kt b/server/core/src/main/java/dev/slimevr/config/module.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/config/config.kt rename to server/core/src/main/java/dev/slimevr/config/module.kt diff --git a/server/core/src/main/java/dev/slimevr/event-dispatcher.kt b/server/core/src/main/java/dev/slimevr/event-dispatcher.kt index 5e9d12f39d..97b7e587f1 100644 --- a/server/core/src/main/java/dev/slimevr/event-dispatcher.kt +++ b/server/core/src/main/java/dev/slimevr/event-dispatcher.kt @@ -4,6 +4,7 @@ import kotlin.reflect.KClass class EventDispatcher(private val keyOf: (T) -> KClass<*> = { it::class }) { @Volatile var listeners: Map, List Unit>> = emptyMap() + @Volatile private var globalListeners: List Unit> = emptyList() fun register(key: KClass<*>, callback: suspend (T) -> Unit) { diff --git a/server/core/src/main/java/dev/slimevr/firmware/module.kt b/server/core/src/main/java/dev/slimevr/firmware/module.kt new file mode 100644 index 0000000000..c904c0c208 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/firmware/module.kt @@ -0,0 +1,147 @@ +package dev.slimevr.firmware + +import dev.slimevr.VRServer +import dev.slimevr.context.BasicBehaviour +import dev.slimevr.context.Context +import dev.slimevr.context.createContext +import dev.slimevr.serial.SerialServer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import solarxr_protocol.datatypes.DeviceIdTable +import solarxr_protocol.rpc.FirmwarePart +import solarxr_protocol.rpc.FirmwareUpdateDeviceId +import solarxr_protocol.rpc.FirmwareUpdateStatus +import solarxr_protocol.rpc.SerialDevicePort + +data class FirmwareJobStatus( + val portLocation: String, + val firmwareDeviceId: FirmwareUpdateDeviceId, + val status: FirmwareUpdateStatus, + val progress: Int = 0, +) + +data class FirmwareManagerState( + val jobs: Map, +) + +sealed interface FirmwareManagerActions { + data class UpdateJob( + val portLocation: String, + val firmwareDeviceId: FirmwareUpdateDeviceId, + val status: FirmwareUpdateStatus, + val progress: Int = 0, + ) : FirmwareManagerActions + + data class RemoveJob(val portLocation: String) : FirmwareManagerActions +} + +typealias FirmwareManagerContext = Context +typealias FirmwareManagerBehaviour = BasicBehaviour + +val FirmwareManagerBaseBehaviour = FirmwareManagerBehaviour( + reducer = { s, a -> + when (a) { + is FirmwareManagerActions.UpdateJob -> s.copy( + jobs = s.jobs + + ( + a.portLocation to FirmwareJobStatus( + portLocation = a.portLocation, + firmwareDeviceId = a.firmwareDeviceId, + status = a.status, + progress = a.progress, + ) + ), + ) + + is FirmwareManagerActions.RemoveJob -> s.copy(jobs = s.jobs - a.portLocation) + } + }, + observer = null, +) + +data class FirmwareManager( + val context: FirmwareManagerContext, + val flash: suspend (portLocation: String, parts: List, needManualReboot: Boolean, ssid: String?, password: String?, server: VRServer) -> Unit, + val otaFlash: suspend (deviceIp: String, firmwareDeviceId: FirmwareUpdateDeviceId, part: FirmwarePart, VRServer) -> Unit, + val cancelAll: suspend () -> Unit, +) + +fun createFirmwareManager( + serialServer: SerialServer, + scope: CoroutineScope, +): FirmwareManager { + val behaviours = listOf(FirmwareManagerBaseBehaviour) + + val context = createContext( + initialState = FirmwareManagerState(jobs = mapOf()), + reducers = behaviours.map { it.reducer }, + scope = scope, + ) + + val runningJobs = mutableMapOf() + + val flash: suspend (String, List, Boolean, String?, String?, VRServer) -> Unit = { portLocation, parts, needManualReboot, ssid, password, server -> + runningJobs[portLocation]?.cancelAndJoin() + runningJobs[portLocation] = scope.launch { + doSerialFlash( + portLocation = portLocation, + parts = parts, + needManualReboot = needManualReboot, + ssid = ssid, + password = password, + serialServer = serialServer, + server = server, + onStatus = { status, progress -> + context.dispatch( + FirmwareManagerActions.UpdateJob( + portLocation = portLocation, + firmwareDeviceId = SerialDevicePort(port = portLocation), + status = status, + progress = progress, + ), + ) + }, + scope = scope, + ) + } + } + + val otaFlash: suspend (String, FirmwareUpdateDeviceId, FirmwarePart, VRServer) -> Unit = { deviceIp, firmwareDeviceId, part, server -> + runningJobs[deviceIp]?.cancelAndJoin() + runningJobs[deviceIp] = scope.launch { + doOtaFlash( + deviceIp = deviceIp, + deviceId = (firmwareDeviceId as? DeviceIdTable)?.id ?: error("device id should exist"), + part = part, + server = server, + onStatus = { status, progress -> + context.dispatch( + FirmwareManagerActions.UpdateJob( + portLocation = deviceIp, + firmwareDeviceId = firmwareDeviceId, + status = status, + progress = progress, + ), + ) + }, + ) + } + } + + val cancelAll: suspend () -> Unit = { + runningJobs.values.forEach { it.cancelAndJoin() } + runningJobs.clear() + } + + val manager = FirmwareManager( + context = context, + flash = flash, + otaFlash = otaFlash, + cancelAll = cancelAll, + ) + + behaviours.map { it.observer }.forEach { it?.invoke(context) } + return manager +} diff --git a/server/core/src/main/java/dev/slimevr/firmware/ota.kt b/server/core/src/main/java/dev/slimevr/firmware/ota.kt index a66b8ec461..aea9a82287 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/ota.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/ota.kt @@ -31,11 +31,9 @@ private const val OTA_PORT = 8266 private const val OTA_PASSWORD = "SlimeVR-OTA" private const val OTA_CHUNK_SIZE = 2048 -private fun bytesToMd5(bytes: ByteArray): String = - MessageDigest.getInstance("MD5").digest(bytes).joinToString("") { "%02x".format(it) } +private fun bytesToMd5(bytes: ByteArray): String = MessageDigest.getInstance("MD5").digest(bytes).joinToString("") { "%02x".format(it) } -private suspend fun sendDatagram(socket: BoundDatagramSocket, message: String, target: InetSocketAddress) = - socket.send(Datagram(buildPacket { writeFully(message.toByteArray()) }, target)) +private suspend fun sendDatagram(socket: BoundDatagramSocket, message: String, target: InetSocketAddress) = socket.send(Datagram(buildPacket { writeFully(message.toByteArray()) }, target)) /** * Sends the OTA invitation over UDP and performs the optional AUTH challenge-response. @@ -155,7 +153,7 @@ suspend fun doOtaFlash( onStatus(FirmwareUpdateStatus.REBOOTING, 0) - // wait for the tracker with that MAC to connect to the server via UDP + // wait for the tracker with the correct id to come online val connected = withTimeoutOrNull(60_000) { server.context.state .map { state -> state.devices.values.any { it.context.state.value.id.toUByte() == deviceId.id } } diff --git a/server/core/src/main/java/dev/slimevr/firmware/server.kt b/server/core/src/main/java/dev/slimevr/firmware/serial.kt similarity index 56% rename from server/core/src/main/java/dev/slimevr/firmware/server.kt rename to server/core/src/main/java/dev/slimevr/firmware/serial.kt index 10aa07108f..fdb9be9f39 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/server.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/serial.kt @@ -3,85 +3,23 @@ package dev.slimevr.firmware import dev.llelievr.espflashkotlin.Flasher import dev.llelievr.espflashkotlin.FlashingProgressListener import dev.slimevr.VRServer -import dev.slimevr.context.BasicBehaviour -import dev.slimevr.context.Context -import dev.slimevr.context.createContext import dev.slimevr.serial.SerialConnection import dev.slimevr.serial.SerialServer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -import solarxr_protocol.datatypes.DeviceIdTable import solarxr_protocol.rpc.FirmwarePart -import solarxr_protocol.rpc.FirmwareUpdateDeviceId import solarxr_protocol.rpc.FirmwareUpdateStatus -import solarxr_protocol.rpc.SerialDevicePort private val MAC_REGEX = Regex("mac: (([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2})", RegexOption.IGNORE_CASE) -data class FirmwareJobStatus( - val portLocation: String, - val firmwareDeviceId: FirmwareUpdateDeviceId, - val status: FirmwareUpdateStatus, - val progress: Int = 0, -) - -data class FirmwareManagerState( - val jobs: Map, -) - -sealed interface FirmwareManagerActions { - data class UpdateJob( - val portLocation: String, - val firmwareDeviceId: FirmwareUpdateDeviceId, - val status: FirmwareUpdateStatus, - val progress: Int = 0, - ) : FirmwareManagerActions - - data class RemoveJob(val portLocation: String) : FirmwareManagerActions -} - -typealias FirmwareManagerContext = Context -typealias FirmwareManagerBehaviour = BasicBehaviour - -val FirmwareManagerBaseBehaviour = FirmwareManagerBehaviour( - reducer = { s, a -> - when (a) { - is FirmwareManagerActions.UpdateJob -> s.copy( - jobs = s.jobs + (a.portLocation to FirmwareJobStatus( - portLocation = a.portLocation, - firmwareDeviceId = a.firmwareDeviceId, - status = a.status, - progress = a.progress, - )), - ) - - is FirmwareManagerActions.RemoveJob -> s.copy(jobs = s.jobs - a.portLocation) - } - }, - observer = null, -) - -data class FirmwareManager( - val context: FirmwareManagerContext, - val flash: suspend (portLocation: String, parts: List, needManualReboot: Boolean, ssid: String?, password: String?, server: VRServer) -> Unit, - val otaFlash: suspend (deviceIp: String, firmwareDeviceId: FirmwareUpdateDeviceId, part: FirmwarePart, VRServer) -> Unit, - val cancelAll: suspend () -> Unit, -) - @OptIn(ExperimentalCoroutinesApi::class) suspend fun doSerialFlash( portLocation: String, @@ -256,79 +194,3 @@ internal suspend fun doSerialFlashPostFlash( onStatus(FirmwareUpdateStatus.DONE, 0) } - -fun createFirmwareManager( - serialServer: SerialServer, - scope: CoroutineScope, -): FirmwareManager { - val behaviours = listOf(FirmwareManagerBaseBehaviour) - - val context = createContext( - initialState = FirmwareManagerState(jobs = mapOf()), - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - val runningJobs = mutableMapOf() - - val flash: suspend (String, List, Boolean, String?, String?, VRServer) -> Unit = { portLocation, parts, needManualReboot, ssid, password, server -> - runningJobs[portLocation]?.cancelAndJoin() - runningJobs[portLocation] = scope.launch { - doSerialFlash( - portLocation = portLocation, - parts = parts, - needManualReboot = needManualReboot, - ssid = ssid, - password = password, - serialServer = serialServer, - server = server, - onStatus = { status, progress -> - context.dispatch(FirmwareManagerActions.UpdateJob( - portLocation = portLocation, - firmwareDeviceId = SerialDevicePort(port = portLocation), - status = status, - progress = progress, - )) - }, - scope = scope, - ) - } - } - - val otaFlash: suspend (String, FirmwareUpdateDeviceId, FirmwarePart, VRServer) -> Unit = { deviceIp, firmwareDeviceId, part, server -> - runningJobs[deviceIp]?.cancelAndJoin() - runningJobs[deviceIp] = scope.launch { - doOtaFlash( - deviceIp = deviceIp, - deviceId = (firmwareDeviceId as? DeviceIdTable)?.id ?: error("device id should exist"), - part = part, - server = server, - onStatus = { status, progress -> - context.dispatch( - FirmwareManagerActions.UpdateJob( - portLocation = deviceIp, - firmwareDeviceId = firmwareDeviceId, - status = status, - progress = progress, - ), - ) - }, - ) - } - } - - val cancelAll: suspend () -> Unit = { - runningJobs.values.forEach { it.cancelAndJoin() } - runningJobs.clear() - } - - val manager = FirmwareManager( - context = context, - flash = flash, - otaFlash = otaFlash, - cancelAll = cancelAll, - ) - - behaviours.map { it.observer }.forEach { it?.invoke(context) } - return manager -} diff --git a/server/core/src/main/java/dev/slimevr/serial/server.kt b/server/core/src/main/java/dev/slimevr/serial/module.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/serial/server.kt rename to server/core/src/main/java/dev/slimevr/serial/module.kt diff --git a/server/core/src/main/java/dev/slimevr/skeleton/skeleton.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/skeleton/skeleton.kt rename to server/core/src/main/java/dev/slimevr/skeleton/module.kt diff --git a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt similarity index 99% rename from server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt rename to server/core/src/main/java/dev/slimevr/solarxr/module.kt index 1e2d029f81..be7e878cb2 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/solarxr.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -1,11 +1,11 @@ package dev.slimevr.solarxr import com.google.flatbuffers.FlatBufferBuilder +import dev.slimevr.EventDispatcher import dev.slimevr.VRServer import dev.slimevr.context.Context import dev.slimevr.context.CustomBehaviour import dev.slimevr.context.createContext -import dev.slimevr.EventDispatcher import io.ktor.util.moveToByteArray import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -27,7 +27,6 @@ sealed interface SolarXRConnectionActions { typealias SolarXRConnectionContext = Context typealias SolarXRConnectionBehaviour = CustomBehaviour - data class SolarXRConnection( val context: SolarXRConnectionContext, val serverContext: VRServer, diff --git a/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt b/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt index 40439beb77..179d6001a6 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt @@ -36,10 +36,14 @@ suspend fun createSolarXRWebsocketServer(serverContext: VRServer) { routing { webSocket { - val solarxrConnection = - createSolarXRConnection(serverContext, scope = this, onSend = { + AppLogger.solarxr.info("[WS] New connection") + val solarxrConnection = createSolarXRConnection( + serverContext, + scope = this, + onSend = { send(Frame.Binary(fin = true, data = it)) - }) + }, + ) for (frame in incoming) { when (frame) { @@ -49,7 +53,7 @@ suspend fun createSolarXRWebsocketServer(serverContext: VRServer) { ) is Frame.Close -> { - AppLogger.solarxr.info("Connection closed") + AppLogger.solarxr.info("[WS] Connection closed") } else -> {} diff --git a/server/core/src/main/java/dev/slimevr/tracker/device.kt b/server/core/src/main/java/dev/slimevr/tracker/device.kt index 06d10a6db5..6d76c75df5 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/device.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/device.kt @@ -66,7 +66,7 @@ fun createDevice( mcuType: McuType, firmware: String? = null, protocolVersion: Int, - serverContext: VRServer + serverContext: VRServer, ): Device { val deviceState = DeviceState( id = id, diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt index 9a155f9ad2..30bd070b79 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt @@ -57,8 +57,6 @@ sealed interface UDPConnectionActions { typealias UDPConnectionContext = Context typealias UDPConnectionBehaviour = CustomBehaviour - - val PacketBehaviour = UDPConnectionBehaviour( reducer = { s, a -> when (a) { @@ -175,7 +173,7 @@ val HandshakeBehaviour = UDPConnectionBehaviour( val newDevice = createDevice( id = deviceId, scope = it.serverContext.context.scope, - address = it.context.state.value.address, + address = it.context.state.value.address, macAddress = packet.data.macString, boardType = packet.data.boardType, protocolVersion = packet.data.protocolVersion, @@ -299,13 +297,12 @@ val SensorRotationBehaviour = UDPConnectionBehaviour( }, ) - data class UDPConnection( val context: UDPConnectionContext, val serverContext: VRServer, val packetEvents: UDPPacketDispatcher, val packetChannel: Channel>, - val send: (UDPPacket) -> Unit + val send: (UDPPacket) -> Unit, ) { fun getDevice(): Device? { val deviceId = context.state.value.deviceId @@ -391,5 +388,3 @@ data class UDPConnection( } } } - - diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt index cf4bb899a2..3e3a3e4edb 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt @@ -90,7 +90,7 @@ data class Handshake( companion object { fun read(src: Source): Handshake = with(src) { if (remaining == 0L) return Handshake() - val b = if (remaining >= 4) BoardType.fromValue(readInt().toUShort()) ?: BoardType.UNKNOWN else BoardType.UNKNOWN + val b = if (remaining >= 4) BoardType.fromValue(readInt().toUShort()) ?: BoardType.UNKNOWN else BoardType.UNKNOWN val i = if (remaining >= 4) readInt() else 0 val m = if (remaining >= 4) McuType.fromValue(readInt().toUShort()) ?: McuType.Other else McuType.Other if (remaining >= 12) { @@ -304,49 +304,27 @@ data class ProtocolChange(val targetProtocol: Int = 0, val targetVersion: Int = fun readPacket(type: PacketType, src: Source): UDPPacket = when (type) { PacketType.HEARTBEAT -> Heartbeat - PacketType.HANDSHAKE -> Handshake.read(src) - PacketType.ROTATION -> Rotation.read(src) - PacketType.ACCEL -> Accel.read(src) - PacketType.PING_PONG -> PingPong.read(src) - PacketType.SERIAL -> Serial.read(src) - PacketType.BATTERY_LEVEL -> BatteryLevel.read(src) - PacketType.TAP -> Tap.read(src) - PacketType.ERROR -> ErrorPacket.read(src) - PacketType.SENSOR_INFO -> SensorInfo.read(src) - PacketType.ROTATION_2 -> Rotation2.read(src) - PacketType.ROTATION_DATA -> RotationData.read(src) - PacketType.MAGNETOMETER_ACCURACY -> MagnetometerAccuracy.read(src) - PacketType.SIGNAL_STRENGTH -> SignalStrength.read(src) - PacketType.TEMPERATURE -> Temperature.read(src) - PacketType.USER_ACTION -> UserActionPacket.read(src) - PacketType.FEATURE_FLAGS -> FeatureFlags.read(src) - PacketType.ROTATION_AND_ACCEL -> RotationAndAccel.read(src) - PacketType.ACK_CONFIG_CHANGE -> AckConfigChange.read(src) - PacketType.SET_CONFIG_FLAG -> SetConfigFlag() - PacketType.FLEX_DATA -> FlexData.read(src) - PacketType.POSITION -> PositionPacket.read(src) - PacketType.PROTOCOL_CHANGE -> ProtocolChange.read(src) } diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt index 00a93c9abe..268bd8f2d7 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt @@ -71,8 +71,9 @@ suspend fun createUDPTrackerServer( newContext.packetChannel.trySend(event) } } - if (took.inWholeMilliseconds > 2) + if (took.inWholeMilliseconds > 2) { AppLogger.udp.warn("Packet processing took too long ${took.inWholeMilliseconds}") + } } } } diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 757fd7f299..8b3a871370 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -28,4 +28,4 @@ fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create( fun buildTestVrServer(scope: CoroutineScope): VRServer { val serialServer = buildTestSerialServer(scope) return VRServer.create(scope, serialServer, createFirmwareManager(serialServer, scope)) -} \ No newline at end of file +} diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt index fac5f1ff08..e01851b2b0 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -14,6 +14,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import solarxr_protocol.datatypes.hardware_info.BoardType +import solarxr_protocol.datatypes.hardware_info.McuType import solarxr_protocol.rpc.FirmwareUpdateStatus import kotlin.test.Test import kotlin.test.assertEquals @@ -296,7 +298,17 @@ class DoSerialFlashTest { delay(200) server.onDataReceived("COM1", "looking for the server") delay(300) - val device = createDevice(backgroundScope, vrServer.nextHandle(), address = "192.168.1.100", macAddress = "AA:BB:CC:DD:EE:FF", DeviceOrigin.UDP, vrServer) + val device = createDevice( + backgroundScope, + id = vrServer.nextHandle(), + address = "192.168.1.100", + macAddress = "AA:BB:CC:DD:EE:FF", + origin = DeviceOrigin.UDP, + protocolVersion = 0, + serverContext = vrServer, + boardType = BoardType.SLIMEVR, + mcuType = McuType.ESP8266 + ) vrServer.context.dispatch(VRServerActions.NewDevice(device.context.state.value.id, device)) } diff --git a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt index 755afbc7f7..9f8c4d4002 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt @@ -13,13 +13,12 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -private fun serialJob(port: String, status: FirmwareUpdateStatus, progress: Int = 0) = - FirmwareManagerActions.UpdateJob( - portLocation = port, - firmwareDeviceId = SerialDevicePort(port = port), - status = status, - progress = progress, - ) +private fun serialJob(port: String, status: FirmwareUpdateStatus, progress: Int = 0) = FirmwareManagerActions.UpdateJob( + portLocation = port, + firmwareDeviceId = SerialDevicePort(port = port), + status = status, + progress = progress, +) class FirmwareManagerReducerTest { private fun makeContext(scope: kotlinx.coroutines.CoroutineScope) = createContext( @@ -86,4 +85,4 @@ class FirmwareManagerReducerTest { assertEquals(1, context.state.value.jobs.size) } -} \ No newline at end of file +} diff --git a/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt b/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt index 15198bec9c..1eaa0401af 100644 --- a/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt +++ b/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt @@ -51,4 +51,4 @@ class SerialConnectionReducerTest { val result = reducer(state(connected = true), SerialConnectionActions.Disconnected) assertFalse(result.connected) } -} \ No newline at end of file +} diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt index 994b32ccc7..7d0cc17dbb 100644 --- a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -83,5 +83,5 @@ class DataFeedTest { assertEquals(0, sendCount) } - //TODO: need more tests for the content of a datafeed + check if the masks work + // TODO: need more tests for the content of a datafeed + check if the masks work } From 6c4fdedcd1dc54e86429081eff565469c08dd526 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 26 Mar 2026 17:53:29 +0100 Subject: [PATCH 018/350] Make ota and serial check for the device status --- .../core/src/main/java/dev/slimevr/firmware/ota.kt | 8 +++++++- .../src/main/java/dev/slimevr/firmware/serial.kt | 4 +++- .../src/main/java/dev/slimevr/solarxr/datafeed.kt | 2 +- .../core/src/main/java/dev/slimevr/tracker/device.kt | 3 +++ .../src/main/java/dev/slimevr/tracker/tracker.kt | 2 -- .../main/java/dev/slimevr/tracker/udp/connection.kt | 12 +++++++++--- .../java/dev/slimevr/firmware/DoSerialFlashTest.kt | 2 +- 7 files changed, 24 insertions(+), 9 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/firmware/ota.kt b/server/core/src/main/java/dev/slimevr/firmware/ota.kt index aea9a82287..d300ef2bd6 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/ota.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/ota.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull import solarxr_protocol.datatypes.DeviceId +import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.FirmwarePart import solarxr_protocol.rpc.FirmwareUpdateStatus import java.io.DataInputStream @@ -156,7 +157,12 @@ suspend fun doOtaFlash( // wait for the tracker with the correct id to come online val connected = withTimeoutOrNull(60_000) { server.context.state - .map { state -> state.devices.values.any { it.context.state.value.id.toUByte() == deviceId.id } } + .map { state -> + state.devices.values.any { + it.context.state.value.id.toUByte() == deviceId.id && + it.context.state.value.status != TrackerStatus.DISCONNECTED + } + } .filter { it } .first() } diff --git a/server/core/src/main/java/dev/slimevr/firmware/serial.kt b/server/core/src/main/java/dev/slimevr/firmware/serial.kt index fdb9be9f39..2d7f7cdc98 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/serial.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/serial.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.FirmwarePart import solarxr_protocol.rpc.FirmwareUpdateStatus @@ -182,7 +183,8 @@ internal suspend fun doSerialFlashPostFlash( // wait for the tracker with that MAC to connect to the server via UDP val connected = withTimeoutOrNull(60_000) { server.context.state - .map { state -> state.devices.values.any { it.context.state.value.macAddress?.uppercase() == macAddress } } + .map { state -> state.devices.values.any { it.context.state.value.macAddress?.uppercase() == macAddress + && it.context.state.value.status != TrackerStatus.DISCONNECTED } } .filter { it } .first() } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index fbd6485fb3..b0cc681819 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -33,7 +33,7 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas trackerNum = tracker.id.toUByte(), deviceId = DeviceId(device.id.toUByte()), ), - status = if (trackerMask.status == true) tracker.status else null, + status = if (trackerMask.status == true) device.status else null, rotation = if (trackerMask.rotation == true) tracker.rawRotation.let { Quat(it.x, it.y, it.z, it.w) } else null, info = if (trackerMask.info == true) { TrackerInfo( diff --git a/server/core/src/main/java/dev/slimevr/tracker/device.kt b/server/core/src/main/java/dev/slimevr/tracker/device.kt index 6d76c75df5..6f41ec1e6c 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/device.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/device.kt @@ -9,6 +9,7 @@ import io.ktor.http.HttpProtocolVersion import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.BoardType import solarxr_protocol.datatypes.hardware_info.ImuType import solarxr_protocol.datatypes.hardware_info.McuType @@ -33,6 +34,7 @@ data class DeviceState( val boardType: BoardType, val mcuType: McuType, val protocolVersion: Int, + val status: TrackerStatus, val origin: DeviceOrigin, ) @@ -82,6 +84,7 @@ fun createDevice( protocolVersion = protocolVersion, ping = null, signalStrength = null, + status = TrackerStatus.DISCONNECTED, ) val behaviours = listOf(DeviceStatsBehaviour) diff --git a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt index 616af42633..43ca09494b 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/tracker.kt @@ -20,7 +20,6 @@ data class TrackerState( val hardwareId: String, val sensorType: ImuType, val bodyPart: BodyPart?, - val status: TrackerStatus, val customName: String?, val rawRotation: Quaternion, val deviceId: Int, @@ -61,7 +60,6 @@ fun createTracker( hardwareId = hardwareId, name = "Tracker #$id", rawRotation = Quaternion.IDENTITY, - status = TrackerStatus.DISCONNECTED, bodyPart = null, origin = origin, deviceId = deviceId, diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt index 30bd070b79..9a4b85c6fc 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt @@ -237,6 +237,15 @@ val SensorInfoBehaviour = UDPConnectionBehaviour( }, observer = { observerContext -> observerContext.packetEvents.onPacket { event -> + val device = observerContext.getDevice() + ?: error("invalid state - a device should exist at this point") + + device.context.dispatch( + DeviceActions.Update { + copy(status = event.data.status) + }, + ) + val tracker = observerContext.getTracker(event.data.sensorId) val action = TrackerActions.Update { @@ -249,9 +258,6 @@ val SensorInfoBehaviour = UDPConnectionBehaviour( if (tracker != null) { tracker.context.dispatch(action) } else { - - val device = observerContext.getDevice() - ?: error("invalid state - a device should exist at this point") val deviceState = device.context.state.value val trackerId = observerContext.serverContext.nextHandle() val newTracker = createTracker( diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt index e01851b2b0..02291d07b4 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -307,7 +307,7 @@ class DoSerialFlashTest { protocolVersion = 0, serverContext = vrServer, boardType = BoardType.SLIMEVR, - mcuType = McuType.ESP8266 + mcuType = McuType.ESP8266, ) vrServer.context.dispatch(VRServerActions.NewDevice(device.context.state.value.id, device)) } From 5946ca1b7af36704407389c35357b57f120ef929 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 26 Mar 2026 18:44:51 +0100 Subject: [PATCH 019/350] Better udp and feeder connection handling --- .../main/java/dev/slimevr/firmware/serial.kt | 8 +- .../main/java/dev/slimevr/tracker/device.kt | 9 +- .../dev/slimevr/tracker/udp/connection.kt | 84 +++++++++++++++---- .../java/dev/slimevr/tracker/udp/packets.kt | 7 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 68 +++++++++------ 5 files changed, 121 insertions(+), 55 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/firmware/serial.kt b/server/core/src/main/java/dev/slimevr/firmware/serial.kt index 2d7f7cdc98..acd2623427 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/serial.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/serial.kt @@ -183,8 +183,12 @@ internal suspend fun doSerialFlashPostFlash( // wait for the tracker with that MAC to connect to the server via UDP val connected = withTimeoutOrNull(60_000) { server.context.state - .map { state -> state.devices.values.any { it.context.state.value.macAddress?.uppercase() == macAddress - && it.context.state.value.status != TrackerStatus.DISCONNECTED } } + .map { state -> + state.devices.values.any { + it.context.state.value.macAddress?.uppercase() == macAddress && + it.context.state.value.status != TrackerStatus.DISCONNECTED + } + } .filter { it } .first() } diff --git a/server/core/src/main/java/dev/slimevr/tracker/device.kt b/server/core/src/main/java/dev/slimevr/tracker/device.kt index 6f41ec1e6c..4d8a3058cc 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/device.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/device.kt @@ -64,9 +64,6 @@ fun createDevice( address: String, macAddress: String? = null, origin: DeviceOrigin, - boardType: BoardType, - mcuType: McuType, - firmware: String? = null, protocolVersion: Int, serverContext: VRServer, ): Device { @@ -78,13 +75,13 @@ fun createDevice( origin = origin, address = address, macAddress = macAddress, - boardType = boardType, - firmware = firmware, - mcuType = mcuType, protocolVersion = protocolVersion, ping = null, signalStrength = null, status = TrackerStatus.DISCONNECTED, + mcuType = McuType.Other, + boardType = BoardType.UNKNOWN, + firmware = null ) val behaviours = listOf(DeviceStatsBehaviour) diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt index 9a4b85c6fc..02d92c8ea3 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.io.Buffer import kotlinx.io.readByteArray +import solarxr_protocol.datatypes.TrackerStatus import java.net.DatagramPacket import java.net.DatagramSocket import java.net.InetAddress @@ -52,11 +53,14 @@ sealed interface UDPConnectionActions { data class Handshake(val deviceId: Int) : UDPConnectionActions data class LastPacket(val packetNum: Long? = null, val time: Long) : UDPConnectionActions data class AssignTracker(val trackerId: TrackerIdNum) : UDPConnectionActions + data object Disconnected : UDPConnectionActions } typealias UDPConnectionContext = Context typealias UDPConnectionBehaviour = CustomBehaviour +private const val CONNECTION_TIMEOUT_MS = 5000L + val PacketBehaviour = UDPConnectionBehaviour( reducer = { s, a -> when (a) { @@ -78,7 +82,7 @@ val PacketBehaviour = UDPConnectionBehaviour( val state = it.context.state.value val now = System.currentTimeMillis() - if (now - state.lastPacket > 5000 && packet.packetNumber == 0L) { + if (now - state.lastPacket > CONNECTION_TIMEOUT_MS && packet.packetNumber == 0L) { it.context.dispatch( UDPConnectionActions.LastPacket( packetNum = 0, @@ -160,6 +164,10 @@ val HandshakeBehaviour = UDPConnectionBehaviour( deviceId = a.deviceId, ) + is UDPConnectionActions.Disconnected -> s.copy( + didHandshake = false, + ) + else -> s } }, @@ -167,32 +175,66 @@ val HandshakeBehaviour = UDPConnectionBehaviour( it.packetEvents.onPacket { packet -> val state = it.context.state.value - if (state.deviceId == null) { + val device = if (state.deviceId == null) { val deviceId = it.serverContext.nextHandle() - val newDevice = createDevice( id = deviceId, scope = it.serverContext.context.scope, - address = it.context.state.value.address, + address = state.address, macAddress = packet.data.macString, - boardType = packet.data.boardType, - protocolVersion = packet.data.protocolVersion, - mcuType = packet.data.mcuType, - firmware = packet.data.firmware, origin = DeviceOrigin.UDP, + protocolVersion = packet.data.protocolVersion, serverContext = it.serverContext, ) - - it.serverContext.context.dispatch( - VRServerActions.NewDevice( - deviceId = deviceId, - context = newDevice, - ), - ) + it.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId = deviceId, context = newDevice)) it.context.dispatch(UDPConnectionActions.Handshake(deviceId)) - it.send(Handshake()) + newDevice } else { - it.send(Handshake()) + it.context.dispatch(UDPConnectionActions.Handshake(state.deviceId)) + it.getDevice() ?: run { + AppLogger.udp.warn("Reconnect handshake but device ${state.deviceId} not found") + it.send(Handshake()) + return@onPacket + } + } + + // Apply handshake fields to device, always, for both first connect and reconnect + device.context.dispatch( + DeviceActions.Update { + copy( + macAddress = packet.data.macString ?: macAddress, + boardType = packet.data.boardType, + mcuType = packet.data.mcuType, + firmware = packet.data.firmware ?: firmware, + protocolVersion = packet.data.protocolVersion, + ) + }, + ) + + it.send(Handshake()) + } + }, +) + +val TimeoutBehaviour = UDPConnectionBehaviour( + observer = { + it.context.scope.launch { + while (isActive) { + val state = it.context.state.value + if (!state.didHandshake) { + delay(500) + continue + } + val timeUntilTimeout = CONNECTION_TIMEOUT_MS - (System.currentTimeMillis() - state.lastPacket) + if (timeUntilTimeout <= 0) { + AppLogger.udp.info("Connection timed out for ${state.id}") + it.context.dispatch(UDPConnectionActions.Disconnected) + it.getDevice()?.context?.dispatch( + DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, + ) + } else { + delay(timeUntilTimeout + 1) + } } } }, @@ -251,7 +293,6 @@ val SensorInfoBehaviour = UDPConnectionBehaviour( val action = TrackerActions.Update { copy( sensorType = event.data.imuType, - status = event.data.status, ) } @@ -340,6 +381,7 @@ data class UDPConnection( val behaviours = listOf( PacketBehaviour, HandshakeBehaviour, + TimeoutBehaviour, PingBehaviour, DeviceStatsBehaviour, SensorInfoBehaviour, @@ -386,6 +428,12 @@ data class UDPConnection( // Dedicated coroutine per connection so the receive loop is never blocked by packet processing scope.launch { for (event in packetChannel) { + // We skip any packet from the tracker that are not handshake packets + // if we didn't do a handshake with the server + // this prevents from receiving packets if the server does not know about the + // tracker yet. This usually happen when you restart the server with already + // connected trackers + if (!context.state.value.didHandshake && event.data !is PreHandshakePacket) continue dispatcher.emit(event) } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt index 3e3a3e4edb..f6d73dabdf 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt @@ -68,6 +68,9 @@ sealed interface UDPPacket { fun write(dst: Sink) {} } +/** Packets that are processed before the handshake is complete */ +sealed interface PreHandshakePacket : UDPPacket + sealed interface SensorSpecificPacket : UDPPacket { val sensorId: Int } @@ -81,7 +84,7 @@ data class Handshake( val protocolVersion: Int = 0, val firmware: String? = null, val macString: String? = null, -) : UDPPacket { +) : PreHandshakePacket { override fun write(dst: Sink) { dst.writeByte(PacketType.HANDSHAKE.id.toByte()) dst.write("Hey OVR =D 5".toByteArray(Charsets.US_ASCII)) @@ -126,7 +129,7 @@ data class Accel(val acceleration: Vector3 = Vector3.NULL, override val sensorId } } -data class PingPong(val pingId: Int = 0) : UDPPacket { +data class PingPong(val pingId: Int = 0) : PreHandshakePacket { override fun write(dst: Sink) { dst.writeInt(pingId) } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index dd987e968d..e59c3afb48 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -8,6 +8,7 @@ import dev.slimevr.desktop.platform.TrackerAdded import dev.slimevr.desktop.platform.Version import dev.slimevr.solarxr.createSolarXRConnection import dev.slimevr.solarxr.onSolarXRMessage +import dev.slimevr.tracker.DeviceActions import dev.slimevr.tracker.DeviceOrigin import dev.slimevr.tracker.TrackerActions import dev.slimevr.tracker.createDevice @@ -21,9 +22,7 @@ import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import solarxr_protocol.datatypes.hardware_info.BoardType import solarxr_protocol.datatypes.hardware_info.ImuType -import solarxr_protocol.datatypes.hardware_info.McuType import java.nio.ByteBuffer const val PROTOCOL_VERSION = 5 @@ -104,32 +103,47 @@ suspend fun handleFeederConnection( val msg = ProtobufMessage.ADAPTER.decode(bytes) if (msg.tracker_added != null) { - val deviceId = server.nextHandle() - val device = createDevice( - scope = this, - id = deviceId, - address = msg.tracker_added.tracker_serial, - origin = DeviceOrigin.FEEDER, - boardType = BoardType.UNKNOWN, - firmware = msg.version.toString(), - protocolVersion = 0, - mcuType = McuType.Other, - macAddress = msg.tracker_added.tracker_serial, // FIXME: prob not correct - serverContext = server, - ) - server.context.dispatch(VRServerActions.NewDevice(deviceId, device)) - - val trackerId = server.nextHandle() - val tracker = createTracker( - scope = this, - id = trackerId, - deviceId = deviceId, - sensorType = ImuType.MPU9250, // TODO: prob need to make sensor type optional - hardwareId = msg.tracker_added.tracker_serial, - origin = DeviceOrigin.FEEDER, - serverContext = server, + val serial = msg.tracker_added.tracker_serial + val protocolVersion = msg.version?.protocol_version ?: 0 + val firmware = msg.version?.toString() + + // Check for existing tracker with same hardwareId (reconnect case) + val existingTracker = server.context.state.value.trackers.values + .find { it.context.state.value.hardwareId == serial } + + val device = if (existingTracker != null) { + server.getDevice(existingTracker.context.state.value.deviceId) ?: error("could not find existing device") + } else { + val deviceId = server.nextHandle() + val newDevice = createDevice( + scope = this, + id = deviceId, + address = serial, + macAddress = serial, // FIXME: prob not correct + origin = DeviceOrigin.FEEDER, + protocolVersion = protocolVersion, + serverContext = server, + ) + server.context.dispatch(VRServerActions.NewDevice(deviceId, newDevice)) + + val trackerId = server.nextHandle() + val tracker = createTracker( + scope = this, + id = trackerId, + deviceId = deviceId, + sensorType = ImuType.MPU9250, // TODO: prob need to make sensor type optional + hardwareId = serial, + origin = DeviceOrigin.FEEDER, + serverContext = server, + ) + server.context.dispatch(VRServerActions.NewTracker(trackerId, tracker)) + + newDevice + } + + device.context.dispatch( + DeviceActions.Update { copy(firmware = firmware, protocolVersion = protocolVersion) }, ) - server.context.dispatch(VRServerActions.NewTracker(trackerId, tracker)) } if (msg.position != null) { From fc476a16833de56f327066cc91e4527af7c19320 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 26 Mar 2026 19:05:40 +0100 Subject: [PATCH 020/350] Proper handling of the reconnect on ota and serial --- .../src/main/java/dev/slimevr/firmware/ota.kt | 16 ++++++++++------ .../src/main/java/dev/slimevr/firmware/serial.kt | 11 ++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/firmware/ota.kt b/server/core/src/main/java/dev/slimevr/firmware/ota.kt index d300ef2bd6..7d8a72be5b 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/ota.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/ota.kt @@ -12,6 +12,9 @@ import io.ktor.utils.io.core.writeFully import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout @@ -154,14 +157,15 @@ suspend fun doOtaFlash( onStatus(FirmwareUpdateStatus.REBOOTING, 0) - // wait for the tracker with the correct id to come online + // Wait for the device to come back online after reboot. + // flatMapLatest switches to the matched device's own state flow so that + // status changes (which don't emit a new VRServerState) are also observed. + @OptIn(ExperimentalCoroutinesApi::class) val connected = withTimeoutOrNull(60_000) { server.context.state - .map { state -> - state.devices.values.any { - it.context.state.value.id.toUByte() == deviceId.id && - it.context.state.value.status != TrackerStatus.DISCONNECTED - } + .flatMapLatest { state -> + val device = state.devices.values.find { it.context.state.value.id.toUByte() == deviceId.id } + device?.context?.state?.map { it.status != TrackerStatus.DISCONNECTED } ?: flowOf(false) } .filter { it } .first() diff --git a/server/core/src/main/java/dev/slimevr/firmware/serial.kt b/server/core/src/main/java/dev/slimevr/firmware/serial.kt index acd2623427..a21903d200 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/serial.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/serial.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch @@ -181,13 +183,12 @@ internal suspend fun doSerialFlashPostFlash( } // wait for the tracker with that MAC to connect to the server via UDP + @OptIn(ExperimentalCoroutinesApi::class) val connected = withTimeoutOrNull(60_000) { server.context.state - .map { state -> - state.devices.values.any { - it.context.state.value.macAddress?.uppercase() == macAddress && - it.context.state.value.status != TrackerStatus.DISCONNECTED - } + .flatMapLatest { state -> + val device = state.devices.values.find { it.context.state.value.macAddress?.uppercase() == macAddress } + device?.context?.state?.map { it.status != TrackerStatus.DISCONNECTED } ?: flowOf(false) } .filter { it } .first() From ea92fb4c0198bfad2f5a62a8a069a49703f2a670 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 26 Mar 2026 22:41:47 +0100 Subject: [PATCH 021/350] Basic HID support --- server/README.md | 1 + .../{tracker/device.kt => device/module.kt} | 5 +- .../src/main/java/dev/slimevr/firmware/ota.kt | 7 +- .../src/main/java/dev/slimevr/hid/module.kt | 290 ++++++++++++++++++ .../src/main/java/dev/slimevr/hid/packets.kt | 203 ++++++++++++ .../core/src/main/java/dev/slimevr/logger.kt | 1 + .../main/java/dev/slimevr/solarxr/datafeed.kt | 3 +- .../slimevr/tracker/{tracker.kt => module.kt} | 2 +- .../slimevr/{tracker => }/udp/connection.kt | 11 +- .../dev/slimevr/{tracker => }/udp/packets.kt | 2 +- .../dev/slimevr/{tracker => }/udp/server.kt | 2 +- .../src/main/java/dev/slimevr/vrserver.kt | 2 +- .../dev/slimevr/firmware/DoSerialFlashTest.kt | 4 +- server/desktop/build.gradle.kts | 1 + .../src/main/java/dev/slimevr/desktop/Main.kt | 6 +- .../main/java/dev/slimevr/desktop/hid/hid.kt | 119 +++++++ .../java/dev/slimevr/desktop/ipc/protocol.kt | 9 +- 17 files changed, 639 insertions(+), 29 deletions(-) rename server/core/src/main/java/dev/slimevr/{tracker/device.kt => device/module.kt} (93%) create mode 100644 server/core/src/main/java/dev/slimevr/hid/module.kt create mode 100644 server/core/src/main/java/dev/slimevr/hid/packets.kt rename server/core/src/main/java/dev/slimevr/tracker/{tracker.kt => module.kt} (97%) rename server/core/src/main/java/dev/slimevr/{tracker => }/udp/connection.kt (98%) rename server/core/src/main/java/dev/slimevr/{tracker => }/udp/packets.kt (99%) rename server/core/src/main/java/dev/slimevr/{tracker => }/udp/server.kt (98%) create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt diff --git a/server/README.md b/server/README.md index 4c08ab0cba..58001d44cd 100644 --- a/server/README.md +++ b/server/README.md @@ -245,6 +245,7 @@ Each client runs in its own `launch` block. When the socket disconnects, the cor | `tracker/udp/` | Everything specific to the SlimeVR UDP wire protocol | | `solarxr/` | SolarXR WebSocket server + FlatBuffers message handling | | `config/` | JSON config read/write with autosave; no business logic | +| `firmware/` | OTA update and serial flash logic; interacts with devices over the network, independent of the UDP tracker protocol | --- diff --git a/server/core/src/main/java/dev/slimevr/tracker/device.kt b/server/core/src/main/java/dev/slimevr/device/module.kt similarity index 93% rename from server/core/src/main/java/dev/slimevr/tracker/device.kt rename to server/core/src/main/java/dev/slimevr/device/module.kt index 4d8a3058cc..08fdf37717 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/device.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -1,17 +1,14 @@ -package dev.slimevr.tracker +package dev.slimevr.device -import dev.slimevr.AppLogger import dev.slimevr.VRServer import dev.slimevr.context.BasicBehaviour import dev.slimevr.context.Context import dev.slimevr.context.createContext -import io.ktor.http.HttpProtocolVersion import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.BoardType -import solarxr_protocol.datatypes.hardware_info.ImuType import solarxr_protocol.datatypes.hardware_info.McuType enum class DeviceOrigin { diff --git a/server/core/src/main/java/dev/slimevr/firmware/ota.kt b/server/core/src/main/java/dev/slimevr/firmware/ota.kt index 7d8a72be5b..d83077cf8d 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/ota.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/ota.kt @@ -12,7 +12,6 @@ import io.ktor.utils.io.core.writeFully import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map @@ -158,9 +157,9 @@ suspend fun doOtaFlash( onStatus(FirmwareUpdateStatus.REBOOTING, 0) // Wait for the device to come back online after reboot. - // flatMapLatest switches to the matched device's own state flow so that - // status changes (which don't emit a new VRServerState) are also observed. - @OptIn(ExperimentalCoroutinesApi::class) + // which don't emit a new VRServerState, are also observed. + // flatMapLatest switches to the matched device's own state flow so that status changes, + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) val connected = withTimeoutOrNull(60_000) { server.context.state .flatMapLatest { state -> diff --git a/server/core/src/main/java/dev/slimevr/hid/module.kt b/server/core/src/main/java/dev/slimevr/hid/module.kt new file mode 100644 index 0000000000..fe8c90a017 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/hid/module.kt @@ -0,0 +1,290 @@ +package dev.slimevr.hid + +import dev.slimevr.AppLogger +import dev.slimevr.EventDispatcher +import dev.slimevr.VRServer +import dev.slimevr.VRServerActions +import dev.slimevr.context.Context +import dev.slimevr.context.CustomBehaviour +import dev.slimevr.context.createContext +import dev.slimevr.device.Device +import dev.slimevr.device.DeviceActions +import dev.slimevr.device.DeviceOrigin +import dev.slimevr.device.createDevice +import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerActions +import dev.slimevr.tracker.createTracker +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import solarxr_protocol.datatypes.TrackerStatus + +const val HID_TRACKER_RECEIVER_VID = 0x1209 +const val HID_TRACKER_RECEIVER_PID = 0x7690 +const val HID_TRACKER_PID = 0x7692 + +// --- State and actions --- + +data class HIDTrackerRecord( + val hidId: Int, + val address: String, + val deviceId: Int, + val trackerId: Int?, +) + +data class HIDReceiverState( + val serialNumber: String, + val trackers: Map, +) + +sealed interface HIDReceiverActions { + data class DeviceRegistered(val hidId: Int, val address: String, val deviceId: Int) : HIDReceiverActions + data class TrackerRegistered(val hidId: Int, val trackerId: Int) : HIDReceiverActions +} + +typealias HIDReceiverContext = Context +typealias HIDReceiverBehaviour = CustomBehaviour +typealias HIDPacketDispatcher = EventDispatcher + +@Suppress("UNCHECKED_CAST") +inline fun HIDPacketDispatcher.onPacket(crossinline callback: suspend (T) -> Unit) { + register(T::class) { callback(it as T) } +} + +val HIDRegistrationBehaviour = HIDReceiverBehaviour( + reducer = { s, a -> + when (a) { + is HIDReceiverActions.DeviceRegistered -> s.copy( + trackers = s.trackers + (a.hidId to HIDTrackerRecord( + hidId = a.hidId, + address = a.address, + deviceId = a.deviceId, + trackerId = null, + )), + ) + + else -> s + } + }, + observer = { receiver -> + receiver.packetEvents.onPacket { packet -> + val state = receiver.context.state.value + val existing = state.trackers[packet.hidId] + if (existing != null) return@onPacket + + val existingDevice = receiver.serverContext.context.state.value.devices.values + .find { it.context.state.value.macAddress == packet.address && it.context.state.value.origin == DeviceOrigin.HID } + + if (existingDevice != null) { + receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, existingDevice.context.state.value.id)) + AppLogger.hid.info("Reconnected HID device ${packet.address} (hidId=${packet.hidId})") + return@onPacket + } + + val deviceId = receiver.serverContext.nextHandle() + val device = createDevice( + scope = receiver.serverContext.context.scope, + id = deviceId, + address = packet.address, + macAddress = packet.address, + origin = DeviceOrigin.HID, + protocolVersion = 0, + serverContext = receiver.serverContext, + ) + receiver.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId, device)) + receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, deviceId)) + AppLogger.hid.info("Registered HID device ${packet.address} (hidId=${packet.hidId})") + } + }, +) + +val HIDDeviceInfoBehaviour = HIDReceiverBehaviour( + reducer = { s, a -> + when (a) { + is HIDReceiverActions.TrackerRegistered -> { + val existing = s.trackers[a.hidId] ?: return@HIDReceiverBehaviour s + s.copy(trackers = s.trackers + (a.hidId to existing.copy(trackerId = a.trackerId))) + } + + else -> s + } + }, + observer = { receiver -> + receiver.packetEvents.onPacket { packet -> + val device = receiver.getDevice(packet.hidId) ?: return@onPacket + + device.context.dispatch( + DeviceActions.Update { + copy( + boardType = packet.boardType, + mcuType = packet.mcuType, + firmware = packet.firmware, + batteryLevel = packet.batteryLevel, + batteryVoltage = packet.batteryVoltage, + signalStrength = packet.rssi, + ) + }, + ) + + val tracker = receiver.getTracker(packet.hidId) + if (tracker == null) { + val deviceState = device.context.state.value + + val existingTracker = receiver.serverContext.context.state.value.trackers.values + .find { it.context.state.value.hardwareId == deviceState.address && it.context.state.value.origin == DeviceOrigin.HID } + + if (existingTracker != null) { + receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, existingTracker.context.state.value.id)) + existingTracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType) }) + } else { + val trackerId = receiver.serverContext.nextHandle() + val newTracker = createTracker( + scope = receiver.serverContext.context.scope, + id = trackerId, + deviceId = deviceState.id, + sensorType = packet.imuType, + hardwareId = deviceState.address, + origin = DeviceOrigin.HID, + serverContext = receiver.serverContext, + ) + receiver.serverContext.context.dispatch(VRServerActions.NewTracker(trackerId, newTracker)) + receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, trackerId)) + AppLogger.hid.info("Registered HID tracker for device ${deviceState.address} (hidId=${packet.hidId})") + } + + device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) }) + } else { + tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType) }) + } + } + }, +) + +val HIDRotationBehaviour = HIDReceiverBehaviour( + observer = { receiver -> + receiver.packetEvents.onPacket { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + } + + receiver.packetEvents.onPacket { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + } + + receiver.packetEvents.onPacket { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + } + + receiver.packetEvents.onPacket { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + } + }, +) + +val HIDBatteryBehaviour = HIDReceiverBehaviour( + observer = { receiver -> + receiver.packetEvents.onPacket { packet -> + receiver.getDevice(packet.hidId)?.context?.dispatch( + DeviceActions.Update { + copy(batteryLevel = packet.batteryLevel, batteryVoltage = packet.batteryVoltage, signalStrength = packet.rssi) + }, + ) + } + + receiver.packetEvents.onPacket { packet -> + receiver.getDevice(packet.hidId)?.context?.dispatch( + DeviceActions.Update { copy(signalStrength = packet.rssi) }, + ) + } + }, +) + +val HIDStatusBehaviour = HIDReceiverBehaviour( + observer = { receiver -> + receiver.packetEvents.onPacket { packet -> + if (receiver.getTracker(packet.hidId) == null) return@onPacket + receiver.getDevice(packet.hidId)?.context?.dispatch( + DeviceActions.Update { copy(status = packet.status, signalStrength = packet.rssi) }, + ) + } + }, +) + +data class HIDReceiver( + val context: HIDReceiverContext, + val serverContext: VRServer, + val packetEvents: HIDPacketDispatcher, +) { + fun getDevice(hidId: Int): Device? { + val record = context.state.value.trackers[hidId] ?: return null + return serverContext.getDevice(record.deviceId) + } + + fun getTracker(hidId: Int): Tracker? { + val record = context.state.value.trackers[hidId] ?: return null + val trackerId = record.trackerId ?: return null + return serverContext.getTracker(trackerId) + } +} + +fun createHIDReceiver( + serialNumber: String, + data: Flow, + serverContext: VRServer, + scope: CoroutineScope, +): HIDReceiver { + val behaviours = listOf( + HIDRegistrationBehaviour, + HIDDeviceInfoBehaviour, + HIDRotationBehaviour, + HIDBatteryBehaviour, + HIDStatusBehaviour, + ) + + val context = createContext( + initialState = HIDReceiverState( + serialNumber = serialNumber, + trackers = mapOf(), + ), + reducers = behaviours.map { it.reducer }, + scope = scope, + ) + + val dispatcher = HIDPacketDispatcher() + + val receiver = HIDReceiver( + context = context, + serverContext = serverContext, + packetEvents = dispatcher, + ) + + behaviours.map { it.observer }.forEach { it?.invoke(receiver) } + + data + .onEach { report -> parseHIDPackets(report).forEach { dispatcher.emit(it) } } + .launchIn(scope) + + scope.launch { + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { + for (record in context.state.value.trackers.values) { + serverContext.getDevice(record.deviceId)?.context?.dispatch( + DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, + ) + } + } + } + } + + return receiver +} diff --git a/server/core/src/main/java/dev/slimevr/hid/packets.kt b/server/core/src/main/java/dev/slimevr/hid/packets.kt new file mode 100644 index 0000000000..112260781d --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/hid/packets.kt @@ -0,0 +1,203 @@ +package dev.slimevr.hid + +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Quaternion.Companion.fromRotationVector +import io.github.axisangles.ktmath.Vector3 +import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.datatypes.hardware_info.BoardType +import solarxr_protocol.datatypes.hardware_info.ImuType +import solarxr_protocol.datatypes.hardware_info.McuType +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +private const val HID_PACKET_SIZE = 16 + +private val AXES_OFFSET = fromRotationVector(-PI.toFloat() / 2f, 0f, 0f) + +sealed interface HIDPacket { + val hidId: Int +} + +/** Receiver associates a wireless tracker ID with its 6-byte address (type 255). */ +data class HIDDeviceRegister(override val hidId: Int, val address: String) : HIDPacket + +/** Board/MCU/firmware/battery + IMU type for tracker registration (type 0). */ +data class HIDDeviceInfo( + override val hidId: Int, + val imuType: ImuType, + val boardType: BoardType, + val mcuType: McuType, + val firmware: String, + val batteryLevel: Float, + val batteryVoltage: Float, + val rssi: Int, +) : HIDPacket + +/** Full-precision Q15 quaternion + Q7 acceleration (type 1). */ +data class HIDRotation( + override val hidId: Int, + val rotation: Quaternion, + val acceleration: Vector3, +) : HIDPacket + +/** Compact exp-map quaternion + Q7 acceleration + battery level + rssi (type 2). */ +data class HIDRotationBattery( + override val hidId: Int, + val rotation: Quaternion, + val acceleration: Vector3, + val batteryLevel: Float, + val batteryVoltage: Float, + val rssi: Int, +) : HIDPacket + +/** Tracker status report + rssi (type 3). */ +data class HIDStatus( + override val hidId: Int, + val status: TrackerStatus, + val rssi: Int, +) : HIDPacket + +/** Full-precision Q15 quaternion + Q10 magnetometer (type 4). */ +data class HIDRotationMag( + override val hidId: Int, + val rotation: Quaternion, + val magnetometer: Vector3, +) : HIDPacket + +/** Button state + compact exp-map quaternion + Q7 acceleration + rssi (type 7). */ +data class HIDRotationButton( + override val hidId: Int, + val button: Int, + val rotation: Quaternion, + val acceleration: Vector3, + val rssi: Int, +) : HIDPacket + +private fun readLE16Signed(data: ByteArray, offset: Int): Int = + data[offset + 1].toInt() shl 8 or data[offset].toUByte().toInt() + +private fun decodeQ15Quat(data: ByteArray, offset: Int): Quaternion { + val scale = 1f / 32768f + val x = readLE16Signed(data, offset).toShort().toFloat() * scale + val y = readLE16Signed(data, offset + 2).toShort().toFloat() * scale + val z = readLE16Signed(data, offset + 4).toShort().toFloat() * scale + val w = readLE16Signed(data, offset + 6).toShort().toFloat() * scale + return AXES_OFFSET * Quaternion(w, x, y, z) +} + +private fun decodeExpMapQuat(data: ByteArray, offset: Int): Quaternion { + val buf = ByteBuffer.wrap(data, offset, 4).order(ByteOrder.LITTLE_ENDIAN).int.toUInt() + val vx = ((buf and 1023u).toFloat() / 1024f) * 2f - 1f + val vy = ((buf shr 10 and 2047u).toFloat() / 2048f) * 2f - 1f + val vz = ((buf shr 21 and 2047u).toFloat() / 2048f) * 2f - 1f + val d = vx * vx + vy * vy + vz * vz + val invSqrtD = 1f / sqrt(d + 1e-6f) + val a = (PI.toFloat() / 2f) * d * invSqrtD + val s = sin(a) + val k = s * invSqrtD + return AXES_OFFSET * Quaternion(cos(a), k * vx, k * vy, k * vz) +} + +private fun decodeAccel(data: ByteArray, offset: Int): Vector3 { + val scale = 1f / 128f + return Vector3( + readLE16Signed(data, offset).toShort().toFloat() * scale, + readLE16Signed(data, offset + 2).toShort().toFloat() * scale, + readLE16Signed(data, offset + 4).toShort().toFloat() * scale, + ) +} + +private fun decodeBattery(raw: Int): Float = if (raw == 128) 1f else (raw and 127).toFloat() + +private fun decodeBatteryVoltage(raw: Int): Float = (raw.toFloat() + 245f) / 100f + +private fun parseSingleHIDPacket(data: ByteArray, i: Int): HIDPacket? { + val packetType = data[i].toUByte().toInt() + val hidId = data[i + 1].toUByte().toInt() + + return when (packetType) { + 255 -> { + val addr = ByteBuffer.wrap(data, i + 2, 8).order(ByteOrder.LITTLE_ENDIAN).long and 0x0000_FFFF_FFFF_FFFFL + HIDDeviceRegister(hidId, "%012X".format(addr)) + } + + 0 -> { + val batt = data[i + 2].toUByte().toInt() + val battV = data[i + 3].toUByte().toInt() + val brdId = data[i + 5].toUByte().toInt() + val mcuId = data[i + 6].toUByte().toInt() + val imuId = data[i + 8].toUByte().toInt() + val fwDate = data[i + 11].toUByte().toInt() shl 8 or data[i + 10].toUByte().toInt() + val fwMajor = data[i + 12].toUByte().toInt() + val fwMinor = data[i + 13].toUByte().toInt() + val fwPatch = data[i + 14].toUByte().toInt() + val rssi = data[i + 15].toUByte().toInt() + val fwYear = 2020 + (fwDate shr 9 and 127) + val fwMonth = fwDate shr 5 and 15 + val fwDay = fwDate and 31 + HIDDeviceInfo( + hidId = hidId, + imuType = ImuType.fromValue(imuId.toUShort()) ?: ImuType.Other, + boardType = BoardType.fromValue(brdId.toUShort()) ?: BoardType.UNKNOWN, + mcuType = McuType.fromValue(mcuId.toUShort()) ?: McuType.Other, + firmware = "%04d-%02d-%02d %d.%d.%d".format(fwYear, fwMonth, fwDay, fwMajor, fwMinor, fwPatch), + batteryLevel = decodeBattery(batt), + batteryVoltage = decodeBatteryVoltage(battV), + rssi = -rssi, + ) + } + + 1 -> HIDRotation( + hidId = hidId, + rotation = decodeQ15Quat(data, i + 2), + acceleration = decodeAccel(data, i + 10), + ) + + 2 -> HIDRotationBattery( + hidId = hidId, + rotation = decodeExpMapQuat(data, i + 5), + acceleration = decodeAccel(data, i + 9), + batteryLevel = decodeBattery(data[i + 2].toUByte().toInt()), + batteryVoltage = decodeBatteryVoltage(data[i + 3].toUByte().toInt()), + rssi = -data[i + 15].toUByte().toInt(), + ) + + 3 -> HIDStatus( + hidId = hidId, + status = TrackerStatus.fromValue((data[i + 2].toUByte().toInt() + 1).toUByte()) ?: TrackerStatus.OK, + rssi = -data[i + 15].toUByte().toInt(), + ) + + 4 -> { + val scaleMag = 1000f / 1024f + HIDRotationMag( + hidId = hidId, + rotation = decodeQ15Quat(data, i + 2), + magnetometer = Vector3( + readLE16Signed(data, i + 10).toShort().toFloat() * scaleMag, + readLE16Signed(data, i + 12).toShort().toFloat() * scaleMag, + readLE16Signed(data, i + 14).toShort().toFloat() * scaleMag, + ), + ) + } + + 7 -> HIDRotationButton( + hidId = hidId, + button = data[i + 2].toUByte().toInt(), + rotation = decodeExpMapQuat(data, i + 5), + acceleration = decodeAccel(data, i + 9), + rssi = -data[i + 15].toUByte().toInt(), + ) + + else -> null + } +} + +fun parseHIDPackets(data: ByteArray): List { + if (data.size % HID_PACKET_SIZE != 0) return emptyList() + return (0 until data.size / HID_PACKET_SIZE).mapNotNull { parseSingleHIDPacket(data, it * HID_PACKET_SIZE) } +} diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index b04523cea0..44205efffa 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -11,6 +11,7 @@ object AppLogger { val device = logger("Device") val udp = logger("UDPConnection") val solarxr = logger("SolarXR") + val hid = logger("HID") val serial = logger("Serial") val firmware = logger("Firmware") diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index b0cc681819..4fce6a38e1 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -2,7 +2,7 @@ package dev.slimevr.solarxr import com.google.flatbuffers.FlatBufferBuilder import dev.slimevr.VRServer -import dev.slimevr.tracker.DeviceState +import dev.slimevr.device.DeviceState import dev.slimevr.tracker.TrackerState import io.ktor.util.moveToByteArray import kotlinx.coroutines.cancelAndJoin @@ -22,7 +22,6 @@ import solarxr_protocol.data_feed.tracker.TrackerInfo import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.Ipv4Address import solarxr_protocol.datatypes.TrackerId -import solarxr_protocol.datatypes.hardware_info.HardwareAddress import solarxr_protocol.datatypes.hardware_info.HardwareInfo import solarxr_protocol.datatypes.hardware_info.HardwareStatus import solarxr_protocol.datatypes.math.Quat diff --git a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt similarity index 97% rename from server/core/src/main/java/dev/slimevr/tracker/tracker.kt rename to server/core/src/main/java/dev/slimevr/tracker/module.kt index 43ca09494b..09e38f1df8 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/tracker.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -4,12 +4,12 @@ import dev.slimevr.VRServer import dev.slimevr.context.BasicBehaviour import dev.slimevr.context.Context import dev.slimevr.context.createContext +import dev.slimevr.device.DeviceOrigin import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.BodyPart -import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.ImuType data class TrackerIdNum(val id: Int, val trackerNum: Int) diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt similarity index 98% rename from server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt rename to server/core/src/main/java/dev/slimevr/udp/connection.kt index 02d92c8ea3..1d3cc00a09 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -1,6 +1,5 @@ -package dev.slimevr.tracker.udp +package dev.slimevr.udp -import dev.llelievr.espflashkotlin.Packet import dev.slimevr.AppLogger import dev.slimevr.EventDispatcher import dev.slimevr.VRServer @@ -8,13 +7,13 @@ import dev.slimevr.VRServerActions import dev.slimevr.context.Context import dev.slimevr.context.CustomBehaviour import dev.slimevr.context.createContext -import dev.slimevr.tracker.Device -import dev.slimevr.tracker.DeviceActions -import dev.slimevr.tracker.DeviceOrigin +import dev.slimevr.device.Device +import dev.slimevr.device.DeviceActions +import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import dev.slimevr.tracker.TrackerIdNum -import dev.slimevr.tracker.createDevice +import dev.slimevr.device.createDevice import dev.slimevr.tracker.createTracker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt b/server/core/src/main/java/dev/slimevr/udp/packets.kt similarity index 99% rename from server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt rename to server/core/src/main/java/dev/slimevr/udp/packets.kt index f6d73dabdf..67e21ab2d7 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/udp/packets.kt @@ -1,4 +1,4 @@ -package dev.slimevr.tracker.udp +package dev.slimevr.udp import dev.slimevr.EventDispatcher import io.github.axisangles.ktmath.Quaternion diff --git a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt similarity index 98% rename from server/core/src/main/java/dev/slimevr/tracker/udp/server.kt rename to server/core/src/main/java/dev/slimevr/udp/server.kt index 268bd8f2d7..649d145498 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -1,4 +1,4 @@ -package dev.slimevr.tracker.udp +package dev.slimevr.udp import dev.slimevr.AppLogger import dev.slimevr.VRServer diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index 2a50b6741c..77df62869f 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -5,7 +5,7 @@ import dev.slimevr.context.CustomBehaviour import dev.slimevr.context.createContext import dev.slimevr.firmware.FirmwareManager import dev.slimevr.serial.SerialServer -import dev.slimevr.tracker.Device +import dev.slimevr.device.Device import dev.slimevr.tracker.Tracker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChangedBy diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt index 02291d07b4..709cd302ab 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -6,8 +6,8 @@ import dev.slimevr.VRServerActions import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialPortInfo import dev.slimevr.serial.SerialServer -import dev.slimevr.tracker.DeviceOrigin -import dev.slimevr.tracker.createDevice +import dev.slimevr.device.DeviceOrigin +import dev.slimevr.device.createDevice import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.launch diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index db22322f92..f1e38e8a7a 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -83,6 +83,7 @@ dependencies { exclude(group = "com.fazecast", module = "android") } implementation("org.hid4java:hid4java:0.8.0") + implementation("io.klogging:klogging:0.11.7") } tasks.shadowJar { 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 42e388aadf..442eb157ce 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -4,12 +4,13 @@ package dev.slimevr.desktop import dev.slimevr.VRServer 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.firmware.createFirmwareManager import dev.slimevr.resolveConfigDirectory import dev.slimevr.solarxr.createSolarXRWebsocketServer -import dev.slimevr.tracker.udp.createUDPTrackerServer +import dev.slimevr.udp.createUDPTrackerServer import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -29,5 +30,8 @@ fun main(args: Array) = runBlocking { launch { createIpcServers(server) } + launch { + createDesktopHIDManager(server, this) + } Unit } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt new file mode 100644 index 0000000000..ea4c9ce176 --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -0,0 +1,119 @@ +package dev.slimevr.desktop.hid + +import dev.slimevr.AppLogger +import dev.slimevr.VRServer +import dev.slimevr.hid.HID_TRACKER_PID +import dev.slimevr.hid.HID_TRACKER_RECEIVER_PID +import dev.slimevr.hid.HID_TRACKER_RECEIVER_VID +import dev.slimevr.hid.HIDReceiver +import dev.slimevr.hid.createHIDReceiver +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.hid4java.HidDevice +import org.hid4java.HidManager +import org.hid4java.HidServicesSpecification +import org.hid4java.jna.HidApi +import org.hid4java.jna.HidDeviceInfoStructure + +private const val HID_POLL_INTERVAL_MS = 3000L + +private fun isCompatibleDevice(vid: Int, pid: Int) = + vid == HID_TRACKER_RECEIVER_VID && (pid == HID_TRACKER_RECEIVER_PID || pid == HID_TRACKER_PID) + +private val hidSpec = HidServicesSpecification().apply { isAutoStart = false } + +// Initialize the native HID library. Must be called before enumerateDevices. +private val hidServices by lazy { HidManager.getHidServices(hidSpec) } + +private fun enumerateCompatibleDevices(): Map { + hidServices // ensure native lib is loaded + val root = HidApi.enumerateDevices(0, 0) ?: return emptyMap() + val result = mutableMapOf() + var info: HidDeviceInfoStructure? = root + while (info != null) { + if (isCompatibleDevice(info.vendor_id.toInt(), info.product_id.toInt())) { + val device = HidDevice(info, null, hidSpec) + // Use path as key, unique per physical device, available without opening + result[info.path] = device + } + info = info.next() + } + HidApi.freeEnumeration(root) + return result +} + +private data class ActiveReceiver(val job: Job, val receiver: HIDReceiver) + +fun createDesktopHIDManager(serverContext: VRServer, scope: CoroutineScope) { + val active = mutableMapOf() + + scope.launch { + while (isActive) { + val found = withContext(Dispatchers.IO) { + try { enumerateCompatibleDevices() } catch (_: Exception) { emptyMap() } + } + + // Devices no longer present + jobs that exited on their own (read error) + val toRemove = (active.keys - found.keys) + + active.entries.filter { !it.value.job.isActive }.map { it.key } + for (path in toRemove) { + val entry = active.remove(path) ?: continue + entry.job.cancel() + entry.job.join() + AppLogger.hid.info("HID device removed: $path") + } + + // Open newly detected devices + for ((path, hidDevice) in found) { + if (path in active) continue + + if (!hidDevice.open()) { + AppLogger.hid.warn("Failed to open HID device: $path") + continue + } + + val serial = hidDevice.serialNumber ?: path + AppLogger.hid.info("HID device detected: $serial") + + val deviceJob = Job(scope.coroutineContext[Job]) + val deviceScope = CoroutineScope(scope.coroutineContext + deviceJob) + + val dataFlow = channelFlow { + try { + while (isActive) { + val data = withContext(Dispatchers.IO) { + try { hidDevice.readAll(0) } catch (_: Exception) { null } + } + when { + data == null -> return@channelFlow // read error, device gone + data.isNotEmpty() -> send(data) + else -> delay(1) // no data yet, yield without busy-spinning + } + } + } finally { + withContext(NonCancellable + Dispatchers.IO) { hidDevice.close() } + } + } + + val receiver = createHIDReceiver( + serialNumber = serial, + data = dataFlow, + serverContext = serverContext, + scope = deviceScope, + ) + deviceJob.complete() + + active[path] = ActiveReceiver(deviceJob, receiver) + } + + delay(HID_POLL_INTERVAL_MS) + } + } +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index e59c3afb48..38e758f0e8 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -8,17 +8,14 @@ import dev.slimevr.desktop.platform.TrackerAdded import dev.slimevr.desktop.platform.Version import dev.slimevr.solarxr.createSolarXRConnection import dev.slimevr.solarxr.onSolarXRMessage -import dev.slimevr.tracker.DeviceActions -import dev.slimevr.tracker.DeviceOrigin +import dev.slimevr.device.DeviceActions +import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.TrackerActions -import dev.slimevr.tracker.createDevice +import dev.slimevr.device.createDevice import dev.slimevr.tracker.createTracker import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock From 8168e1366ab5cc4b5245f12e9e741d78ce665414 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Fri, 27 Mar 2026 02:24:08 +0100 Subject: [PATCH 022/350] VRC settings (Non tested on windows) --- .../core/src/main/java/dev/slimevr/logger.kt | 1 + .../main/java/dev/slimevr/solarxr/module.kt | 2 +- .../main/java/dev/slimevr/solarxr/vrchat.kt | 47 +++++++ .../main/java/dev/slimevr/vrchat/module.kt | 129 ++++++++++++++++++ .../src/main/java/dev/slimevr/vrserver.kt | 14 +- .../src/test/java/dev/slimevr/TestServer.kt | 11 +- .../dev/slimevr/firmware/DoSerialFlashTest.kt | 14 +- .../src/main/java/dev/slimevr/desktop/Main.kt | 7 +- .../java/dev/slimevr/desktop/vrchat/linux.kt | 106 ++++++++++++++ .../dev/slimevr/desktop/vrchat/vrc-config.kt | 66 +++++++++ .../dev/slimevr/desktop/vrchat/windows.kt | 107 +++++++++++++++ 11 files changed, 495 insertions(+), 9 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt create mode 100644 server/core/src/main/java/dev/slimevr/vrchat/module.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/vrchat/windows.kt diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index 44205efffa..ead2e5eba5 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -14,6 +14,7 @@ object AppLogger { val hid = logger("HID") val serial = logger("Serial") val firmware = logger("Firmware") + val vrc = logger("VRChat") init { loggingConfiguration { diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index be7e878cb2..59e73efed8 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -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, diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt new file mode 100644 index 0000000000..f74dcf6f82 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt @@ -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 { + conn.sendRpc(buildCurrentResponse()) + } + + conn.rpcDispatcher.on { req -> + val key = req.key ?: return@on + vrcManager.context.dispatch(VRCConfigActions.ToggleMutedWarning(key)) + } + }, +) diff --git a/server/core/src/main/java/dev/slimevr/vrchat/module.kt b/server/core/src/main/java/dev/slimevr/vrchat/module.kt new file mode 100644 index 0000000000..80aea5753f --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vrchat/module.kt @@ -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, +) + +sealed interface VRCConfigActions { + data class UpdateValues(val values: VRCConfigValues?) : VRCConfigActions + data class ToggleMutedWarning(val key: String) : VRCConfigActions +} + +typealias VRCConfigContext = Context +typealias VRCConfigBehaviour = BasicBehaviour + +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, +): 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) +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index 77df62869f..f454bcc766 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -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 } } -} +} \ No newline at end of file diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 8b3a871370..0d993063d9 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -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(), + ) + ) } diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt index 709cd302ab..8deff7c9a2 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -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() 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 442eb157ce..7fbea2153b 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.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) = 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) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt new file mode 100644 index 0000000000..c2fc7ab51b --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt @@ -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): Map { + val keysMap = mutableMapOf() + 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, 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, 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 = 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() + 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) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt new file mode 100644 index 0000000000..d049825f5f --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt @@ -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 + }, +) \ No newline at end of file diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/windows.kt new file mode 100644 index 0000000000..9bda92efd5 --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/windows.kt @@ -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 { + val keysMap = mutableMapOf() + 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 = 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) From fa1d2012e14952f89d97fa871978d7914f740fc1 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Fri, 27 Mar 2026 02:53:26 +0100 Subject: [PATCH 023/350] Make vrc config save mute settings to config --- .../main/java/dev/slimevr/config/settings.kt | 5 +-- .../src/main/java/dev/slimevr/config/user.kt | 4 +-- .../main/java/dev/slimevr/vrchat/module.kt | 34 +++++++++++++++---- .../src/main/java/dev/slimevr/desktop/Main.kt | 1 + .../java/dev/slimevr/desktop/vrchat/linux.kt | 1 - .../dev/slimevr/desktop/vrchat/vrc-config.kt | 8 +++-- 6 files changed, 40 insertions(+), 13 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 4a5aeea0f5..8ccfb3af1a 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -19,6 +19,7 @@ private const val SETTINGS_CONFIG_VERSION = 1 @Serializable data class SettingsConfigState( val trackerPort: Int = 6969, + val mutedVRCWarnings: List = listOf(), val version: Int = SETTINGS_CONFIG_VERSION, ) @@ -41,7 +42,7 @@ data class SettingsState( ) sealed interface SettingsActions { - data class Update(val transform: SettingsState.() -> SettingsState) : SettingsActions + data class Update(val transform: SettingsConfigState.() -> SettingsConfigState) : SettingsActions data class LoadProfile(val newState: SettingsState) : SettingsActions } @@ -57,7 +58,7 @@ data class Settings( val DefaultSettingsBehaviour = SettingsBehaviour( reducer = { s, a -> when (a) { - is SettingsActions.Update -> a.transform(s) + is SettingsActions.Update -> s.copy(data = a.transform(s.data)) is SettingsActions.LoadProfile -> a.newState else -> s } diff --git a/server/core/src/main/java/dev/slimevr/config/user.kt b/server/core/src/main/java/dev/slimevr/config/user.kt index 0d9553c01a..1ae00c6f9d 100644 --- a/server/core/src/main/java/dev/slimevr/config/user.kt +++ b/server/core/src/main/java/dev/slimevr/config/user.kt @@ -41,7 +41,7 @@ data class UserConfigState( ) sealed interface UserConfigActions { - data class Update(val transform: UserConfigState.() -> UserConfigState) : UserConfigActions + data class Update(val transform: UserConfigData.() -> UserConfigData) : UserConfigActions data class LoadProfile(val newState: UserConfigState) : UserConfigActions } @@ -57,7 +57,7 @@ data class UserConfig( val DefaultUserBehaviour = UserConfigBehaviour( reducer = { s, a -> when (a) { - is UserConfigActions.Update -> a.transform(s) + is UserConfigActions.Update -> s.copy(data = a.transform(s.data)) is UserConfigActions.LoadProfile -> a.newState else -> s } diff --git a/server/core/src/main/java/dev/slimevr/vrchat/module.kt b/server/core/src/main/java/dev/slimevr/vrchat/module.kt index 80aea5753f..6763164255 100644 --- a/server/core/src/main/java/dev/slimevr/vrchat/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrchat/module.kt @@ -1,11 +1,18 @@ package dev.slimevr.vrchat import dev.slimevr.VRServer +import dev.slimevr.config.AppConfig +import dev.slimevr.config.SettingsActions import dev.slimevr.context.BasicBehaviour import dev.slimevr.context.Context +import dev.slimevr.context.CustomBehaviour import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.VRCAvatarMeasurementType @@ -31,7 +38,7 @@ val VRC_VALID_KEYS = setOf( data class VRCConfigState( val currentValues: VRCConfigValues?, val isSupported: Boolean, - val mutedWarnings: Set, + val mutedWarnings: List, ) sealed interface VRCConfigActions { @@ -40,10 +47,11 @@ sealed interface VRCConfigActions { } typealias VRCConfigContext = Context -typealias VRCConfigBehaviour = BasicBehaviour +typealias VRCConfigBehaviour = CustomBehaviour data class VRCConfigManager( val context: VRCConfigContext, + val config: AppConfig, val userHeight: () -> Double, ) @@ -58,6 +66,14 @@ val DefaultVRCConfigBehaviour = VRCConfigBehaviour( } } }, + observer = { context -> + + context.context.state.map { it.mutedWarnings }.distinctUntilChanged().onEach { warnings -> + context.config.settings.context.dispatch(SettingsActions.Update { + copy(mutedVRCWarnings = warnings) + }) + }.launchIn(scope = context.context.scope) + } ) fun computeRecommendedValues(server: VRServer, userHeight: Double): VRCConfigRecommendedValues { @@ -104,20 +120,23 @@ fun computeValidity(values: VRCConfigValues, recommended: VRCConfigRecommendedVa ) fun createVRCConfigManager( + config: AppConfig, scope: CoroutineScope, userHeight: () -> Double, isSupported: Boolean, values: Flow, ): VRCConfigManager { + val modules = listOf(DefaultVRCConfigBehaviour) + val initialState = VRCConfigState( currentValues = null, isSupported = isSupported, - mutedWarnings = emptySet(), + mutedWarnings = listOf(), ) val context = createContext( initialState = initialState, - reducers = listOf(DefaultVRCConfigBehaviour.reducer), + reducers = modules.map { it.reducer }, scope = scope, ) @@ -125,5 +144,8 @@ fun createVRCConfigManager( values.collect { context.dispatch(VRCConfigActions.UpdateValues(it)) } } - return VRCConfigManager(context = context, userHeight = userHeight) -} \ No newline at end of file + val manager = VRCConfigManager(context = context, userHeight = userHeight, config = config) + modules.map { it.observer }.forEach { it?.invoke(manager) } + + return manager +} 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 7fbea2153b..11f2c740c5 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -21,6 +21,7 @@ fun main(args: Array) = runBlocking { val serialServer = createDesktopSerialServer(this) val firmwareManager = createFirmwareManager(serialServer = serialServer, scope = this) val vrcConfigManager = createDesktopVRCConfigManager( + config = config, scope = this, userHeight = { config.userConfig.context.state.value.data.userHeight.toDouble() }, ) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt index c2fc7ab51b..67e5a70345 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt @@ -96,7 +96,6 @@ internal fun linuxVRCConfigFlow(): Flow = 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. diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt index d049825f5f..7bb0af89fa 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt @@ -2,6 +2,7 @@ package dev.slimevr.desktop.vrchat import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform +import dev.slimevr.config.AppConfig import dev.slimevr.vrchat.VRCConfigManager import dev.slimevr.vrchat.createVRCConfigManager import kotlinx.coroutines.CoroutineScope @@ -13,21 +14,24 @@ import solarxr_protocol.rpc.VRCTrackerModel internal const val VRC_REG_PATH = "Software\\VRChat\\VRChat" -fun createDesktopVRCConfigManager(scope: CoroutineScope, userHeight: () -> Double): VRCConfigManager = +fun createDesktopVRCConfigManager(config: AppConfig, scope: CoroutineScope, userHeight: () -> Double): VRCConfigManager = when (CURRENT_PLATFORM) { Platform.WINDOWS -> createVRCConfigManager( + config = config, scope = scope, userHeight = userHeight, isSupported = true, values = windowsVRCConfigFlow(), ) Platform.LINUX -> createVRCConfigManager( + config = config, scope = scope, userHeight = userHeight, isSupported = true, values = linuxVRCConfigFlow(), ) else -> createVRCConfigManager( + config = config, scope = scope, userHeight = userHeight, isSupported = false, @@ -63,4 +67,4 @@ internal suspend fun buildVRCConfigValues( 1 -> VRCAvatarMeasurementType.HEIGHT else -> VRCAvatarMeasurementType.UNKNOWN }, -) \ No newline at end of file +) From 9d1e7764e6e353488ea6d3699fc06e669094b126 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Fri, 27 Mar 2026 05:29:41 +0100 Subject: [PATCH 024/350] Re organise project --- .../src/main/java/dev/slimevr/behaviours.kt | 18 + .../java/dev/slimevr/config/behaviours.kt | 22 ++ .../main/java/dev/slimevr/config/module.kt | 88 ++--- .../main/java/dev/slimevr/config/settings.kt | 64 ++-- .../src/main/java/dev/slimevr/config/user.kt | 64 ++-- .../main/java/dev/slimevr/context/context.kt | 57 ++- .../java/dev/slimevr/device/behaviours.kt | 15 + .../main/java/dev/slimevr/device/module.kt | 93 ++--- .../java/dev/slimevr/firmware/behaviours.kt | 18 + .../main/java/dev/slimevr/firmware/module.kt | 94 ++--- .../main/java/dev/slimevr/hid/behaviours.kt | 166 +++++++++ .../src/main/java/dev/slimevr/hid/module.kt | 266 +++---------- .../java/dev/slimevr/serial/behaviours.kt | 30 ++ .../java/dev/slimevr/serial/connection.kt | 67 ++-- .../main/java/dev/slimevr/serial/module.kt | 62 +--- .../main/java/dev/slimevr/solarxr/datafeed.kt | 54 ++- .../main/java/dev/slimevr/solarxr/firmware.kt | 24 +- .../main/java/dev/slimevr/solarxr/module.kt | 68 ++-- .../main/java/dev/slimevr/solarxr/serial.kt | 39 +- .../main/java/dev/slimevr/solarxr/vrchat.kt | 30 +- .../java/dev/slimevr/solarxr/ws-server.kt | 5 +- .../java/dev/slimevr/tracker/behaviours.kt | 15 + .../main/java/dev/slimevr/tracker/module.kt | 85 ++--- .../main/java/dev/slimevr/udp/behaviours.kt | 228 ++++++++++++ .../main/java/dev/slimevr/udp/connection.kt | 350 ++---------------- .../src/main/java/dev/slimevr/udp/server.kt | 2 +- .../java/dev/slimevr/vrchat/behaviours.kt | 26 ++ .../main/java/dev/slimevr/vrchat/module.kt | 93 ++--- .../src/main/java/dev/slimevr/vrserver.kt | 66 +--- .../src/test/java/dev/slimevr/TestServer.kt | 17 +- .../dev/slimevr/firmware/DoSerialFlashTest.kt | 37 +- .../reducers/FirmwareManagerReducerTest.kt | 6 +- .../dev/slimevr/serial/SerialServerTest.kt | 20 +- .../reducers/SerialConnectionReducerTest.kt | 12 +- .../java/dev/slimevr/solarxr/DataFeedTest.kt | 18 +- .../src/main/java/dev/slimevr/desktop/Main.kt | 40 +- .../main/java/dev/slimevr/desktop/hid/hid.kt | 3 +- .../main/java/dev/slimevr/desktop/ipc/ipc.kt | 7 +- .../java/dev/slimevr/desktop/ipc/linux.kt | 4 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 17 +- .../java/dev/slimevr/desktop/ipc/windows.kt | 4 +- .../java/dev/slimevr/desktop/serial/serial.kt | 6 +- .../dev/slimevr/desktop/vrchat/vrc-config.kt | 12 +- 43 files changed, 1116 insertions(+), 1296 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/config/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/device/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/firmware/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/hid/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/serial/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/tracker/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/udp/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt diff --git a/server/core/src/main/java/dev/slimevr/behaviours.kt b/server/core/src/main/java/dev/slimevr/behaviours.kt new file mode 100644 index 0000000000..20ed766c2d --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/behaviours.kt @@ -0,0 +1,18 @@ +package dev.slimevr + +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +object BaseBehaviour : VRServerBehaviour { + override fun reduce(state: VRServerState, action: VRServerActions) = when (action) { + is VRServerActions.NewTracker -> state.copy(trackers = state.trackers + (action.trackerId to action.context)) + is VRServerActions.NewDevice -> state.copy(devices = state.devices + (action.deviceId to action.context)) + } + + override fun observe(receiver: VRServer) { + receiver.context.state.distinctUntilChangedBy { it.trackers.size }.onEach { + println("tracker list size changed") + }.launchIn(receiver.context.scope) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/config/behaviours.kt b/server/core/src/main/java/dev/slimevr/config/behaviours.kt new file mode 100644 index 0000000000..7134bdbe1c --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/config/behaviours.kt @@ -0,0 +1,22 @@ +package dev.slimevr.config + +object DefaultGlobalConfigBehaviour : GlobalConfigBehaviour { + override fun reduce(state: GlobalConfigState, action: GlobalConfigActions) = when (action) { + is GlobalConfigActions.SetUserProfile -> state.copy(selectedUserProfile = action.name) + is GlobalConfigActions.SetSettingsProfile -> state.copy(selectedSettingsProfile = action.name) + } +} + +object DefaultSettingsBehaviour : SettingsBehaviour { + override fun reduce(state: SettingsState, action: SettingsActions) = when (action) { + is SettingsActions.Update -> state.copy(data = action.transform(state.data)) + is SettingsActions.LoadProfile -> action.newState + } +} + +object DefaultUserBehaviour : UserConfigBehaviour { + override fun reduce(state: UserConfigState, action: UserConfigActions) = when (action) { + is UserConfigActions.Update -> state.copy(data = action.transform(state.data)) + is UserConfigActions.LoadProfile -> action.newState + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/config/module.kt b/server/core/src/main/java/dev/slimevr/config/module.kt index 260bf4ea0b..8325ff23e5 100644 --- a/server/core/src/main/java/dev/slimevr/config/module.kt +++ b/server/core/src/main/java/dev/slimevr/config/module.kt @@ -1,8 +1,7 @@ package dev.slimevr.config -import dev.slimevr.context.BasicBehaviour +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject @@ -27,7 +26,7 @@ sealed interface GlobalConfigActions { } typealias GlobalConfigContext = Context -typealias GlobalConfigBehaviour = BasicBehaviour +typealias GlobalConfigBehaviour = Behaviour private fun migrateGlobalConfig(json: JsonObject): JsonObject { val version = json["version"]?.jsonPrimitive?.intOrNull ?: 0 @@ -42,61 +41,50 @@ private fun parseAndMigrateGlobalConfig(raw: String): GlobalConfigState { return jsonConfig.decodeFromJsonElement(migrateGlobalConfig(json)) } -val DefaultGlobalConfigBehaviour = GlobalConfigBehaviour( - reducer = { s, a -> - when (a) { - is GlobalConfigActions.SetUserProfile -> s.copy(selectedUserProfile = a.name) - is GlobalConfigActions.SetSettingsProfile -> s.copy(selectedSettingsProfile = a.name) - } - }, -) - -data class AppConfig( +class AppConfig( val globalContext: GlobalConfigContext, val userConfig: UserConfig, val settings: Settings, - val switchUserProfile: suspend (String) -> Unit, - val switchSettingsProfile: suspend (String) -> Unit, -) - -suspend fun createAppConfig(scope: CoroutineScope, configFolder: File): AppConfig { - val initialGlobal = loadFileWithBackup(File(configFolder, "global.json"), GlobalConfigState()) { - parseAndMigrateGlobalConfig(it) - } - - val behaviours = listOf(DefaultGlobalConfigBehaviour) - - val globalContext = createContext( - initialState = initialGlobal, - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - launchAutosave( - scope = scope, - state = globalContext.state, - toFile = { File(configFolder, "global.json") }, - serialize = { jsonConfig.encodeToString(it) }, - ) - - val userConfig = createUserConfig(scope, configFolder, initialGlobal.selectedUserProfile) - val settings = createSettings(scope, configFolder, initialGlobal.selectedSettingsProfile) - - val switchUserProfile: suspend (String) -> Unit = { name -> +) { + suspend fun switchUserProfile(name: String) { globalContext.dispatch(GlobalConfigActions.SetUserProfile(name)) userConfig.swap(name) } - val switchSettingsProfile: suspend (String) -> Unit = { name -> + suspend fun switchSettingsProfile(name: String) { globalContext.dispatch(GlobalConfigActions.SetSettingsProfile(name)) settings.swap(name) } - return AppConfig( - globalContext = globalContext, - userConfig = userConfig, - settings = settings, - switchUserProfile = switchUserProfile, - switchSettingsProfile = switchSettingsProfile, - ) -} + companion object { + suspend fun create(scope: CoroutineScope, configFolder: File): AppConfig { + val initialGlobal = loadFileWithBackup(File(configFolder, "global.json"), GlobalConfigState()) { + parseAndMigrateGlobalConfig(it) + } + + val behaviours = listOf(DefaultGlobalConfigBehaviour) + val globalContext = Context.create( + initialState = initialGlobal, + scope = scope, + behaviours = behaviours, + ) + behaviours.forEach { it.observe(globalContext) } + + launchAutosave( + scope = scope, + state = globalContext.state, + toFile = { File(configFolder, "global.json") }, + serialize = { jsonConfig.encodeToString(it) }, + ) + + val userConfig = UserConfig.create(scope, configFolder, initialGlobal.selectedUserProfile) + val settings = Settings.create(scope, configFolder, initialGlobal.selectedSettingsProfile) + + return AppConfig( + globalContext = globalContext, + userConfig = userConfig, + settings = settings, + ) + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 8ccfb3af1a..2848cd1a8e 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -1,8 +1,7 @@ package dev.slimevr.config +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.CustomBehaviour -import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -47,50 +46,24 @@ sealed interface SettingsActions { } typealias SettingsContext = Context -typealias SettingsBehaviour = CustomBehaviour +typealias SettingsBehaviour = Behaviour -data class Settings( +class Settings( val context: SettingsContext, val configDir: String, - val swap: suspend (String) -> Unit, -) - -val DefaultSettingsBehaviour = SettingsBehaviour( - reducer = { s, a -> - when (a) { - is SettingsActions.Update -> s.copy(data = a.transform(s.data)) - is SettingsActions.LoadProfile -> a.newState - else -> s - } - }, -) - -suspend fun createSettings(scope: CoroutineScope, configDir: File, name: String): Settings { - val settingsDir = File(configDir, "settings") + private val scope: CoroutineScope, + private val settingsDir: File, +) { + private var autosaveJob: Job = startAutosave() - val initialData = loadFileWithBackup(File(settingsDir, "$name.json"), SettingsConfigState()) { - parseAndMigrateSettingsConfig(it) - } - val initialState = SettingsState(name = name, data = initialData) - - val behaviours = listOf(DefaultSettingsBehaviour) - - val context = createContext( - initialState = initialState, - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - fun startAutosave() = launchAutosave( + private fun startAutosave() = launchAutosave( scope = scope, state = context.state, toFile = { state -> File(settingsDir, "${state.name}.json") }, serialize = { state -> jsonConfig.encodeToString(state.data) }, ) - var autosaveJob: Job = startAutosave() - - val swap: suspend (String) -> Unit = { newName -> + suspend fun swap(newName: String) { autosaveJob.cancelAndJoin() val newData = loadFileWithBackup(File(settingsDir, "$newName.json"), SettingsConfigState()) { @@ -102,5 +75,20 @@ suspend fun createSettings(scope: CoroutineScope, configDir: File, name: String) autosaveJob = startAutosave() } - return Settings(context, configDir = settingsDir.toString(), swap) -} + companion object { + suspend fun create(scope: CoroutineScope, configDir: File, name: String): Settings { + val settingsDir = File(configDir, "settings") + + val initialData = loadFileWithBackup(File(settingsDir, "$name.json"), SettingsConfigState()) { + parseAndMigrateSettingsConfig(it) + } + val initialState = SettingsState(name = name, data = initialData) + + val behaviours = listOf(DefaultSettingsBehaviour) + val context = Context.create(initialState = initialState, scope = scope, behaviours = behaviours) + val settings = Settings(context, configDir = settingsDir.toString(), scope = scope, settingsDir = settingsDir) + behaviours.forEach { it.observe(settings) } + return settings + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/config/user.kt b/server/core/src/main/java/dev/slimevr/config/user.kt index 1ae00c6f9d..ce9939f1d6 100644 --- a/server/core/src/main/java/dev/slimevr/config/user.kt +++ b/server/core/src/main/java/dev/slimevr/config/user.kt @@ -1,8 +1,7 @@ package dev.slimevr.config +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.CustomBehaviour -import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -46,50 +45,24 @@ sealed interface UserConfigActions { } typealias UserConfigContext = Context -typealias UserConfigBehaviour = CustomBehaviour +typealias UserConfigBehaviour = Behaviour -data class UserConfig( +class UserConfig( val context: UserConfigContext, val configDir: String, - val swap: suspend (String) -> Unit, -) - -val DefaultUserBehaviour = UserConfigBehaviour( - reducer = { s, a -> - when (a) { - is UserConfigActions.Update -> s.copy(data = a.transform(s.data)) - is UserConfigActions.LoadProfile -> a.newState - else -> s - } - }, -) - -suspend fun createUserConfig(scope: CoroutineScope, configDir: File, name: String): UserConfig { - val userConfigDir = File(configDir, "user") + private val scope: CoroutineScope, + private val userConfigDir: File, +) { + private var autosaveJob: Job = startAutosave() - val initialData = loadFileWithBackup(File(userConfigDir, "$name.json"), UserConfigData()) { - parseAndMigrateUserConfig(it) - } - val initialState = UserConfigState(name = name, data = initialData) - - val behaviours = listOf(DefaultUserBehaviour) - - val context = createContext( - initialState = initialState, - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - fun startAutosave() = launchAutosave( + private fun startAutosave() = launchAutosave( scope = scope, state = context.state, toFile = { state -> File(userConfigDir, "${state.name}.json") }, serialize = { state -> jsonConfig.encodeToString(state.data) }, ) - var autosaveJob: Job = startAutosave() - - val swap: suspend (String) -> Unit = { newName -> + suspend fun swap(newName: String) { autosaveJob.cancelAndJoin() val newData = loadFileWithBackup(File(userConfigDir, "$newName.json"), UserConfigData()) { @@ -101,5 +74,20 @@ suspend fun createUserConfig(scope: CoroutineScope, configDir: File, name: Strin autosaveJob = startAutosave() } - return UserConfig(context, userConfigDir.toString(), swap) -} + companion object { + suspend fun create(scope: CoroutineScope, configDir: File, name: String): UserConfig { + val userConfigDir = File(configDir, "user") + + val initialData = loadFileWithBackup(File(userConfigDir, "$name.json"), UserConfigData()) { + parseAndMigrateUserConfig(it) + } + val initialState = UserConfigState(name = name, data = initialData) + + val behaviours = listOf(DefaultUserBehaviour) + val context = Context.create(initialState = initialState, scope = scope, behaviours = behaviours) + val userConfig = UserConfig(context, userConfigDir.toString(), scope = scope, userConfigDir = userConfigDir) + behaviours.forEach { it.observe(userConfig) } + return userConfig + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index dd71f9accc..68f96f68ec 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -7,47 +7,38 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update interface Behaviour { - val reducer: ((S, A) -> S)? - val observer: ((C) -> Unit)? + fun reduce(state: S, action: A): S = state + fun observe(receiver: C) {} } -data class BasicBehaviour( - override val reducer: ((S, A) -> S)? = null, - override val observer: ((Context) -> Unit)? = null, -) : Behaviour> - -data class CustomBehaviour( - override val reducer: ((S, A) -> S)? = null, - override val observer: ((C) -> Unit)? = null, -) : Behaviour - -data class Context( - val state: StateFlow, - val dispatch: suspend (A) -> Unit, - val dispatchAll: suspend (List) -> Unit, +class Context( + private val mutableStateFlow: MutableStateFlow, + private val applyAction: (S, A) -> S, val scope: CoroutineScope, -) - -fun createContext( - initialState: S, - scope: CoroutineScope, - reducers: List<((S, A) -> S)?>, -): Context { - val mutableStateFlow = MutableStateFlow(initialState) - - val applyAction: (S, A) -> S = { currentState, action -> - reducers.filterNotNull().fold(currentState) { s, reducer -> reducer(s, action) } - } +) { + val state: StateFlow = mutableStateFlow.asStateFlow() - val dispatch: suspend (A) -> Unit = { action -> + fun dispatch(action: A) { mutableStateFlow.update { applyAction(it, action) } } - val dispatchAll: suspend (List) -> Unit = { actions -> + fun dispatchAll(actions: List) { mutableStateFlow.update { currentState -> actions.fold(currentState) { s, action -> applyAction(s, action) } } } - val context = Context(mutableStateFlow.asStateFlow(), dispatch, dispatchAll, scope) - return context -} + + companion object { + fun create( + initialState: S, + scope: CoroutineScope, + behaviours: List>, + ): Context { + val mutableStateFlow = MutableStateFlow(initialState) + val applyAction: (S, A) -> S = { currentState, action -> + behaviours.fold(currentState) { s, b -> b.reduce(s, action) } + } + return Context(mutableStateFlow, applyAction, scope) + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/device/behaviours.kt b/server/core/src/main/java/dev/slimevr/device/behaviours.kt new file mode 100644 index 0000000000..8a418f94a9 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/device/behaviours.kt @@ -0,0 +1,15 @@ +package dev.slimevr.device + +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +object DeviceStatsBehaviour : DeviceBehaviour { + override fun reduce(state: DeviceState, action: DeviceActions) = + if (action is DeviceActions.Update) action.transform(state) else state + + override fun observe(receiver: DeviceContext) { + receiver.state.onEach { +// AppLogger.device.info("Device state changed", it) + }.launchIn(receiver.scope) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/device/module.kt b/server/core/src/main/java/dev/slimevr/device/module.kt index 08fdf37717..8869db614c 100644 --- a/server/core/src/main/java/dev/slimevr/device/module.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -1,12 +1,8 @@ package dev.slimevr.device -import dev.slimevr.VRServer -import dev.slimevr.context.BasicBehaviour +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.BoardType import solarxr_protocol.datatypes.hardware_info.McuType @@ -39,59 +35,42 @@ sealed interface DeviceActions { data class Update(val transform: DeviceState.() -> DeviceState) : DeviceActions } -val DeviceStatsBehaviour = DeviceBehaviour( - reducer = { s, a -> if (a is DeviceActions.Update) a.transform(s) else s }, - observer = { - it.state.onEach { state -> -// AppLogger.device.info("Device state changed", state) - }.launchIn(it.scope) - }, -) - typealias DeviceContext = Context -typealias DeviceBehaviour = BasicBehaviour +typealias DeviceBehaviour = Behaviour -data class Device( +class Device( val context: DeviceContext, -) - -fun createDevice( - scope: CoroutineScope, - id: Int, - address: String, - macAddress: String? = null, - origin: DeviceOrigin, - protocolVersion: Int, - serverContext: VRServer, -): Device { - val deviceState = DeviceState( - id = id, - name = "Device $id", - batteryLevel = 0f, - batteryVoltage = 0f, - origin = origin, - address = address, - macAddress = macAddress, - protocolVersion = protocolVersion, - ping = null, - signalStrength = null, - status = TrackerStatus.DISCONNECTED, - mcuType = McuType.Other, - boardType = BoardType.UNKNOWN, - firmware = null - ) +) { + companion object { + fun create( + scope: CoroutineScope, + id: Int, + address: String, + macAddress: String? = null, + origin: DeviceOrigin, + protocolVersion: Int, + ): Device { + val deviceState = DeviceState( + id = id, + name = "Device $id", + batteryLevel = 0f, + batteryVoltage = 0f, + origin = origin, + address = address, + macAddress = macAddress, + protocolVersion = protocolVersion, + ping = null, + signalStrength = null, + status = TrackerStatus.DISCONNECTED, + mcuType = McuType.Other, + boardType = BoardType.UNKNOWN, + firmware = null, + ) - val behaviours = listOf(DeviceStatsBehaviour) - - val context = createContext( - initialState = deviceState, - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - behaviours.map { it.observer }.forEach { it?.invoke(context) } - - return Device( - context = context, - ) -} + val behaviours = listOf(DeviceStatsBehaviour) + val context = Context.create(initialState = deviceState, scope = scope, behaviours = behaviours) + behaviours.forEach { it.observe(context) } + return Device(context = context) + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt b/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt new file mode 100644 index 0000000000..fada70219c --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt @@ -0,0 +1,18 @@ +package dev.slimevr.firmware + +object FirmwareManagerBaseBehaviour : FirmwareManagerBehaviour { + override fun reduce(state: FirmwareManagerState, action: FirmwareManagerActions) = when (action) { + is FirmwareManagerActions.UpdateJob -> state.copy( + jobs = state.jobs + ( + action.portLocation to FirmwareJobStatus( + portLocation = action.portLocation, + firmwareDeviceId = action.firmwareDeviceId, + status = action.status, + progress = action.progress, + ) + ), + ) + + is FirmwareManagerActions.RemoveJob -> state.copy(jobs = state.jobs - action.portLocation) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/firmware/module.kt b/server/core/src/main/java/dev/slimevr/firmware/module.kt index c904c0c208..1441e74def 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/module.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/module.kt @@ -1,9 +1,8 @@ package dev.slimevr.firmware import dev.slimevr.VRServer -import dev.slimevr.context.BasicBehaviour +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.createContext import dev.slimevr.serial.SerialServer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -38,51 +37,23 @@ sealed interface FirmwareManagerActions { } typealias FirmwareManagerContext = Context -typealias FirmwareManagerBehaviour = BasicBehaviour +typealias FirmwareManagerBehaviour = Behaviour -val FirmwareManagerBaseBehaviour = FirmwareManagerBehaviour( - reducer = { s, a -> - when (a) { - is FirmwareManagerActions.UpdateJob -> s.copy( - jobs = s.jobs + - ( - a.portLocation to FirmwareJobStatus( - portLocation = a.portLocation, - firmwareDeviceId = a.firmwareDeviceId, - status = a.status, - progress = a.progress, - ) - ), - ) - - is FirmwareManagerActions.RemoveJob -> s.copy(jobs = s.jobs - a.portLocation) - } - }, - observer = null, -) - -data class FirmwareManager( +class FirmwareManager( val context: FirmwareManagerContext, - val flash: suspend (portLocation: String, parts: List, needManualReboot: Boolean, ssid: String?, password: String?, server: VRServer) -> Unit, - val otaFlash: suspend (deviceIp: String, firmwareDeviceId: FirmwareUpdateDeviceId, part: FirmwarePart, VRServer) -> Unit, - val cancelAll: suspend () -> Unit, -) - -fun createFirmwareManager( - serialServer: SerialServer, - scope: CoroutineScope, -): FirmwareManager { - val behaviours = listOf(FirmwareManagerBaseBehaviour) - - val context = createContext( - initialState = FirmwareManagerState(jobs = mapOf()), - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - val runningJobs = mutableMapOf() - - val flash: suspend (String, List, Boolean, String?, String?, VRServer) -> Unit = { portLocation, parts, needManualReboot, ssid, password, server -> + private val serialServer: SerialServer, + private val scope: CoroutineScope, +) { + private val runningJobs = mutableMapOf() + + suspend fun flash( + portLocation: String, + parts: List, + needManualReboot: Boolean, + ssid: String?, + password: String?, + server: VRServer, + ) { runningJobs[portLocation]?.cancelAndJoin() runningJobs[portLocation] = scope.launch { doSerialFlash( @@ -108,7 +79,12 @@ fun createFirmwareManager( } } - val otaFlash: suspend (String, FirmwareUpdateDeviceId, FirmwarePart, VRServer) -> Unit = { deviceIp, firmwareDeviceId, part, server -> + suspend fun otaFlash( + deviceIp: String, + firmwareDeviceId: FirmwareUpdateDeviceId, + part: FirmwarePart, + server: VRServer, + ) { runningJobs[deviceIp]?.cancelAndJoin() runningJobs[deviceIp] = scope.launch { doOtaFlash( @@ -130,18 +106,22 @@ fun createFirmwareManager( } } - val cancelAll: suspend () -> Unit = { + suspend fun cancelAll() { runningJobs.values.forEach { it.cancelAndJoin() } runningJobs.clear() } - val manager = FirmwareManager( - context = context, - flash = flash, - otaFlash = otaFlash, - cancelAll = cancelAll, - ) - - behaviours.map { it.observer }.forEach { it?.invoke(context) } - return manager -} + companion object { + fun create(serialServer: SerialServer, scope: CoroutineScope): FirmwareManager { + val behaviours = listOf(FirmwareManagerBaseBehaviour) + val context = Context.create( + initialState = FirmwareManagerState(jobs = mapOf()), + scope = scope, + behaviours = behaviours, + ) + val manager = FirmwareManager(context = context, serialServer = serialServer, scope = scope) + behaviours.forEach { it.observe(context) } + return manager + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt new file mode 100644 index 0000000000..704894cc88 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -0,0 +1,166 @@ +package dev.slimevr.hid + +import dev.slimevr.AppLogger +import dev.slimevr.VRServerActions +import dev.slimevr.device.Device +import dev.slimevr.device.DeviceActions +import dev.slimevr.device.DeviceOrigin +import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerActions +import solarxr_protocol.datatypes.TrackerStatus + +object HIDRegistrationBehaviour : HIDReceiverBehaviour { + override fun reduce(state: HIDReceiverState, action: HIDReceiverActions) = when (action) { + is HIDReceiverActions.DeviceRegistered -> state.copy( + trackers = state.trackers + (action.hidId to HIDTrackerRecord( + hidId = action.hidId, + address = action.address, + deviceId = action.deviceId, + trackerId = null, + )), + ) + else -> state + } + + override fun observe(receiver: HIDReceiver) { + receiver.packetEvents.onPacket { packet -> + val state = receiver.context.state.value + val existing = state.trackers[packet.hidId] + if (existing != null) return@onPacket + + val existingDevice = receiver.serverContext.context.state.value.devices.values + .find { it.context.state.value.macAddress == packet.address && it.context.state.value.origin == DeviceOrigin.HID } + + if (existingDevice != null) { + receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, existingDevice.context.state.value.id)) + AppLogger.hid.info("Reconnected HID device ${packet.address} (hidId=${packet.hidId})") + return@onPacket + } + + val deviceId = receiver.serverContext.nextHandle() + val device = Device.create( + scope = receiver.serverContext.context.scope, + id = deviceId, + address = packet.address, + macAddress = packet.address, + origin = DeviceOrigin.HID, + protocolVersion = 0, + ) + receiver.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId, device)) + receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, deviceId)) + AppLogger.hid.info("Registered HID device ${packet.address} (hidId=${packet.hidId})") + } + } +} + +object HIDDeviceInfoBehaviour : HIDReceiverBehaviour { + override fun reduce(state: HIDReceiverState, action: HIDReceiverActions): HIDReceiverState = when (action) { + is HIDReceiverActions.TrackerRegistered -> { + val existing = state.trackers[action.hidId] ?: return state + state.copy(trackers = state.trackers + (action.hidId to existing.copy(trackerId = action.trackerId))) + } + else -> state + } + + override fun observe(receiver: HIDReceiver) { + receiver.packetEvents.onPacket { packet -> + val device = receiver.getDevice(packet.hidId) ?: return@onPacket + + device.context.dispatch( + DeviceActions.Update { + copy( + boardType = packet.boardType, + mcuType = packet.mcuType, + firmware = packet.firmware, + batteryLevel = packet.batteryLevel, + batteryVoltage = packet.batteryVoltage, + signalStrength = packet.rssi, + ) + }, + ) + + val tracker = receiver.getTracker(packet.hidId) + if (tracker == null) { + val deviceState = device.context.state.value + + val existingTracker = receiver.serverContext.context.state.value.trackers.values + .find { it.context.state.value.hardwareId == deviceState.address && it.context.state.value.origin == DeviceOrigin.HID } + + if (existingTracker != null) { + receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, existingTracker.context.state.value.id)) + existingTracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType) }) + } else { + val trackerId = receiver.serverContext.nextHandle() + val newTracker = Tracker.create( + scope = receiver.serverContext.context.scope, + id = trackerId, + deviceId = deviceState.id, + sensorType = packet.imuType, + hardwareId = deviceState.address, + origin = DeviceOrigin.HID, + ) + receiver.serverContext.context.dispatch(VRServerActions.NewTracker(trackerId, newTracker)) + receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, trackerId)) + AppLogger.hid.info("Registered HID tracker for device ${deviceState.address} (hidId=${packet.hidId})") + } + + device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) }) + } else { + tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType) }) + } + } + } +} + +object HIDRotationBehaviour : HIDReceiverBehaviour { + override fun observe(receiver: HIDReceiver) { + receiver.packetEvents.onPacket { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + } + + receiver.packetEvents.onPacket { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + } + + receiver.packetEvents.onPacket { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + } + + receiver.packetEvents.onPacket { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + } + } +} + +object HIDBatteryBehaviour : HIDReceiverBehaviour { + override fun observe(receiver: HIDReceiver) { + receiver.packetEvents.onPacket { packet -> + receiver.getDevice(packet.hidId)?.context?.dispatch( + DeviceActions.Update { + copy(batteryLevel = packet.batteryLevel, batteryVoltage = packet.batteryVoltage, signalStrength = packet.rssi) + }, + ) + } + + receiver.packetEvents.onPacket { packet -> + receiver.getDevice(packet.hidId)?.context?.dispatch( + DeviceActions.Update { copy(signalStrength = packet.rssi) }, + ) + } + } +} + +object HIDStatusBehaviour : HIDReceiverBehaviour { + override fun observe(receiver: HIDReceiver) { + receiver.packetEvents.onPacket { packet -> + if (receiver.getTracker(packet.hidId) == null) return@onPacket + receiver.getDevice(packet.hidId)?.context?.dispatch( + DeviceActions.Update { copy(status = packet.status, signalStrength = packet.rssi) }, + ) + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/hid/module.kt b/server/core/src/main/java/dev/slimevr/hid/module.kt index fe8c90a017..238f9fa766 100644 --- a/server/core/src/main/java/dev/slimevr/hid/module.kt +++ b/server/core/src/main/java/dev/slimevr/hid/module.kt @@ -1,19 +1,12 @@ package dev.slimevr.hid -import dev.slimevr.AppLogger import dev.slimevr.EventDispatcher import dev.slimevr.VRServer -import dev.slimevr.VRServerActions +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.CustomBehaviour -import dev.slimevr.context.createContext import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions -import dev.slimevr.device.DeviceOrigin -import dev.slimevr.device.createDevice import dev.slimevr.tracker.Tracker -import dev.slimevr.tracker.TrackerActions -import dev.slimevr.tracker.createTracker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation @@ -48,7 +41,7 @@ sealed interface HIDReceiverActions { } typealias HIDReceiverContext = Context -typealias HIDReceiverBehaviour = CustomBehaviour +typealias HIDReceiverBehaviour = Behaviour typealias HIDPacketDispatcher = EventDispatcher @Suppress("UNCHECKED_CAST") @@ -56,169 +49,7 @@ inline fun HIDPacketDispatcher.onPacket(crossinline call register(T::class) { callback(it as T) } } -val HIDRegistrationBehaviour = HIDReceiverBehaviour( - reducer = { s, a -> - when (a) { - is HIDReceiverActions.DeviceRegistered -> s.copy( - trackers = s.trackers + (a.hidId to HIDTrackerRecord( - hidId = a.hidId, - address = a.address, - deviceId = a.deviceId, - trackerId = null, - )), - ) - - else -> s - } - }, - observer = { receiver -> - receiver.packetEvents.onPacket { packet -> - val state = receiver.context.state.value - val existing = state.trackers[packet.hidId] - if (existing != null) return@onPacket - - val existingDevice = receiver.serverContext.context.state.value.devices.values - .find { it.context.state.value.macAddress == packet.address && it.context.state.value.origin == DeviceOrigin.HID } - - if (existingDevice != null) { - receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, existingDevice.context.state.value.id)) - AppLogger.hid.info("Reconnected HID device ${packet.address} (hidId=${packet.hidId})") - return@onPacket - } - - val deviceId = receiver.serverContext.nextHandle() - val device = createDevice( - scope = receiver.serverContext.context.scope, - id = deviceId, - address = packet.address, - macAddress = packet.address, - origin = DeviceOrigin.HID, - protocolVersion = 0, - serverContext = receiver.serverContext, - ) - receiver.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId, device)) - receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, deviceId)) - AppLogger.hid.info("Registered HID device ${packet.address} (hidId=${packet.hidId})") - } - }, -) - -val HIDDeviceInfoBehaviour = HIDReceiverBehaviour( - reducer = { s, a -> - when (a) { - is HIDReceiverActions.TrackerRegistered -> { - val existing = s.trackers[a.hidId] ?: return@HIDReceiverBehaviour s - s.copy(trackers = s.trackers + (a.hidId to existing.copy(trackerId = a.trackerId))) - } - - else -> s - } - }, - observer = { receiver -> - receiver.packetEvents.onPacket { packet -> - val device = receiver.getDevice(packet.hidId) ?: return@onPacket - - device.context.dispatch( - DeviceActions.Update { - copy( - boardType = packet.boardType, - mcuType = packet.mcuType, - firmware = packet.firmware, - batteryLevel = packet.batteryLevel, - batteryVoltage = packet.batteryVoltage, - signalStrength = packet.rssi, - ) - }, - ) - - val tracker = receiver.getTracker(packet.hidId) - if (tracker == null) { - val deviceState = device.context.state.value - - val existingTracker = receiver.serverContext.context.state.value.trackers.values - .find { it.context.state.value.hardwareId == deviceState.address && it.context.state.value.origin == DeviceOrigin.HID } - - if (existingTracker != null) { - receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, existingTracker.context.state.value.id)) - existingTracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType) }) - } else { - val trackerId = receiver.serverContext.nextHandle() - val newTracker = createTracker( - scope = receiver.serverContext.context.scope, - id = trackerId, - deviceId = deviceState.id, - sensorType = packet.imuType, - hardwareId = deviceState.address, - origin = DeviceOrigin.HID, - serverContext = receiver.serverContext, - ) - receiver.serverContext.context.dispatch(VRServerActions.NewTracker(trackerId, newTracker)) - receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, trackerId)) - AppLogger.hid.info("Registered HID tracker for device ${deviceState.address} (hidId=${packet.hidId})") - } - - device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) }) - } else { - tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType) }) - } - } - }, -) - -val HIDRotationBehaviour = HIDReceiverBehaviour( - observer = { receiver -> - receiver.packetEvents.onPacket { packet -> - val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) - } - - receiver.packetEvents.onPacket { packet -> - val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) - } - - receiver.packetEvents.onPacket { packet -> - val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) - } - - receiver.packetEvents.onPacket { packet -> - val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) - } - }, -) - -val HIDBatteryBehaviour = HIDReceiverBehaviour( - observer = { receiver -> - receiver.packetEvents.onPacket { packet -> - receiver.getDevice(packet.hidId)?.context?.dispatch( - DeviceActions.Update { - copy(batteryLevel = packet.batteryLevel, batteryVoltage = packet.batteryVoltage, signalStrength = packet.rssi) - }, - ) - } - - receiver.packetEvents.onPacket { packet -> - receiver.getDevice(packet.hidId)?.context?.dispatch( - DeviceActions.Update { copy(signalStrength = packet.rssi) }, - ) - } - }, -) - -val HIDStatusBehaviour = HIDReceiverBehaviour( - observer = { receiver -> - receiver.packetEvents.onPacket { packet -> - if (receiver.getTracker(packet.hidId) == null) return@onPacket - receiver.getDevice(packet.hidId)?.context?.dispatch( - DeviceActions.Update { copy(status = packet.status, signalStrength = packet.rssi) }, - ) - } - }, -) - -data class HIDReceiver( +class HIDReceiver( val context: HIDReceiverContext, val serverContext: VRServer, val packetEvents: HIDPacketDispatcher, @@ -233,58 +64,57 @@ data class HIDReceiver( val trackerId = record.trackerId ?: return null return serverContext.getTracker(trackerId) } -} - -fun createHIDReceiver( - serialNumber: String, - data: Flow, - serverContext: VRServer, - scope: CoroutineScope, -): HIDReceiver { - val behaviours = listOf( - HIDRegistrationBehaviour, - HIDDeviceInfoBehaviour, - HIDRotationBehaviour, - HIDBatteryBehaviour, - HIDStatusBehaviour, - ) - - val context = createContext( - initialState = HIDReceiverState( - serialNumber = serialNumber, - trackers = mapOf(), - ), - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - val dispatcher = HIDPacketDispatcher() + companion object { + fun create( + serialNumber: String, + data: Flow, + serverContext: VRServer, + scope: CoroutineScope, + ): HIDReceiver { + val behaviours = listOf( + HIDRegistrationBehaviour, + HIDDeviceInfoBehaviour, + HIDRotationBehaviour, + HIDBatteryBehaviour, + HIDStatusBehaviour, + ) - val receiver = HIDReceiver( - context = context, - serverContext = serverContext, - packetEvents = dispatcher, - ) + val context = Context.create( + initialState = HIDReceiverState(serialNumber = serialNumber, trackers = mapOf()), + scope = scope, + behaviours = behaviours, + ) - behaviours.map { it.observer }.forEach { it?.invoke(receiver) } + val dispatcher = HIDPacketDispatcher() - data - .onEach { report -> parseHIDPackets(report).forEach { dispatcher.emit(it) } } - .launchIn(scope) + val receiver = HIDReceiver( + context = context, + serverContext = serverContext, + packetEvents = dispatcher, + ) - scope.launch { - try { - awaitCancellation() - } finally { - withContext(NonCancellable) { - for (record in context.state.value.trackers.values) { - serverContext.getDevice(record.deviceId)?.context?.dispatch( - DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, - ) + behaviours.forEach { it.observe(receiver) } + + data + .onEach { report -> parseHIDPackets(report).forEach { dispatcher.emit(it) } } + .launchIn(scope) + + scope.launch { + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { + for (record in context.state.value.trackers.values) { + serverContext.getDevice(record.deviceId)?.context?.dispatch( + DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, + ) + } + } } } + + return receiver } } - - return receiver -} +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/serial/behaviours.kt b/server/core/src/main/java/dev/slimevr/serial/behaviours.kt new file mode 100644 index 0000000000..403c5c8176 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/serial/behaviours.kt @@ -0,0 +1,30 @@ +package dev.slimevr.serial + +internal const val MAX_LOG_LINES = 500 + +object SerialServerBaseBehaviour : SerialServerBehaviour { + override fun reduce(state: SerialServerState, action: SerialServerActions) = when (action) { + is SerialServerActions.PortDetected -> + state.copy(availablePorts = state.availablePorts + (action.info.portLocation to action.info)) + + is SerialServerActions.PortLost -> + state.copy(availablePorts = state.availablePorts - action.portLocation) + + is SerialServerActions.RegisterConnection -> + state.copy(connections = state.connections + (action.portLocation to action.connection)) + + is SerialServerActions.RemoveConnection -> + state.copy(connections = state.connections - action.portLocation) + } +} + +object SerialLogBehaviour : SerialConnectionBehaviour { + override fun reduce(state: SerialConnectionState, action: SerialConnectionActions) = when (action) { + is SerialConnectionActions.LogLine -> { + val lines = if (state.logLines.size >= MAX_LOG_LINES) state.logLines.drop(1) else state.logLines + state.copy(logLines = lines + action.line) + } + + SerialConnectionActions.Disconnected -> state.copy(connected = false) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/serial/connection.kt b/server/core/src/main/java/dev/slimevr/serial/connection.kt index 31999de3ab..dffa104fdc 100644 --- a/server/core/src/main/java/dev/slimevr/serial/connection.kt +++ b/server/core/src/main/java/dev/slimevr/serial/connection.kt @@ -1,12 +1,9 @@ package dev.slimevr.serial +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.CustomBehaviour -import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope -private const val MAX_LOG_LINES = 500 - data class SerialPortHandle( val portLocation: String, val descriptivePortName: String, @@ -27,50 +24,32 @@ sealed interface SerialConnectionActions { } typealias SerialConnectionContext = Context -typealias SerialConnectionBehaviour = - CustomBehaviour +typealias SerialConnectionBehaviour = Behaviour sealed interface SerialConnection { - data class Console( + class Console( val context: SerialConnectionContext, val handle: SerialPortHandle, - ) : SerialConnection - - data object Flashing : SerialConnection -} - -val SerialLogBehaviour = SerialConnectionBehaviour( - reducer = { s, a -> - when (a) { - is SerialConnectionActions.LogLine -> { - val lines = if (s.logLines.size >= MAX_LOG_LINES) s.logLines.drop(1) else s.logLines - s.copy(logLines = lines + a.line) + ) : SerialConnection { + companion object { + fun create(handle: SerialPortHandle, scope: CoroutineScope): Console { + val behaviours = listOf(SerialLogBehaviour) + val context = Context.create( + initialState = SerialConnectionState( + portLocation = handle.portLocation, + descriptivePortName = handle.descriptivePortName, + connected = true, + logLines = listOf(), + ), + scope = scope, + behaviours = behaviours, + ) + val conn = Console(context = context, handle = handle) + behaviours.forEach { it.observe(conn) } + return conn } - - SerialConnectionActions.Disconnected -> s.copy(connected = false) } - }, - observer = null, -) - -fun createSerialConnection( - handle: SerialPortHandle, - scope: CoroutineScope, -): SerialConnection.Console { - val behaviours = listOf(SerialLogBehaviour) - - val context = createContext( - initialState = SerialConnectionState( - portLocation = handle.portLocation, - descriptivePortName = handle.descriptivePortName, - connected = true, - logLines = listOf(), - ), - reducers = behaviours.map { it.reducer }, - scope = scope, - ) + } - val conn = SerialConnection.Console(context = context, handle = handle) - behaviours.map { it.observer }.forEach { it?.invoke(conn) } - return conn -} + data object Flashing : SerialConnection +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/serial/module.kt b/server/core/src/main/java/dev/slimevr/serial/module.kt index 348061f1c0..22d327dc94 100644 --- a/server/core/src/main/java/dev/slimevr/serial/module.kt +++ b/server/core/src/main/java/dev/slimevr/serial/module.kt @@ -1,9 +1,8 @@ package dev.slimevr.serial import dev.llelievr.espflashkotlin.FlasherSerialInterface -import dev.slimevr.context.BasicBehaviour +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import solarxr_protocol.rpc.SerialDevice @@ -35,43 +34,23 @@ sealed interface SerialServerActions { } typealias SerialServerContext = Context -typealias SerialServerBehaviour = BasicBehaviour - -val SerialServerBaseBehaviour = SerialServerBehaviour( - reducer = { s, a -> - when (a) { - is SerialServerActions.PortDetected -> - s.copy(availablePorts = s.availablePorts + (a.info.portLocation to a.info)) - - is SerialServerActions.PortLost -> - s.copy(availablePorts = s.availablePorts - a.portLocation) - - is SerialServerActions.RegisterConnection -> - s.copy(connections = s.connections + (a.portLocation to a.connection)) - - is SerialServerActions.RemoveConnection -> - s.copy(connections = s.connections - a.portLocation) - } - }, - observer = null, -) +typealias SerialServerBehaviour = Behaviour class SerialServer( val context: SerialServerContext, private val scope: CoroutineScope, private val openPortFactory: ( portLocation: String, - scope: CoroutineScope, - onDataReceived: suspend (portLocation: String, line: String) -> Unit, - onPortDisconnected: suspend (portLocation: String) -> Unit, + onDataReceived: (portLocation: String, line: String) -> Unit, + onPortDisconnected: (portLocation: String) -> Unit, ) -> SerialPortHandle?, private val openFlashingPortFactory: () -> FlashingHandler, ) { - suspend fun onPortDetected(info: SerialPortInfo) { + fun onPortDetected(info: SerialPortInfo) { context.dispatch(SerialServerActions.PortDetected(info)) } - suspend fun onPortLost(portLocation: String) { + fun onPortLost(portLocation: String) { val conn = context.state.value.connections[portLocation] if (conn is SerialConnection.Console) { conn.handle.close() @@ -80,12 +59,12 @@ class SerialServer( context.dispatch(SerialServerActions.PortLost(portLocation)) } - suspend fun onDataReceived(portLocation: String, line: String) { + fun onDataReceived(portLocation: String, line: String) { val conn = context.state.value.connections[portLocation] if (conn is SerialConnection.Console) conn.context.dispatch(SerialConnectionActions.LogLine(line)) } - suspend fun onPortDisconnected(portLocation: String) { + fun onPortDisconnected(portLocation: String) { val conn = context.state.value.connections[portLocation] if (conn !is SerialConnection.Console) return conn.context.dispatch(SerialConnectionActions.Disconnected) @@ -93,14 +72,14 @@ class SerialServer( context.dispatch(SerialServerActions.RemoveConnection(portLocation)) } - suspend fun openConnection(portLocation: String) { + fun openConnection(portLocation: String) { val state = context.state.value if (!state.availablePorts.containsKey(portLocation) || state.connections.containsKey(portLocation)) return - val handle = openPortFactory(portLocation, scope, ::onDataReceived, ::onPortDisconnected) ?: return - context.dispatch(SerialServerActions.RegisterConnection(portLocation, createSerialConnection(handle, scope))) + val handle = openPortFactory(portLocation, ::onDataReceived, ::onPortDisconnected) ?: return + context.dispatch(SerialServerActions.RegisterConnection(portLocation, SerialConnection.Console.create(handle, scope))) } - suspend fun closeConnection(portLocation: String) { + fun closeConnection(portLocation: String) { val conn = context.state.value.connections[portLocation] if (conn !is SerialConnection.Console) return conn.context.dispatch(SerialConnectionActions.Disconnected) @@ -108,7 +87,7 @@ class SerialServer( context.dispatch(SerialServerActions.RemoveConnection(portLocation)) } - suspend fun openForFlashing(portLocation: String): FlashingHandler? { + fun openForFlashing(portLocation: String): FlashingHandler? { val state = context.state.value if (!state.availablePorts.containsKey(portLocation) || state.connections.containsKey(portLocation)) return null closeConnection(portLocation) @@ -124,20 +103,15 @@ class SerialServer( companion object { fun create( - openPort: ( - portLocation: String, - scope: CoroutineScope, - onDataReceived: suspend (portLocation: String, line: String) -> Unit, - onPortDisconnected: suspend (portLocation: String) -> Unit, - ) -> SerialPortHandle?, + openPort: (portLocation: String, onDataReceived: (String, String) -> Unit, onPortDisconnected: (String) -> Unit) -> SerialPortHandle?, openFlashingPort: () -> FlashingHandler, scope: CoroutineScope, ): SerialServer { val behaviours = listOf(SerialServerBaseBehaviour) - val context = createContext( + val context = Context.create( initialState = SerialServerState(availablePorts = mapOf(), connections = mapOf()), - reducers = behaviours.map { it.reducer }, scope = scope, + behaviours = behaviours, ) val server = SerialServer( context = context, @@ -145,8 +119,8 @@ class SerialServer( openPortFactory = openPort, openFlashingPortFactory = openFlashingPort, ) - behaviours.map { it.observer }.forEach { it?.invoke(context) } + behaviours.forEach { it.observe(context) } return server } } -} +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 4fce6a38e1..3ce91ab365 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -84,11 +84,9 @@ fun createDatafeedFrame( index: Int = 0, ): DataFeedMessageHeader { val serverState = serverContext.context.state.value - val trackers = - serverState.trackers.values.map { it.context.state.value } - val devices = - serverState.devices.values.map { it.context.state.value } - .map { device -> createDevice(device, trackers, datafeedConfig) } + val trackers = serverState.trackers.values.map { it.context.state.value } + val devices = serverState.devices.values.map { it.context.state.value } + .map { device -> createDevice(device, trackers, datafeedConfig) } return DataFeedMessageHeader( message = DataFeedUpdate( devices = if (datafeedConfig.dataMask?.deviceData != null) devices else null, @@ -97,23 +95,22 @@ fun createDatafeedFrame( ) } -val DataFeedInitBehaviour = SolarXRConnectionBehaviour( - reducer = { s, a -> - when (a) { - is SolarXRConnectionActions.SetConfig -> s.copy( - dataFeedConfigs = a.configs, - datafeedTimers = a.timers, - ) - } - }, - observer = { context -> - context.dataFeedDispatcher.on { event -> +object DataFeedInitBehaviour : SolarXRConnectionBehaviour { + override fun reduce(state: SolarXRConnectionState, action: SolarXRConnectionActions) = when (action) { + is SolarXRConnectionActions.SetConfig -> state.copy( + dataFeedConfigs = action.configs, + datafeedTimers = action.timers, + ) + } + + override fun observe(receiver: SolarXRConnection) { + receiver.dataFeedDispatcher.on { event -> val datafeeds = event.dataFeeds ?: return@on - context.context.state.value.datafeedTimers.forEach { it.cancelAndJoin() } + receiver.context.state.value.datafeedTimers.forEach { it.cancelAndJoin() } val timers = datafeeds.mapIndexed { index, config -> - context.context.scope.launch { + receiver.context.scope.launch { val fbb = FlatBufferBuilder(1024) val minTime = config.minimumTimeSinceLast.toLong() while (isActive) { @@ -121,36 +118,33 @@ val DataFeedInitBehaviour = SolarXRConnectionBehaviour( fbb.finish( MessageBundle( dataFeedMsgs = listOf( - createDatafeedFrame(context.serverContext, config, index), + createDatafeedFrame(receiver.serverContext, config, index), ), ).encode(fbb), ) - context.send(fbb.dataBuffer().moveToByteArray()) + receiver.send(fbb.dataBuffer().moveToByteArray()) delay(minTime) } } } - context.context.dispatch( - SolarXRConnectionActions.SetConfig( - datafeeds, - timers = timers, - ), + receiver.context.dispatch( + SolarXRConnectionActions.SetConfig(datafeeds, timers = timers), ) } - context.dataFeedDispatcher.on { event -> + receiver.dataFeedDispatcher.on { event -> val config = event.config ?: return@on val fbb = FlatBufferBuilder(1024) fbb.finish( MessageBundle( dataFeedMsgs = listOf( - createDatafeedFrame(serverContext = context.serverContext, datafeedConfig = config), + createDatafeedFrame(serverContext = receiver.serverContext, datafeedConfig = config), ), ).encode(fbb), ) - context.send(fbb.dataBuffer().moveToByteArray()) + receiver.send(fbb.dataBuffer().moveToByteArray()) } - }, -) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt index a0f8b38e84..c0218676e1 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt @@ -1,6 +1,7 @@ package dev.slimevr.solarxr import dev.slimevr.firmware.FirmwareJobStatus +import dev.slimevr.firmware.FirmwareManager import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -12,10 +13,9 @@ import solarxr_protocol.rpc.FirmwareUpdateStopQueuesRequest import solarxr_protocol.rpc.OTAFirmwareUpdate import solarxr_protocol.rpc.SerialFirmwareUpdate -val FirmwareBehaviour = SolarXRConnectionBehaviour( - observer = { conn -> - val scope = conn.context.scope - val firmwareManager = conn.serverContext.firmwareManager +class FirmwareBehaviour(private val firmwareManager: FirmwareManager) : SolarXRConnectionBehaviour { + override fun observe(receiver: SolarXRConnection) { + val scope = receiver.context.scope var prevJobs: Map = firmwareManager.context.state.value.jobs @@ -25,7 +25,7 @@ val FirmwareBehaviour = SolarXRConnectionBehaviour( .onEach { jobs -> jobs.forEach { (portLocation, jobStatus) -> if (prevJobs[portLocation] != jobStatus) { - conn.sendRpc( + receiver.sendRpc( FirmwareUpdateStatusResponse( deviceId = jobStatus.firmwareDeviceId, status = jobStatus.status, @@ -38,7 +38,7 @@ val FirmwareBehaviour = SolarXRConnectionBehaviour( } .launchIn(scope) - conn.rpcDispatcher.on { req -> + receiver.rpcDispatcher.on { req -> when (val method = req.method) { is SerialFirmwareUpdate -> { val portLocation = method.deviceId?.port ?: return@on @@ -49,24 +49,24 @@ val FirmwareBehaviour = SolarXRConnectionBehaviour( method.needmanualreboot, method.ssid, method.password, - conn.serverContext, + receiver.serverContext, ) } is OTAFirmwareUpdate -> { val deviceId = method.deviceId ?: return@on val part = method.firmwarePart ?: return@on - val device = conn.serverContext.getDevice(deviceId.id.toInt()) ?: return@on + val device = receiver.serverContext.getDevice(deviceId.id.toInt()) ?: return@on val deviceIp = device.context.state.value.address - firmwareManager.otaFlash(deviceIp, DeviceIdTable(id = deviceId), part, conn.serverContext) + firmwareManager.otaFlash(deviceIp, DeviceIdTable(id = deviceId), part, receiver.serverContext) } else -> return@on } } - conn.rpcDispatcher.on { + receiver.rpcDispatcher.on { firmwareManager.cancelAll() } - }, -) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 59e73efed8..0992c98b03 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -3,9 +3,8 @@ package dev.slimevr.solarxr import com.google.flatbuffers.FlatBufferBuilder import dev.slimevr.EventDispatcher import dev.slimevr.VRServer +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.CustomBehaviour -import dev.slimevr.context.createContext import io.ktor.util.moveToByteArray import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -25,36 +24,18 @@ sealed interface SolarXRConnectionActions { } typealias SolarXRConnectionContext = Context -typealias SolarXRConnectionBehaviour = CustomBehaviour +typealias SolarXRConnectionBehaviour = Behaviour -data class SolarXRConnection( +class SolarXRConnection( val context: SolarXRConnectionContext, val serverContext: VRServer, val dataFeedDispatcher: EventDispatcher, val rpcDispatcher: EventDispatcher, - val send: suspend (ByteArray) -> Unit, - val sendRpc: suspend (RpcMessage) -> Unit, -) - -fun createSolarXRConnection( - serverContext: VRServer, - onSend: suspend (ByteArray) -> Unit, - scope: CoroutineScope, -): SolarXRConnection { - val state = SolarXRConnectionState( - dataFeedConfigs = listOf(), - datafeedTimers = listOf(), - ) - - val behaviours = listOf(DataFeedInitBehaviour, SerialConsoleBehaviour, FirmwareBehaviour, VRCBehaviour) + private val onSend: suspend (ByteArray) -> Unit, +) { + suspend fun send(bytes: ByteArray) = onSend(bytes) - val context = createContext( - initialState = state, - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - val sendRpc: suspend (RpcMessage) -> Unit = { message -> + suspend fun sendRpc(message: RpcMessage) { val fbb = FlatBufferBuilder(256) fbb.finish( MessageBundle(rpcMsgs = listOf(RpcMessageHeader(message = message))).encode(fbb), @@ -62,16 +43,29 @@ fun createSolarXRConnection( onSend(fbb.dataBuffer().moveToByteArray()) } - val conn = SolarXRConnection( - context = context, - serverContext = serverContext, - dataFeedDispatcher = EventDispatcher(), - rpcDispatcher = EventDispatcher(), - send = onSend, - sendRpc = sendRpc, - ) + companion object { + fun create( + serverContext: VRServer, + onSend: suspend (ByteArray) -> Unit, + scope: CoroutineScope, + behaviours: List, + ): SolarXRConnection { + val context = Context.create( + initialState = SolarXRConnectionState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), + scope = scope, + behaviours = behaviours, + ) - behaviours.map { it.observer }.forEach { it?.invoke(conn) } + val conn = SolarXRConnection( + context = context, + serverContext = serverContext, + dataFeedDispatcher = EventDispatcher(), + rpcDispatcher = EventDispatcher(), + onSend = onSend, + ) - return conn -} + behaviours.forEach { it.observe(conn) } + return conn + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt index a666b68033..99332c7f59 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt @@ -2,6 +2,7 @@ package dev.slimevr.solarxr import dev.slimevr.serial.SerialConnection import dev.slimevr.serial.SerialPortInfo +import dev.slimevr.serial.SerialServer import kotlinx.coroutines.Job import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn @@ -21,13 +22,11 @@ import solarxr_protocol.rpc.SerialTrackerGetWifiScanRequest import solarxr_protocol.rpc.SerialTrackerRebootRequest import solarxr_protocol.rpc.SerialUpdateResponse -val SerialConsoleBehaviour = SolarXRConnectionBehaviour( - observer = { conn -> - val scope = conn.context.scope - val serialServer = conn.serverContext.serialServer +class SerialBehaviour(private val serialServer: SerialServer) : SolarXRConnectionBehaviour { + override fun observe(receiver: SolarXRConnection) { + val scope = receiver.context.scope - // We assume that you can only subscribe to one serial console - // at a time + // We assume that you can only subscribe to one serial console at a time var logSubscription: Job? = null var activePortLocation: String? = null @@ -40,14 +39,14 @@ val SerialConsoleBehaviour = SolarXRConnectionBehaviour( .distinctUntilChanged() .onEach { ports -> (ports.keys - prevPortKeys).forEach { key -> - conn.sendRpc(NewSerialDeviceResponse(device = ports[key]!!.toSerialDevice())) + receiver.sendRpc(NewSerialDeviceResponse(device = ports[key]!!.toSerialDevice())) } prevPortKeys = ports.keys.toSet() } .launchIn(scope) - conn.rpcDispatcher.on { - conn.sendRpc( + receiver.rpcDispatcher.on { + receiver.sendRpc( SerialDevicesResponse( devices = serialServer.context.state.value.availablePorts.values .map { it.toSerialDevice() }, @@ -55,7 +54,7 @@ val SerialConsoleBehaviour = SolarXRConnectionBehaviour( ) } - conn.rpcDispatcher.on { req -> + receiver.rpcDispatcher.on { req -> val portLocation = if (req.auto == true) { serialServer.context.state.value.availablePorts.keys.firstOrNull() } else { @@ -80,54 +79,54 @@ val SerialConsoleBehaviour = SolarXRConnectionBehaviour( if (disconnected) return@collect connState.logLines.drop(lastSentCount).forEach { line -> - conn.sendRpc(SerialUpdateResponse(log = line + "\n")) + receiver.sendRpc(SerialUpdateResponse(log = line + "\n")) } lastSentCount = connState.logLines.size if (!connState.connected) { disconnected = true activePortLocation = null - conn.sendRpc(SerialUpdateResponse(closed = true)) + receiver.sendRpc(SerialUpdateResponse(closed = true)) } } } } - conn.rpcDispatcher.on { + receiver.rpcDispatcher.on { logSubscription?.cancel() logSubscription = null activePortLocation = null } - conn.rpcDispatcher.on { + receiver.rpcDispatcher.on { val portLocation = activePortLocation ?: return@on val c = serialServer.context.state.value.connections[portLocation] if (c is SerialConnection.Console) c.handle.writeCommand("REBOOT") } - conn.rpcDispatcher.on { + receiver.rpcDispatcher.on { val portLocation = activePortLocation ?: return@on val c = serialServer.context.state.value.connections[portLocation] if (c is SerialConnection.Console) c.handle.writeCommand("GET INFO") } - conn.rpcDispatcher.on { + receiver.rpcDispatcher.on { val portLocation = activePortLocation ?: return@on val c = serialServer.context.state.value.connections[portLocation] if (c is SerialConnection.Console) c.handle.writeCommand("FRST") } - conn.rpcDispatcher.on { + receiver.rpcDispatcher.on { val portLocation = activePortLocation ?: return@on val c = serialServer.context.state.value.connections[portLocation] if (c is SerialConnection.Console) c.handle.writeCommand("GET WIFISCAN") } - conn.rpcDispatcher.on { req -> + receiver.rpcDispatcher.on { req -> val portLocation = activePortLocation ?: return@on val command = req.command ?: return@on val c = serialServer.context.state.value.connections[portLocation] if (c is SerialConnection.Console) c.handle.writeCommand(command) } - }, -) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt index f74dcf6f82..ff5ed5d41b 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt @@ -1,6 +1,7 @@ package dev.slimevr.solarxr import dev.slimevr.vrchat.VRCConfigActions +import dev.slimevr.vrchat.VRCConfigManager import dev.slimevr.vrchat.computeRecommendedValues import dev.slimevr.vrchat.computeValidity import kotlinx.coroutines.flow.drop @@ -10,15 +11,16 @@ 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 - +class VrcBehaviour( + private val vrcManager: VRCConfigManager, + private val userHeight: () -> Double, +) : SolarXRConnectionBehaviour { + override fun observe(receiver: SolarXRConnection) { 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()) + val recommended = computeRecommendedValues(receiver.serverContext, userHeight()) return VRCConfigStateChangeResponse( isSupported = true, validity = computeValidity(values, recommended), @@ -28,20 +30,18 @@ val VRCBehaviour = SolarXRConnectionBehaviour( ) } - // 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 + // Drop the initial value — we only want to push updates when the config changes vrcManager.context.state.drop(1).onEach { - conn.sendRpc(buildCurrentResponse()) - }.launchIn(conn.context.scope) + receiver.sendRpc(buildCurrentResponse()) + }.launchIn(receiver.context.scope) - conn.rpcDispatcher.on { - conn.sendRpc(buildCurrentResponse()) + receiver.rpcDispatcher.on { + receiver.sendRpc(buildCurrentResponse()) } - conn.rpcDispatcher.on { req -> + receiver.rpcDispatcher.on { req -> val key = req.key ?: return@on vrcManager.context.dispatch(VRCConfigActions.ToggleMutedWarning(key)) } - }, -) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt b/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt index 179d6001a6..5e3e053f08 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt @@ -30,19 +30,20 @@ suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRConnection) { } } -suspend fun createSolarXRWebsocketServer(serverContext: VRServer) { +suspend fun createSolarXRWebsocketServer(serverContext: VRServer, behaviours: List) { val engine = embeddedServer(Netty, port = SOLARXR_PORT) { install(WebSockets) routing { webSocket { AppLogger.solarxr.info("[WS] New connection") - val solarxrConnection = createSolarXRConnection( + val solarxrConnection = SolarXRConnection.create( serverContext, scope = this, onSend = { send(Frame.Binary(fin = true, data = it)) }, + behaviours = behaviours, ) for (frame in incoming) { diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt new file mode 100644 index 0000000000..ff2bab5a8b --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -0,0 +1,15 @@ +package dev.slimevr.tracker + +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +object TrackerInfosBehaviour : TrackerBehaviour { + override fun reduce(state: TrackerState, action: TrackerActions) = + if (action is TrackerActions.Update) action.transform(state) else state + + override fun observe(receiver: TrackerContext) { + receiver.state.onEach { +// AppLogger.tracker.info("Tracker state changed {State}", it) + }.launchIn(receiver.scope) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 09e38f1df8..5a4d1d29f4 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -1,14 +1,10 @@ package dev.slimevr.tracker -import dev.slimevr.VRServer -import dev.slimevr.context.BasicBehaviour +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.createContext import dev.slimevr.device.DeviceOrigin import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.hardware_info.ImuType @@ -31,53 +27,36 @@ sealed interface TrackerActions { } typealias TrackerContext = Context -typealias TrackerBehaviour = BasicBehaviour +typealias TrackerBehaviour = Behaviour -data class Tracker( +class Tracker( val context: TrackerContext, -) - -val TrackerInfosBehaviour = TrackerBehaviour( - reducer = { s, a -> if (a is TrackerActions.Update) a.transform(s) else s }, - observer = { - it.state.onEach { state -> -// AppLogger.tracker.info("Tracker state changed {State}", state) - }.launchIn(it.scope) - }, -) - -fun createTracker( - scope: CoroutineScope, - id: Int, - deviceId: Int, - sensorType: ImuType, - hardwareId: String, - origin: DeviceOrigin, - serverContext: VRServer, -): Tracker { - val trackerState = TrackerState( - id = id, - hardwareId = hardwareId, - name = "Tracker #$id", - rawRotation = Quaternion.IDENTITY, - bodyPart = null, - origin = origin, - deviceId = deviceId, - customName = null, - sensorType = sensorType, - ) - - val behaviours = listOf(TrackerInfosBehaviour) - - val context = createContext( - initialState = trackerState, - reducers = behaviours.map { it.reducer }, - scope = scope, - ) - - behaviours.map { it.observer }.forEach { it?.invoke(context) } - - return Tracker( - context = context, - ) -} +) { + companion object { + fun create( + scope: CoroutineScope, + id: Int, + deviceId: Int, + sensorType: ImuType, + hardwareId: String, + origin: DeviceOrigin, + ): Tracker { + val trackerState = TrackerState( + id = id, + hardwareId = hardwareId, + name = "Tracker #$id", + rawRotation = Quaternion.IDENTITY, + bodyPart = null, + origin = origin, + deviceId = deviceId, + customName = null, + sensorType = sensorType, + ) + + val behaviours = listOf(TrackerInfosBehaviour) + val context = Context.create(initialState = trackerState, scope = scope, behaviours = behaviours) + behaviours.forEach { it.observe(context) } + return Tracker(context = context) + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt new file mode 100644 index 0000000000..eecada3778 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -0,0 +1,228 @@ +package dev.slimevr.udp + +import dev.slimevr.AppLogger +import dev.slimevr.VRServerActions +import dev.slimevr.device.Device +import dev.slimevr.device.DeviceActions +import dev.slimevr.device.DeviceOrigin +import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerActions +import dev.slimevr.tracker.TrackerIdNum +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import solarxr_protocol.datatypes.TrackerStatus + +internal const val CONNECTION_TIMEOUT_MS = 5000L + +object PacketBehaviour : UDPConnectionBehaviour { + override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { + is UDPConnectionActions.LastPacket -> { + var newState = state.copy(lastPacket = action.time) + if (action.packetNum != null) newState = newState.copy(lastPacketNum = action.packetNum) + newState + } + else -> state + } + + override fun observe(receiver: UDPConnection) { + receiver.packetEvents.onAny { packet -> + val state = receiver.context.state.value + val now = System.currentTimeMillis() + if (now - state.lastPacket > CONNECTION_TIMEOUT_MS && packet.packetNumber == 0L) { + receiver.context.dispatch(UDPConnectionActions.LastPacket(packetNum = 0, time = now)) + AppLogger.udp.info("Reconnecting") + } else if (packet.packetNumber < state.lastPacketNum) { + AppLogger.udp.warn("WARN: Received packet with wrong packet number") + return@onAny + } else { + receiver.context.dispatch(UDPConnectionActions.LastPacket(time = now)) + } + } + } +} + +object PingBehaviour : UDPConnectionBehaviour { + override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { + is UDPConnectionActions.StartPing -> state.copy(lastPing = state.lastPing.copy(startTime = action.startTime)) + is UDPConnectionActions.ReceivedPong -> state.copy(lastPing = state.lastPing.copy(duration = action.duration, id = action.id)) + else -> state + } + + override fun observe(receiver: UDPConnection) { + // Send the ping every 1s + receiver.context.scope.launch { + while (isActive) { + val state = receiver.context.state.value + if (state.didHandshake) { + receiver.context.dispatch(UDPConnectionActions.StartPing(startTime = System.currentTimeMillis())) + receiver.send(PingPong(state.lastPing.id + 1)) + } + delay(1000) + } + } + + // listen for the pong + receiver.packetEvents.onPacket { packet -> + val state = receiver.context.state.value + val deviceId = state.deviceId ?: return@onPacket + + if (packet.data.pingId != state.lastPing.id + 1) { + AppLogger.udp.warn("Ping ID does not match, ignoring ${packet.data.pingId} != ${state.lastPing.id + 1}") + return@onPacket + } + + val ping = System.currentTimeMillis() - state.lastPing.startTime + val device = receiver.serverContext.getDevice(deviceId) ?: return@onPacket + + receiver.context.dispatch(UDPConnectionActions.ReceivedPong(id = packet.data.pingId, duration = ping)) + device.context.dispatch(DeviceActions.Update { copy(ping = ping) }) + } + } +} + +object HandshakeBehaviour : UDPConnectionBehaviour { + override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { + is UDPConnectionActions.Handshake -> state.copy(didHandshake = true, deviceId = action.deviceId) + is UDPConnectionActions.Disconnected -> state.copy(didHandshake = false) + else -> state + } + + override fun observe(receiver: UDPConnection) { + receiver.packetEvents.onPacket { packet -> + val state = receiver.context.state.value + + val device = if (state.deviceId == null) { + val deviceId = receiver.serverContext.nextHandle() + val newDevice = Device.create( + id = deviceId, + scope = receiver.serverContext.context.scope, + address = state.address, + macAddress = packet.data.macString, + origin = DeviceOrigin.UDP, + protocolVersion = packet.data.protocolVersion, + ) + receiver.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId = deviceId, context = newDevice)) + receiver.context.dispatch(UDPConnectionActions.Handshake(deviceId)) + newDevice + } else { + receiver.context.dispatch(UDPConnectionActions.Handshake(state.deviceId)) + receiver.getDevice() ?: run { + AppLogger.udp.warn("Reconnect handshake but device ${state.deviceId} not found") + receiver.send(Handshake()) + return@onPacket + } + } + + // Apply handshake fields to device, always, for both first connect and reconnect + device.context.dispatch( + DeviceActions.Update { + copy( + macAddress = packet.data.macString ?: macAddress, + boardType = packet.data.boardType, + mcuType = packet.data.mcuType, + firmware = packet.data.firmware ?: firmware, + protocolVersion = packet.data.protocolVersion, + ) + }, + ) + + receiver.send(Handshake()) + } + } +} + +object TimeoutBehaviour : UDPConnectionBehaviour { + override fun observe(receiver: UDPConnection) { + receiver.context.scope.launch { + while (isActive) { + val state = receiver.context.state.value + if (!state.didHandshake) { + delay(500) + continue + } + val timeUntilTimeout = CONNECTION_TIMEOUT_MS - (System.currentTimeMillis() - state.lastPacket) + if (timeUntilTimeout <= 0) { + AppLogger.udp.info("Connection timed out for ${state.id}") + receiver.context.dispatch(UDPConnectionActions.Disconnected) + receiver.getDevice()?.context?.dispatch( + DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, + ) + } else { + delay(timeUntilTimeout + 1) + } + } + } + } +} + +object DeviceStatsBehaviour : UDPConnectionBehaviour { + override fun observe(receiver: UDPConnection) { + receiver.packetEvents.onPacket { event -> + val device = receiver.getDevice() ?: return@onPacket + device.context.dispatch( + DeviceActions.Update { + copy(batteryLevel = event.data.level, batteryVoltage = event.data.voltage) + }, + ) + } + + receiver.packetEvents.onPacket { event -> + val device = receiver.getDevice() ?: return@onPacket + device.context.dispatch(DeviceActions.Update { copy(signalStrength = event.data.signal) }) + } + } +} + +object SensorInfoBehaviour : UDPConnectionBehaviour { + override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { + is UDPConnectionActions.AssignTracker -> state.copy(trackerIds = state.trackerIds + action.trackerId) + else -> state + } + + override fun observe(receiver: UDPConnection) { + receiver.packetEvents.onPacket { event -> + val device = receiver.getDevice() + ?: error("invalid state - a device should exist at this point") + + device.context.dispatch(DeviceActions.Update { copy(status = event.data.status) }) + + val tracker = receiver.getTracker(event.data.sensorId) + val action = TrackerActions.Update { copy(sensorType = event.data.imuType) } + + if (tracker != null) { + tracker.context.dispatch(action) + } else { + val deviceState = device.context.state.value + val trackerId = receiver.serverContext.nextHandle() + val newTracker = Tracker.create( + id = trackerId, + hardwareId = "${deviceState.address}:${event.data.sensorId}", + sensorType = event.data.imuType, + deviceId = deviceState.id, + origin = DeviceOrigin.UDP, + scope = receiver.serverContext.context.scope, + ) + + receiver.serverContext.context.dispatch( + VRServerActions.NewTracker(trackerId = trackerId, context = newTracker), + ) + receiver.context.dispatch( + UDPConnectionActions.AssignTracker( + trackerId = TrackerIdNum(id = trackerId, trackerNum = event.data.sensorId), + ), + ) + newTracker.context.dispatch(action) + } + } + } +} + +object SensorRotationBehaviour : UDPConnectionBehaviour { + override fun observe(receiver: UDPConnection) { + receiver.packetEvents.onPacket { event -> + val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = event.data.rotation) }) + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 1d3cc00a09..f716003f07 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -1,29 +1,18 @@ package dev.slimevr.udp -import dev.slimevr.AppLogger import dev.slimevr.EventDispatcher import dev.slimevr.VRServer -import dev.slimevr.VRServerActions +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.CustomBehaviour -import dev.slimevr.context.createContext import dev.slimevr.device.Device -import dev.slimevr.device.DeviceActions -import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.Tracker -import dev.slimevr.tracker.TrackerActions import dev.slimevr.tracker.TrackerIdNum -import dev.slimevr.device.createDevice -import dev.slimevr.tracker.createTracker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.io.Buffer import kotlinx.io.readByteArray -import solarxr_protocol.datatypes.TrackerStatus import java.net.DatagramPacket import java.net.DatagramSocket import java.net.InetAddress @@ -56,316 +45,35 @@ sealed interface UDPConnectionActions { } typealias UDPConnectionContext = Context -typealias UDPConnectionBehaviour = CustomBehaviour +typealias UDPConnectionBehaviour = Behaviour -private const val CONNECTION_TIMEOUT_MS = 5000L - -val PacketBehaviour = UDPConnectionBehaviour( - reducer = { s, a -> - when (a) { - is UDPConnectionActions.LastPacket -> { - var newState = s.copy(lastPacket = a.time) - - if (a.packetNum != null) { - newState = newState.copy(lastPacketNum = a.packetNum) - } - - newState - } - - else -> s - } - }, - observer = { - it.packetEvents.onAny { packet -> - val state = it.context.state.value - - val now = System.currentTimeMillis() - if (now - state.lastPacket > CONNECTION_TIMEOUT_MS && packet.packetNumber == 0L) { - it.context.dispatch( - UDPConnectionActions.LastPacket( - packetNum = 0, - time = now, - ), - ) - AppLogger.udp.info("Reconnecting") - } else if (packet.packetNumber < state.lastPacketNum) { - AppLogger.udp.warn("WARN: Received packet with wrong packet number") - return@onAny - } else { - it.context.dispatch(UDPConnectionActions.LastPacket(time = now)) - } - } - }, -) - -val PingBehaviour = UDPConnectionBehaviour( - reducer = { s, a -> - when (a) { - is UDPConnectionActions.StartPing -> { - s.copy(lastPing = s.lastPing.copy(startTime = a.startTime)) - } - - is UDPConnectionActions.ReceivedPong -> { - s.copy(lastPing = s.lastPing.copy(duration = a.duration, id = a.id)) - } - - else -> s - } - }, - observer = { - // Send the ping every 1s - it.context.scope.launch { - while (isActive) { - val state = it.context.state.value - if (state.didHandshake) { - it.context.dispatch(UDPConnectionActions.StartPing(startTime = System.currentTimeMillis())) - it.send(PingPong(state.lastPing.id + 1)) - } - delay(1000) - } - } - - // listen for the pong - it.packetEvents.onPacket { packet -> - val state = it.context.state.value - val deviceId = state.deviceId ?: return@onPacket - - if (packet.data.pingId != state.lastPing.id + 1) { - AppLogger.udp.warn("Ping ID does not match, ignoring ${packet.data.pingId} != ${state.lastPing.id + 1}") - return@onPacket - } - - val ping = System.currentTimeMillis() - state.lastPing.startTime - - val device = it.serverContext.getDevice(deviceId) ?: return@onPacket - - it.context.dispatch( - UDPConnectionActions.ReceivedPong( - id = packet.data.pingId, - duration = ping, - ), - ) - device.context.dispatch( - DeviceActions.Update { - copy(ping = ping) - }, - ) - } - }, -) - -val HandshakeBehaviour = UDPConnectionBehaviour( - reducer = { s, a -> - when (a) { - is UDPConnectionActions.Handshake -> s.copy( - didHandshake = true, - deviceId = a.deviceId, - ) - - is UDPConnectionActions.Disconnected -> s.copy( - didHandshake = false, - ) - - else -> s - } - }, - observer = { - it.packetEvents.onPacket { packet -> - val state = it.context.state.value - - val device = if (state.deviceId == null) { - val deviceId = it.serverContext.nextHandle() - val newDevice = createDevice( - id = deviceId, - scope = it.serverContext.context.scope, - address = state.address, - macAddress = packet.data.macString, - origin = DeviceOrigin.UDP, - protocolVersion = packet.data.protocolVersion, - serverContext = it.serverContext, - ) - it.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId = deviceId, context = newDevice)) - it.context.dispatch(UDPConnectionActions.Handshake(deviceId)) - newDevice - } else { - it.context.dispatch(UDPConnectionActions.Handshake(state.deviceId)) - it.getDevice() ?: run { - AppLogger.udp.warn("Reconnect handshake but device ${state.deviceId} not found") - it.send(Handshake()) - return@onPacket - } - } - - // Apply handshake fields to device, always, for both first connect and reconnect - device.context.dispatch( - DeviceActions.Update { - copy( - macAddress = packet.data.macString ?: macAddress, - boardType = packet.data.boardType, - mcuType = packet.data.mcuType, - firmware = packet.data.firmware ?: firmware, - protocolVersion = packet.data.protocolVersion, - ) - }, - ) - - it.send(Handshake()) - } - }, -) - -val TimeoutBehaviour = UDPConnectionBehaviour( - observer = { - it.context.scope.launch { - while (isActive) { - val state = it.context.state.value - if (!state.didHandshake) { - delay(500) - continue - } - val timeUntilTimeout = CONNECTION_TIMEOUT_MS - (System.currentTimeMillis() - state.lastPacket) - if (timeUntilTimeout <= 0) { - AppLogger.udp.info("Connection timed out for ${state.id}") - it.context.dispatch(UDPConnectionActions.Disconnected) - it.getDevice()?.context?.dispatch( - DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, - ) - } else { - delay(timeUntilTimeout + 1) - } - } - } - }, -) - -val DeviceStatsBehaviour = UDPConnectionBehaviour( - observer = { - it.packetEvents.onPacket { event -> - val device = it.getDevice() ?: return@onPacket - - device.context.dispatch( - DeviceActions.Update { - copy( - batteryLevel = event.data.level, - batteryVoltage = event.data.voltage, - ) - }, - ) - } - - it.packetEvents.onPacket { event -> - val device = it.getDevice() ?: return@onPacket - - device.context.dispatch( - DeviceActions.Update { - copy(signalStrength = event.data.signal) - }, - ) - } - }, -) - -val SensorInfoBehaviour = UDPConnectionBehaviour( - reducer = { s, a -> - when (a) { - is UDPConnectionActions.AssignTracker -> { - s.copy(trackerIds = s.trackerIds + a.trackerId) - } - - else -> s - } - }, - observer = { observerContext -> - observerContext.packetEvents.onPacket { event -> - val device = observerContext.getDevice() - ?: error("invalid state - a device should exist at this point") - - device.context.dispatch( - DeviceActions.Update { - copy(status = event.data.status) - }, - ) - - val tracker = observerContext.getTracker(event.data.sensorId) - - val action = TrackerActions.Update { - copy( - sensorType = event.data.imuType, - ) - } - - if (tracker != null) { - tracker.context.dispatch(action) - } else { - val deviceState = device.context.state.value - val trackerId = observerContext.serverContext.nextHandle() - val newTracker = createTracker( - id = trackerId, - hardwareId = "${deviceState.address}:${event.data.sensorId}", - sensorType = event.data.imuType, - deviceId = deviceState.id, - origin = DeviceOrigin.UDP, - serverContext = observerContext.serverContext, - scope = observerContext.serverContext.context.scope, - ) - - observerContext.serverContext.context.dispatch( - VRServerActions.NewTracker( - trackerId = trackerId, - context = newTracker, - ), - ) - observerContext.context.dispatch( - UDPConnectionActions.AssignTracker( - trackerId = TrackerIdNum( - id = trackerId, - trackerNum = event.data.sensorId, - ), - ), - ) - newTracker.context.dispatch(action) - } - } - }, -) - -val SensorRotationBehaviour = UDPConnectionBehaviour( - observer = { context -> - context.packetEvents.onPacket { event -> - val tracker = context.getTracker(event.data.sensorId) ?: return@onPacket - tracker.context.dispatch( - TrackerActions.Update { - copy(rawRotation = event.data.rotation) - }, - ) - } - }, -) - -data class UDPConnection( +class UDPConnection( val context: UDPConnectionContext, val serverContext: VRServer, val packetEvents: UDPPacketDispatcher, val packetChannel: Channel>, - val send: (UDPPacket) -> Unit, + private val socket: DatagramSocket, + private val remoteInetAddress: InetAddress, + private val remotePort: Int, + private val scope: CoroutineScope, ) { + fun send(packet: UDPPacket) { + scope.launch(Dispatchers.IO) { + val buf = Buffer() + writePacket(buf, packet) + val bytes = buf.readByteArray() + socket.send(DatagramPacket(bytes, bytes.size, remoteInetAddress, remotePort)) + } + } + fun getDevice(): Device? { val deviceId = context.state.value.deviceId - return if (deviceId != null) { - serverContext.getDevice(deviceId) - } else { - null - } + return if (deviceId != null) serverContext.getDevice(deviceId) else null } fun getTracker(id: Int): Tracker? { val trackerId = context.state.value.trackerIds.find { it.trackerNum == id } - return if (trackerId != null) { - serverContext.getTracker(trackerId.id) - } else { - null - } + return if (trackerId != null) serverContext.getTracker(trackerId.id) else null } companion object { @@ -387,7 +95,7 @@ data class UDPConnection( SensorRotationBehaviour, ) - val context = createContext( + val context = Context.create( initialState = UDPConnectionState( id = id, lastPacket = System.currentTimeMillis(), @@ -399,8 +107,8 @@ data class UDPConnection( deviceId = null, trackerIds = listOf(), ), - reducers = behaviours.map { it.reducer }, scope = scope, + behaviours = behaviours, ) val dispatcher = EventDispatcher> { it.data::class } @@ -410,19 +118,15 @@ data class UDPConnection( val conn = UDPConnection( context = context, serverContext = serverContext, - dispatcher, + packetEvents = dispatcher, packetChannel = packetChannel, - send = { packet: UDPPacket -> - scope.launch(Dispatchers.IO) { - val buf = Buffer() - writePacket(buf, packet) - val bytes = buf.readByteArray() - socket.send(DatagramPacket(bytes, bytes.size, remoteInetAddress, remotePort)) - } - }, + socket = socket, + remoteInetAddress = remoteInetAddress, + remotePort = remotePort, + scope = scope, ) - behaviours.map { it.observer }.forEach { it?.invoke(conn) } + behaviours.forEach { it.observe(conn) } // Dedicated coroutine per connection so the receive loop is never blocked by packet processing scope.launch { @@ -440,4 +144,4 @@ data class UDPConnection( return conn } } -} +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index 649d145498..2130b37c14 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -13,7 +13,7 @@ import java.net.DatagramPacket import java.net.DatagramSocket import kotlin.time.measureTime -data class UDPTrackerServerState( +class UDPTrackerServerState( val port: Int, val connections: MutableMap, ) diff --git a/server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt b/server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt new file mode 100644 index 0000000000..7128f8166b --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt @@ -0,0 +1,26 @@ +package dev.slimevr.vrchat + +import dev.slimevr.config.SettingsActions +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +object DefaultVRCConfigBehaviour : VRCConfigBehaviour { + override fun reduce(state: VRCConfigState, action: VRCConfigActions) = when (action) { + is VRCConfigActions.UpdateValues -> state.copy(currentValues = action.values) + is VRCConfigActions.ToggleMutedWarning -> { + if (action.key !in VRC_VALID_KEYS) state + else if (action.key in state.mutedWarnings) state.copy(mutedWarnings = state.mutedWarnings - action.key) + else state.copy(mutedWarnings = state.mutedWarnings + action.key) + } + } + + override fun observe(receiver: VRCConfigManager) { + receiver.context.state.map { it.mutedWarnings }.distinctUntilChanged().onEach { warnings -> + receiver.config.settings.context.dispatch(SettingsActions.Update { + copy(mutedVRCWarnings = warnings) + }) + }.launchIn(receiver.context.scope) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/vrchat/module.kt b/server/core/src/main/java/dev/slimevr/vrchat/module.kt index 6763164255..a4b733e4f8 100644 --- a/server/core/src/main/java/dev/slimevr/vrchat/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrchat/module.kt @@ -2,17 +2,10 @@ package dev.slimevr.vrchat import dev.slimevr.VRServer import dev.slimevr.config.AppConfig -import dev.slimevr.config.SettingsActions -import dev.slimevr.context.BasicBehaviour +import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.context.CustomBehaviour -import dev.slimevr.context.createContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.VRCAvatarMeasurementType @@ -47,34 +40,41 @@ sealed interface VRCConfigActions { } typealias VRCConfigContext = Context -typealias VRCConfigBehaviour = CustomBehaviour +typealias VRCConfigBehaviour = Behaviour -data class VRCConfigManager( +class VRCConfigManager( val context: VRCConfigContext, val config: AppConfig, - 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) +) { + companion object { + fun create( + config: AppConfig, + scope: CoroutineScope, + isSupported: Boolean, + values: Flow, + ): VRCConfigManager { + val behaviours = listOf(DefaultVRCConfigBehaviour) + + val context = Context.create( + initialState = VRCConfigState( + currentValues = null, + isSupported = isSupported, + mutedWarnings = listOf(), + ), + scope = scope, + behaviours = behaviours, + ) + + scope.launch { + values.collect { context.dispatch(VRCConfigActions.UpdateValues(it)) } } - } - }, - observer = { context -> - context.context.state.map { it.mutedWarnings }.distinctUntilChanged().onEach { warnings -> - context.config.settings.context.dispatch(SettingsActions.Update { - copy(mutedVRCWarnings = warnings) - }) - }.launchIn(scope = context.context.scope) + val manager = VRCConfigManager(context = context, config = config) + behaviours.forEach { it.observe(manager) } + return manager + } } -) +} fun computeRecommendedValues(server: VRServer, userHeight: Double): VRCConfigRecommendedValues { val trackers = server.context.state.value.trackers.values @@ -117,35 +117,4 @@ fun computeValidity(values: VRCConfigValues, recommended: VRCConfigRecommendedVa calibrationVisualsOk = values.calibrationVisuals == recommended.calibrationVisuals, avatarMeasurementTypeOk = values.avatarMeasurementType == recommended.avatarMeasurementType, shoulderWidthCompensationOk = values.shoulderWidthCompensation == recommended.shoulderWidthCompensation, - ) - -fun createVRCConfigManager( - config: AppConfig, - scope: CoroutineScope, - userHeight: () -> Double, - isSupported: Boolean, - values: Flow, -): VRCConfigManager { - val modules = listOf(DefaultVRCConfigBehaviour) - - val initialState = VRCConfigState( - currentValues = null, - isSupported = isSupported, - mutedWarnings = listOf(), - ) - - val context = createContext( - initialState = initialState, - reducers = modules.map { it.reducer }, - scope = scope, - ) - - scope.launch { - values.collect { context.dispatch(VRCConfigActions.UpdateValues(it)) } - } - - val manager = VRCConfigManager(context = context, userHeight = userHeight, config = config) - modules.map { it.observer }.forEach { it?.invoke(manager) } - - return manager -} + ) \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index f454bcc766..5c1507dc52 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -1,17 +1,10 @@ package dev.slimevr +import dev.slimevr.context.Behaviour 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.tracker.Tracker -import dev.slimevr.vrchat.VRCConfigManager import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch @@ -27,32 +20,11 @@ sealed interface VRServerActions { } typealias VRServerContext = Context -typealias VRServerBehaviour = CustomBehaviour - -val BaseBehaviour = VRServerBehaviour( - reducer = { s, a -> - when (a) { - is VRServerActions.NewTracker -> s.copy(trackers = s.trackers + (a.trackerId to a.context)) - is VRServerActions.NewDevice -> s.copy(devices = s.devices + (a.deviceId to a.context)) - } - }, - observer = { context -> - context.context.state.distinctUntilChangedBy { state -> state.trackers.size }.onEach { - println("tracker list size changed") - }.launchIn(context.context.scope) - - context.serialServer.context.state.distinctUntilChangedBy { state -> state.availablePorts.size }.onEach { - println("Avalable ports $it") - }.launchIn(context.context.scope) - }, -) +typealias VRServerBehaviour = Behaviour @OptIn(ExperimentalAtomicApi::class) -data class VRServer( +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, @@ -62,35 +34,15 @@ data class VRServer( fun getDevice(id: Int) = context.state.value.devices[id] companion object { - fun create( - scope: CoroutineScope, - serialServer: SerialServer, - firmwareManager: FirmwareManager, - vrcConfigManager: VRCConfigManager, - ): VRServer { - val state = VRServerState( - trackers = mapOf(), - devices = mapOf(), - ) - + fun create(scope: CoroutineScope): VRServer { val behaviours = listOf(BaseBehaviour) - - val context = createContext( - initialState = state, - reducers = behaviours.map { it.reducer }, + val context = Context.create( + initialState = VRServerState(trackers = mapOf(), devices = mapOf()), scope = scope, + behaviours = behaviours, ) - - val server = VRServer( - context = context, - serialServer = serialServer, - firmwareManager = firmwareManager, - vrcConfigManager = vrcConfigManager, - handleCounter = AtomicInt(0), - ) - - behaviours.map { it.observer }.forEach { it?.invoke(server) } - + val server = VRServer(context = context, handleCounter = AtomicInt(0)) + behaviours.forEach { it.observe(server) } return server } } diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 0d993063d9..d508baea10 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -1,15 +1,12 @@ package dev.slimevr 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", {}, {}) }, + openPort = { loc, _, _ -> SerialPortHandle(loc, "Fake $loc", {}, {}) }, openFlashingPort = { object : FlasherSerialInterface { override fun openSerial(port: Any) = Unit @@ -27,14 +24,4 @@ fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create( scope = scope, ) -fun buildTestVrServer(scope: CoroutineScope): VRServer { - val serialServer = buildTestSerialServer(scope) - return VRServer.create(scope, serialServer, createFirmwareManager(serialServer, scope), - createVRCConfigManager( - scope = scope, - userHeight = { 1.6 }, - isSupported = false, - values = emptyFlow(), - ) - ) -} +fun buildTestVrServer(scope: CoroutineScope): VRServer = VRServer.create(scope) \ No newline at end of file diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt index 8deff7c9a2..33f751ed0e 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -8,8 +8,7 @@ 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 dev.slimevr.device.Device import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -50,7 +49,7 @@ private fun buildSerialServer( scope: kotlinx.coroutines.CoroutineScope, flashHandler: () -> FlasherSerialInterface = ::failingFlashHandler, ) = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = flashHandler, scope = scope, ) @@ -59,18 +58,9 @@ private fun buildSerialServer( // backgroundScope lets those run on the test scheduler but doesn't cause // UncompletedCoroutinesError when the test ends. private fun buildVrServer( - mainScope: kotlinx.coroutines.CoroutineScope, backgroundScope: kotlinx.coroutines.CoroutineScope, - serialServer: SerialServer, ): VRServer { - val firmwareManager = createFirmwareManager(serialServer, mainScope) - 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) + return VRServer.create(backgroundScope) } class DoSerialFlashTest { @@ -87,7 +77,7 @@ class DoSerialFlashTest { ssid = null, password = null, serialServer = server, - server = buildVrServer(this, backgroundScope, server), + server = buildVrServer(backgroundScope), onStatus = { s, _ -> statuses += s }, scope = this, ) @@ -109,7 +99,7 @@ class DoSerialFlashTest { ssid = null, password = null, serialServer = server, - server = buildVrServer(this, backgroundScope, server), + server = buildVrServer(backgroundScope), onStatus = { s, _ -> statuses += s }, scope = this, ) @@ -130,7 +120,7 @@ class DoSerialFlashTest { ssid = null, password = null, serialServer = server, - server = buildVrServer(this, backgroundScope, server), + server = buildVrServer(backgroundScope), onStatus = { s, _ -> statuses += s }, scope = this, ) @@ -151,7 +141,7 @@ class DoSerialFlashTest { ssid = "wifi", password = "pass", serialServer = server, - server = buildVrServer(this, backgroundScope, server), + server = buildVrServer(backgroundScope), onStatus = { s, _ -> statuses += s }, ) @@ -173,7 +163,7 @@ class DoSerialFlashTest { ssid = "wifi", password = "pass", serialServer = server, - server = buildVrServer(this, backgroundScope, server), + server = buildVrServer(backgroundScope), onStatus = { s, _ -> statuses += s }, ) } @@ -199,7 +189,7 @@ class DoSerialFlashTest { ssid = null, password = null, serialServer = server, - server = buildVrServer(this, backgroundScope, server), + server = buildVrServer(backgroundScope), onStatus = { s, _ -> statuses += s }, ) } @@ -229,7 +219,7 @@ class DoSerialFlashTest { ssid = "wifi", password = "pass", serialServer = server, - server = buildVrServer(this, backgroundScope, server), + server = buildVrServer(backgroundScope), onStatus = { s, _ -> statuses += s }, ) } @@ -261,7 +251,7 @@ class DoSerialFlashTest { ssid = "wifi", password = "pass", serialServer = server, - server = buildVrServer(this, backgroundScope, server), + server = buildVrServer(backgroundScope), onStatus = { s, _ -> statuses += s }, ) } @@ -286,7 +276,7 @@ class DoSerialFlashTest { val server = buildSerialServer(this) server.onPortDetected(fakePort()) server.openConnection("COM1") - val vrServer = buildVrServer(this, backgroundScope, server) + val vrServer = buildVrServer(backgroundScope) val statuses = mutableListOf() launch { @@ -307,14 +297,13 @@ class DoSerialFlashTest { delay(200) server.onDataReceived("COM1", "looking for the server") delay(300) - val device = createDevice( + val device = Device.create( backgroundScope, id = vrServer.nextHandle(), address = "192.168.1.100", macAddress = "AA:BB:CC:DD:EE:FF", origin = DeviceOrigin.UDP, protocolVersion = 0, - serverContext = vrServer, ) vrServer.context.dispatch(VRServerActions.NewDevice(device.context.state.value.id, device)) device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) }) diff --git a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt index 9f8c4d4002..a6cf464433 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt @@ -1,6 +1,6 @@ package dev.slimevr.firmware.reducers -import dev.slimevr.context.createContext +import dev.slimevr.context.Context import dev.slimevr.firmware.FirmwareManagerActions import dev.slimevr.firmware.FirmwareManagerBaseBehaviour import dev.slimevr.firmware.FirmwareManagerState @@ -21,9 +21,9 @@ private fun serialJob(port: String, status: FirmwareUpdateStatus, progress: Int ) class FirmwareManagerReducerTest { - private fun makeContext(scope: kotlinx.coroutines.CoroutineScope) = createContext( + private fun makeContext(scope: kotlinx.coroutines.CoroutineScope) = Context.create( initialState = FirmwareManagerState(jobs = mapOf()), - reducers = listOf(FirmwareManagerBaseBehaviour.reducer), + behaviours = listOf(FirmwareManagerBaseBehaviour), scope = scope, ) diff --git a/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt b/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt index 705a30dab1..1197d9f9ff 100644 --- a/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt +++ b/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt @@ -34,7 +34,7 @@ class SerialServerTest { @Test fun `openForFlashing registers Flashing connection`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -49,7 +49,7 @@ class SerialServerTest { @Test fun `openForFlashing returns null when port has an existing connection`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -64,7 +64,7 @@ class SerialServerTest { @Test fun `openForFlashing returns null for unknown port`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -79,7 +79,7 @@ class SerialServerTest { @Test fun `closeSerial removes Flashing connection asynchronously`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -100,7 +100,7 @@ class SerialServerTest { @Test fun `openConnection registers Console connection`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -114,7 +114,7 @@ class SerialServerTest { @Test fun `onPortLost closes Console and removes connection`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -130,7 +130,7 @@ class SerialServerTest { @Test fun `openConnection while flashing is a no-op`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -147,7 +147,7 @@ class SerialServerTest { @Test fun `port can be flashed again after previous flash completes`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -167,7 +167,7 @@ class SerialServerTest { @Test fun `openConnection succeeds after flash completes`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) @@ -184,7 +184,7 @@ class SerialServerTest { @Test fun `onPortLost during flash removes Flashing connection`() = runTest { val server = SerialServer.create( - openPort = { loc, _, _, _ -> fakePortHandle(loc) }, + openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, scope = this, ) diff --git a/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt b/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt index 1eaa0401af..1bd3b9ccba 100644 --- a/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt +++ b/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt @@ -8,8 +8,6 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse class SerialConnectionReducerTest { - private val reducer = SerialLogBehaviour.reducer!! - private fun state(lines: List = emptyList(), connected: Boolean = true) = SerialConnectionState( portLocation = "COM1", descriptivePortName = "Test Port", @@ -19,20 +17,20 @@ class SerialConnectionReducerTest { @Test fun `LogLine appends to empty log`() { - val result = reducer(state(), SerialConnectionActions.LogLine("hello")) + val result = SerialLogBehaviour.reduce(state(), SerialConnectionActions.LogLine("hello")) assertEquals(listOf("hello"), result.logLines) } @Test fun `LogLine appends to existing log`() { - val result = reducer(state(listOf("a", "b")), SerialConnectionActions.LogLine("c")) + val result = SerialLogBehaviour.reduce(state(listOf("a", "b")), SerialConnectionActions.LogLine("c")) assertEquals(listOf("a", "b", "c"), result.logLines) } @Test fun `LogLine drops oldest line when at capacity`() { val full = state(lines = List(500) { "line $it" }) - val result = reducer(full, SerialConnectionActions.LogLine("new")) + val result = SerialLogBehaviour.reduce(full, SerialConnectionActions.LogLine("new")) assertEquals(500, result.logLines.size) assertEquals("line 1", result.logLines.first()) assertEquals("new", result.logLines.last()) @@ -41,14 +39,14 @@ class SerialConnectionReducerTest { @Test fun `LogLine does not drop below capacity`() { val almostFull = state(lines = List(499) { "line $it" }) - val result = reducer(almostFull, SerialConnectionActions.LogLine("new")) + val result = SerialLogBehaviour.reduce(almostFull, SerialConnectionActions.LogLine("new")) assertEquals(500, result.logLines.size) assertEquals("line 0", result.logLines.first()) } @Test fun `Disconnected sets connected to false`() { - val result = reducer(state(connected = true), SerialConnectionActions.Disconnected) + val result = SerialLogBehaviour.reduce(state(connected = true), SerialConnectionActions.Disconnected) assertFalse(result.connected) } } diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt index 7d0cc17dbb..91d7842963 100644 --- a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -10,6 +10,14 @@ import solarxr_protocol.data_feed.StartDataFeed import kotlin.test.Test import kotlin.test.assertEquals +private fun testConn(backgroundScope: kotlinx.coroutines.CoroutineScope, onSend: suspend (ByteArray) -> Unit) = + SolarXRConnection.create( + buildTestVrServer(backgroundScope), + onSend = onSend, + scope = backgroundScope, + behaviours = listOf(DataFeedInitBehaviour), + ) + private fun config(intervalMs: Int) = DataFeedConfig(minimumTimeSinceLast = intervalMs.toUShort()) @OptIn(ExperimentalCoroutinesApi::class) @@ -18,7 +26,7 @@ class DataFeedTest { @Test fun `StartDataFeed sends frames at the configured interval`() = runTest { var sendCount = 0 - val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope) + val conn = testConn(backgroundScope) { sendCount++ } conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100)))) @@ -30,7 +38,7 @@ class DataFeedTest { @Test fun `StartDataFeed with multiple configs runs each at its own frequency`() = runTest { var sendCount = 0 - val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope) + val conn = testConn(backgroundScope) { sendCount++ } conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100), config(200)))) @@ -43,7 +51,7 @@ class DataFeedTest { @Test fun `PollDataFeed sends exactly one frame without starting a repeating timer`() = runTest { var sendCount = 0 - val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope) + val conn = testConn(backgroundScope) { sendCount++ } conn.dataFeedDispatcher.emit(PollDataFeed(config = config(100))) @@ -54,7 +62,7 @@ class DataFeedTest { @Test fun `StartDataFeed cancels old timers when called a second time`() = runTest { var sendCount = 0 - val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope) + val conn = testConn(backgroundScope) { sendCount++ } conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100)))) advanceTimeBy(250) @@ -70,7 +78,7 @@ class DataFeedTest { @Test fun `StartDataFeed with empty list stops all existing timers`() = runTest { var sendCount = 0 - val conn = createSolarXRConnection(buildTestVrServer(backgroundScope), onSend = { sendCount++ }, scope = backgroundScope) + val conn = testConn(backgroundScope) { sendCount++ } conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100)))) advanceTimeBy(250) 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 11f2c740c5..2d09303d77 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -3,13 +3,17 @@ package dev.slimevr.desktop import dev.slimevr.VRServer -import dev.slimevr.config.createAppConfig +import dev.slimevr.config.AppConfig 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.firmware.FirmwareManager import dev.slimevr.resolveConfigDirectory +import dev.slimevr.solarxr.DataFeedInitBehaviour +import dev.slimevr.solarxr.FirmwareBehaviour +import dev.slimevr.solarxr.SerialBehaviour +import dev.slimevr.solarxr.VrcBehaviour import dev.slimevr.solarxr.createSolarXRWebsocketServer import dev.slimevr.udp.createUDPTrackerServer import kotlinx.coroutines.launch @@ -17,27 +21,29 @@ import kotlinx.coroutines.runBlocking fun main(args: Array) = runBlocking { val configFolder = resolveConfigDirectory() ?: error("Unable to resolve config folder") - val config = createAppConfig(this, configFolder = configFolder.toFile()) + val config = AppConfig.create(this, configFolder = configFolder.toFile()) + val server = VRServer.create(this) + val serialServer = createDesktopSerialServer(this) - val firmwareManager = createFirmwareManager(serialServer = serialServer, scope = this) - val vrcConfigManager = createDesktopVRCConfigManager( - config = config, - scope = this, - userHeight = { config.userConfig.context.state.value.data.userHeight.toDouble() }, - ) - val server = VRServer.create(this, serialServer, firmwareManager, vrcConfigManager) + val firmwareManager = FirmwareManager.create(serialServer = serialServer, scope = this) + + val vrcConfigManager = createDesktopVRCConfigManager(config = config, scope = this) launch { createUDPTrackerServer(server, config) } - launch { - createSolarXRWebsocketServer(server) - } - launch { - createIpcServers(server) - } launch { createDesktopHIDManager(server, this) } + + val solarXRBehaviours = listOf( + DataFeedInitBehaviour, + SerialBehaviour(serialServer), + FirmwareBehaviour(firmwareManager), + VrcBehaviour(vrcConfigManager, userHeight = { config.userConfig.context.state.value.data.userHeight.toDouble() }), + ) + launch { createSolarXRWebsocketServer(server, solarXRBehaviours) } + launch { createIpcServers(server, solarXRBehaviours) } + Unit -} +} \ No newline at end of file diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index ea4c9ce176..5d93560d82 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -6,7 +6,6 @@ import dev.slimevr.hid.HID_TRACKER_PID import dev.slimevr.hid.HID_TRACKER_RECEIVER_PID import dev.slimevr.hid.HID_TRACKER_RECEIVER_VID import dev.slimevr.hid.HIDReceiver -import dev.slimevr.hid.createHIDReceiver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -102,7 +101,7 @@ fun createDesktopHIDManager(serverContext: VRServer, scope: CoroutineScope) { } } - val receiver = createHIDReceiver( + val receiver = HIDReceiver.create( serialNumber = serial, data = dataFlow, serverContext = serverContext, diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt index 14d8e1bf95..66060451b3 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt @@ -3,6 +3,7 @@ package dev.slimevr.desktop.ipc import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform import dev.slimevr.VRServer +import dev.slimevr.solarxr.SolarXRConnectionBehaviour import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -14,18 +15,18 @@ const val DRIVER_PIPE = "\\\\.\\pipe\\SlimeVRDriver" const val FEEDER_PIPE = "\\\\.\\pipe\\SlimeVRInput" const val SOLARXR_PIPE = "\\\\.\\pipe\\SlimeVRRpc" -suspend fun createIpcServers(server: VRServer) = coroutineScope { +suspend fun createIpcServers(server: VRServer, behaviours: List) = coroutineScope { when (CURRENT_PLATFORM) { Platform.LINUX, Platform.OSX -> { launch { createUnixDriverSocket(server) } launch { createUnixFeederSocket(server) } - launch { createUnixSolarXRSocket(server) } + launch { createUnixSolarXRSocket(server, behaviours) } } Platform.WINDOWS -> { launch { createWindowsDriverPipe(server) } launch { createWindowsFeederPipe(server) } - launch { createWindowsSolarXRPipe(server) } + launch { createWindowsSolarXRPipe(server, behaviours) } } else -> Unit diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index 05fd369ee2..3b3494ce32 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -2,6 +2,7 @@ package dev.slimevr.desktop.ipc import dev.slimevr.VRServer import dev.slimevr.getSocketDirectory +import dev.slimevr.solarxr.SolarXRConnectionBehaviour import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn @@ -32,11 +33,12 @@ suspend fun createUnixFeederSocket(server: VRServer) = acceptUnixClients(FEEDER_ ) } -suspend fun createUnixSolarXRSocket(server: VRServer) = acceptUnixClients(SOLARXR_SOCKET_NAME) { channel -> +suspend fun createUnixSolarXRSocket(server: VRServer, behaviours: List) = acceptUnixClients(SOLARXR_SOCKET_NAME) { channel -> handleSolarXRConnection( server = server, messages = readFramedMessages(channel), send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, + behaviours = behaviours ) } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 38e758f0e8..6ab5e17e52 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -6,13 +6,14 @@ import dev.slimevr.desktop.platform.Position import dev.slimevr.desktop.platform.ProtobufMessage import dev.slimevr.desktop.platform.TrackerAdded import dev.slimevr.desktop.platform.Version -import dev.slimevr.solarxr.createSolarXRConnection +import dev.slimevr.solarxr.SolarXRConnectionBehaviour +import dev.slimevr.solarxr.SolarXRConnection import dev.slimevr.solarxr.onSolarXRMessage import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.TrackerActions -import dev.slimevr.device.createDevice -import dev.slimevr.tracker.createTracker +import dev.slimevr.device.Device +import dev.slimevr.tracker.Tracker import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow @@ -112,26 +113,24 @@ suspend fun handleFeederConnection( server.getDevice(existingTracker.context.state.value.deviceId) ?: error("could not find existing device") } else { val deviceId = server.nextHandle() - val newDevice = createDevice( + val newDevice = Device.create( scope = this, id = deviceId, address = serial, macAddress = serial, // FIXME: prob not correct origin = DeviceOrigin.FEEDER, protocolVersion = protocolVersion, - serverContext = server, ) server.context.dispatch(VRServerActions.NewDevice(deviceId, newDevice)) val trackerId = server.nextHandle() - val tracker = createTracker( + val tracker = Tracker.create( scope = this, id = trackerId, deviceId = deviceId, sensorType = ImuType.MPU9250, // TODO: prob need to make sensor type optional hardwareId = serial, origin = DeviceOrigin.FEEDER, - serverContext = server, ) server.context.dispatch(VRServerActions.NewTracker(trackerId, tracker)) @@ -165,11 +164,13 @@ suspend fun handleSolarXRConnection( server: VRServer, messages: Flow, send: suspend (ByteArray) -> Unit, + behaviours: List, ) = coroutineScope { - val connection = createSolarXRConnection( + val connection = SolarXRConnection.create( serverContext = server, scope = this, onSend = send, + behaviours = behaviours, ) messages.collect { bytes -> diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt index 4c89afb747..190b911ded 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt @@ -7,6 +7,7 @@ import com.sun.jna.platform.win32.WinError import com.sun.jna.platform.win32.WinNT import com.sun.jna.ptr.IntByReference import dev.slimevr.VRServer +import dev.slimevr.solarxr.SolarXRConnectionBehaviour import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn @@ -35,11 +36,12 @@ suspend fun createWindowsFeederPipe(server: VRServer) = acceptWindowsClients(FEE ) } -suspend fun createWindowsSolarXRPipe(server: VRServer) = acceptWindowsClients(SOLARXR_PIPE) { handle -> +suspend fun createWindowsSolarXRPipe(server: VRServer, behaviours: List) = acceptWindowsClients(SOLARXR_PIPE) { handle -> handleSolarXRConnection( server = server, messages = readFramedMessages(handle), send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, + behaviours = behaviours ) } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt b/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt index 552e2357b3..2af92a6d86 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt @@ -104,7 +104,11 @@ private suspend fun runSerialPoller(server: SerialServer) { } fun createDesktopSerialServer(scope: CoroutineScope): SerialServer { - val server = SerialServer.create(openPort = ::openPort, openFlashingPort = { DesktopFlashingHandler() }, scope = scope) + val server = SerialServer.create( + openPort = { portLocation, onDataReceived, onPortDisconnected -> openPort(portLocation, scope, onDataReceived, onPortDisconnected) }, + openFlashingPort = { DesktopFlashingHandler() }, + scope = scope, + ) scope.launch { runSerialPoller(server) } return server } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt index 7bb0af89fa..f429f969e8 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt @@ -4,7 +4,6 @@ import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform import dev.slimevr.config.AppConfig import dev.slimevr.vrchat.VRCConfigManager -import dev.slimevr.vrchat.createVRCConfigManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.emptyFlow import solarxr_protocol.rpc.VRCAvatarMeasurementType @@ -14,26 +13,23 @@ import solarxr_protocol.rpc.VRCTrackerModel internal const val VRC_REG_PATH = "Software\\VRChat\\VRChat" -fun createDesktopVRCConfigManager(config: AppConfig, scope: CoroutineScope, userHeight: () -> Double): VRCConfigManager = +fun createDesktopVRCConfigManager(config: AppConfig, scope: CoroutineScope): VRCConfigManager = when (CURRENT_PLATFORM) { - Platform.WINDOWS -> createVRCConfigManager( + Platform.WINDOWS -> VRCConfigManager.create( config = config, scope = scope, - userHeight = userHeight, isSupported = true, values = windowsVRCConfigFlow(), ) - Platform.LINUX -> createVRCConfigManager( + Platform.LINUX -> VRCConfigManager.create( config = config, scope = scope, - userHeight = userHeight, isSupported = true, values = linuxVRCConfigFlow(), ) - else -> createVRCConfigManager( + else -> VRCConfigManager.create( config = config, scope = scope, - userHeight = userHeight, isSupported = false, values = emptyFlow(), ) From 38743dc8b8ff4669c424d2501b638c9587eed0ea Mon Sep 17 00:00:00 2001 From: loucass003 Date: Fri, 27 Mar 2026 05:55:10 +0100 Subject: [PATCH 025/350] organise more stuff --- server/README.md | 192 +++++++++++------- .../main/java/dev/slimevr/config/settings.kt | 5 +- .../src/main/java/dev/slimevr/config/user.kt | 5 +- .../main/java/dev/slimevr/solarxr/module.kt | 17 +- .../java/dev/slimevr/solarxr/ws-server.kt | 15 -- .../src/main/java/dev/slimevr/vrserver.kt | 9 +- 6 files changed, 148 insertions(+), 95 deletions(-) diff --git a/server/README.md b/server/README.md index 58001d44cd..c0c605a414 100644 --- a/server/README.md +++ b/server/README.md @@ -20,17 +20,18 @@ This is not accidental. It gives us: The `Context` type (`context/context.kt`) is the building block of every module: ```kotlin -data class Context( - val state: StateFlow, // current state, readable by anyone - val dispatch: suspend (A) -> Unit, - val dispatchAll: suspend (List) -> Unit, - val scope: CoroutineScope, // lifetime of this module -) +class Context( + val state: StateFlow, // current state, readable by anyone + val scope: CoroutineScope, // lifetime of this module +) { + fun dispatch(action: A) + fun dispatchAll(actions: List) +} ``` -`createContext` wires everything together: -1. Takes an `initialState` and a list of **reducers** (`(S, A) -> S`) -2. On each `dispatch`, folds all reducers over the current state +`Context.create` wires everything together: +1. Takes an `initialState` and a list of **behaviours** +2. On each `dispatch`, folds all behaviours' `reduce` over the current state 3. Publishes the new state on the `StateFlow` **Never mutate state directly.** Always go through `dispatch`. @@ -39,38 +40,93 @@ data class Context( ## Behaviours: Splitting Concerns -A `Behaviour` bundles two optional pieces: +A `Behaviour` is an interface with two methods, both with no-op defaults: ```kotlin -data class BasicBehaviour( - val reducer: ((S, A) -> S)? = null, - val observer: ((Context) -> Unit)? = null, -) +interface Behaviour { + fun reduce(state: S, action: A): S = state + fun observe(receiver: C) {} +} ``` -- **`reducer`**: Pure function. Handles the actions it cares about, returns the rest unchanged. -- **`observer`**: Called once at construction. Launches coroutines, registers event listeners, subscribes to other state flows. +- **`reduce`**: Pure function. Handles the actions it cares about, returns the rest unchanged. Override only if the behaviour needs to modify state. +- **`observe`**: Called once at construction. Launches coroutines, registers event listeners, subscribes to other state flows. Override only if the behaviour has side effects. -Use `CustomBehaviour` when the observer needs access to more than just the context — for example, a `UDPConnection` behaviour needs the connection's `send` function and `PacketDispatcher`, not just its `Context`. +The type parameter `C` is what the observer receives. For modules where the behaviour only needs the context, `C = Context`. For modules where behaviours need access to the full service object (its `send` method, dispatchers, etc.), `C` is the service class itself: + +```kotlin +// Observer receives only the context +typealias DeviceBehaviour = Behaviour + +// Observer receives the full connection object +typealias UDPConnectionBehaviour = Behaviour +``` Every module follows the same construction pattern: ```kotlin val behaviours = listOf(BehaviourA, BehaviourB, BehaviourC) -val context = createContext( - initialState = ..., - reducers = behaviours.map { it.reducer }, - scope = scope, +val context = Context.create( + initialState = ..., + scope = scope, + behaviours = behaviours, ) -behaviours.map { it.observer }.forEach { it?.invoke(context) } +val module = MyModule(context, ...) +behaviours.forEach { it.observe(module) } // or it.observe(context) for basic modules ``` This is where observers are started. Order matters for reducers (applied top-to-bottom), but rarely matters for observers. --- +## Behaviour File Layout + +Behaviours live in their own `behaviours.kt` file, separate from the module they belong to, within the same package: + +``` +udp/ +├── behaviours.kt ← PacketBehaviour, PingBehaviour, HandshakeBehaviour, … +├── connection.kt ← UDPConnection class, state, actions, typealias +└── packets.kt ← packet type definitions +``` + +Group behaviours that share the same receiver type in a single file. Behaviours with dependencies on external services (e.g. `SerialBehaviour`, `FirmwareBehaviour`) are standalone classes — one per file is fine when they have distinct concerns. + +--- + +## Stateless vs. Stateful Behaviours + +**Stateless behaviours** (no dependencies at construction time) are `object`s: + +```kotlin +object PacketBehaviour : UDPConnectionBehaviour { + override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { + is UDPConnectionActions.LastPacket -> state.copy(...) + else -> state + } + override fun observe(receiver: UDPConnection) { ... } +} +``` + +**Behaviours with dependencies** are classes, constructed at the call site: + +```kotlin +class FirmwareBehaviour(private val firmwareManager: FirmwareManager) : SolarXRConnectionBehaviour { + override fun observe(receiver: SolarXRConnection) { ... } +} + +// At the call site: +listOf( + DataFeedInitBehaviour, + FirmwareBehaviour(firmwareManager), + SerialBehaviour(serialServer), +) +``` + +--- + ## Actions Actions are `sealed interface`s with `data class` variants. This means: @@ -85,7 +141,7 @@ Use `data class Update(val transform: State.() -> State)` when you need a flexib ## The PacketDispatcher Pattern -`PacketDispatcher` (`solarxr/solarxr.kt`) routes incoming messages to typed listeners without a giant `when` block: +`PacketDispatcher` routes incoming messages to typed listeners without a giant `when` block: ```kotlin dispatcher.on { packet -> /* only called for SensorInfo */ } @@ -93,14 +149,14 @@ dispatcher.onAny { packet -> /* called for everything */ } dispatcher.emit(packet) // routes to correct listeners ``` -Use this wherever you have a stream of heterogeneous messages (UDP packets, SolarXR messages). Each behaviour registers its own listener in its `observer` — the dispatcher is passed as part of the module struct. +Use this wherever you have a stream of heterogeneous messages (UDP packets, SolarXR messages). Each behaviour registers its own listener in its `observe` — the dispatcher is passed as part of the module struct. --- ## Coroutines and Lifetime - Every module is given a `CoroutineScope` at creation. Cancelling that scope tears down all coroutines the module launched. -- Observers should use `it.context.scope.launch { ... }` so their work is scoped to the module. +- Observers should use `receiver.context.scope.launch { ... }` so their work is scoped to the module. - Blocking I/O goes on `Dispatchers.IO`. State updates and logic stay on the default dispatcher. - **Avoid `runBlocking`** inside observers or handlers — it blocks the coroutine thread. The one acceptable use is synchronous listener registration before a scope is started. @@ -124,59 +180,59 @@ To add a new major section of the server (say, a HID device connection): 1. **Define the state**: ```kotlin data class HIDConnectionState( - val deviceId: Int?, - val connected: Boolean, + val deviceId: Int?, + val connected: Boolean, ) ``` 2. **Define sealed actions**: ```kotlin sealed interface HIDConnectionActions { - data class Connected(val deviceId: Int) : HIDConnectionActions - data object Disconnected : HIDConnectionActions + data class Connected(val deviceId: Int) : HIDConnectionActions + data object Disconnected : HIDConnectionActions } ``` 3. **Create type aliases** (keeps signatures readable): ```kotlin typealias HIDConnectionContext = Context -typealias HIDConnectionBehaviour = CustomBehaviour +typealias HIDConnectionBehaviour = Behaviour ``` -4. **Define the module struct** (holds context + extra runtime state): +4. **Define the module class** (holds context + extra runtime state): ```kotlin -data class HIDConnection( - val context: HIDConnectionContext, - val serverContext: VRServer, - val send: suspend (ByteArray) -> Unit, -) +class HIDConnection( + val context: HIDConnectionContext, + val serverContext: VRServer, + private val onSend: suspend (ByteArray) -> Unit, +) { + suspend fun send(bytes: ByteArray) = onSend(bytes) +} ``` -5. **Write behaviours** as top-level `val`s (stateless, reusable): +5. **Write behaviours** in a separate `behaviours.kt` file: ```kotlin -val HIDHandshakeBehaviour = HIDConnectionBehaviour( - reducer = { s, a -> when (a) { - is HIDConnectionActions.Connected -> s.copy(deviceId = a.deviceId, connected = true) - is HIDConnectionActions.Disconnected -> s.copy(connected = false) - }}, - observer = { conn -> - // launch coroutines, subscribe to events, etc. - } -) +object HIDHandshakeBehaviour : HIDConnectionBehaviour { + override fun reduce(state: HIDConnectionState, action: HIDConnectionActions) = when (action) { + is HIDConnectionActions.Connected -> state.copy(deviceId = action.deviceId, connected = true) + is HIDConnectionActions.Disconnected -> state.copy(connected = false) + } + override fun observe(receiver: HIDConnection) { + // launch coroutines, subscribe to events, etc. + } +} ``` -6. **Write a factory function**: +6. **Write a `companion object { fun create() }`**: ```kotlin -fun createHIDConnection( - serverContext: VRServer, - scope: CoroutineScope, - send: suspend (ByteArray) -> Unit, -): HIDConnection { - val behaviours = listOf(HIDHandshakeBehaviour, ...) - val context = createContext(initialState = ..., reducers = behaviours.map { it.reducer }, scope = scope) - val conn = HIDConnection(context, serverContext, send) - behaviours.map { it.observer }.forEach { it?.invoke(conn) } - return conn +companion object { + fun create(serverContext: VRServer, scope: CoroutineScope, send: suspend (ByteArray) -> Unit): HIDConnection { + val behaviours = listOf(HIDHandshakeBehaviour, ...) + val context = Context.create(initialState = ..., scope = scope, behaviours = behaviours) + val conn = HIDConnection(context, serverContext, send) + behaviours.forEach { it.observe(conn) } + return conn + } } ``` @@ -184,7 +240,7 @@ fun createHIDConnection( ## Adding a New Behaviour to an Existing Module -Find the existing `createX` function, add your behaviour to the `behaviours` list. That's it. The behaviour's reducer and observer are automatically picked up. +Find the `create` function, add your behaviour to the `behaviours` list. That's it. The behaviour's `reduce` and `observe` are automatically picked up. Example: adding battery tracking to a HID connection requires only adding a `HIDBatteryBehaviour` to the list — nothing else changes. @@ -192,14 +248,14 @@ Example: adding battery tracking to a HID connection requires only adding a `HID ## Adding a New UDP Packet Type -1. Add the packet class and its `read` function in `tracker/udp/packets.kt` -2. In the relevant behaviour's observer, register a listener: +1. Add the packet class and its `read` function in `udp/packets.kt` +2. In a behaviour's `observe`, register a listener: ```kotlin -connection.packetEvents.on { event -> - // handle it +receiver.packetEvents.on { event -> + // handle it } ``` -3. In `tracker/udp/server.kt`, route the new packet type to `emit`. +3. In `udp/server.kt`, route the new packet type to `emit`. --- @@ -241,8 +297,8 @@ Each client runs in its own `launch` block. When the socket disconnects, the cor |---|---| | `server/core` | Protocol-agnostic business logic (trackers, devices, config, SolarXR) | | `server/desktop` | Platform-specific entry point, IPC socket wiring, platform abstractions | -| `context/context.kt` | The `Context` / `Behaviour` / `createContext` primitives — do not add domain logic here | -| `tracker/udp/` | Everything specific to the SlimeVR UDP wire protocol | +| `context/context.kt` | The `Context` / `Behaviour` primitives — do not add domain logic here | +| `udp/` | Everything specific to the SlimeVR UDP wire protocol | | `solarxr/` | SolarXR WebSocket server + FlatBuffers message handling | | `config/` | JSON config read/write with autosave; no business logic | | `firmware/` | OTA update and serial flash logic; interacts with devices over the network, independent of the UDP tracker protocol | @@ -253,8 +309,8 @@ Each client runs in its own `launch` block. When the socket disconnects, the cor - **Limit OOP to strictly necessary cases.** Prefer plain functions, function types, and data classes. Avoid classes and inheritance unless there is a genuine need for encapsulated mutable state or polymorphism. A single-method interface should almost always be a function type instead (`() -> Unit`, `suspend (String) -> Unit`, etc.). When in doubt, write a function. - **Prefer plain functions over extension functions.** Only use extension functions when the receiver type is genuinely the primary subject and the function would be confusing without it. -- Behaviours are top-level `val`s (or `object`s if they have no type parameters), not inner classes. -- Factory functions are named `createX`, not `XBuilder` or `X.create` (though `companion object { fun create() }` is acceptable when scoping makes it clearer, as in `VRServer.create`). +- Behaviours are `object`s (no dependencies) or `class`es (with dependencies), defined in a dedicated `behaviours.kt` file in the same package as the module they belong to. +- Module creation lives in `companion object { fun create(...) }`. - State data classes use `copy(...)` inside reducers and `Update { copy(...) }` actions — never expose a `MutableStateFlow` directly. - **Never use `var` in a state data class** — state must be immutable, all fields `val`. Using `var` in any data class is almost certainly a design mistake; if you need mutable fields, prefer a plain class or rethink the structure. -- Use `sealed interface` for action types, not `sealed class`, to avoid the extra constructor overhead. +- Use `sealed interface` for action types, not `sealed class`, to avoid the extra constructor overhead. \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 2848cd1a8e..563dcc0b94 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -50,7 +50,6 @@ typealias SettingsBehaviour = Behaviour, @@ -26,6 +27,20 @@ sealed interface SolarXRConnectionActions { typealias SolarXRConnectionContext = Context typealias SolarXRConnectionBehaviour = Behaviour +suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRConnection) { + val messageBundle = MessageBundle.fromByteBuffer(message) + + messageBundle.dataFeedMsgs?.forEach { + val msg = it.message ?: return + context.dataFeedDispatcher.emit(msg) + } + + messageBundle.rpcMsgs?.forEach { + val msg = it.message ?: return + context.rpcDispatcher.emit(msg) + } +} + class SolarXRConnection( val context: SolarXRConnectionContext, val serverContext: VRServer, @@ -68,4 +83,4 @@ class SolarXRConnection( return conn } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt b/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt index 5e3e053f08..c4e5593c9f 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt @@ -15,21 +15,6 @@ import solarxr_protocol.rpc.ResetRequest import java.nio.ByteBuffer const val SOLARXR_PORT = 21110 - -suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRConnection) { - val messageBundle = MessageBundle.fromByteBuffer(message) - - messageBundle.dataFeedMsgs?.forEach { - val msg = it.message ?: return - context.dataFeedDispatcher.emit(msg) - } - - messageBundle.rpcMsgs?.forEach { - val msg = it.message ?: return - context.rpcDispatcher.emit(msg) - } -} - suspend fun createSolarXRWebsocketServer(serverContext: VRServer, behaviours: List) { val engine = embeddedServer(Netty, port = SOLARXR_PORT) { install(WebSockets) diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index 5c1507dc52..1aec22fd43 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -25,10 +25,9 @@ typealias VRServerBehaviour = Behaviour Date: Fri, 27 Mar 2026 05:58:46 +0100 Subject: [PATCH 026/350] Format --- .../src/main/java/dev/slimevr/behaviours.kt | 2 +- .../java/dev/slimevr/config/behaviours.kt | 2 +- .../main/java/dev/slimevr/config/module.kt | 2 +- .../main/java/dev/slimevr/context/context.kt | 2 +- .../java/dev/slimevr/device/behaviours.kt | 5 +-- .../main/java/dev/slimevr/device/module.kt | 2 +- .../java/dev/slimevr/firmware/behaviours.kt | 19 ++++---- .../main/java/dev/slimevr/firmware/module.kt | 2 +- .../main/java/dev/slimevr/hid/behaviours.kt | 19 +++++--- .../src/main/java/dev/slimevr/hid/module.kt | 2 +- .../src/main/java/dev/slimevr/hid/packets.kt | 3 +- .../java/dev/slimevr/serial/behaviours.kt | 2 +- .../java/dev/slimevr/serial/connection.kt | 2 +- .../main/java/dev/slimevr/serial/module.kt | 2 +- .../main/java/dev/slimevr/solarxr/datafeed.kt | 2 +- .../main/java/dev/slimevr/solarxr/firmware.kt | 2 +- .../main/java/dev/slimevr/solarxr/serial.kt | 2 +- .../main/java/dev/slimevr/solarxr/vrchat.kt | 2 +- .../java/dev/slimevr/tracker/behaviours.kt | 5 +-- .../main/java/dev/slimevr/tracker/module.kt | 2 +- .../main/java/dev/slimevr/udp/behaviours.kt | 3 +- .../main/java/dev/slimevr/udp/connection.kt | 2 +- .../java/dev/slimevr/vrchat/behaviours.kt | 21 ++++++--- .../main/java/dev/slimevr/vrchat/module.kt | 27 ++++++------ .../src/test/java/dev/slimevr/TestServer.kt | 2 +- .../dev/slimevr/firmware/DoSerialFlashTest.kt | 8 ++-- .../java/dev/slimevr/solarxr/DataFeedTest.kt | 13 +++--- .../src/main/java/dev/slimevr/desktop/Main.kt | 2 +- .../main/java/dev/slimevr/desktop/hid/hid.kt | 26 +++++++---- .../java/dev/slimevr/desktop/ipc/linux.kt | 2 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 10 ++--- .../java/dev/slimevr/desktop/ipc/windows.kt | 2 +- .../java/dev/slimevr/desktop/vrchat/linux.kt | 22 +++++++--- .../dev/slimevr/desktop/vrchat/vrc-config.kt | 43 ++++++++++--------- 34 files changed, 145 insertions(+), 119 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/behaviours.kt b/server/core/src/main/java/dev/slimevr/behaviours.kt index 20ed766c2d..3ce833d22f 100644 --- a/server/core/src/main/java/dev/slimevr/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/behaviours.kt @@ -15,4 +15,4 @@ object BaseBehaviour : VRServerBehaviour { println("tracker list size changed") }.launchIn(receiver.context.scope) } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/config/behaviours.kt b/server/core/src/main/java/dev/slimevr/config/behaviours.kt index 7134bdbe1c..c7d6a503c2 100644 --- a/server/core/src/main/java/dev/slimevr/config/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/config/behaviours.kt @@ -19,4 +19,4 @@ object DefaultUserBehaviour : UserConfigBehaviour { is UserConfigActions.Update -> state.copy(data = action.transform(state.data)) is UserConfigActions.LoadProfile -> action.newState } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/config/module.kt b/server/core/src/main/java/dev/slimevr/config/module.kt index 8325ff23e5..96a88e91bf 100644 --- a/server/core/src/main/java/dev/slimevr/config/module.kt +++ b/server/core/src/main/java/dev/slimevr/config/module.kt @@ -87,4 +87,4 @@ class AppConfig( ) } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index 68f96f68ec..abcddfd89f 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -41,4 +41,4 @@ class Context( return Context(mutableStateFlow, applyAction, scope) } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/device/behaviours.kt b/server/core/src/main/java/dev/slimevr/device/behaviours.kt index 8a418f94a9..064c19384f 100644 --- a/server/core/src/main/java/dev/slimevr/device/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/device/behaviours.kt @@ -4,12 +4,11 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach object DeviceStatsBehaviour : DeviceBehaviour { - override fun reduce(state: DeviceState, action: DeviceActions) = - if (action is DeviceActions.Update) action.transform(state) else state + override fun reduce(state: DeviceState, action: DeviceActions) = if (action is DeviceActions.Update) action.transform(state) else state override fun observe(receiver: DeviceContext) { receiver.state.onEach { // AppLogger.device.info("Device state changed", it) }.launchIn(receiver.scope) } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/device/module.kt b/server/core/src/main/java/dev/slimevr/device/module.kt index 8869db614c..5c33d4f005 100644 --- a/server/core/src/main/java/dev/slimevr/device/module.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -73,4 +73,4 @@ class Device( return Device(context = context) } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt b/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt index fada70219c..0dfee283e5 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt @@ -3,16 +3,17 @@ package dev.slimevr.firmware object FirmwareManagerBaseBehaviour : FirmwareManagerBehaviour { override fun reduce(state: FirmwareManagerState, action: FirmwareManagerActions) = when (action) { is FirmwareManagerActions.UpdateJob -> state.copy( - jobs = state.jobs + ( - action.portLocation to FirmwareJobStatus( - portLocation = action.portLocation, - firmwareDeviceId = action.firmwareDeviceId, - status = action.status, - progress = action.progress, - ) - ), + jobs = state.jobs + + ( + action.portLocation to FirmwareJobStatus( + portLocation = action.portLocation, + firmwareDeviceId = action.firmwareDeviceId, + status = action.status, + progress = action.progress, + ) + ), ) is FirmwareManagerActions.RemoveJob -> state.copy(jobs = state.jobs - action.portLocation) } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/firmware/module.kt b/server/core/src/main/java/dev/slimevr/firmware/module.kt index 1441e74def..203d23894f 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/module.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/module.kt @@ -124,4 +124,4 @@ class FirmwareManager( return manager } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 704894cc88..2a8d14a45e 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -12,13 +12,17 @@ import solarxr_protocol.datatypes.TrackerStatus object HIDRegistrationBehaviour : HIDReceiverBehaviour { override fun reduce(state: HIDReceiverState, action: HIDReceiverActions) = when (action) { is HIDReceiverActions.DeviceRegistered -> state.copy( - trackers = state.trackers + (action.hidId to HIDTrackerRecord( - hidId = action.hidId, - address = action.address, - deviceId = action.deviceId, - trackerId = null, - )), + trackers = state.trackers + + ( + action.hidId to HIDTrackerRecord( + hidId = action.hidId, + address = action.address, + deviceId = action.deviceId, + trackerId = null, + ) + ), ) + else -> state } @@ -59,6 +63,7 @@ object HIDDeviceInfoBehaviour : HIDReceiverBehaviour { val existing = state.trackers[action.hidId] ?: return state state.copy(trackers = state.trackers + (action.hidId to existing.copy(trackerId = action.trackerId))) } + else -> state } @@ -163,4 +168,4 @@ object HIDStatusBehaviour : HIDReceiverBehaviour { ) } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/hid/module.kt b/server/core/src/main/java/dev/slimevr/hid/module.kt index 238f9fa766..db61b99b0e 100644 --- a/server/core/src/main/java/dev/slimevr/hid/module.kt +++ b/server/core/src/main/java/dev/slimevr/hid/module.kt @@ -117,4 +117,4 @@ class HIDReceiver( return receiver } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/hid/packets.kt b/server/core/src/main/java/dev/slimevr/hid/packets.kt index 112260781d..86ca277fb7 100644 --- a/server/core/src/main/java/dev/slimevr/hid/packets.kt +++ b/server/core/src/main/java/dev/slimevr/hid/packets.kt @@ -77,8 +77,7 @@ data class HIDRotationButton( val rssi: Int, ) : HIDPacket -private fun readLE16Signed(data: ByteArray, offset: Int): Int = - data[offset + 1].toInt() shl 8 or data[offset].toUByte().toInt() +private fun readLE16Signed(data: ByteArray, offset: Int): Int = data[offset + 1].toInt() shl 8 or data[offset].toUByte().toInt() private fun decodeQ15Quat(data: ByteArray, offset: Int): Quaternion { val scale = 1f / 32768f diff --git a/server/core/src/main/java/dev/slimevr/serial/behaviours.kt b/server/core/src/main/java/dev/slimevr/serial/behaviours.kt index 403c5c8176..b25d2b152c 100644 --- a/server/core/src/main/java/dev/slimevr/serial/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/serial/behaviours.kt @@ -27,4 +27,4 @@ object SerialLogBehaviour : SerialConnectionBehaviour { SerialConnectionActions.Disconnected -> state.copy(connected = false) } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/serial/connection.kt b/server/core/src/main/java/dev/slimevr/serial/connection.kt index dffa104fdc..520dd178c6 100644 --- a/server/core/src/main/java/dev/slimevr/serial/connection.kt +++ b/server/core/src/main/java/dev/slimevr/serial/connection.kt @@ -52,4 +52,4 @@ sealed interface SerialConnection { } data object Flashing : SerialConnection -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/serial/module.kt b/server/core/src/main/java/dev/slimevr/serial/module.kt index 22d327dc94..7580a834c5 100644 --- a/server/core/src/main/java/dev/slimevr/serial/module.kt +++ b/server/core/src/main/java/dev/slimevr/serial/module.kt @@ -123,4 +123,4 @@ class SerialServer( return server } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 3ce91ab365..25358d3ea5 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -147,4 +147,4 @@ object DataFeedInitBehaviour : SolarXRConnectionBehaviour { receiver.send(fbb.dataBuffer().moveToByteArray()) } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt index c0218676e1..d4449b4ffd 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt @@ -69,4 +69,4 @@ class FirmwareBehaviour(private val firmwareManager: FirmwareManager) : SolarXRC firmwareManager.cancelAll() } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt index 99332c7f59..c34a8d1b47 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt @@ -129,4 +129,4 @@ class SerialBehaviour(private val serialServer: SerialServer) : SolarXRConnectio if (c is SerialConnection.Console) c.handle.writeCommand(command) } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt index ff5ed5d41b..4be41532d2 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt @@ -44,4 +44,4 @@ class VrcBehaviour( vrcManager.context.dispatch(VRCConfigActions.ToggleMutedWarning(key)) } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index ff2bab5a8b..7d42851159 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -4,12 +4,11 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach object TrackerInfosBehaviour : TrackerBehaviour { - override fun reduce(state: TrackerState, action: TrackerActions) = - if (action is TrackerActions.Update) action.transform(state) else state + override fun reduce(state: TrackerState, action: TrackerActions) = if (action is TrackerActions.Update) action.transform(state) else state override fun observe(receiver: TrackerContext) { receiver.state.onEach { // AppLogger.tracker.info("Tracker state changed {State}", it) }.launchIn(receiver.scope) } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 5a4d1d29f4..a816e584dd 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -59,4 +59,4 @@ class Tracker( return Tracker(context = context) } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index eecada3778..393178423e 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -22,6 +22,7 @@ object PacketBehaviour : UDPConnectionBehaviour { if (action.packetNum != null) newState = newState.copy(lastPacketNum = action.packetNum) newState } + else -> state } @@ -225,4 +226,4 @@ object SensorRotationBehaviour : UDPConnectionBehaviour { tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = event.data.rotation) }) } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index f716003f07..5d0abb8fec 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -144,4 +144,4 @@ class UDPConnection( return conn } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt b/server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt index 7128f8166b..848ad692b1 100644 --- a/server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt @@ -9,18 +9,25 @@ import kotlinx.coroutines.flow.onEach object DefaultVRCConfigBehaviour : VRCConfigBehaviour { override fun reduce(state: VRCConfigState, action: VRCConfigActions) = when (action) { is VRCConfigActions.UpdateValues -> state.copy(currentValues = action.values) + is VRCConfigActions.ToggleMutedWarning -> { - if (action.key !in VRC_VALID_KEYS) state - else if (action.key in state.mutedWarnings) state.copy(mutedWarnings = state.mutedWarnings - action.key) - else state.copy(mutedWarnings = state.mutedWarnings + action.key) + if (action.key !in VRC_VALID_KEYS) { + state + } else if (action.key in state.mutedWarnings) { + state.copy(mutedWarnings = state.mutedWarnings - action.key) + } else { + state.copy(mutedWarnings = state.mutedWarnings + action.key) + } } } override fun observe(receiver: VRCConfigManager) { receiver.context.state.map { it.mutedWarnings }.distinctUntilChanged().onEach { warnings -> - receiver.config.settings.context.dispatch(SettingsActions.Update { - copy(mutedVRCWarnings = warnings) - }) + receiver.config.settings.context.dispatch( + SettingsActions.Update { + copy(mutedVRCWarnings = warnings) + }, + ) }.launchIn(receiver.context.scope) } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/vrchat/module.kt b/server/core/src/main/java/dev/slimevr/vrchat/module.kt index a4b733e4f8..6225ad2c4e 100644 --- a/server/core/src/main/java/dev/slimevr/vrchat/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrchat/module.kt @@ -94,8 +94,8 @@ fun computeRecommendedValues(server: VRServer, userHeight: Double): VRCConfigRec return VRCConfigRecommendedValues( legacyMode = false, shoulderTrackingDisabled = - (!hasLeftHandWithPosition || !hasRightHandWithPosition || isMissingAnArmTracker) && - ((hasLeftHandWithPosition && hasRightHandWithPosition) || isMissingAShoulderTracker), + (!hasLeftHandWithPosition || !hasRightHandWithPosition || isMissingAnArmTracker) && + ((hasLeftHandWithPosition && hasRightHandWithPosition) || isMissingAShoulderTracker), userHeight = userHeight.toFloat(), calibrationRange = 0.2f, trackerModel = VRCTrackerModel.AXIS, @@ -106,15 +106,14 @@ fun computeRecommendedValues(server: VRServer, userHeight: Double): VRCConfigRec ) } -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, - ) \ No newline at end of file +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, +) diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index d508baea10..8de6c05f67 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -24,4 +24,4 @@ fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create( scope = scope, ) -fun buildTestVrServer(scope: CoroutineScope): VRServer = VRServer.create(scope) \ No newline at end of file +fun buildTestVrServer(scope: CoroutineScope): VRServer = VRServer.create(scope) diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt index 33f751ed0e..faaaa95c8e 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -3,12 +3,12 @@ package dev.slimevr.firmware import dev.llelievr.espflashkotlin.FlasherSerialInterface import dev.slimevr.VRServer import dev.slimevr.VRServerActions +import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions +import dev.slimevr.device.DeviceOrigin import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialPortInfo import dev.slimevr.serial.SerialServer -import dev.slimevr.device.DeviceOrigin -import dev.slimevr.device.Device import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -59,9 +59,7 @@ private fun buildSerialServer( // UncompletedCoroutinesError when the test ends. private fun buildVrServer( backgroundScope: kotlinx.coroutines.CoroutineScope, -): VRServer { - return VRServer.create(backgroundScope) -} +): VRServer = VRServer.create(backgroundScope) class DoSerialFlashTest { diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt index 91d7842963..cd561cff49 100644 --- a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -10,13 +10,12 @@ import solarxr_protocol.data_feed.StartDataFeed import kotlin.test.Test import kotlin.test.assertEquals -private fun testConn(backgroundScope: kotlinx.coroutines.CoroutineScope, onSend: suspend (ByteArray) -> Unit) = - SolarXRConnection.create( - buildTestVrServer(backgroundScope), - onSend = onSend, - scope = backgroundScope, - behaviours = listOf(DataFeedInitBehaviour), - ) +private fun testConn(backgroundScope: kotlinx.coroutines.CoroutineScope, onSend: suspend (ByteArray) -> Unit) = SolarXRConnection.create( + buildTestVrServer(backgroundScope), + onSend = onSend, + scope = backgroundScope, + behaviours = listOf(DataFeedInitBehaviour), +) private fun config(intervalMs: Int) = DataFeedConfig(minimumTimeSinceLast = intervalMs.toUShort()) 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 2d09303d77..ca29015309 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -46,4 +46,4 @@ fun main(args: Array) = runBlocking { launch { createIpcServers(server, solarXRBehaviours) } Unit -} \ No newline at end of file +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index 5d93560d82..5c5bad1ddc 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -2,10 +2,10 @@ package dev.slimevr.desktop.hid import dev.slimevr.AppLogger import dev.slimevr.VRServer +import dev.slimevr.hid.HIDReceiver import dev.slimevr.hid.HID_TRACKER_PID import dev.slimevr.hid.HID_TRACKER_RECEIVER_PID import dev.slimevr.hid.HID_TRACKER_RECEIVER_VID -import dev.slimevr.hid.HIDReceiver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -23,8 +23,7 @@ import org.hid4java.jna.HidDeviceInfoStructure private const val HID_POLL_INTERVAL_MS = 3000L -private fun isCompatibleDevice(vid: Int, pid: Int) = - vid == HID_TRACKER_RECEIVER_VID && (pid == HID_TRACKER_RECEIVER_PID || pid == HID_TRACKER_PID) +private fun isCompatibleDevice(vid: Int, pid: Int) = vid == HID_TRACKER_RECEIVER_VID && (pid == HID_TRACKER_RECEIVER_PID || pid == HID_TRACKER_PID) private val hidSpec = HidServicesSpecification().apply { isAutoStart = false } @@ -32,7 +31,7 @@ private val hidSpec = HidServicesSpecification().apply { isAutoStart = false } private val hidServices by lazy { HidManager.getHidServices(hidSpec) } private fun enumerateCompatibleDevices(): Map { - hidServices // ensure native lib is loaded + hidServices // ensure native lib is loaded val root = HidApi.enumerateDevices(0, 0) ?: return emptyMap() val result = mutableMapOf() var info: HidDeviceInfoStructure? = root @@ -56,7 +55,11 @@ fun createDesktopHIDManager(serverContext: VRServer, scope: CoroutineScope) { scope.launch { while (isActive) { val found = withContext(Dispatchers.IO) { - try { enumerateCompatibleDevices() } catch (_: Exception) { emptyMap() } + try { + enumerateCompatibleDevices() + } catch (_: Exception) { + emptyMap() + } } // Devices no longer present + jobs that exited on their own (read error) @@ -88,12 +91,19 @@ fun createDesktopHIDManager(serverContext: VRServer, scope: CoroutineScope) { try { while (isActive) { val data = withContext(Dispatchers.IO) { - try { hidDevice.readAll(0) } catch (_: Exception) { null } + try { + hidDevice.readAll(0) + } catch (_: Exception) { + null + } } when { - data == null -> return@channelFlow // read error, device gone + data == null -> return@channelFlow + + // read error, device gone data.isNotEmpty() -> send(data) - else -> delay(1) // no data yet, yield without busy-spinning + + else -> delay(1) // no data yet, yield without busy-spinning } } } finally { diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index 3b3494ce32..74bc2bd974 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -38,7 +38,7 @@ suspend fun createUnixSolarXRSocket(server: VRServer, behaviours: List withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, - behaviours = behaviours + behaviours = behaviours, ) } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 6ab5e17e52..1857317425 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -6,14 +6,14 @@ import dev.slimevr.desktop.platform.Position import dev.slimevr.desktop.platform.ProtobufMessage import dev.slimevr.desktop.platform.TrackerAdded import dev.slimevr.desktop.platform.Version -import dev.slimevr.solarxr.SolarXRConnectionBehaviour -import dev.slimevr.solarxr.SolarXRConnection -import dev.slimevr.solarxr.onSolarXRMessage +import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin -import dev.slimevr.tracker.TrackerActions -import dev.slimevr.device.Device +import dev.slimevr.solarxr.SolarXRConnection +import dev.slimevr.solarxr.SolarXRConnectionBehaviour +import dev.slimevr.solarxr.onSolarXRMessage import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerActions import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt index 190b911ded..454fbd20a6 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt @@ -41,7 +41,7 @@ suspend fun createWindowsSolarXRPipe(server: VRServer, behaviours: List withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, - behaviours = behaviours + behaviours = behaviours, ) } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt index 67e5a70345..828f6333f7 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt @@ -18,7 +18,10 @@ 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 = "," } +private val HEX_FORMAT = HexFormat { + upperCase = false + bytes.byteSeparator = "," +} internal val linuxUserRegPath = System.getenv("HOME")?.let { home -> listOf( @@ -70,8 +73,11 @@ internal suspend fun linuxGetQwordValue(registry: Map, key: Stri internal suspend fun linuxGetDwordValue(registry: Map, 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") + 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) { @@ -92,10 +98,12 @@ internal fun linuxVRCConfigFlow(): Flow = if (keys.isEmpty()) { emit(null) } else { - emit(buildVRCConfigValues( - intValue = { key -> keys[key]?.let { linuxGetDwordValue(registry, it) } }, - doubleValue = { key -> keys[key]?.let { linuxGetQwordValue(registry, it) } }, - )) + emit( + buildVRCConfigValues( + intValue = { key -> keys[key]?.let { linuxGetDwordValue(registry, it) } }, + doubleValue = { key -> keys[key]?.let { linuxGetQwordValue(registry, it) } }, + ), + ) } delay(3000) // it seems that on linux, steam writes to the reg file is unpredictable. diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt index f429f969e8..2591ccf84b 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/vrc-config.kt @@ -13,27 +13,28 @@ import solarxr_protocol.rpc.VRCTrackerModel internal const val VRC_REG_PATH = "Software\\VRChat\\VRChat" -fun createDesktopVRCConfigManager(config: AppConfig, scope: CoroutineScope): VRCConfigManager = - when (CURRENT_PLATFORM) { - Platform.WINDOWS -> VRCConfigManager.create( - config = config, - scope = scope, - isSupported = true, - values = windowsVRCConfigFlow(), - ) - Platform.LINUX -> VRCConfigManager.create( - config = config, - scope = scope, - isSupported = true, - values = linuxVRCConfigFlow(), - ) - else -> VRCConfigManager.create( - config = config, - scope = scope, - isSupported = false, - values = emptyFlow(), - ) - } +fun createDesktopVRCConfigManager(config: AppConfig, scope: CoroutineScope): VRCConfigManager = when (CURRENT_PLATFORM) { + Platform.WINDOWS -> VRCConfigManager.create( + config = config, + scope = scope, + isSupported = true, + values = windowsVRCConfigFlow(), + ) + + Platform.LINUX -> VRCConfigManager.create( + config = config, + scope = scope, + isSupported = true, + values = linuxVRCConfigFlow(), + ) + + else -> VRCConfigManager.create( + config = config, + scope = scope, + isSupported = false, + values = emptyFlow(), + ) +} internal suspend fun buildVRCConfigValues( intValue: suspend (String) -> Int?, From f65f41ad17db852690bbbbc0a424590fd04b15c8 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Sun, 29 Mar 2026 05:35:48 +0200 Subject: [PATCH 027/350] User height calibration (Non tested) --- .../slimevr/heightcalibration/behaviours.kt | 186 +++++++++ .../dev/slimevr/heightcalibration/module.kt | 102 +++++ .../dev/slimevr/solarxr/heightcalibration.kt | 32 ++ .../main/java/dev/slimevr/solarxr/vrchat.kt | 2 +- .../HeightCalibrationTest.kt | 358 ++++++++++++++++++ .../src/main/java/dev/slimevr/desktop/Main.kt | 4 + 6 files changed, 683 insertions(+), 1 deletion(-) create mode 100644 server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/heightcalibration/module.kt create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/heightcalibration.kt create mode 100644 server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt new file mode 100644 index 0000000000..1134d5410d --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt @@ -0,0 +1,186 @@ +@file:OptIn(kotlinx.coroutines.FlowPreview::class) + +package dev.slimevr.heightcalibration + +import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.withTimeoutOrNull +import solarxr_protocol.rpc.UserHeightCalibrationStatus +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sqrt + +internal const val SAMPLE_INTERVAL_MS = 16L + +private const val FLOOR_ALPHA = 0.1f +private const val HMD_ALPHA = 0.1f + +private const val CONTROLLER_STABILITY_THRESHOLD = 0.005f +internal const val CONTROLLER_STABILITY_DURATION = 300_000_000L + +private const val HMD_STABILITY_THRESHOLD = 0.003f +internal const val HEAD_STABILITY_DURATION = 600_000_000L + +internal const val MAX_FLOOR_Y = 0.10f +internal const val HMD_RISE_THRESHOLD = 1.2f +internal const val HEIGHT_MIN = 1.4f +internal const val HEIGHT_MAX = 1.936f + +private val HEAD_ANGLE_THRESHOLD = cos((PI / 180.0) * 15.0) +private val CONTROLLER_ANGLE_THRESHOLD = cos((PI / 180.0) * 45.0) + +internal const val TIMEOUT_MS = 30_000L + +private fun UserHeightCalibrationStatus.isTerminal() = when (this) { + UserHeightCalibrationStatus.DONE, + UserHeightCalibrationStatus.ERROR_TOO_HIGH, + UserHeightCalibrationStatus.ERROR_TOO_SMALL, + -> true + else -> false +} + +private fun isControllerPointingDown(snapshot: TrackerSnapshot): Boolean { + val forward = snapshot.rotation.sandwich(Vector3.NEG_Z) + return (forward dot Vector3.NEG_Y) >= CONTROLLER_ANGLE_THRESHOLD +} + +private fun isHmdLeveled(snapshot: TrackerSnapshot): Boolean { + val up = snapshot.rotation.sandwich(Vector3.POS_Y) + return (up dot Vector3.POS_Y) >= HEAD_ANGLE_THRESHOLD +} + +object CalibrationBehaviour : HeightCalibrationBehaviourType { + override fun reduce(state: HeightCalibrationState, action: HeightCalibrationActions) = + when (action) { + is HeightCalibrationActions.Update -> state.copy( + status = action.status, + currentHeight = action.currentHeight, + ) + } +} + +internal suspend fun runCalibrationSession( + context: HeightCalibrationContext, + hmdUpdates: kotlinx.coroutines.flow.Flow, + controllerUpdates: kotlinx.coroutines.flow.Flow, + clock: () -> Long = System::nanoTime, +) { + var currentFloorLevel = Float.MAX_VALUE + var currentHeight = 0f + var floorStableStart: Long? = null + var heightStableStart: Long? = null + + var floorFiltered: Vector3? = null + var floorEnergyEma = 0f + var hmdFiltered: Vector3? = null + var hmdEnergyEma = 0f + + fun dispatch(status: UserHeightCalibrationStatus, height: Float = currentHeight) { + currentHeight = height + context.dispatch(HeightCalibrationActions.Update(status, height)) + } + + dispatch(UserHeightCalibrationStatus.RECORDING_FLOOR) + + withTimeoutOrNull(TIMEOUT_MS) { + + // Floor phase: collect controller updates until the floor level is locked in + controllerUpdates + .sample(SAMPLE_INTERVAL_MS) + .takeWhile { context.state.value.status != UserHeightCalibrationStatus.WAITING_FOR_RISE } + .collect { snapshot -> + val now = clock() + + if (snapshot.position.y > MAX_FLOOR_Y) { + floorStableStart = null + floorFiltered = null + floorEnergyEma = 0f + return@collect + } + + if (!isControllerPointingDown(snapshot)) { + dispatch(UserHeightCalibrationStatus.WAITING_FOR_CONTROLLER_PITCH) + floorStableStart = null + floorFiltered = null + floorEnergyEma = 0f + return@collect + } + + val pos = snapshot.position + val prev = floorFiltered ?: pos + val newFiltered = prev * (1f - FLOOR_ALPHA) + pos * FLOOR_ALPHA + floorFiltered = newFiltered + currentFloorLevel = minOf(currentFloorLevel, pos.y) + + val dev = pos - newFiltered + floorEnergyEma = floorEnergyEma * (1f - FLOOR_ALPHA) + (dev dot dev) * FLOOR_ALPHA + + if (sqrt(floorEnergyEma) > CONTROLLER_STABILITY_THRESHOLD) { + floorStableStart = null + floorFiltered = null + floorEnergyEma = 0f + return@collect + } + + val stableStart = floorStableStart ?: now.also { floorStableStart = it } + if (now - stableStart >= CONTROLLER_STABILITY_DURATION) { + dispatch(UserHeightCalibrationStatus.WAITING_FOR_RISE) + } + } + + // Height phase: collect HMD updates until a terminal status is reached + hmdUpdates + .sample(SAMPLE_INTERVAL_MS) + .takeWhile { !context.state.value.status.isTerminal() } + .collect { snapshot -> + val now = clock() + val relativeY = snapshot.position.y - currentFloorLevel + + if (relativeY <= HMD_RISE_THRESHOLD) { + dispatch(UserHeightCalibrationStatus.WAITING_FOR_RISE, relativeY) + heightStableStart = null + hmdFiltered = null + hmdEnergyEma = 0f + return@collect + } + + if (!isHmdLeveled(snapshot)) { + dispatch(UserHeightCalibrationStatus.WAITING_FOR_FW_LOOK, relativeY) + heightStableStart = null + hmdFiltered = null + hmdEnergyEma = 0f + return@collect + } + + dispatch(UserHeightCalibrationStatus.RECORDING_HEIGHT, relativeY) + + val pos = snapshot.position + val prev = hmdFiltered ?: pos + val newFiltered = prev * (1f - HMD_ALPHA) + pos * HMD_ALPHA + hmdFiltered = newFiltered + + val dev = pos - newFiltered + hmdEnergyEma = hmdEnergyEma * (1f - HMD_ALPHA) + (dev dot dev) * HMD_ALPHA + + if (sqrt(hmdEnergyEma) > HMD_STABILITY_THRESHOLD) { + heightStableStart = null + hmdFiltered = null + hmdEnergyEma = 0f + return@collect + } + + val stableStart = heightStableStart ?: now.also { heightStableStart = it } + if (now - stableStart >= HEAD_STABILITY_DURATION) { + val finalStatus = when { + relativeY < HEIGHT_MIN -> UserHeightCalibrationStatus.ERROR_TOO_SMALL + relativeY > HEIGHT_MAX -> UserHeightCalibrationStatus.ERROR_TOO_HIGH + else -> UserHeightCalibrationStatus.DONE + } + dispatch(finalStatus, relativeY) + + // TODO (when DONE): persist height to config, update user proportions, clear mounting reset flags + } + } + } ?: dispatch(UserHeightCalibrationStatus.ERROR_TIMEOUT) +} diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt new file mode 100644 index 0000000000..9835e1f93a --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt @@ -0,0 +1,102 @@ +@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + +package dev.slimevr.heightcalibration + +import dev.slimevr.VRServer +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.UserHeightCalibrationStatus + +data class TrackerSnapshot(val position: Vector3, val rotation: Quaternion) + +data class HeightCalibrationState( + val status: UserHeightCalibrationStatus, + val currentHeight: Float, +) + +sealed interface HeightCalibrationActions { + data class Update(val status: UserHeightCalibrationStatus, val currentHeight: Float) : HeightCalibrationActions +} + +typealias HeightCalibrationContext = Context +typealias HeightCalibrationBehaviourType = Behaviour + +val INITIAL_HEIGHT_CALIBRATION_STATE = HeightCalibrationState( + status = UserHeightCalibrationStatus.NONE, + currentHeight = 0f, +) + +class HeightCalibrationManager( + val context: HeightCalibrationContext, + val serverContext: VRServer, +) { + private var sessionJob: Job? = null + + // These Flows do nothing until the calibration use collect on it + val hmdUpdates: Flow = serverContext.context.state + .flatMapLatest { state -> + val hmd = state.trackers.values + .find { it.context.state.value.bodyPart == BodyPart.HEAD } // TODO: Need to check for a head with position support + ?: return@flatMapLatest emptyFlow() + hmd.context.state.map { s -> + TrackerSnapshot( + // TODO: get HMD position from VR system once position is set in the tracker + position = Vector3.NULL, + rotation = s.rawRotation, + ) + } + } + + val controllerUpdates: Flow = serverContext.context.state + .flatMapLatest { state -> + val controllers = state.trackers.values.filter { + val bodyPart = it.context.state.value.bodyPart + bodyPart == BodyPart.LEFT_HAND || bodyPart == BodyPart.RIGHT_HAND + } + if (controllers.isEmpty()) return@flatMapLatest emptyFlow() + combine(controllers.map { controller -> + controller.context.state.map { s -> + // TODO: get controller position from tracker once position is set in the tracker + val position = Vector3.NULL + TrackerSnapshot(position = position, rotation = s.rawRotation) + } + }) { snapshots -> snapshots.minByOrNull { it.position.y }!! } + } + + fun start() { + sessionJob?.cancel() + sessionJob = context.scope.launch { runCalibrationSession(context, hmdUpdates, controllerUpdates) } + } + + fun cancel() { + sessionJob?.cancel() + sessionJob = null + context.dispatch(HeightCalibrationActions.Update(UserHeightCalibrationStatus.NONE, 0f)) + } + + companion object { + fun create( + serverContext: VRServer, + scope: CoroutineScope, + ): HeightCalibrationManager { + val behaviours = listOf(CalibrationBehaviour) + val context = Context.create( + initialState = INITIAL_HEIGHT_CALIBRATION_STATE, + scope = scope, + behaviours = behaviours, + ) + return HeightCalibrationManager(context = context, serverContext = serverContext) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/heightcalibration.kt b/server/core/src/main/java/dev/slimevr/solarxr/heightcalibration.kt new file mode 100644 index 0000000000..ff0786af49 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/heightcalibration.kt @@ -0,0 +1,32 @@ +package dev.slimevr.solarxr + +import dev.slimevr.heightcalibration.HeightCalibrationManager +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import solarxr_protocol.rpc.CancelUserHeightCalibration +import solarxr_protocol.rpc.StartUserHeightCalibration +import solarxr_protocol.rpc.UserHeightRecordingStatusResponse + +class HeightCalibrationBehaviour( + private val heightCalibrationManager: HeightCalibrationManager, +) : SolarXRConnectionBehaviour { + override fun observe(receiver: SolarXRConnection) { + receiver.rpcDispatcher.on { + heightCalibrationManager.start() + } + + receiver.rpcDispatcher.on { + heightCalibrationManager.cancel() + } + + heightCalibrationManager.context.state.drop(1).onEach { state -> + receiver.sendRpc( + UserHeightRecordingStatusResponse( + status = state.status, + hmdheight = state.currentHeight, + ), + ) + }.launchIn(receiver.context.scope) + } +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt index 4be41532d2..53e184787b 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt @@ -30,7 +30,7 @@ class VrcBehaviour( ) } - // Drop the initial value — we only want to push updates when the config changes + // Drop the initial value, we only want to push updates when the config changes vrcManager.context.state.drop(1).onEach { receiver.sendRpc(buildCurrentResponse()) }.launchIn(receiver.context.scope) diff --git a/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt new file mode 100644 index 0000000000..ba87b0867f --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt @@ -0,0 +1,358 @@ +@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class) + +package dev.slimevr.heightcalibration + +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runTest +import solarxr_protocol.rpc.UserHeightCalibrationStatus +import kotlin.test.Test +import kotlin.test.assertEquals + +// Stability durations in milliseconds (converted from nanosecond constants) +private const val FLOOR_STABILITY_MS = CONTROLLER_STABILITY_DURATION / 1_000_000L +private const val HEIGHT_STABILITY_MS = HEAD_STABILITY_DURATION / 1_000_000L + +// Rotation that maps controller forward (-Z) to down (-Y), satisfying the pointing-down check +private val POINTING_DOWN = Quaternion.fromTo(Vector3.NEG_Z, Vector3.NEG_Y) + +// Identity: controller forward is -Z, not pointing down +private val POINTING_FORWARD = Quaternion.IDENTITY + +// Identity: HMD up is +Y, within 15° leveled threshold +private val HMD_LEVEL = Quaternion.IDENTITY + +// 90° around Z: HMD up maps to +X, failing the leveled check +private val HMD_TILTED = Quaternion.fromTo(Vector3.POS_Y, Vector3.POS_X) + +// Position just below the floor threshold (0.10m) +private val FLOOR_POSITION = Vector3(0f, 0.05f, 0f) + +// Position comfortably above rise threshold (1.2m) with zero floor level +private val STANDING_POSITION = Vector3(0f, 1.7f, 0f) + +private fun makeContext(scope: kotlinx.coroutines.CoroutineScope) = HeightCalibrationContext.create( + initialState = INITIAL_HEIGHT_CALIBRATION_STATE, + scope = scope, + behaviours = listOf(CalibrationBehaviour), +) + +// Launches a calibration session with the virtual-time clock, so that advancing virtual time +// drives both the sample() operator and the stability duration checks in one unified timeline. +private fun TestScope.launchSession( + context: HeightCalibrationContext, + hmdFlow: MutableSharedFlow, + controllerFlow: MutableSharedFlow, +): Job { + val scope = this + return launch { + runCalibrationSession(context, hmdFlow, controllerFlow, clock = { scope.currentTime * 1_000_000L }) + } +} + +// Emits snapshot at SAMPLE_INTERVAL_MS rate for the given duration of virtual time, +// then does one extra advance to let any pending coroutine resumptions finish. +private suspend fun TestScope.emitFor( + flow: MutableSharedFlow, + snapshot: TrackerSnapshot, + durationMs: Long, +) { + val end = currentTime + durationMs + while (currentTime < end) { + flow.emit(snapshot) + advanceTimeBy(SAMPLE_INTERVAL_MS) + } + advanceTimeBy(SAMPLE_INTERVAL_MS) +} + +// Holds the controller steady on the floor long enough for the floor phase to complete. +private suspend fun TestScope.completeFloorPhase( + controllerFlow: MutableSharedFlow, + floorSnapshot: TrackerSnapshot, +) { + emitFor(controllerFlow, floorSnapshot, FLOOR_STABILITY_MS + SAMPLE_INTERVAL_MS * 5) +} + +// Holds the HMD steady at standing height long enough for the height phase to complete. +private suspend fun TestScope.completeHeightPhase( + hmdFlow: MutableSharedFlow, + hmdSnapshot: TrackerSnapshot, +) { + emitFor(hmdFlow, hmdSnapshot, HEIGHT_STABILITY_MS + SAMPLE_INTERVAL_MS * 5) +} + +class HeightCalibrationReducerTest { + @Test + fun `Update changes status and height`() = runTest { + val context = makeContext(this) + + context.dispatch(HeightCalibrationActions.Update(UserHeightCalibrationStatus.RECORDING_HEIGHT, 1.65f)) + + assertEquals(UserHeightCalibrationStatus.RECORDING_HEIGHT, context.state.value.status) + assertEquals(1.65f, context.state.value.currentHeight) + } +} + +class HeightCalibrationSessionTest { + @Test + fun `session starts in RECORDING_FLOOR`() = runTest { + val context = makeContext(this) + val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) + val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) + val job = launchSession(context, hmdFlow, controllerFlow) + advanceTimeBy(SAMPLE_INTERVAL_MS) + + assertEquals(UserHeightCalibrationStatus.RECORDING_FLOOR, context.state.value.status) + job.cancel() + } + + @Test + fun `controller too high does not change status`() = runTest { + val context = makeContext(this) + val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) + val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) + val job = launchSession(context, hmdFlow, controllerFlow) + advanceTimeBy(SAMPLE_INTERVAL_MS) + + emitFor(controllerFlow, TrackerSnapshot(Vector3(0f, 0.5f, 0f), POINTING_DOWN), SAMPLE_INTERVAL_MS) + + assertEquals(UserHeightCalibrationStatus.RECORDING_FLOOR, context.state.value.status) + job.cancel() + } + + @Test + fun `controller not pointing down transitions to WAITING_FOR_CONTROLLER_PITCH`() = runTest { + val context = makeContext(this) + val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) + val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) + val job = launchSession(context, hmdFlow, controllerFlow) + advanceTimeBy(SAMPLE_INTERVAL_MS) + + emitFor(controllerFlow, TrackerSnapshot(FLOOR_POSITION, POINTING_FORWARD), SAMPLE_INTERVAL_MS) + + assertEquals(UserHeightCalibrationStatus.WAITING_FOR_CONTROLLER_PITCH, context.state.value.status) + job.cancel() + } + + @Test + fun `stable floor transitions to WAITING_FOR_RISE`() = runTest { + val context = makeContext(this) + val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) + val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) + val job = launchSession(context, hmdFlow, controllerFlow) + advanceTimeBy(SAMPLE_INTERVAL_MS) + + completeFloorPhase(controllerFlow, TrackerSnapshot(FLOOR_POSITION, POINTING_DOWN)) + + assertEquals(UserHeightCalibrationStatus.WAITING_FOR_RISE, context.state.value.status) + job.cancel() + } + + @Test + fun `HMD below rise threshold stays WAITING_FOR_RISE`() = runTest { + val context = makeContext(this) + val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) + val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) + val job = launchSession(context, hmdFlow, controllerFlow) + advanceTimeBy(SAMPLE_INTERVAL_MS) + + completeFloorPhase(controllerFlow, TrackerSnapshot(FLOOR_POSITION, POINTING_DOWN)) + + emitFor(hmdFlow, TrackerSnapshot(Vector3(0f, 0.5f, 0f), HMD_LEVEL), SAMPLE_INTERVAL_MS) + + assertEquals(UserHeightCalibrationStatus.WAITING_FOR_RISE, context.state.value.status) + job.cancel() + } + + @Test + fun `HMD not leveled transitions to WAITING_FOR_FW_LOOK`() = runTest { + val context = makeContext(this) + val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) + val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) + val job = launchSession(context, hmdFlow, controllerFlow) + advanceTimeBy(SAMPLE_INTERVAL_MS) + + completeFloorPhase(controllerFlow, TrackerSnapshot(FLOOR_POSITION, POINTING_DOWN)) + + emitFor(hmdFlow, TrackerSnapshot(STANDING_POSITION, HMD_TILTED), SAMPLE_INTERVAL_MS) + + assertEquals(UserHeightCalibrationStatus.WAITING_FOR_FW_LOOK, context.state.value.status) + job.cancel() + } + + @Test + fun `stable HMD at valid height transitions to DONE`() = runTest { + val context = makeContext(this) + val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) + val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) + val job = launchSession(context, hmdFlow, controllerFlow) + advanceTimeBy(SAMPLE_INTERVAL_MS) + + completeFloorPhase(controllerFlow, TrackerSnapshot(FLOOR_POSITION, POINTING_DOWN)) + completeHeightPhase(hmdFlow, TrackerSnapshot(STANDING_POSITION, HMD_LEVEL)) + + assertEquals(UserHeightCalibrationStatus.DONE, context.state.value.status) + assertEquals(STANDING_POSITION.y - FLOOR_POSITION.y, context.state.value.currentHeight) + job.cancel() + } + + @Test + fun `stable HMD below HEIGHT_MIN transitions to ERROR_TOO_SMALL`() = runTest { + val context = makeContext(this) + val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) + val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) + val job = launchSession(context, hmdFlow, controllerFlow) + advanceTimeBy(SAMPLE_INTERVAL_MS) + + completeFloorPhase(controllerFlow, TrackerSnapshot(Vector3(0f, 0f, 0f), POINTING_DOWN)) + completeHeightPhase(hmdFlow, TrackerSnapshot(Vector3(0f, 1.3f, 0f), HMD_LEVEL)) + + assertEquals(UserHeightCalibrationStatus.ERROR_TOO_SMALL, context.state.value.status) + job.cancel() + } + + @Test + fun `stable HMD above HEIGHT_MAX transitions to ERROR_TOO_HIGH`() = runTest { + val context = makeContext(this) + val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) + val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) + val job = launchSession(context, hmdFlow, controllerFlow) + advanceTimeBy(SAMPLE_INTERVAL_MS) + + completeFloorPhase(controllerFlow, TrackerSnapshot(Vector3(0f, 0f, 0f), POINTING_DOWN)) + completeHeightPhase(hmdFlow, TrackerSnapshot(Vector3(0f, 2.0f, 0f), HMD_LEVEL)) + + assertEquals(UserHeightCalibrationStatus.ERROR_TOO_HIGH, context.state.value.status) + job.cancel() + } + + @Test + fun `unstable floor sample resets controller stability window`() = runTest { + val context = makeContext(this) + val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) + val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) + val job = launchSession(context, hmdFlow, controllerFlow) + advanceTimeBy(SAMPLE_INTERVAL_MS) + + val stableSnapshot = TrackerSnapshot(FLOOR_POSITION, POINTING_DOWN) + // X offset of 1m pushes energy well above CONTROLLER_STABILITY_THRESHOLD + val unstableSnapshot = TrackerSnapshot(Vector3(1f, FLOOR_POSITION.y, 0f), POINTING_DOWN) + + // Build up stability but not long enough to complete + emitFor(controllerFlow, stableSnapshot, FLOOR_STABILITY_MS - SAMPLE_INTERVAL_MS * 5) + assertEquals(UserHeightCalibrationStatus.RECORDING_FLOOR, context.state.value.status) + + // Unstable sample resets the stability window + emitFor(controllerFlow, unstableSnapshot, SAMPLE_INTERVAL_MS) + assertEquals(UserHeightCalibrationStatus.RECORDING_FLOOR, context.state.value.status) + + // Must hold stable for the full duration again from the reset point + completeFloorPhase(controllerFlow, stableSnapshot) + assertEquals(UserHeightCalibrationStatus.WAITING_FOR_RISE, context.state.value.status) + job.cancel() + } + + @Test + fun `out-of-threshold HMD sample resets height stability window`() = runTest { + val context = makeContext(this) + val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) + val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) + val job = launchSession(context, hmdFlow, controllerFlow) + advanceTimeBy(SAMPLE_INTERVAL_MS) + + completeFloorPhase(controllerFlow, TrackerSnapshot(FLOOR_POSITION, POINTING_DOWN)) + + val stableSnapshot = TrackerSnapshot(STANDING_POSITION, HMD_LEVEL) + val unstableSnapshot = TrackerSnapshot(Vector3(0f, 1.9f, 0f), HMD_LEVEL) + + // Build up stability but not long enough to complete + emitFor(hmdFlow, stableSnapshot, HEIGHT_STABILITY_MS - SAMPLE_INTERVAL_MS * 5) + assertEquals(UserHeightCalibrationStatus.RECORDING_HEIGHT, context.state.value.status) + + // Unstable sample resets the stability window + emitFor(hmdFlow, unstableSnapshot, SAMPLE_INTERVAL_MS) + assertEquals(UserHeightCalibrationStatus.RECORDING_HEIGHT, context.state.value.status) + + // Must hold stable for the full duration again from the reset point + completeHeightPhase(hmdFlow, stableSnapshot) + assertEquals(UserHeightCalibrationStatus.DONE, context.state.value.status) + job.cancel() + } + + @Test + fun `timeout fires ERROR_TIMEOUT`() = runTest { + val context = makeContext(this) + val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) + val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) + val job = launch { runCalibrationSession(context, hmdFlow, controllerFlow) } + + advanceTimeBy(TIMEOUT_MS + 1) + + assertEquals(UserHeightCalibrationStatus.ERROR_TIMEOUT, context.state.value.status) + job.cancel() + } + + @Test + fun `controller pitch recovery leads to WAITING_FOR_RISE`() = runTest { + val context = makeContext(this) + val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) + val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) + val job = launchSession(context, hmdFlow, controllerFlow) + advanceTimeBy(SAMPLE_INTERVAL_MS) + + // Bad pitch triggers WAITING_FOR_CONTROLLER_PITCH + emitFor(controllerFlow, TrackerSnapshot(FLOOR_POSITION, POINTING_FORWARD), SAMPLE_INTERVAL_MS) + assertEquals(UserHeightCalibrationStatus.WAITING_FOR_CONTROLLER_PITCH, context.state.value.status) + + // Recovery: hold steady on floor for the required duration + completeFloorPhase(controllerFlow, TrackerSnapshot(FLOOR_POSITION, POINTING_DOWN)) + assertEquals(UserHeightCalibrationStatus.WAITING_FOR_RISE, context.state.value.status) + job.cancel() + } + + @Test + fun `leveling HMD after WAITING_FOR_FW_LOOK transitions to RECORDING_HEIGHT`() = runTest { + val context = makeContext(this) + val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) + val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) + val job = launchSession(context, hmdFlow, controllerFlow) + advanceTimeBy(SAMPLE_INTERVAL_MS) + + completeFloorPhase(controllerFlow, TrackerSnapshot(FLOOR_POSITION, POINTING_DOWN)) + + // Tilted HMD + emitFor(hmdFlow, TrackerSnapshot(STANDING_POSITION, HMD_TILTED), SAMPLE_INTERVAL_MS) + assertEquals(UserHeightCalibrationStatus.WAITING_FOR_FW_LOOK, context.state.value.status) + + // Recovery: level the HMD + emitFor(hmdFlow, TrackerSnapshot(STANDING_POSITION, HMD_LEVEL), SAMPLE_INTERVAL_MS) + assertEquals(UserHeightCalibrationStatus.RECORDING_HEIGHT, context.state.value.status) + job.cancel() + } + + @Test + fun `HMD rising above threshold transitions to RECORDING_HEIGHT`() = runTest { + val context = makeContext(this) + val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) + val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) + val job = launchSession(context, hmdFlow, controllerFlow) + advanceTimeBy(SAMPLE_INTERVAL_MS) + + completeFloorPhase(controllerFlow, TrackerSnapshot(FLOOR_POSITION, POINTING_DOWN)) + + // HMD below threshold + emitFor(hmdFlow, TrackerSnapshot(Vector3(0f, 0.5f, 0f), HMD_LEVEL), SAMPLE_INTERVAL_MS) + assertEquals(UserHeightCalibrationStatus.WAITING_FOR_RISE, context.state.value.status) + + // HMD rises above threshold + emitFor(hmdFlow, TrackerSnapshot(STANDING_POSITION, HMD_LEVEL), SAMPLE_INTERVAL_MS) + assertEquals(UserHeightCalibrationStatus.RECORDING_HEIGHT, context.state.value.status) + job.cancel() + } +} 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 ca29015309..ca454883d7 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -9,9 +9,11 @@ import dev.slimevr.desktop.ipc.createIpcServers import dev.slimevr.desktop.serial.createDesktopSerialServer import dev.slimevr.desktop.vrchat.createDesktopVRCConfigManager import dev.slimevr.firmware.FirmwareManager +import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.resolveConfigDirectory import dev.slimevr.solarxr.DataFeedInitBehaviour import dev.slimevr.solarxr.FirmwareBehaviour +import dev.slimevr.solarxr.HeightCalibrationBehaviour import dev.slimevr.solarxr.SerialBehaviour import dev.slimevr.solarxr.VrcBehaviour import dev.slimevr.solarxr.createSolarXRWebsocketServer @@ -28,6 +30,7 @@ fun main(args: Array) = runBlocking { val firmwareManager = FirmwareManager.create(serialServer = serialServer, scope = this) val vrcConfigManager = createDesktopVRCConfigManager(config = config, scope = this) + val heightCalibrationManager = HeightCalibrationManager.create(serverContext = server, scope = this) launch { createUDPTrackerServer(server, config) @@ -41,6 +44,7 @@ fun main(args: Array) = runBlocking { SerialBehaviour(serialServer), FirmwareBehaviour(firmwareManager), VrcBehaviour(vrcConfigManager, userHeight = { config.userConfig.context.state.value.data.userHeight.toDouble() }), + HeightCalibrationBehaviour(heightCalibrationManager), ) launch { createSolarXRWebsocketServer(server, solarXRBehaviours) } launch { createIpcServers(server, solarXRBehaviours) } From 71b74914a3e4783694c17b09bf494e2e55e19f25 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 30 Mar 2026 05:22:55 +0200 Subject: [PATCH 028/350] Better logs and ping id sequence for udp devices --- .../main/java/dev/slimevr/udp/behaviours.kt | 23 +++++++++---------- .../main/java/dev/slimevr/udp/connection.kt | 6 ++--- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 393178423e..dfcd7e7fb7 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import solarxr_protocol.datatypes.TrackerStatus +import kotlin.random.Random internal const val CONNECTION_TIMEOUT_MS = 5000L @@ -32,9 +33,9 @@ object PacketBehaviour : UDPConnectionBehaviour { val now = System.currentTimeMillis() if (now - state.lastPacket > CONNECTION_TIMEOUT_MS && packet.packetNumber == 0L) { receiver.context.dispatch(UDPConnectionActions.LastPacket(packetNum = 0, time = now)) - AppLogger.udp.info("Reconnecting") + AppLogger.udp.info("[${state.address}] Reconnecting") } else if (packet.packetNumber < state.lastPacketNum) { - AppLogger.udp.warn("WARN: Received packet with wrong packet number") + AppLogger.udp.warn("[${state.address}] WARN: Received packet with wrong packet number") return@onAny } else { receiver.context.dispatch(UDPConnectionActions.LastPacket(time = now)) @@ -45,8 +46,7 @@ object PacketBehaviour : UDPConnectionBehaviour { object PingBehaviour : UDPConnectionBehaviour { override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { - is UDPConnectionActions.StartPing -> state.copy(lastPing = state.lastPing.copy(startTime = action.startTime)) - is UDPConnectionActions.ReceivedPong -> state.copy(lastPing = state.lastPing.copy(duration = action.duration, id = action.id)) + is UDPConnectionActions.StartPing -> state.copy(lastPing = state.lastPing.copy(startTime = action.startTime, id = action.pingId)) else -> state } @@ -56,8 +56,9 @@ object PingBehaviour : UDPConnectionBehaviour { while (isActive) { val state = receiver.context.state.value if (state.didHandshake) { - receiver.context.dispatch(UDPConnectionActions.StartPing(startTime = System.currentTimeMillis())) - receiver.send(PingPong(state.lastPing.id + 1)) + val pingId = Random.nextInt() + receiver.context.dispatch(UDPConnectionActions.StartPing(startTime = System.currentTimeMillis(), pingId = pingId)) + receiver.send(PingPong(pingId)) } delay(1000) } @@ -68,15 +69,13 @@ object PingBehaviour : UDPConnectionBehaviour { val state = receiver.context.state.value val deviceId = state.deviceId ?: return@onPacket - if (packet.data.pingId != state.lastPing.id + 1) { - AppLogger.udp.warn("Ping ID does not match, ignoring ${packet.data.pingId} != ${state.lastPing.id + 1}") + if (packet.data.pingId != state.lastPing.id) { + AppLogger.udp.warn("[${state.address}] Ping ID does not match, ignoring ${packet.data.pingId} != ${state.lastPing.id}") return@onPacket } val ping = System.currentTimeMillis() - state.lastPing.startTime val device = receiver.serverContext.getDevice(deviceId) ?: return@onPacket - - receiver.context.dispatch(UDPConnectionActions.ReceivedPong(id = packet.data.pingId, duration = ping)) device.context.dispatch(DeviceActions.Update { copy(ping = ping) }) } } @@ -109,7 +108,7 @@ object HandshakeBehaviour : UDPConnectionBehaviour { } else { receiver.context.dispatch(UDPConnectionActions.Handshake(state.deviceId)) receiver.getDevice() ?: run { - AppLogger.udp.warn("Reconnect handshake but device ${state.deviceId} not found") + AppLogger.udp.warn("[${state.address}] Reconnect handshake but device ${state.deviceId} not found") receiver.send(Handshake()) return@onPacket } @@ -144,7 +143,7 @@ object TimeoutBehaviour : UDPConnectionBehaviour { } val timeUntilTimeout = CONNECTION_TIMEOUT_MS - (System.currentTimeMillis() - state.lastPacket) if (timeUntilTimeout <= 0) { - AppLogger.udp.info("Connection timed out for ${state.id}") + AppLogger.udp.info("[${state.address}] Connection timed out for ${state.id}") receiver.context.dispatch(UDPConnectionActions.Disconnected) receiver.getDevice()?.context?.dispatch( DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 5d0abb8fec..9f7dd2b7bb 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -20,7 +20,6 @@ import java.net.InetAddress data class LastPing( val id: Int, val startTime: Long, - val duration: Long, ) data class UDPConnectionState( @@ -36,8 +35,7 @@ data class UDPConnectionState( ) sealed interface UDPConnectionActions { - data class StartPing(val startTime: Long) : UDPConnectionActions - data class ReceivedPong(val id: Int, val duration: Long) : UDPConnectionActions + data class StartPing(val startTime: Long, val pingId: Int) : UDPConnectionActions data class Handshake(val deviceId: Int) : UDPConnectionActions data class LastPacket(val packetNum: Long? = null, val time: Long) : UDPConnectionActions data class AssignTracker(val trackerId: TrackerIdNum) : UDPConnectionActions @@ -100,7 +98,7 @@ class UDPConnection( id = id, lastPacket = System.currentTimeMillis(), lastPacketNum = 0, - lastPing = LastPing(id = 0, duration = 0, startTime = 0), + lastPing = LastPing(id = 0, startTime = 0), didHandshake = false, address = remoteIp, port = remotePort, From e07ef046a64b571aea87a140cbd265062abee20a Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 30 Mar 2026 05:23:14 +0200 Subject: [PATCH 029/350] batch dispatch together --- server/core/src/main/java/dev/slimevr/serial/module.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/serial/module.kt b/server/core/src/main/java/dev/slimevr/serial/module.kt index 7580a834c5..42928a919e 100644 --- a/server/core/src/main/java/dev/slimevr/serial/module.kt +++ b/server/core/src/main/java/dev/slimevr/serial/module.kt @@ -55,8 +55,10 @@ class SerialServer( if (conn is SerialConnection.Console) { conn.handle.close() } - context.dispatch(SerialServerActions.RemoveConnection(portLocation)) - context.dispatch(SerialServerActions.PortLost(portLocation)) + context.dispatchAll(listOf( + SerialServerActions.RemoveConnection(portLocation), + SerialServerActions.PortLost(portLocation) + )) } fun onDataReceived(portLocation: String, line: String) { From 0ab80528fe6b33a279f150465c2e74e87224b289 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 30 Mar 2026 05:23:31 +0200 Subject: [PATCH 030/350] More position data for devices --- .../main/java/dev/slimevr/heightcalibration/module.kt | 10 ++++------ .../core/src/main/java/dev/slimevr/tracker/module.kt | 3 +++ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt index 9835e1f93a..3d1d749e7c 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt @@ -47,12 +47,11 @@ class HeightCalibrationManager( val hmdUpdates: Flow = serverContext.context.state .flatMapLatest { state -> val hmd = state.trackers.values - .find { it.context.state.value.bodyPart == BodyPart.HEAD } // TODO: Need to check for a head with position support + .find { it.context.state.value.bodyPart == BodyPart.HEAD && it.context.state.value.position != null } ?: return@flatMapLatest emptyFlow() hmd.context.state.map { s -> TrackerSnapshot( - // TODO: get HMD position from VR system once position is set in the tracker - position = Vector3.NULL, + position = s.position ?: error("head (or HMD) will always have a position in this case"), rotation = s.rawRotation, ) } @@ -62,13 +61,12 @@ class HeightCalibrationManager( .flatMapLatest { state -> val controllers = state.trackers.values.filter { val bodyPart = it.context.state.value.bodyPart - bodyPart == BodyPart.LEFT_HAND || bodyPart == BodyPart.RIGHT_HAND + (bodyPart == BodyPart.LEFT_HAND || bodyPart == BodyPart.RIGHT_HAND) && it.context.state.value.position != null } if (controllers.isEmpty()) return@flatMapLatest emptyFlow() combine(controllers.map { controller -> controller.context.state.map { s -> - // TODO: get controller position from tracker once position is set in the tracker - val position = Vector3.NULL + val position = s.position ?: error("hands (or Controller) will always have a position in this case") TrackerSnapshot(position = position, rotation = s.rawRotation) } }) { snapshots -> snapshots.minByOrNull { it.position.y }!! } diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index a816e584dd..dacfd39b7a 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -4,6 +4,7 @@ import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.device.DeviceOrigin import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.hardware_info.ImuType @@ -20,6 +21,7 @@ data class TrackerState( val rawRotation: Quaternion, val deviceId: Int, val origin: DeviceOrigin, + val position: Vector3? ) sealed interface TrackerActions { @@ -51,6 +53,7 @@ class Tracker( deviceId = deviceId, customName = null, sensorType = sensorType, + position = null ) val behaviours = listOf(TrackerInfosBehaviour) From dd6c1e7567cad532dee5193ec62c7204a73e7c9e Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 30 Mar 2026 05:23:37 +0200 Subject: [PATCH 031/350] Lint --- server/core/src/main/java/dev/slimevr/hid/behaviours.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 2a8d14a45e..d21d1bc9cb 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -20,7 +20,7 @@ object HIDRegistrationBehaviour : HIDReceiverBehaviour { deviceId = action.deviceId, trackerId = null, ) - ), + ), ) else -> state From 17e87178fccae6a6a2093eaa200ccd480d41ebce Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 30 Mar 2026 05:39:49 +0200 Subject: [PATCH 032/350] Remove some console warnings --- server/core/build.gradle.kts | 1 + server/desktop/build.gradle.kts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index d51cfae270..c63b1b0ccb 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") implementation("com.mayakapps.kache:kache:2.1.1") implementation("io.klogging:klogging:0.11.7") + implementation("io.klogging:slf4j-klogging:0.11.7") val ktor_version = "3.4.1" implementation("io.ktor:ktor-server-core-jvm:$ktor_version") diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index f1e38e8a7a..456f085b86 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -100,6 +100,7 @@ tasks.shadowJar { } application { mainClass.set("dev.slimevr.desktop.Main") + applicationDefaultJvmArgs = listOf("--enable-native-access=ALL-UNNAMED") } buildConfig { @@ -116,5 +117,6 @@ buildConfig { tasks.run { standardInput = System.`in` // this is not working + jvmArgs("--enable-native-access=ALL-UNNAMED") args = listOf("run") } From 02ca403e24c86e47657a6b0ab0030ac3b815d4d8 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 30 Mar 2026 11:24:21 +0200 Subject: [PATCH 033/350] More data for udp trackers --- .../main/java/dev/slimevr/solarxr/datafeed.kt | 6 +++-- .../java/dev/slimevr/tracker/behaviours.kt | 25 ++++++++++++++++--- .../main/java/dev/slimevr/tracker/module.kt | 8 +++--- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 25358d3ea5..ae4a8a49ec 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -25,6 +25,7 @@ import solarxr_protocol.datatypes.TrackerId import solarxr_protocol.datatypes.hardware_info.HardwareInfo import solarxr_protocol.datatypes.hardware_info.HardwareStatus import solarxr_protocol.datatypes.math.Quat +import solarxr_protocol.datatypes.math.Vec3f import java.nio.ByteBuffer private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMask: TrackerDataMask): TrackerData = TrackerData( @@ -34,6 +35,7 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas ), status = if (trackerMask.status == true) device.status else null, rotation = if (trackerMask.rotation == true) tracker.rawRotation.let { Quat(it.x, it.y, it.z, it.w) } else null, + position = if (trackerMask.position == true && tracker.position != null) tracker.position.let { Vec3f(it.x, it.y, it.z) } else null, info = if (trackerMask.info == true) { TrackerInfo( imuType = tracker.sensorType, @@ -43,6 +45,7 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas } else { null }, + tps = if (trackerMask.tps == true) tracker.tps else null, ) private fun createDevice( @@ -56,8 +59,7 @@ private fun createDevice( id = DeviceId(device.id.toUByte()), hardwareStatus = HardwareStatus( batteryVoltage = device.batteryVoltage, - batteryPctEstimate = device.batteryLevel.toUInt() - .toUByte(), + batteryPctEstimate = (device.batteryLevel * 100).toUInt().toUByte(), ping = device.ping?.toUShort(), ), hardwareInfo = HardwareInfo( diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 7d42851159..3030e881e9 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -1,14 +1,33 @@ package dev.slimevr.tracker +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.incrementAndFetch -object TrackerInfosBehaviour : TrackerBehaviour { +object TrackerBasicBehaviour : TrackerBehaviour { override fun reduce(state: TrackerState, action: TrackerActions) = if (action is TrackerActions.Update) action.transform(state) else state +} +object TrackerTPSBehaviour : TrackerBehaviour { + @OptIn(ExperimentalAtomicApi::class) override fun observe(receiver: TrackerContext) { - receiver.state.onEach { -// AppLogger.tracker.info("Tracker state changed {State}", it) + val count = AtomicInt(0) + + receiver.state.distinctUntilChangedBy { it.rawRotation }.onEach { + count.incrementAndFetch() }.launchIn(receiver.scope) + + receiver.scope.launch { + while (isActive) { + delay(1000) + receiver.dispatch(TrackerActions.Update { copy(tps = count.exchange(0).toUShort()) }) + } + } } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index dacfd39b7a..68d4ec682d 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -21,7 +21,8 @@ data class TrackerState( val rawRotation: Quaternion, val deviceId: Int, val origin: DeviceOrigin, - val position: Vector3? + val tps: UShort, + val position: Vector3?, ) sealed interface TrackerActions { @@ -53,10 +54,11 @@ class Tracker( deviceId = deviceId, customName = null, sensorType = sensorType, - position = null + position = null, + tps = 0u, ) - val behaviours = listOf(TrackerInfosBehaviour) + val behaviours = listOf(TrackerBasicBehaviour, TrackerTPSBehaviour) val context = Context.create(initialState = trackerState, scope = scope, behaviours = behaviours) behaviours.forEach { it.observe(context) } return Tracker(context = context) From 2fc64b3e8bf7ffac8fea76b3ef8f60afb59146d2 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 30 Mar 2026 11:24:49 +0200 Subject: [PATCH 034/350] Lint --- server/README.md | 84 +++++++++---------- .../src/main/java/dev/slimevr/firmware/ota.kt | 2 +- .../slimevr/heightcalibration/behaviours.kt | 15 ++-- .../dev/slimevr/heightcalibration/module.kt | 14 ++-- .../main/java/dev/slimevr/hid/behaviours.kt | 2 +- .../main/java/dev/slimevr/serial/module.kt | 10 ++- .../dev/slimevr/desktop/vrchat/windows.kt | 21 +++-- 7 files changed, 81 insertions(+), 67 deletions(-) diff --git a/server/README.md b/server/README.md index c0c605a414..1bd19f1a8f 100644 --- a/server/README.md +++ b/server/README.md @@ -21,11 +21,11 @@ The `Context` type (`context/context.kt`) is the building block of every m ```kotlin class Context( - val state: StateFlow, // current state, readable by anyone - val scope: CoroutineScope, // lifetime of this module + val state: StateFlow, // current state, readable by anyone + val scope: CoroutineScope, // lifetime of this module ) { - fun dispatch(action: A) - fun dispatchAll(actions: List) + fun dispatch(action: A) + fun dispatchAll(actions: List) } ``` @@ -44,8 +44,8 @@ A `Behaviour` is an interface with two methods, both with no-op defaults: ```kotlin interface Behaviour { - fun reduce(state: S, action: A): S = state - fun observe(receiver: C) {} + fun reduce(state: S, action: A): S = state + fun observe(receiver: C) {} } ``` @@ -68,9 +68,9 @@ Every module follows the same construction pattern: val behaviours = listOf(BehaviourA, BehaviourB, BehaviourC) val context = Context.create( - initialState = ..., - scope = scope, - behaviours = behaviours, + initialState = ..., + scope = scope, + behaviours = behaviours, ) val module = MyModule(context, ...) @@ -102,11 +102,11 @@ Group behaviours that share the same receiver type in a single file. Behaviours ```kotlin object PacketBehaviour : UDPConnectionBehaviour { - override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { - is UDPConnectionActions.LastPacket -> state.copy(...) - else -> state - } - override fun observe(receiver: UDPConnection) { ... } + override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { + is UDPConnectionActions.LastPacket -> state.copy(...) + else -> state + } + override fun observe(receiver: UDPConnection) { ... } } ``` @@ -114,14 +114,14 @@ object PacketBehaviour : UDPConnectionBehaviour { ```kotlin class FirmwareBehaviour(private val firmwareManager: FirmwareManager) : SolarXRConnectionBehaviour { - override fun observe(receiver: SolarXRConnection) { ... } + override fun observe(receiver: SolarXRConnection) { ... } } // At the call site: listOf( - DataFeedInitBehaviour, - FirmwareBehaviour(firmwareManager), - SerialBehaviour(serialServer), + DataFeedInitBehaviour, + FirmwareBehaviour(firmwareManager), + SerialBehaviour(serialServer), ) ``` @@ -180,16 +180,16 @@ To add a new major section of the server (say, a HID device connection): 1. **Define the state**: ```kotlin data class HIDConnectionState( - val deviceId: Int?, - val connected: Boolean, + val deviceId: Int?, + val connected: Boolean, ) ``` 2. **Define sealed actions**: ```kotlin sealed interface HIDConnectionActions { - data class Connected(val deviceId: Int) : HIDConnectionActions - data object Disconnected : HIDConnectionActions + data class Connected(val deviceId: Int) : HIDConnectionActions + data object Disconnected : HIDConnectionActions } ``` @@ -202,37 +202,37 @@ typealias HIDConnectionBehaviour = Behaviour Unit, + val context: HIDConnectionContext, + val serverContext: VRServer, + private val onSend: suspend (ByteArray) -> Unit, ) { - suspend fun send(bytes: ByteArray) = onSend(bytes) + suspend fun send(bytes: ByteArray) = onSend(bytes) } ``` 5. **Write behaviours** in a separate `behaviours.kt` file: ```kotlin object HIDHandshakeBehaviour : HIDConnectionBehaviour { - override fun reduce(state: HIDConnectionState, action: HIDConnectionActions) = when (action) { - is HIDConnectionActions.Connected -> state.copy(deviceId = action.deviceId, connected = true) - is HIDConnectionActions.Disconnected -> state.copy(connected = false) - } - override fun observe(receiver: HIDConnection) { - // launch coroutines, subscribe to events, etc. - } + override fun reduce(state: HIDConnectionState, action: HIDConnectionActions) = when (action) { + is HIDConnectionActions.Connected -> state.copy(deviceId = action.deviceId, connected = true) + is HIDConnectionActions.Disconnected -> state.copy(connected = false) + } + override fun observe(receiver: HIDConnection) { + // launch coroutines, subscribe to events, etc. + } } ``` 6. **Write a `companion object { fun create() }`**: ```kotlin companion object { - fun create(serverContext: VRServer, scope: CoroutineScope, send: suspend (ByteArray) -> Unit): HIDConnection { - val behaviours = listOf(HIDHandshakeBehaviour, ...) - val context = Context.create(initialState = ..., scope = scope, behaviours = behaviours) - val conn = HIDConnection(context, serverContext, send) - behaviours.forEach { it.observe(conn) } - return conn - } + fun create(serverContext: VRServer, scope: CoroutineScope, send: suspend (ByteArray) -> Unit): HIDConnection { + val behaviours = listOf(HIDHandshakeBehaviour, ...) + val context = Context.create(initialState = ..., scope = scope, behaviours = behaviours) + val conn = HIDConnection(context, serverContext, send) + behaviours.forEach { it.observe(conn) } + return conn + } } ``` @@ -252,7 +252,7 @@ Example: adding battery tracking to a HID connection requires only adding a `HID 2. In a behaviour's `observe`, register a listener: ```kotlin receiver.packetEvents.on { event -> - // handle it + // handle it } ``` 3. In `udp/server.kt`, route the new packet type to `emit`. @@ -313,4 +313,4 @@ Each client runs in its own `launch` block. When the socket disconnects, the cor - Module creation lives in `companion object { fun create(...) }`. - State data classes use `copy(...)` inside reducers and `Update { copy(...) }` actions — never expose a `MutableStateFlow` directly. - **Never use `var` in a state data class** — state must be immutable, all fields `val`. Using `var` in any data class is almost certainly a design mistake; if you need mutable fields, prefer a plain class or rethink the structure. -- Use `sealed interface` for action types, not `sealed class`, to avoid the extra constructor overhead. \ No newline at end of file +- Use `sealed interface` for action types, not `sealed class`, to avoid the extra constructor overhead. diff --git a/server/core/src/main/java/dev/slimevr/firmware/ota.kt b/server/core/src/main/java/dev/slimevr/firmware/ota.kt index d83077cf8d..90498f796b 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/ota.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/ota.kt @@ -157,8 +157,8 @@ suspend fun doOtaFlash( onStatus(FirmwareUpdateStatus.REBOOTING, 0) // Wait for the device to come back online after reboot. - // which don't emit a new VRServerState, are also observed. // flatMapLatest switches to the matched device's own state flow so that status changes, + // which don't emit a new VRServerState, are also observed. @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) val connected = withTimeoutOrNull(60_000) { server.context.state diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt index 1134d5410d..db57446f7d 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt @@ -37,6 +37,7 @@ private fun UserHeightCalibrationStatus.isTerminal() = when (this) { UserHeightCalibrationStatus.ERROR_TOO_HIGH, UserHeightCalibrationStatus.ERROR_TOO_SMALL, -> true + else -> false } @@ -51,13 +52,12 @@ private fun isHmdLeveled(snapshot: TrackerSnapshot): Boolean { } object CalibrationBehaviour : HeightCalibrationBehaviourType { - override fun reduce(state: HeightCalibrationState, action: HeightCalibrationActions) = - when (action) { - is HeightCalibrationActions.Update -> state.copy( - status = action.status, - currentHeight = action.currentHeight, - ) - } + override fun reduce(state: HeightCalibrationState, action: HeightCalibrationActions) = when (action) { + is HeightCalibrationActions.Update -> state.copy( + status = action.status, + currentHeight = action.currentHeight, + ) + } } internal suspend fun runCalibrationSession( @@ -84,7 +84,6 @@ internal suspend fun runCalibrationSession( dispatch(UserHeightCalibrationStatus.RECORDING_FLOOR) withTimeoutOrNull(TIMEOUT_MS) { - // Floor phase: collect controller updates until the floor level is locked in controllerUpdates .sample(SAMPLE_INTERVAL_MS) diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt index 3d1d749e7c..b5fcbf87cb 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt @@ -64,12 +64,14 @@ class HeightCalibrationManager( (bodyPart == BodyPart.LEFT_HAND || bodyPart == BodyPart.RIGHT_HAND) && it.context.state.value.position != null } if (controllers.isEmpty()) return@flatMapLatest emptyFlow() - combine(controllers.map { controller -> - controller.context.state.map { s -> - val position = s.position ?: error("hands (or Controller) will always have a position in this case") - TrackerSnapshot(position = position, rotation = s.rawRotation) - } - }) { snapshots -> snapshots.minByOrNull { it.position.y }!! } + combine( + controllers.map { controller -> + controller.context.state.map { s -> + val position = s.position ?: error("hands (or Controller) will always have a position in this case") + TrackerSnapshot(position = position, rotation = s.rawRotation) + } + }, + ) { snapshots -> snapshots.minByOrNull { it.position.y }!! } } fun start() { diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index d21d1bc9cb..2a8d14a45e 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -20,7 +20,7 @@ object HIDRegistrationBehaviour : HIDReceiverBehaviour { deviceId = action.deviceId, trackerId = null, ) - ), + ), ) else -> state diff --git a/server/core/src/main/java/dev/slimevr/serial/module.kt b/server/core/src/main/java/dev/slimevr/serial/module.kt index 42928a919e..c40abd6c5a 100644 --- a/server/core/src/main/java/dev/slimevr/serial/module.kt +++ b/server/core/src/main/java/dev/slimevr/serial/module.kt @@ -55,10 +55,12 @@ class SerialServer( if (conn is SerialConnection.Console) { conn.handle.close() } - context.dispatchAll(listOf( - SerialServerActions.RemoveConnection(portLocation), - SerialServerActions.PortLost(portLocation) - )) + context.dispatchAll( + listOf( + SerialServerActions.RemoveConnection(portLocation), + SerialServerActions.PortLost(portLocation), + ), + ) } fun onDataReceived(portLocation: String, line: String) { diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/windows.kt index 9bda92efd5..dd17b0869d 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/windows.kt @@ -25,6 +25,7 @@ private interface RegistryNotify : Library { const val REG_NOTIFY_CHANGE_LAST_SET = 0x00000004 } + @Suppress("FunctionName") fun RegNotifyChangeKeyValue( hKey: WinReg.HKEY, bWatchSubtree: Boolean, @@ -86,15 +87,25 @@ internal fun windowsVRCConfigFlow(): Flow = flow { try { if (hEvent != null) { RegistryNotify.INSTANCE.RegNotifyChangeKeyValue( - phkResult.value, false, RegistryNotify.REG_NOTIFY_CHANGE_LAST_SET, hEvent, true, + 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) } }, - )) + 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) } From 242920316bc1efebd306704df31a022810834258 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 30 Mar 2026 14:22:44 +0200 Subject: [PATCH 035/350] Packets bundling support --- flake.nix | 2 +- .../main/java/dev/slimevr/udp/behaviours.kt | 48 +++++- .../main/java/dev/slimevr/udp/connection.kt | 5 + .../src/main/java/dev/slimevr/udp/packets.kt | 151 +++++++++++++++++- 4 files changed, 200 insertions(+), 6 deletions(-) diff --git a/flake.nix b/flake.nix index 372fc5cada..55d3764279 100644 --- a/flake.nix +++ b/flake.nix @@ -12,7 +12,7 @@ perSystem = { pkgs, ... }: let - java = pkgs.jdk24; + java = pkgs.javaPackages.compiler.temurin-bin.jdk-24; runtimeLibs = pkgs: (with pkgs; [ java diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index dfcd7e7fb7..2ccffeb3c2 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -34,7 +34,9 @@ object PacketBehaviour : UDPConnectionBehaviour { if (now - state.lastPacket > CONNECTION_TIMEOUT_MS && packet.packetNumber == 0L) { receiver.context.dispatch(UDPConnectionActions.LastPacket(packetNum = 0, time = now)) AppLogger.udp.info("[${state.address}] Reconnecting") - } else if (packet.packetNumber < state.lastPacketNum) { + } else if (packet.packetNumber != null && packet.packetNumber < state.lastPacketNum) { + // Note: Packet number is nullable for bundled packets, as only the bundle packet itself has the number + // the packets inside of it do not AppLogger.udp.warn("[${state.address}] WARN: Received packet with wrong packet number") return@onAny } else { @@ -74,7 +76,7 @@ object PingBehaviour : UDPConnectionBehaviour { return@onPacket } - val ping = System.currentTimeMillis() - state.lastPing.startTime + val ping = (System.currentTimeMillis() - state.lastPing.startTime) / 2 val device = receiver.serverContext.getDevice(deviceId) ?: return@onPacket device.context.dispatch(DeviceActions.Update { copy(ping = ping) }) } @@ -226,3 +228,45 @@ object SensorRotationBehaviour : UDPConnectionBehaviour { } } } + +object BundledPacketBehaviour : UDPConnectionBehaviour { + override fun observe(receiver: UDPConnection) { + receiver.packetEvents.onPacket { event -> + for (packet in event.data.packets) { + if (!receiver.context.state.value.didHandshake && packet !is PreHandshakePacket) continue + // we set the packetNumber to null so we ignore the check + // it should be done by the parent packet + receiver.packetEvents.emit(PacketEvent(packet, packetNumber = null)) + } + } + + receiver.packetEvents.onPacket { event -> + for (packet in event.data.packets) { + if (!receiver.context.state.value.didHandshake && packet !is PreHandshakePacket) continue + // we set the packetNumber to null so we ignore the check + // it should be done by the parent packet + receiver.packetEvents.emit(PacketEvent(packet, packetNumber = null)) + } + } + } +} + +object FlagsBehaviour : UDPConnectionBehaviour { + override fun reduce( + state: UDPConnectionState, + action: UDPConnectionActions + ): UDPConnectionState { + return when (action) { + is UDPConnectionActions.FirmwareFeatures -> state.copy(features = action.features) + else -> state + } + } + + override fun observe(receiver: UDPConnection) { + receiver.packetEvents.onPacket { event -> + receiver.context.dispatch(UDPConnectionActions.FirmwareFeatures(event.data.firmwareFeatures)) + // send back the server features + receiver.send(FeatureFlags()) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 9f7dd2b7bb..5046a278f2 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -32,6 +32,7 @@ data class UDPConnectionState( val port: Int, val deviceId: Int?, val trackerIds: List, + val features: FirmwareFeatures?, ) sealed interface UDPConnectionActions { @@ -39,6 +40,7 @@ sealed interface UDPConnectionActions { data class Handshake(val deviceId: Int) : UDPConnectionActions data class LastPacket(val packetNum: Long? = null, val time: Long) : UDPConnectionActions data class AssignTracker(val trackerId: TrackerIdNum) : UDPConnectionActions + data class FirmwareFeatures(val features: dev.slimevr.udp.FirmwareFeatures) : UDPConnectionActions data object Disconnected : UDPConnectionActions } @@ -91,6 +93,8 @@ class UDPConnection( DeviceStatsBehaviour, SensorInfoBehaviour, SensorRotationBehaviour, + BundledPacketBehaviour, + FlagsBehaviour ) val context = Context.create( @@ -104,6 +108,7 @@ class UDPConnection( port = remotePort, deviceId = null, trackerIds = listOf(), + features = null ), scope = scope, behaviours = behaviours, diff --git a/server/core/src/main/java/dev/slimevr/udp/packets.kt b/server/core/src/main/java/dev/slimevr/udp/packets.kt index 67e21ab2d7..ceeca77f46 100644 --- a/server/core/src/main/java/dev/slimevr/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/udp/packets.kt @@ -3,6 +3,7 @@ package dev.slimevr.udp import dev.slimevr.EventDispatcher import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 +import io.ktor.utils.io.core.ByteReadPacket import io.ktor.utils.io.core.remaining import kotlinx.io.Sink import kotlinx.io.Source @@ -10,11 +11,80 @@ import kotlinx.io.readByteArray import kotlinx.io.readFloat import kotlinx.io.readString import kotlinx.io.readUByte +import kotlinx.io.readUShort +import kotlinx.io.write import kotlinx.io.writeUByte import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.BoardType import solarxr_protocol.datatypes.hardware_info.ImuType import solarxr_protocol.datatypes.hardware_info.McuType +import java.nio.ByteBuffer + +enum class ServerFeatureFlags { + /** Server can parse bundle packets: `PACKET_BUNDLE` = 100 (0x64). */ + PROTOCOL_BUNDLE_SUPPORT, + + /** Server can parse bundle packets with compact headers and packed IMU rotation/acceleration frames: + - `PACKET_BUNDLE_COMPACT` = 101 (0x65), + - `PACKET_ROTATION_AND_ACCELERATION` = 23 (0x17). */ + PROTOCOL_BUNDLE_COMPACT_SUPPORT + + ; + + companion object { + val flagsEnabled: Set = setOf( + PROTOCOL_BUNDLE_SUPPORT, + PROTOCOL_BUNDLE_COMPACT_SUPPORT + ) + + val packed: ByteArray by lazy { + val count = entries.size + val byteLength = if (count == 0) 0 else (count - 1) / 8 + 1 + val tempPacked = ByteArray(byteLength) + + for (flag in flagsEnabled) { + val bit = flag.ordinal + val byteIndex = bit / 8 + val bitIndex = bit % 8 + tempPacked[byteIndex] = (tempPacked[byteIndex].toInt() or (1 shl bitIndex)).toByte() + } + tempPacked + } + } +} + +class FirmwareFeatures { + enum class FirmwareFeatureFlags { + REMOTE_COMMAND, + B64_WIFI_SCANNING, + SENSOR_CONFIG; + + companion object { + // "Size + 7" ensures that 1-8 flags = 1 byte, 9-16 flags = 2 bytes, etc. + val byteCount = (entries.size + 7) / 8 + } + } + + private val flags = ByteArray(FirmwareFeatureFlags.byteCount) + + fun has(flag: FirmwareFeatureFlags): Boolean { + val bit = flag.ordinal + val byteIndex = bit / 8 + if (byteIndex >= flags.size) return false + + return (flags[byteIndex].toInt() and (1 shl (bit % 8))) != 0 + } + + companion object { + + fun from(received: ByteBuffer, length: Int): FirmwareFeatures { + val res = FirmwareFeatures() + val bytesToRead = res.flags.size.coerceAtMost(length) + received.get(res.flags, 0, bytesToRead) + return res + } + } +} private fun Source.readU8(): Int = readByte().toInt() and 0xFF @@ -55,6 +125,8 @@ enum class PacketType(val id: Int) { SET_CONFIG_FLAG(25), FLEX_DATA(26), POSITION(27), + PACKET_BUNDLE(100), + PACKET_BUNDLE_COMPACT(101), PROTOCOL_CHANGE(200), ; @@ -114,6 +186,68 @@ data class Handshake( } } +data class PacketBundle( + val packets: List, +): PreHandshakePacket { + companion object { + fun read(src: Source): PacketBundle = with(src) { + val readPackets = mutableListOf() + while (remaining >= 2) { + val bundlePacketLen = readUShort().toInt() + if (bundlePacketLen <= 0) continue + + val rawBytes = readByteArray(bundlePacketLen) + val subSrc = ByteReadPacket(rawBytes) + + subSrc.use { subSrc -> + if (subSrc.remaining >= 4) { + val packetId = subSrc.readInt() + val type = PacketType.fromId(packetId) + + if (type != null) { + // 4. Pass the isolated sub-source to your existing parser + val packetData = readPacket(type, subSrc) + readPackets.add(packetData) + } + } + } + } + return PacketBundle(packets = readPackets) + } + } +} + +data class PacketBundleCompact( + val packets: List, +): PreHandshakePacket { + companion object { + fun read(src: Source): PacketBundle = with(src) { + val readPackets = mutableListOf() + while (remaining >= 1) { + val bundlePacketLen = readUByte().toInt() + if (bundlePacketLen <= 0) continue + + val rawBytes = readByteArray(bundlePacketLen) + val subSrc = ByteReadPacket(rawBytes) + + subSrc.use { subSrc -> + if (subSrc.remaining >= 4) { + val packetId = subSrc.readUByte().toInt() + val type = PacketType.fromId(packetId) + + if (type != null) { + // 4. Pass the isolated sub-source to your existing parser + val packetData = readPacket(type, subSrc) + readPackets.add(packetData) + } + } + } + } + return PacketBundle(packets = readPackets) + } + } +} + data class Rotation(override val sensorId: Int = 0, val rotation: Quaternion = Quaternion.IDENTITY) : SensorSpecificPacket { companion object { fun read(src: Source) = Rotation(0, src.readSafeQuat()) @@ -243,9 +377,18 @@ data class UserActionPacket(val type: Int = 0) : UDPPacket { } } -data class FeatureFlags(val firmwareFeatures: ByteArray = byteArrayOf()) : UDPPacket { +data class FeatureFlags(val firmwareFeatures: FirmwareFeatures = FirmwareFeatures()) : UDPPacket { + override fun write(dst: Sink) { + dst.write(ServerFeatureFlags.packed) + } + companion object { - fun read(src: Source) = FeatureFlags(src.readByteArray(src.remaining.toInt())) + fun read(src: Source) = FeatureFlags( + FirmwareFeatures.from( + length = src.remaining.toInt(), + received = ByteBuffer.wrap(src.readByteArray()), + ) + ) } } @@ -328,6 +471,8 @@ fun readPacket(type: PacketType, src: Source): UDPPacket = when (type) { PacketType.SET_CONFIG_FLAG -> SetConfigFlag() PacketType.FLEX_DATA -> FlexData.read(src) PacketType.POSITION -> PositionPacket.read(src) + PacketType.PACKET_BUNDLE -> PacketBundle.read(src) + PacketType.PACKET_BUNDLE_COMPACT -> PacketBundleCompact.read(src) PacketType.PROTOCOL_CHANGE -> ProtocolChange.read(src) } @@ -351,7 +496,7 @@ fun writePacket(dst: Sink, packet: UDPPacket) { data class PacketEvent( val data: T, - val packetNumber: Long, + val packetNumber: Long?, ) typealias UDPPacketDispatcher = EventDispatcher> From a171bc2783059b4432cf28f8ceea505882caf1a6 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 1 Apr 2026 01:45:58 +0200 Subject: [PATCH 036/350] More udp tracker data --- .../main/java/dev/slimevr/context/context.kt | 4 +- .../main/java/dev/slimevr/solarxr/datafeed.kt | 44 ++++++++++--------- .../main/java/dev/slimevr/tracker/module.kt | 2 + .../main/java/dev/slimevr/udp/behaviours.kt | 19 ++++++++ .../main/java/dev/slimevr/udp/connection.kt | 3 +- .../src/main/java/dev/slimevr/udp/packets.kt | 1 + .../src/main/java/dev/slimevr/udp/server.kt | 3 +- .../github/axisangles/ktmath/EulerAngles.kt | 3 +- .../io/github/axisangles/ktmath/Matrix3.kt | 5 +-- .../io/github/axisangles/ktmath/Quaternion.kt | 3 +- .../io/github/axisangles/ktmath/Vector3.kt | 3 +- 11 files changed, 58 insertions(+), 32 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index abcddfd89f..6def9340b9 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -19,7 +19,9 @@ class Context( val state: StateFlow = mutableStateFlow.asStateFlow() fun dispatch(action: A) { - mutableStateFlow.update { applyAction(it, action) } + mutableStateFlow.update { + applyAction(it, action) + } } fun dispatchAll(actions: List) { diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index ae4a8a49ec..f03e7f95e8 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -21,6 +21,7 @@ import solarxr_protocol.data_feed.tracker.TrackerDataMask import solarxr_protocol.data_feed.tracker.TrackerInfo import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.Ipv4Address +import solarxr_protocol.datatypes.Temperature import solarxr_protocol.datatypes.TrackerId import solarxr_protocol.datatypes.hardware_info.HardwareInfo import solarxr_protocol.datatypes.hardware_info.HardwareStatus @@ -28,25 +29,28 @@ import solarxr_protocol.datatypes.math.Quat import solarxr_protocol.datatypes.math.Vec3f import java.nio.ByteBuffer -private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMask: TrackerDataMask): TrackerData = TrackerData( - trackerId = TrackerId( - trackerNum = tracker.id.toUByte(), - deviceId = DeviceId(device.id.toUByte()), - ), - status = if (trackerMask.status == true) device.status else null, - rotation = if (trackerMask.rotation == true) tracker.rawRotation.let { Quat(it.x, it.y, it.z, it.w) } else null, - position = if (trackerMask.position == true && tracker.position != null) tracker.position.let { Vec3f(it.x, it.y, it.z) } else null, - info = if (trackerMask.info == true) { - TrackerInfo( - imuType = tracker.sensorType, - bodyPart = tracker.bodyPart, - displayName = tracker.name, - ) - } else { - null - }, - tps = if (trackerMask.tps == true) tracker.tps else null, -) +private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMask: TrackerDataMask, datafeedConfig: DataFeedConfig): TrackerData { + return TrackerData( + trackerId = TrackerId( + trackerNum = tracker.id.toUByte(), + deviceId = DeviceId(device.id.toUByte()), + ), + status = if (trackerMask.status == true) device.status else null, + rotation = if (trackerMask.rotation == true) tracker.rawRotation.let { Quat(it.x, it.y, it.z, it.w) } else null, + position = if (trackerMask.position == true && tracker.position != null) tracker.position.let { Vec3f(it.x, it.y, it.z) } else null, + info = if (trackerMask.info == true) { + TrackerInfo( + imuType = tracker.sensorType, + bodyPart = tracker.bodyPart, + displayName = tracker.name, + ) + } else { + null + }, + tps = if (trackerMask.tps == true) tracker.tps else null, + temp = if (trackerMask.temp == true && tracker.imuTemp != null) Temperature(temp = tracker.imuTemp) else null, + ) +} private fun createDevice( device: DeviceState, @@ -73,7 +77,7 @@ private fun createDevice( ), trackers = if (trackerMask != null) { trackers.filter { it.deviceId == device.id } - .map { tracker -> createTracker(device, tracker, trackerMask) } + .map { tracker -> createTracker(device, tracker, trackerMask, datafeedConfig) } } else { null }, diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 68d4ec682d..828440d086 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -22,6 +22,7 @@ data class TrackerState( val deviceId: Int, val origin: DeviceOrigin, val tps: UShort, + val imuTemp: Float?, val position: Vector3?, ) @@ -56,6 +57,7 @@ class Tracker( sensorType = sensorType, position = null, tps = 0u, + imuTemp = null, ) val behaviours = listOf(TrackerBasicBehaviour, TrackerTPSBehaviour) diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 2ccffeb3c2..50fdee1852 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -226,6 +226,16 @@ object SensorRotationBehaviour : UDPConnectionBehaviour { val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = event.data.rotation) }) } + + receiver.packetEvents.onPacket { event -> + val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = event.data.rotation) }) + } + + receiver.packetEvents.onPacket { event -> + val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = event.data.rotation) }) + } } } @@ -270,3 +280,12 @@ object FlagsBehaviour : UDPConnectionBehaviour { } } } + +object TemperatureBehaviour : UDPConnectionBehaviour { + override fun observe(receiver: UDPConnection) { + receiver.packetEvents.onPacket { event -> + val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(imuTemp = event.data.temp) }) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 5046a278f2..f368c705db 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -94,7 +94,8 @@ class UDPConnection( SensorInfoBehaviour, SensorRotationBehaviour, BundledPacketBehaviour, - FlagsBehaviour + FlagsBehaviour, + TemperatureBehaviour ) val context = Context.create( diff --git a/server/core/src/main/java/dev/slimevr/udp/packets.kt b/server/core/src/main/java/dev/slimevr/udp/packets.kt index ceeca77f46..5ffc2bffa3 100644 --- a/server/core/src/main/java/dev/slimevr/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/udp/packets.kt @@ -496,6 +496,7 @@ fun writePacket(dst: Sink, packet: UDPPacket) { data class PacketEvent( val data: T, + // Packet number is optional for the inner packets of a bundle packet val packetNumber: Long?, ) diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index 2130b37c14..3e5c8fc0c8 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -34,6 +34,7 @@ suspend fun createUDPTrackerServer( val recvPacket = DatagramPacket(recvBuffer, recvBuffer.size) supervisorScope { + val udpScope = this launch(Dispatchers.IO) { while (isActive) { socket.receive(recvPacket) @@ -64,7 +65,7 @@ suspend fun createUDPTrackerServer( remotePort = port, socket = socket, serverContext = serverContext, - scope = this, + scope = udpScope, ) state.connections[ip] = newContext diff --git a/server/core/src/main/java/io/github/axisangles/ktmath/EulerAngles.kt b/server/core/src/main/java/io/github/axisangles/ktmath/EulerAngles.kt index 98dfac676b..b5f2d699d3 100644 --- a/server/core/src/main/java/io/github/axisangles/ktmath/EulerAngles.kt +++ b/server/core/src/main/java/io/github/axisangles/ktmath/EulerAngles.kt @@ -8,9 +8,8 @@ import kotlin.math.sin enum class EulerOrder { XYZ, YZX, ZXY, ZYX, YXZ, XZY } -@JvmInline @Serializable -value class EulerAngles(val order: EulerOrder, val x: Float, val y: Float, val z: Float) { +class EulerAngles(val order: EulerOrder, val x: Float, val y: Float, val z: Float) { operator fun component1(): EulerOrder = order operator fun component2(): Float = x operator fun component3(): Float = y diff --git a/server/core/src/main/java/io/github/axisangles/ktmath/Matrix3.kt b/server/core/src/main/java/io/github/axisangles/ktmath/Matrix3.kt index 5d787fc3b9..cd44ed5edb 100644 --- a/server/core/src/main/java/io/github/axisangles/ktmath/Matrix3.kt +++ b/server/core/src/main/java/io/github/axisangles/ktmath/Matrix3.kt @@ -5,9 +5,8 @@ package io.github.axisangles.ktmath import kotlinx.serialization.Serializable import kotlin.math.* -@JvmInline @Serializable -value class Matrix3 +class Matrix3 @Suppress("ktlint") constructor( val xx: Float, val yx: Float, val zx: Float, val xy: Float, val yy: Float, val zy: Float, @@ -474,4 +473,4 @@ data class ObjectMatrix3( operator fun Float.times(that: Matrix3): Matrix3 = that * this -operator fun Float.div(that: Matrix3): Matrix3 = that.inv() * this \ No newline at end of file +operator fun Float.div(that: Matrix3): Matrix3 = that.inv() * this diff --git a/server/core/src/main/java/io/github/axisangles/ktmath/Quaternion.kt b/server/core/src/main/java/io/github/axisangles/ktmath/Quaternion.kt index 1f4cccb398..fffdad4c64 100644 --- a/server/core/src/main/java/io/github/axisangles/ktmath/Quaternion.kt +++ b/server/core/src/main/java/io/github/axisangles/ktmath/Quaternion.kt @@ -5,9 +5,8 @@ package io.github.axisangles.ktmath import kotlinx.serialization.Serializable import kotlin.math.* -@JvmInline @Serializable -value class Quaternion(val w: Float, val x: Float, val y: Float, val z: Float) { +class Quaternion(val w: Float, val x: Float, val y: Float, val z: Float) { companion object { val NULL = Quaternion(0f, 0f, 0f, 0f) val IDENTITY = Quaternion(1f, 0f, 0f, 0f) diff --git a/server/core/src/main/java/io/github/axisangles/ktmath/Vector3.kt b/server/core/src/main/java/io/github/axisangles/ktmath/Vector3.kt index 1b1d8481f3..dabb1e5699 100644 --- a/server/core/src/main/java/io/github/axisangles/ktmath/Vector3.kt +++ b/server/core/src/main/java/io/github/axisangles/ktmath/Vector3.kt @@ -5,9 +5,8 @@ package io.github.axisangles.ktmath import kotlinx.serialization.Serializable import kotlin.math.* -@JvmInline @Serializable -value class Vector3(val x: Float, val y: Float, val z: Float) { +class Vector3(val x: Float, val y: Float, val z: Float) { companion object { val NULL = Vector3(0f, 0f, 0f) val POS_X = Vector3(1f, 0f, 0f) From ae5a8f4e978cc7569a5e8d18c3a13d6c4048ff20 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 8 Apr 2026 05:56:44 +0200 Subject: [PATCH 037/350] Wifi provisioning V0 --- .../java/dev/slimevr/firmware/behaviours.kt | 12 +- .../main/java/dev/slimevr/firmware/module.kt | 34 +-- .../main/java/dev/slimevr/firmware/serial.kt | 27 ++- .../dev/slimevr/provisioning/behaviours.kt | 19 ++ .../java/dev/slimevr/provisioning/module.kt | 105 +++++++++ .../java/dev/slimevr/provisioning/phases.kt | 218 ++++++++++++++++++ .../java/dev/slimevr/serial/behaviours.kt | 4 +- .../java/dev/slimevr/serial/connection.kt | 4 + .../main/java/dev/slimevr/solarxr/datafeed.kt | 1 + .../java/dev/slimevr/solarxr/provisioning.kt | 41 ++++ .../main/java/dev/slimevr/tracker/module.kt | 4 +- .../reducers/FirmwareManagerReducerTest.kt | 11 +- .../src/main/java/dev/slimevr/desktop/Main.kt | 4 + 13 files changed, 441 insertions(+), 43 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/provisioning/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/provisioning/module.kt create mode 100644 server/core/src/main/java/dev/slimevr/provisioning/phases.kt create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/provisioning.kt diff --git a/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt b/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt index 0dfee283e5..528199c9b4 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt @@ -3,17 +3,9 @@ package dev.slimevr.firmware object FirmwareManagerBaseBehaviour : FirmwareManagerBehaviour { override fun reduce(state: FirmwareManagerState, action: FirmwareManagerActions) = when (action) { is FirmwareManagerActions.UpdateJob -> state.copy( - jobs = state.jobs + - ( - action.portLocation to FirmwareJobStatus( - portLocation = action.portLocation, - firmwareDeviceId = action.firmwareDeviceId, - status = action.status, - progress = action.progress, - ) - ), + jobs = state.jobs + (action.jobStatus.portLocation to action.jobStatus), ) - is FirmwareManagerActions.RemoveJob -> state.copy(jobs = state.jobs - action.portLocation) + is FirmwareManagerActions.ClearJobs -> state.copy(jobs = mapOf()) } } diff --git a/server/core/src/main/java/dev/slimevr/firmware/module.kt b/server/core/src/main/java/dev/slimevr/firmware/module.kt index 203d23894f..72af72956b 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/module.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/module.kt @@ -27,17 +27,16 @@ data class FirmwareManagerState( sealed interface FirmwareManagerActions { data class UpdateJob( - val portLocation: String, - val firmwareDeviceId: FirmwareUpdateDeviceId, - val status: FirmwareUpdateStatus, - val progress: Int = 0, + val jobStatus: FirmwareJobStatus ) : FirmwareManagerActions data class RemoveJob(val portLocation: String) : FirmwareManagerActions + + data object ClearJobs : FirmwareManagerActions } typealias FirmwareManagerContext = Context -typealias FirmwareManagerBehaviour = Behaviour +typealias FirmwareManagerBehaviour = Behaviour class FirmwareManager( val context: FirmwareManagerContext, @@ -67,15 +66,18 @@ class FirmwareManager( onStatus = { status, progress -> context.dispatch( FirmwareManagerActions.UpdateJob( - portLocation = portLocation, - firmwareDeviceId = SerialDevicePort(port = portLocation), - status = status, - progress = progress, + FirmwareJobStatus( + portLocation = portLocation, + firmwareDeviceId = SerialDevicePort(port = portLocation), + status = status, + progress = progress, + ) ), ) }, scope = scope, ) + context.dispatch(FirmwareManagerActions.RemoveJob(portLocation)) } } @@ -95,18 +97,22 @@ class FirmwareManager( onStatus = { status, progress -> context.dispatch( FirmwareManagerActions.UpdateJob( - portLocation = deviceIp, - firmwareDeviceId = firmwareDeviceId, - status = status, - progress = progress, + FirmwareJobStatus( + portLocation = deviceIp, + firmwareDeviceId = firmwareDeviceId, + status = status, + progress = progress, + ) ), ) }, ) + context.dispatch(FirmwareManagerActions.RemoveJob(deviceIp)) } } suspend fun cancelAll() { + context.dispatch(FirmwareManagerActions.ClearJobs) runningJobs.values.forEach { it.cancelAndJoin() } runningJobs.clear() } @@ -120,7 +126,7 @@ class FirmwareManager( behaviours = behaviours, ) val manager = FirmwareManager(context = context, serialServer = serialServer, scope = scope) - behaviours.forEach { it.observe(context) } + behaviours.forEach { it.observe(manager) } return manager } } diff --git a/server/core/src/main/java/dev/slimevr/firmware/serial.kt b/server/core/src/main/java/dev/slimevr/firmware/serial.kt index a21903d200..4beae6fd93 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/serial.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/serial.kt @@ -3,6 +3,7 @@ package dev.slimevr.firmware import dev.llelievr.espflashkotlin.Flasher import dev.llelievr.espflashkotlin.FlashingProgressListener import dev.slimevr.VRServer +import dev.slimevr.serial.MAC_REGEX import dev.slimevr.serial.SerialConnection import dev.slimevr.serial.SerialServer import kotlinx.coroutines.CoroutineScope @@ -21,7 +22,6 @@ import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.FirmwarePart import solarxr_protocol.rpc.FirmwareUpdateStatus -private val MAC_REGEX = Regex("mac: (([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2})", RegexOption.IGNORE_CASE) @OptIn(ExperimentalCoroutinesApi::class) suspend fun doSerialFlash( @@ -183,16 +183,7 @@ internal suspend fun doSerialFlashPostFlash( } // wait for the tracker with that MAC to connect to the server via UDP - @OptIn(ExperimentalCoroutinesApi::class) - val connected = withTimeoutOrNull(60_000) { - server.context.state - .flatMapLatest { state -> - val device = state.devices.values.find { it.context.state.value.macAddress?.uppercase() == macAddress } - device?.context?.state?.map { it.status != TrackerStatus.DISCONNECTED } ?: flowOf(false) - } - .filter { it } - .first() - } + val connected = waitForConnected(server, macAddress) if (connected == null) { onStatus(FirmwareUpdateStatus.ERROR_TIMEOUT, 0) @@ -201,3 +192,17 @@ internal suspend fun doSerialFlashPostFlash( onStatus(FirmwareUpdateStatus.DONE, 0) } + +suspend fun waitForConnected(server: VRServer, macAddress: String): Boolean? = + @OptIn(ExperimentalCoroutinesApi::class) + withTimeoutOrNull(30_000) { + server.context.state + .flatMapLatest { state -> + val device = + state.devices.values.find { it.context.state.value.macAddress?.uppercase() == macAddress } + device?.context?.state?.map { it.status != TrackerStatus.DISCONNECTED } + ?: flowOf(false) + } + .filter { it } + .first() + } diff --git a/server/core/src/main/java/dev/slimevr/provisioning/behaviours.kt b/server/core/src/main/java/dev/slimevr/provisioning/behaviours.kt new file mode 100644 index 0000000000..e799f1bed3 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/provisioning/behaviours.kt @@ -0,0 +1,19 @@ +package dev.slimevr.provisioning + +import dev.slimevr.provisioning.ProvisioningManager.Companion.INITIAL_STATE + +object ProvisioningManagerBaseBehaviour : ProvisioningManagerBehaviour { + override fun reduce(state: ProvisioningManagerState, action: ProvisioningActions) = when (action) { + is ProvisioningActions.PortSelected -> state.copy( + portLocation = action.portLocation, + macAddress = null, + ) + is ProvisioningActions.StatusChanged -> state.copy( + status = action.status, + ) + is ProvisioningActions.MacAddressObtained -> state.copy( + macAddress = action.mac, + ) + is ProvisioningActions.Clear -> INITIAL_STATE + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/provisioning/module.kt b/server/core/src/main/java/dev/slimevr/provisioning/module.kt new file mode 100644 index 0000000000..ada8d617ba --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/provisioning/module.kt @@ -0,0 +1,105 @@ +package dev.slimevr.provisioning + +import dev.slimevr.VRServer +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context +import dev.slimevr.serial.SerialConnection +import dev.slimevr.serial.SerialServer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import solarxr_protocol.rpc.WifiProvisioningStatus + +data class ProvisioningManagerState( + val status: WifiProvisioningStatus, + val portLocation: String?, + val macAddress: String?, +) + +sealed interface ProvisioningActions { + data class PortSelected(val portLocation: String) : ProvisioningActions + data class StatusChanged(val status: WifiProvisioningStatus) : ProvisioningActions + data class MacAddressObtained(val mac: String) : ProvisioningActions + data object Clear : ProvisioningActions +} + +typealias ProvisioningManagerContext = Context +typealias ProvisioningManagerBehaviour = Behaviour + +data class ProvisioningManager( + val context: ProvisioningManagerContext, + private val serialServer: SerialServer, + private val scope: CoroutineScope, +) { + // Jobs cannot be held into a state / mutable flow + // as we cannot guarantee immutability + private var provisioningJob: Job? = null + + suspend fun stopProvisioning() { + provisioningJob?.cancelAndJoin() + context.dispatch(ProvisioningActions.Clear) + } + + suspend fun startProvisioning( + server: VRServer, + ssid: String, + password: String?, + port: String?, + ) { + val currentJob = provisioningJob + if (currentJob != null) { + currentJob.cancelAndJoin() + context.dispatch(ProvisioningActions.Clear) + } + provisioningJob = scope.launch { + // TODO handle port field, currently ignored + while (isActive) { + if (!selectAndOpenPort(context, serialServer)) continue + val portLocation = context.state.value.portLocation ?: continue + + val serialConn = + serialServer.context.state.value.connections[portLocation] as? SerialConnection.Console + + if (serialConn == null) { + context.dispatch(ProvisioningActions.StatusChanged(WifiProvisioningStatus.NO_SERIAL_DEVICE_FOUND)) + } else { + provisionPort(context, server, serialConn, ssid, password) + } + + // Any outcome (success or failure) waits for the port to disconnect + // before looking for the next tracker + serialServer.context.state.filter { portLocation !in it.availablePorts }.first() + context.dispatch(ProvisioningActions.Clear) + } + } + } + + companion object { + val INITIAL_STATE = ProvisioningManagerState( + status = WifiProvisioningStatus.NONE, + portLocation = null, + macAddress = null, + ) + + fun create(serialServer: SerialServer, scope: CoroutineScope): ProvisioningManager { + val behaviours = listOf(ProvisioningManagerBaseBehaviour) + val context = Context.create( + initialState = INITIAL_STATE, + scope = scope, + behaviours = behaviours, + ) + val manager = ProvisioningManager( + context = context, + serialServer = serialServer, + scope = scope + ) + behaviours.forEach { it.observe(manager) } + + return manager + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/provisioning/phases.kt b/server/core/src/main/java/dev/slimevr/provisioning/phases.kt new file mode 100644 index 0000000000..dd1d22e716 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/provisioning/phases.kt @@ -0,0 +1,218 @@ +package dev.slimevr.provisioning + +import dev.slimevr.VRServer +import dev.slimevr.firmware.waitForConnected +import dev.slimevr.serial.MAC_REGEX +import dev.slimevr.serial.SerialConnection +import dev.slimevr.serial.SerialConnectionActions +import dev.slimevr.serial.SerialServer +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withTimeoutOrNull +import solarxr_protocol.rpc.WifiProvisioningStatus + +internal const val MAX_CONNECTION_RETRIES = 3 + +// Waits for an available port, opens a serial connection, and dispatches PortSelected. +// Returns false if no port found within timeout. +internal suspend fun selectAndOpenPort( + context: ProvisioningManagerContext, + serialServer: SerialServer, +): Boolean { + val portEntry = withTimeoutOrNull(15_000) { + serialServer.context.state + .mapNotNull { state -> state.availablePorts.entries.firstOrNull() } + .first() + } + + if (portEntry == null) { + context.dispatch(ProvisioningActions.StatusChanged(WifiProvisioningStatus.NO_SERIAL_DEVICE_FOUND)) + delay(2_000) + return false + } + + context.dispatchAll( + listOf( + ProvisioningActions.PortSelected(portEntry.key), + ProvisioningActions.StatusChanged(WifiProvisioningStatus.SERIAL_INIT), + ) + ) + serialServer.openConnection(portEntry.key) + return true +} + +// Reboots the tracker and waits for a MAC address in the serial logs. +// Handles NO_SERIAL_LOGS_ERROR by blocking until logs appear (not counted as a retry). +// Dispatches MacAddressObtained on success. Returns false on failure. +internal suspend fun obtainMacAddress( + context: ProvisioningManagerContext, + serialConn: SerialConnection.Console, +): Boolean { + // Reboot and clear logs before MAC acquisition + serialConn.context.dispatch(SerialConnectionActions.ClearLogs) + serialConn.handle.writeCommand("REBOOT") + delay(2_000) + + while (currentCoroutineContext().isActive) { + context.dispatch(ProvisioningActions.StatusChanged(WifiProvisioningStatus.OBTAINING_MAC_ADDRESS)) + serialConn.handle.writeCommand("GET INFO") + + val mac = withTimeoutOrNull(5_000) { + serialConn.context.state.map { it.logLines } + .mapNotNull { lines -> + lines.firstNotNullOfOrNull { MAC_REGEX.find(it)?.groupValues?.get(1)?.uppercase() } + } + .first() + } + + if (mac != null) { + context.dispatch(ProvisioningActions.MacAddressObtained(mac)) + return true + } + + // If no logs arrived at all, the tracker is connected but silent. + // Show the error and block until logs appear, this is not a retry. + if (serialConn.context.state.value.logLines.isEmpty()) { + context.dispatch(ProvisioningActions.StatusChanged(WifiProvisioningStatus.NO_SERIAL_LOGS_ERROR)) + serialConn.context.state.filter { it.logLines.isNotEmpty() }.first() + + // The GET INFO response may have arrived while we were in the error state. + val existingMac = serialConn.context.state.value.logLines + .firstNotNullOfOrNull { MAC_REGEX.find(it)?.groupValues?.get(1)?.uppercase() } + if (existingMac != null) { + context.dispatch(ProvisioningActions.MacAddressObtained(existingMac)) + return true + } + + // No MAC yet, retry GET INFO without rebooting + continue + } + + // Got logs but no MAC after timeout, genuine error + context.dispatch(ProvisioningActions.StatusChanged(WifiProvisioningStatus.CONNECTION_ERROR)) + delay(3_000) + return false + } + return false +} + +// Sends Wi-Fi credentials and waits for acknowledgement. +// Returns false on timeout. +internal suspend fun sendCredentials( + context: ProvisioningManagerContext, + serialConn: SerialConnection.Console, + ssid: String, + password: String?, +): Boolean { + serialConn.context.dispatch(SerialConnectionActions.ClearLogs) + context.dispatch(ProvisioningActions.StatusChanged(WifiProvisioningStatus.PROVISIONING)) + serialConn.handle.writeCommand("SET WIFI \"$ssid\" \"${password ?: ""}\"\n") + + val acked = withTimeoutOrNull(5_000) { + serialConn.context.state.map { it.logLines } + .filter { lines -> lines.any { "new wifi credentials set" in it.lowercase() } } + .first() + } + + if (acked == null) { + context.dispatch(ProvisioningActions.StatusChanged(WifiProvisioningStatus.CONNECTION_ERROR)) + delay(3_000) + return false + } + return true +} + +// Waits for the tracker to reach "looking for server", retrying on "can't connect" up to MAX_CONNECTION_RETRIES. +// Returns false on timeout or exhausted retries. +internal suspend fun waitForWifiConnect( + context: ProvisioningManagerContext, + serialConn: SerialConnection.Console, +): Boolean { + + var connectRetries = 0 + + while (currentCoroutineContext().isActive) { + serialConn.context.dispatch(SerialConnectionActions.ClearLogs) + context.dispatch(ProvisioningActions.StatusChanged(WifiProvisioningStatus.CONNECTING)) + // null = timeout, true = looking for server, false = can't connect + val connectResult = withTimeoutOrNull(15_000) { + serialConn.context.state.map { it.logLines } + .mapNotNull { lines -> + when { + lines.any { + "looking for the server" in it.lowercase() || + "searching for the server" in it.lowercase() + } -> true + + lines.any { "can't connect from any credentials" in it.lowercase() } -> false + else -> null + } + } + .first() + } + + when (connectResult) { + true -> return true + + false -> if (connectRetries < MAX_CONNECTION_RETRIES) { + connectRetries++ + context.dispatch(ProvisioningActions.StatusChanged(WifiProvisioningStatus.CONNECTION_ERROR)) + delay(3_000) + serialConn.handle.writeCommand("REBOOT") + } else { + context.dispatch(ProvisioningActions.StatusChanged(WifiProvisioningStatus.CONNECTION_ERROR)) + delay(3_000) + return false + } + + else -> { + context.dispatch(ProvisioningActions.StatusChanged(WifiProvisioningStatus.CONNECTION_ERROR)) + delay(3_000) + return false + } + } + } + return false +} + +// Runs all provisioning phases for an already-opened serial connection. +// Dispatches DONE on success. Each phase dispatches its own error status on failure. +internal suspend fun provisionPort( + context: ProvisioningManagerContext, + server: VRServer, + serialConn: SerialConnection.Console, + ssid: String, + password: String?, +) { + if (!obtainMacAddress(context, serialConn)) return + val macAddress = context.state.value.macAddress ?: return + + if (!sendCredentials(context, serialConn, ssid, password)) return + if (!waitForWifiConnect(context, serialConn)) return + if (!waitForServerConnect(context, server, macAddress)) return + + context.dispatch(ProvisioningActions.StatusChanged(WifiProvisioningStatus.DONE)) +} + +// Waits for the tracker to connect to the server via UDP. +// Returns false on timeout. +internal suspend fun waitForServerConnect( + context: ProvisioningManagerContext, + server: VRServer, + macAddress: String, +): Boolean { + context.dispatch(ProvisioningActions.StatusChanged(WifiProvisioningStatus.LOOKING_FOR_SERVER)) + val connected = waitForConnected(server, macAddress) + + if (connected == null) { + context.dispatch(ProvisioningActions.StatusChanged(WifiProvisioningStatus.COULD_NOT_FIND_SERVER)) + delay(3_000) + return false + } + return true +} diff --git a/server/core/src/main/java/dev/slimevr/serial/behaviours.kt b/server/core/src/main/java/dev/slimevr/serial/behaviours.kt index b25d2b152c..353b83ba43 100644 --- a/server/core/src/main/java/dev/slimevr/serial/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/serial/behaviours.kt @@ -24,7 +24,7 @@ object SerialLogBehaviour : SerialConnectionBehaviour { val lines = if (state.logLines.size >= MAX_LOG_LINES) state.logLines.drop(1) else state.logLines state.copy(logLines = lines + action.line) } - - SerialConnectionActions.Disconnected -> state.copy(connected = false) + is SerialConnectionActions.ClearLogs -> state.copy(logLines = listOf()) + is SerialConnectionActions.Disconnected -> state.copy(connected = false) } } diff --git a/server/core/src/main/java/dev/slimevr/serial/connection.kt b/server/core/src/main/java/dev/slimevr/serial/connection.kt index 520dd178c6..c7365d95da 100644 --- a/server/core/src/main/java/dev/slimevr/serial/connection.kt +++ b/server/core/src/main/java/dev/slimevr/serial/connection.kt @@ -4,6 +4,9 @@ import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import kotlinx.coroutines.CoroutineScope +val MAC_REGEX = Regex("mac: (([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2})", RegexOption.IGNORE_CASE) + + data class SerialPortHandle( val portLocation: String, val descriptivePortName: String, @@ -20,6 +23,7 @@ data class SerialConnectionState( sealed interface SerialConnectionActions { data class LogLine(val line: String) : SerialConnectionActions + data object ClearLogs : SerialConnectionActions data object Disconnected : SerialConnectionActions } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index f03e7f95e8..a275ad10e0 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -43,6 +43,7 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas imuType = tracker.sensorType, bodyPart = tracker.bodyPart, displayName = tracker.name, + isImu = tracker.sensorType != null ) } else { null diff --git a/server/core/src/main/java/dev/slimevr/solarxr/provisioning.kt b/server/core/src/main/java/dev/slimevr/solarxr/provisioning.kt new file mode 100644 index 0000000000..3b36785d7f --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/provisioning.kt @@ -0,0 +1,41 @@ +package dev.slimevr.solarxr + +import dev.slimevr.VRServer +import dev.slimevr.provisioning.ProvisioningManager +import solarxr_protocol.rpc.StartWifiProvisioningRequest + +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import solarxr_protocol.rpc.StopWifiProvisioningRequest +import solarxr_protocol.rpc.WifiProvisioningStatusResponse + +class ProvisioningBehaviour( + private val server: VRServer, + private val provisioningManager: ProvisioningManager, +) : SolarXRConnectionBehaviour { + override fun observe(receiver: SolarXRConnection) { + receiver.rpcDispatcher.on { event -> + val ssid = event.ssid ?: return@on + + provisioningManager.startProvisioning( + server, + ssid, + event.password, + event.port, + ) + } + + receiver.rpcDispatcher.on { + provisioningManager.stopProvisioning() + } + + provisioningManager.context.state.drop(1).onEach { state -> + receiver.sendRpc( + WifiProvisioningStatusResponse( + status = state.status, + ), + ) + }.launchIn(receiver.context.scope) + } +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 828440d086..8aa0380842 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -15,7 +15,7 @@ data class TrackerState( val id: Int, val name: String, val hardwareId: String, - val sensorType: ImuType, + val sensorType: ImuType?, val bodyPart: BodyPart?, val customName: String?, val rawRotation: Quaternion, @@ -41,7 +41,7 @@ class Tracker( scope: CoroutineScope, id: Int, deviceId: Int, - sensorType: ImuType, + sensorType: ImuType?, hardwareId: String, origin: DeviceOrigin, ): Tracker { diff --git a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt index a6cf464433..c81b1bc235 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt @@ -1,6 +1,7 @@ package dev.slimevr.firmware.reducers import dev.slimevr.context.Context +import dev.slimevr.firmware.FirmwareJobStatus import dev.slimevr.firmware.FirmwareManagerActions import dev.slimevr.firmware.FirmwareManagerBaseBehaviour import dev.slimevr.firmware.FirmwareManagerState @@ -14,10 +15,12 @@ import kotlin.test.assertNull import kotlin.test.assertTrue private fun serialJob(port: String, status: FirmwareUpdateStatus, progress: Int = 0) = FirmwareManagerActions.UpdateJob( - portLocation = port, - firmwareDeviceId = SerialDevicePort(port = port), - status = status, - progress = progress, + FirmwareJobStatus( + portLocation = port, + firmwareDeviceId = SerialDevicePort(port = port), + status = status, + progress = progress, + ) ) class FirmwareManagerReducerTest { 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 ca454883d7..58dab4a0f3 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -10,10 +10,12 @@ import dev.slimevr.desktop.serial.createDesktopSerialServer import dev.slimevr.desktop.vrchat.createDesktopVRCConfigManager import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager +import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resolveConfigDirectory import dev.slimevr.solarxr.DataFeedInitBehaviour import dev.slimevr.solarxr.FirmwareBehaviour import dev.slimevr.solarxr.HeightCalibrationBehaviour +import dev.slimevr.solarxr.ProvisioningBehaviour import dev.slimevr.solarxr.SerialBehaviour import dev.slimevr.solarxr.VrcBehaviour import dev.slimevr.solarxr.createSolarXRWebsocketServer @@ -31,6 +33,7 @@ fun main(args: Array) = runBlocking { val vrcConfigManager = createDesktopVRCConfigManager(config = config, scope = this) val heightCalibrationManager = HeightCalibrationManager.create(serverContext = server, scope = this) + val provisioningManager = ProvisioningManager.create(serialServer, this) launch { createUDPTrackerServer(server, config) @@ -45,6 +48,7 @@ fun main(args: Array) = runBlocking { FirmwareBehaviour(firmwareManager), VrcBehaviour(vrcConfigManager, userHeight = { config.userConfig.context.state.value.data.userHeight.toDouble() }), HeightCalibrationBehaviour(heightCalibrationManager), + ProvisioningBehaviour(server, provisioningManager) ) launch { createSolarXRWebsocketServer(server, solarXRBehaviours) } launch { createIpcServers(server, solarXRBehaviours) } From 4e69262240a4b1de34bad01f8964baf01a65f883 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 8 Apr 2026 06:46:34 +0200 Subject: [PATCH 038/350] Wifi Provisioning - Unit tests --- .../provisioning/ProvisioningManagerTest.kt | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt diff --git a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt new file mode 100644 index 0000000000..2b87ef3064 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt @@ -0,0 +1,293 @@ +package dev.slimevr.provisioning + +import dev.slimevr.VRServer +import dev.slimevr.VRServerActions +import dev.slimevr.buildTestSerialServer +import dev.slimevr.buildTestVrServer +import dev.slimevr.device.Device +import dev.slimevr.device.DeviceActions +import dev.slimevr.device.DeviceOrigin +import dev.slimevr.serial.SerialPortInfo +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.rpc.WifiProvisioningStatus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +private fun fakePort(loc: String = "COM1") = SerialPortInfo(loc, "Fake $loc", 0x1A86, 0x7523) + +// Injects a fully connected device into the VRServer, simulating a tracker appearing on the network. +private fun connectDevice(vrServer: VRServer, mac: String, scope: CoroutineScope) { + val device = Device.create( + scope, + id = vrServer.nextHandle(), + address = "192.168.1.100", + macAddress = mac, + origin = DeviceOrigin.UDP, + protocolVersion = 0, + ) + vrServer.context.dispatch(VRServerActions.NewDevice(device.context.state.value.id, device)) + device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) }) +} + +@OptIn(ExperimentalCoroutinesApi::class) +class ProvisioningManagerTest { + + @Test + fun `NO_SERIAL_DEVICE_FOUND when no port appears within 15 seconds`() = runTest { + val serialServer = buildTestSerialServer(backgroundScope) + val manager = ProvisioningManager.create(serialServer, backgroundScope) + + manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) + + advanceTimeBy(15_001) + + assertEquals(WifiProvisioningStatus.NO_SERIAL_DEVICE_FOUND, manager.context.state.value.status) + } + + @Test + fun `OBTAINING_MAC_ADDRESS after port is detected and 2 second reboot delay elapses`() = runTest { + val serialServer = buildTestSerialServer(backgroundScope) + val manager = ProvisioningManager.create(serialServer, backgroundScope) + + manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) + serialServer.onPortDetected(fakePort()) + + advanceTimeBy(2_001) + + assertEquals(WifiProvisioningStatus.OBTAINING_MAC_ADDRESS, manager.context.state.value.status) + } + + @Test + fun `NO_SERIAL_LOGS_ERROR when tracker produces no serial output after 5 second timeout`() = runTest { + val serialServer = buildTestSerialServer(backgroundScope) + val manager = ProvisioningManager.create(serialServer, backgroundScope) + + manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) + serialServer.onPortDetected(fakePort()) + + // reboot delay (2s) + MAC acquisition timeout (5s) + advanceTimeBy(7_001) + + assertEquals(WifiProvisioningStatus.NO_SERIAL_LOGS_ERROR, manager.context.state.value.status) + } + + @Test + fun `recovers from NO_SERIAL_LOGS_ERROR and sets macAddress when logs appear containing the MAC`() = runTest { + val serialServer = buildTestSerialServer(backgroundScope) + val manager = ProvisioningManager.create(serialServer, backgroundScope) + + manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) + serialServer.onPortDetected(fakePort()) + + advanceTimeBy(7_001) + assertEquals(WifiProvisioningStatus.NO_SERIAL_LOGS_ERROR, manager.context.state.value.status) + + launch { + delay(100) + serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") + } + + advanceTimeBy(200) + + assertEquals("AA:BB:CC:DD:EE:FF", manager.context.state.value.macAddress) + } + + @Test + fun `CONNECTION_ERROR when logs are present but no MAC received within 5 second timeout`() = runTest { + val serialServer = buildTestSerialServer(backgroundScope) + val manager = ProvisioningManager.create(serialServer, backgroundScope) + + manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) + serialServer.onPortDetected(fakePort()) + + launch { + delay(500) + serialServer.onDataReceived("COM1", "some log line without a mac address") + } + + // reboot delay (2s) + MAC timeout (5s) + error delay (3s) + advanceTimeBy(10_001) + + assertEquals(WifiProvisioningStatus.CONNECTION_ERROR, manager.context.state.value.status) + } + + @Test + fun `CONNECTION_ERROR when WiFi credentials not acknowledged within 5 second timeout`() = runTest { + val serialServer = buildTestSerialServer(backgroundScope) + val manager = ProvisioningManager.create(serialServer, backgroundScope) + + manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) + serialServer.onPortDetected(fakePort()) + + launch { + delay(2_100) + serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") + // No credential acknowledgement sent + } + + // reboot delay (2s) + MAC at 2.1s + credential ack timeout (5s) + error delay (3s) + advanceTimeBy(10_200) + + assertEquals(WifiProvisioningStatus.CONNECTION_ERROR, manager.context.state.value.status) + } + + @Test + fun `CONNECTION_ERROR when WiFi does not connect within 15 second timeout`() = runTest { + val serialServer = buildTestSerialServer(backgroundScope) + val manager = ProvisioningManager.create(serialServer, backgroundScope) + + manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) + serialServer.onPortDetected(fakePort()) + + launch { + delay(2_100) + serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") + delay(100) + serialServer.onDataReceived("COM1", "new wifi credentials set") + // No WiFi connection log + } + + // reboot delay (2s) + MAC + creds ack + WiFi connect timeout (15s) + error delay (3s) + advanceTimeBy(20_500) + + assertEquals(WifiProvisioningStatus.CONNECTION_ERROR, manager.context.state.value.status) + } + + @Test + fun `CONNECTION_ERROR after exhausting all retries on can't connect`() = runTest { + val serialServer = buildTestSerialServer(backgroundScope) + val manager = ProvisioningManager.create(serialServer, backgroundScope) + + manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) + serialServer.onPortDetected(fakePort()) + + launch { + delay(2_100) + serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") + delay(100) + serialServer.onDataReceived("COM1", "new wifi credentials set") + // One initial attempt + MAX_CONNECTION_RETRIES retries; each retry needs a fresh "can't connect" + repeat(MAX_CONNECTION_RETRIES + 1) { + delay(100) + serialServer.onDataReceived("COM1", "can't connect from any credentials") + delay(3_000) // wait out the per-retry delay before the next window opens + } + } + + // 2200ms (MAC + creds) + 4 × (100ms "can't connect" + 3000ms retry delay) + 3000ms final delay + advanceTimeBy(18_000) + + assertEquals(WifiProvisioningStatus.CONNECTION_ERROR, manager.context.state.value.status) + } + + @Test + fun `COULD_NOT_FIND_SERVER when tracker does not appear on the network within 30 second timeout`() = runTest { + val serialServer = buildTestSerialServer(backgroundScope) + val manager = ProvisioningManager.create(serialServer, backgroundScope) + + manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) + serialServer.onPortDetected(fakePort()) + + launch { + delay(2_100) + serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") + delay(100) + serialServer.onDataReceived("COM1", "new wifi credentials set") + delay(100) + serialServer.onDataReceived("COM1", "looking for the server") + // No device connects to the server + } + + // reboot delay (2s) + MAC + creds + WiFi connect + server connect timeout (30s) + error delay (3s) + advanceTimeBy(35_500) + + assertEquals(WifiProvisioningStatus.COULD_NOT_FIND_SERVER, manager.context.state.value.status) + } + + @Test + fun `DONE when full provisioning succeeds`() = runTest { + val serialServer = buildTestSerialServer(backgroundScope) + val vrServer = buildTestVrServer(backgroundScope) + val manager = ProvisioningManager.create(serialServer, backgroundScope) + + manager.startProvisioning(vrServer, "wifi", "pass", null) + serialServer.onPortDetected(fakePort()) + + launch { + delay(2_100) + serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") + delay(100) + serialServer.onDataReceived("COM1", "new wifi credentials set") + delay(100) + serialServer.onDataReceived("COM1", "looking for the server") + delay(100) + connectDevice(vrServer, "AA:BB:CC:DD:EE:FF", backgroundScope) + } + + // Advance past the device connection but well before the 30s device connection timeout + advanceTimeBy(5_000) + + assertEquals(WifiProvisioningStatus.DONE, manager.context.state.value.status) + } + + @Test + fun `resets to NONE and clears port after USB disconnect following DONE`() = runTest { + val serialServer = buildTestSerialServer(backgroundScope) + val vrServer = buildTestVrServer(backgroundScope) + val manager = ProvisioningManager.create(serialServer, backgroundScope) + + manager.startProvisioning(vrServer, "wifi", "pass", null) + serialServer.onPortDetected(fakePort()) + + launch { + delay(2_100) + serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") + delay(100) + serialServer.onDataReceived("COM1", "new wifi credentials set") + delay(100) + serialServer.onDataReceived("COM1", "looking for the server") + delay(100) + connectDevice(vrServer, "AA:BB:CC:DD:EE:FF", backgroundScope) + } + + // Advance past the device connection but well before the 30s server-connect timeout + advanceTimeBy(5_000) + assertEquals(WifiProvisioningStatus.DONE, manager.context.state.value.status) + + serialServer.onPortLost("COM1") + advanceTimeBy(1) + + assertEquals(WifiProvisioningStatus.NONE, manager.context.state.value.status) + assertNull(manager.context.state.value.portLocation) + } + + @Test + fun `does not retry the same port after failure - waits for USB disconnect`() = runTest { + val serialServer = buildTestSerialServer(backgroundScope) + val manager = ProvisioningManager.create(serialServer, backgroundScope) + + manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) + serialServer.onPortDetected(fakePort()) + + launch { + delay(500) + serialServer.onDataReceived("COM1", "some log without mac") + } + + // Reach CONNECTION_ERROR: reboot delay (2s) + MAC timeout (5s) + error delay (3s) + advanceTimeBy(10_001) + assertEquals(WifiProvisioningStatus.CONNECTION_ERROR, manager.context.state.value.status) + + // Port is still connected, advance significant time and confirm provisioning + // is blocked waiting for disconnect, not looping back to select a new port + advanceTimeBy(30_000) + assertEquals(WifiProvisioningStatus.CONNECTION_ERROR, manager.context.state.value.status) + } +} From bf3168af2b14ce98dc2d55ef32c40202f18baa91 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 8 Apr 2026 07:34:24 +0200 Subject: [PATCH 039/350] Proper tps calculation --- .../core/src/main/java/dev/slimevr/tracker/behaviours.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 3030e881e9..3a7ba0d7b0 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -1,5 +1,6 @@ package dev.slimevr.tracker +import kotlin.time.TimeSource import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.launchIn @@ -24,9 +25,13 @@ object TrackerTPSBehaviour : TrackerBehaviour { }.launchIn(receiver.scope) receiver.scope.launch { + var mark = TimeSource.Monotonic.markNow() while (isActive) { delay(1000) - receiver.dispatch(TrackerActions.Update { copy(tps = count.exchange(0).toUShort()) }) + val elapsed = mark.elapsedNow() + val tps = count.exchange(0) * 1000L / elapsed.inWholeMilliseconds + receiver.dispatch(TrackerActions.Update { copy(tps = tps.toUShort()) }) + mark = TimeSource.Monotonic.markNow() } } } From 5aaade29152e3995b4c55a0c339cb93b87c1d0e1 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Thu, 2 Apr 2026 09:43:36 -0400 Subject: [PATCH 040/350] Basic skeleton structure --- .../main/java/dev/slimevr/skeleton/module.kt | 144 +++++++++++++++++- .../main/java/dev/slimevr/solarxr/datafeed.kt | 18 +++ 2 files changed, 154 insertions(+), 8 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index a318db88d7..c38a93fb6a 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -1,19 +1,147 @@ package dev.slimevr.skeleton +import com.jme3.math.FastMath import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.BodyPart -data class Bone( +data class BoneState( val bodyPart: BodyPart, - val localRotation: Quaternion, - val parentBone: Bone?, - val childBone: List, + val length: Float, + val rotation: Quaternion, + val headPosition: Vector3, + val tailPosition: Vector3, + val parentBone: BoneState?, + val childBones: List, ) { - val globalRotation: Quaternion - get() = localRotation // FIXME: do maths LMAO + val localRotation: Quaternion + get() = parentBone?.let { it.rotation.inv() * rotation } ?: rotation + val localHeadPosition: Vector3 + get() = parentBone?.let { headPosition - it.tailPosition } ?: headPosition + val localTailPosition: Vector3 + get() = tailPosition - headPosition } data class SkeletonState( - val bones: Map, - val rootBone: Bone, + val bones: Map, + val rootBone: BoneState, ) + +fun makeBone(bodyPart: BodyPart, parent: BoneState? = null, length: Float = 0.1f): BoneState { + val head = parent?.tailPosition ?: Vector3.NULL + val offset = when (bodyPart) { + BodyPart.HEAD -> Vector3(0f, 0f, length) + BodyPart.LEFT_HAND, BodyPart.RIGHT_HAND -> Vector3(0f, 0f, -length) + BodyPart.LEFT_SHOULDER -> Vector3(-length, -0.08f, 0f) + BodyPart.RIGHT_SHOULDER -> Vector3(length, -0.08f, 0f) + BodyPart.LEFT_HIP -> Vector3(-length, 0f, 0f) + BodyPart.RIGHT_HIP -> Vector3(length, 0f, 0f) + else -> Vector3(0f, -length, 0f) + } + val rot = when (bodyPart) { + BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> Quaternion.rotationAroundXAxis(FastMath.HALF_PI) + else -> Quaternion.IDENTITY + } + + val bone = BoneState( + bodyPart = bodyPart, + length = length, + rotation = rot, + headPosition = head, + tailPosition = head + offset, + parentBone = parent, + childBones = mutableListOf(), + ) + (parent?.childBones as? MutableList)?.add(bone) + return bone +} + +suspend fun SequenceScope.visitBone(bone: BoneState) { + yield(bone) + bone.childBones.forEach { visitBone(it) } +} + +fun navigateHierarchy(rootBone: BoneState): Sequence = sequence { + visitBone(rootBone) +} + +val DEFAULT_SKELETON_STATE = run { + // Head/torso + val head = makeBone(BodyPart.HEAD, length = 0.1f) + val neck = makeBone(BodyPart.NECK, head, 0.1f) + val upperChest = makeBone(BodyPart.UPPER_CHEST, neck, 0.16f) + val chest = makeBone(BodyPart.CHEST, upperChest, 0.16f) + val waist = makeBone(BodyPart.WAIST, chest, 0.2f) + val hip = makeBone(BodyPart.HIP, waist, 0.04f) + + // Left leg + val leftHip = makeBone(BodyPart.LEFT_HIP, hip, 0.13f) + val leftUpperLeg = makeBone(BodyPart.LEFT_UPPER_LEG, leftHip, 0.42f) + val leftLowerLeg = makeBone(BodyPart.LEFT_LOWER_LEG, leftUpperLeg, 0.5f) + val leftFoot = makeBone(BodyPart.LEFT_FOOT, leftLowerLeg, 0.05f) + + // Right leg + val rightHip = makeBone(BodyPart.RIGHT_HIP, hip, 0.13f) + val rightUpperLeg = makeBone(BodyPart.RIGHT_UPPER_LEG, rightHip, 0.42f) + val rightLowerLeg = makeBone(BodyPart.RIGHT_LOWER_LEG, rightUpperLeg, 0.5f) + val rightFoot = makeBone(BodyPart.RIGHT_FOOT, rightLowerLeg, 0.05f) + + // Left arm + val leftShoulder = makeBone(BodyPart.LEFT_SHOULDER, neck, 0.175f) + val leftUpperArm = makeBone(BodyPart.LEFT_UPPER_ARM, leftShoulder, 0.26f) + val leftLowerArm = makeBone(BodyPart.LEFT_LOWER_ARM, leftUpperArm, 0.26f) + val leftHand = makeBone(BodyPart.LEFT_HAND, leftLowerArm, 0.13f) + + // Left fingers + val leftThumbMetacarpal = makeBone(BodyPart.LEFT_THUMB_METACARPAL, leftHand, 0.025f) + val leftThumbProximal = makeBone(BodyPart.LEFT_THUMB_PROXIMAL, leftThumbMetacarpal, 0.025f) + val leftThumbDistal = makeBone(BodyPart.LEFT_THUMB_DISTAL, leftThumbProximal, 0.025f) + + val leftIndexProximal = makeBone(BodyPart.LEFT_INDEX_PROXIMAL, leftHand, 0.025f) + val leftIndexIntermediate = makeBone(BodyPart.LEFT_INDEX_INTERMEDIATE, leftIndexProximal, 0.025f) + val leftIndexDistal = makeBone(BodyPart.LEFT_INDEX_DISTAL, leftIndexIntermediate, 0.025f) + + val leftMiddleProximal = makeBone(BodyPart.LEFT_MIDDLE_PROXIMAL, leftHand, 0.025f) + val leftMiddleIntermediate = makeBone(BodyPart.LEFT_MIDDLE_INTERMEDIATE, leftMiddleProximal, 0.025f) + val leftMiddleDistal = makeBone(BodyPart.LEFT_MIDDLE_DISTAL, leftMiddleIntermediate, 0.025f) + + val leftRingProximal = makeBone(BodyPart.LEFT_RING_PROXIMAL, leftHand, 0.025f) + val leftRingIntermediate = makeBone(BodyPart.LEFT_RING_INTERMEDIATE, leftRingProximal, 0.025f) + val leftRingDistal = makeBone(BodyPart.LEFT_RING_DISTAL, leftRingIntermediate, 0.025f) + + val leftLittleProximal = makeBone(BodyPart.LEFT_LITTLE_PROXIMAL, leftHand, 0.025f) + val leftLittleIntermediate = makeBone(BodyPart.LEFT_LITTLE_INTERMEDIATE, leftLittleProximal, 0.025f) + val leftLittleDistal = makeBone(BodyPart.LEFT_LITTLE_DISTAL, leftLittleIntermediate, 0.025f) + + // Right arm + val rightShoulder = makeBone(BodyPart.RIGHT_SHOULDER, neck, 0.175f) + val rightUpperArm = makeBone(BodyPart.RIGHT_UPPER_ARM, rightShoulder, 0.26f) + val rightLowerArm = makeBone(BodyPart.RIGHT_LOWER_ARM, rightUpperArm, 0.26f) + val rightHand = makeBone(BodyPart.RIGHT_HAND, rightLowerArm, 0.13f) + + // Right fingers + val rightThumbMetacarpal = makeBone(BodyPart.RIGHT_THUMB_METACARPAL, rightHand, 0.025f) + val rightThumbProximal = makeBone(BodyPart.RIGHT_THUMB_PROXIMAL, rightThumbMetacarpal, 0.025f) + val rightThumbDistal = makeBone(BodyPart.RIGHT_THUMB_DISTAL, rightThumbProximal, 0.025f) + + val rightIndexProximal = makeBone(BodyPart.RIGHT_INDEX_PROXIMAL, rightHand, 0.025f) + val rightIndexIntermediate = makeBone(BodyPart.RIGHT_INDEX_INTERMEDIATE, rightIndexProximal, 0.025f) + val rightIndexDistal = makeBone(BodyPart.RIGHT_INDEX_DISTAL, rightIndexIntermediate, 0.025f) + + val rightMiddleProximal = makeBone(BodyPart.RIGHT_MIDDLE_PROXIMAL, rightHand, 0.025f) + val rightMiddleIntermediate = makeBone(BodyPart.RIGHT_MIDDLE_INTERMEDIATE, rightMiddleProximal, 0.025f) + val rightMiddleDistal = makeBone(BodyPart.RIGHT_MIDDLE_DISTAL, rightMiddleIntermediate, 0.025f) + + val rightRingProximal = makeBone(BodyPart.RIGHT_RING_PROXIMAL, rightHand, 0.025f) + val rightRingIntermediate = makeBone(BodyPart.RIGHT_RING_INTERMEDIATE, rightRingProximal, 0.025f) + val rightRingDistal = makeBone(BodyPart.RIGHT_RING_DISTAL, rightRingIntermediate, 0.025f) + + val rightLittleProximal = makeBone(BodyPart.RIGHT_LITTLE_PROXIMAL, rightHand, 0.025f) + val rightLittleIntermediate = makeBone(BodyPart.RIGHT_LITTLE_INTERMEDIATE, rightLittleProximal, 0.025f) + val rightLittleDistal = makeBone(BodyPart.RIGHT_LITTLE_DISTAL, rightLittleIntermediate, 0.025f) + + SkeletonState( + bones = navigateHierarchy(head).associateBy { it.bodyPart }, + rootBone = head, + ) +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index a275ad10e0..83ae997d9d 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -3,6 +3,8 @@ package dev.slimevr.solarxr import com.google.flatbuffers.FlatBufferBuilder import dev.slimevr.VRServer import dev.slimevr.device.DeviceState +import dev.slimevr.skeleton.BoneState +import dev.slimevr.skeleton.DEFAULT_SKELETON_STATE import dev.slimevr.tracker.TrackerState import io.ktor.util.moveToByteArray import kotlinx.coroutines.cancelAndJoin @@ -10,6 +12,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import solarxr_protocol.MessageBundle +import solarxr_protocol.data_feed.Bone import solarxr_protocol.data_feed.DataFeedConfig import solarxr_protocol.data_feed.DataFeedMessageHeader import solarxr_protocol.data_feed.DataFeedUpdate @@ -85,6 +88,15 @@ private fun createDevice( ) } +private fun createBone( + bone: BoneState, +): Bone = Bone( + bodyPart = bone.bodyPart, + rotationG = bone.rotation.let { Quat(it.x, it.y, it.z, it.w) }, + boneLength = bone.length, + headPositionG = bone.headPosition.let { Vec3f(it.x, it.y, it.z) }, +) + fun createDatafeedFrame( serverContext: VRServer, datafeedConfig: DataFeedConfig, @@ -94,9 +106,15 @@ fun createDatafeedFrame( val trackers = serverState.trackers.values.map { it.context.state.value } val devices = serverState.devices.values.map { it.context.state.value } .map { device -> createDevice(device, trackers, datafeedConfig) } + val bones = if (datafeedConfig.boneMask) { + DEFAULT_SKELETON_STATE.bones.values.map { createBone(it) } + } else { + null + } return DataFeedMessageHeader( message = DataFeedUpdate( devices = if (datafeedConfig.dataMask?.deviceData != null) devices else null, + bones = bones, index = index.toUByte(), ), ) From d1b96a5c6b3d661b13eb0b0c4051fd42283b7dc3 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Wed, 8 Apr 2026 03:03:44 -0400 Subject: [PATCH 041/350] Use body part map for skeleton --- .../dev/slimevr/skeleton/bodypartstructure.kt | 65 +++++++++++++++++++ .../main/java/dev/slimevr/skeleton/module.kt | 4 -- 2 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt b/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt new file mode 100644 index 0000000000..8a03a952bd --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt @@ -0,0 +1,65 @@ +package dev.slimevr.skeleton + +import solarxr_protocol.datatypes.BodyPart + +val BODY_PART_CHILD_MAP = mapOf( + BodyPart.HEAD to arrayOf(BodyPart.NECK), + BodyPart.NECK to arrayOf(BodyPart.UPPER_CHEST, BodyPart.LEFT_SHOULDER, BodyPart.RIGHT_SHOULDER), + + BodyPart.LEFT_SHOULDER to arrayOf(BodyPart.LEFT_UPPER_ARM), + BodyPart.LEFT_UPPER_ARM to arrayOf(BodyPart.LEFT_LOWER_ARM), + BodyPart.LEFT_LOWER_ARM to arrayOf(BodyPart.LEFT_HAND), + + BodyPart.LEFT_HAND to arrayOf( + BodyPart.LEFT_THUMB_METACARPAL, + BodyPart.LEFT_INDEX_PROXIMAL, + BodyPart.LEFT_MIDDLE_PROXIMAL, + BodyPart.LEFT_RING_PROXIMAL, + BodyPart.LEFT_LITTLE_PROXIMAL, + ), + BodyPart.LEFT_THUMB_METACARPAL to arrayOf(BodyPart.LEFT_THUMB_PROXIMAL), + BodyPart.LEFT_THUMB_PROXIMAL to arrayOf(BodyPart.LEFT_THUMB_DISTAL), + BodyPart.LEFT_INDEX_PROXIMAL to arrayOf(BodyPart.LEFT_INDEX_INTERMEDIATE), + BodyPart.LEFT_INDEX_INTERMEDIATE to arrayOf(BodyPart.LEFT_INDEX_DISTAL), + BodyPart.LEFT_MIDDLE_PROXIMAL to arrayOf(BodyPart.LEFT_MIDDLE_INTERMEDIATE), + BodyPart.LEFT_MIDDLE_INTERMEDIATE to arrayOf(BodyPart.LEFT_MIDDLE_DISTAL), + BodyPart.LEFT_RING_PROXIMAL to arrayOf(BodyPart.LEFT_RING_INTERMEDIATE), + BodyPart.LEFT_RING_INTERMEDIATE to arrayOf(BodyPart.LEFT_RING_DISTAL), + BodyPart.LEFT_LITTLE_PROXIMAL to arrayOf(BodyPart.LEFT_LITTLE_INTERMEDIATE), + BodyPart.LEFT_LITTLE_INTERMEDIATE to arrayOf(BodyPart.LEFT_LITTLE_DISTAL), + + BodyPart.RIGHT_SHOULDER to arrayOf(BodyPart.RIGHT_UPPER_ARM), + BodyPart.RIGHT_UPPER_ARM to arrayOf(BodyPart.RIGHT_LOWER_ARM), + BodyPart.RIGHT_LOWER_ARM to arrayOf(BodyPart.RIGHT_HAND), + + BodyPart.RIGHT_HAND to arrayOf( + BodyPart.RIGHT_THUMB_METACARPAL, + BodyPart.RIGHT_INDEX_PROXIMAL, + BodyPart.RIGHT_MIDDLE_PROXIMAL, + BodyPart.RIGHT_RING_PROXIMAL, + BodyPart.RIGHT_LITTLE_PROXIMAL, + ), + BodyPart.RIGHT_THUMB_METACARPAL to arrayOf(BodyPart.RIGHT_THUMB_PROXIMAL), + BodyPart.RIGHT_THUMB_PROXIMAL to arrayOf(BodyPart.RIGHT_THUMB_DISTAL), + BodyPart.RIGHT_INDEX_PROXIMAL to arrayOf(BodyPart.RIGHT_INDEX_INTERMEDIATE), + BodyPart.RIGHT_INDEX_INTERMEDIATE to arrayOf(BodyPart.RIGHT_INDEX_DISTAL), + BodyPart.RIGHT_MIDDLE_PROXIMAL to arrayOf(BodyPart.RIGHT_MIDDLE_INTERMEDIATE), + BodyPart.RIGHT_MIDDLE_INTERMEDIATE to arrayOf(BodyPart.RIGHT_MIDDLE_DISTAL), + BodyPart.RIGHT_RING_PROXIMAL to arrayOf(BodyPart.RIGHT_RING_INTERMEDIATE), + BodyPart.RIGHT_RING_INTERMEDIATE to arrayOf(BodyPart.RIGHT_RING_DISTAL), + BodyPart.RIGHT_LITTLE_PROXIMAL to arrayOf(BodyPart.RIGHT_LITTLE_INTERMEDIATE), + BodyPart.RIGHT_LITTLE_INTERMEDIATE to arrayOf(BodyPart.RIGHT_LITTLE_DISTAL), + + BodyPart.UPPER_CHEST to arrayOf(BodyPart.CHEST), + BodyPart.CHEST to arrayOf(BodyPart.WAIST), + BodyPart.WAIST to arrayOf(BodyPart.HIP), + BodyPart.HIP to arrayOf(BodyPart.LEFT_HIP, BodyPart.RIGHT_HIP), + + BodyPart.LEFT_HIP to arrayOf(BodyPart.LEFT_UPPER_LEG), + BodyPart.LEFT_UPPER_LEG to arrayOf(BodyPart.LEFT_LOWER_LEG), + BodyPart.LEFT_LOWER_LEG to arrayOf(BodyPart.LEFT_FOOT), + + BodyPart.RIGHT_HIP to arrayOf(BodyPart.RIGHT_UPPER_LEG), + BodyPart.RIGHT_UPPER_LEG to arrayOf(BodyPart.RIGHT_LOWER_LEG), + BodyPart.RIGHT_LOWER_LEG to arrayOf(BodyPart.RIGHT_FOOT), +) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index c38a93fb6a..2c1c025fb5 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -12,7 +12,6 @@ data class BoneState( val headPosition: Vector3, val tailPosition: Vector3, val parentBone: BoneState?, - val childBones: List, ) { val localRotation: Quaternion get() = parentBone?.let { it.rotation.inv() * rotation } ?: rotation @@ -50,15 +49,12 @@ fun makeBone(bodyPart: BodyPart, parent: BoneState? = null, length: Float = 0.1f headPosition = head, tailPosition = head + offset, parentBone = parent, - childBones = mutableListOf(), ) - (parent?.childBones as? MutableList)?.add(bone) return bone } suspend fun SequenceScope.visitBone(bone: BoneState) { yield(bone) - bone.childBones.forEach { visitBone(it) } } fun navigateHierarchy(rootBone: BoneState): Sequence = sequence { From 6266802a27de608106864b7c4f96994b025d2439 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Wed, 8 Apr 2026 03:53:49 -0400 Subject: [PATCH 042/350] Use BodyPart map to make skeleton --- .../dev/slimevr/skeleton/bodypartstructure.kt | 10 + .../main/java/dev/slimevr/skeleton/module.kt | 181 +++++++----------- 2 files changed, 81 insertions(+), 110 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt b/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt index 8a03a952bd..8b169b4587 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt @@ -63,3 +63,13 @@ val BODY_PART_CHILD_MAP = mapOf( BodyPart.RIGHT_UPPER_LEG to arrayOf(BodyPart.RIGHT_LOWER_LEG), BodyPart.RIGHT_LOWER_LEG to arrayOf(BodyPart.RIGHT_FOOT), ) + +private suspend fun SequenceScope>.visitBone(parentBone: BodyPart?, bone: BodyPart) { + yield(Pair(parentBone, bone)) + val children = BODY_PART_CHILD_MAP[bone] ?: return + for (child in children) visitBone(bone, child) +} + +fun iterateBoneHierarchy() = sequence { + visitBone(null, BodyPart.HEAD) +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 2c1c025fb5..dc3ecefea5 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -26,118 +26,79 @@ data class SkeletonState( val rootBone: BoneState, ) -fun makeBone(bodyPart: BodyPart, parent: BoneState? = null, length: Float = 0.1f): BoneState { - val head = parent?.tailPosition ?: Vector3.NULL - val offset = when (bodyPart) { - BodyPart.HEAD -> Vector3(0f, 0f, length) - BodyPart.LEFT_HAND, BodyPart.RIGHT_HAND -> Vector3(0f, 0f, -length) - BodyPart.LEFT_SHOULDER -> Vector3(-length, -0.08f, 0f) - BodyPart.RIGHT_SHOULDER -> Vector3(length, -0.08f, 0f) - BodyPart.LEFT_HIP -> Vector3(-length, 0f, 0f) - BodyPart.RIGHT_HIP -> Vector3(length, 0f, 0f) - else -> Vector3(0f, -length, 0f) - } - val rot = when (bodyPart) { - BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> Quaternion.rotationAroundXAxis(FastMath.HALF_PI) - else -> Quaternion.IDENTITY - } - - val bone = BoneState( - bodyPart = bodyPart, - length = length, - rotation = rot, - headPosition = head, - tailPosition = head + offset, - parentBone = parent, - ) - return bone -} - -suspend fun SequenceScope.visitBone(bone: BoneState) { - yield(bone) -} - -fun navigateHierarchy(rootBone: BoneState): Sequence = sequence { - visitBone(rootBone) -} - val DEFAULT_SKELETON_STATE = run { - // Head/torso - val head = makeBone(BodyPart.HEAD, length = 0.1f) - val neck = makeBone(BodyPart.NECK, head, 0.1f) - val upperChest = makeBone(BodyPart.UPPER_CHEST, neck, 0.16f) - val chest = makeBone(BodyPart.CHEST, upperChest, 0.16f) - val waist = makeBone(BodyPart.WAIST, chest, 0.2f) - val hip = makeBone(BodyPart.HIP, waist, 0.04f) - - // Left leg - val leftHip = makeBone(BodyPart.LEFT_HIP, hip, 0.13f) - val leftUpperLeg = makeBone(BodyPart.LEFT_UPPER_LEG, leftHip, 0.42f) - val leftLowerLeg = makeBone(BodyPart.LEFT_LOWER_LEG, leftUpperLeg, 0.5f) - val leftFoot = makeBone(BodyPart.LEFT_FOOT, leftLowerLeg, 0.05f) - - // Right leg - val rightHip = makeBone(BodyPart.RIGHT_HIP, hip, 0.13f) - val rightUpperLeg = makeBone(BodyPart.RIGHT_UPPER_LEG, rightHip, 0.42f) - val rightLowerLeg = makeBone(BodyPart.RIGHT_LOWER_LEG, rightUpperLeg, 0.5f) - val rightFoot = makeBone(BodyPart.RIGHT_FOOT, rightLowerLeg, 0.05f) - - // Left arm - val leftShoulder = makeBone(BodyPart.LEFT_SHOULDER, neck, 0.175f) - val leftUpperArm = makeBone(BodyPart.LEFT_UPPER_ARM, leftShoulder, 0.26f) - val leftLowerArm = makeBone(BodyPart.LEFT_LOWER_ARM, leftUpperArm, 0.26f) - val leftHand = makeBone(BodyPart.LEFT_HAND, leftLowerArm, 0.13f) - - // Left fingers - val leftThumbMetacarpal = makeBone(BodyPart.LEFT_THUMB_METACARPAL, leftHand, 0.025f) - val leftThumbProximal = makeBone(BodyPart.LEFT_THUMB_PROXIMAL, leftThumbMetacarpal, 0.025f) - val leftThumbDistal = makeBone(BodyPart.LEFT_THUMB_DISTAL, leftThumbProximal, 0.025f) - - val leftIndexProximal = makeBone(BodyPart.LEFT_INDEX_PROXIMAL, leftHand, 0.025f) - val leftIndexIntermediate = makeBone(BodyPart.LEFT_INDEX_INTERMEDIATE, leftIndexProximal, 0.025f) - val leftIndexDistal = makeBone(BodyPart.LEFT_INDEX_DISTAL, leftIndexIntermediate, 0.025f) - - val leftMiddleProximal = makeBone(BodyPart.LEFT_MIDDLE_PROXIMAL, leftHand, 0.025f) - val leftMiddleIntermediate = makeBone(BodyPart.LEFT_MIDDLE_INTERMEDIATE, leftMiddleProximal, 0.025f) - val leftMiddleDistal = makeBone(BodyPart.LEFT_MIDDLE_DISTAL, leftMiddleIntermediate, 0.025f) - - val leftRingProximal = makeBone(BodyPart.LEFT_RING_PROXIMAL, leftHand, 0.025f) - val leftRingIntermediate = makeBone(BodyPart.LEFT_RING_INTERMEDIATE, leftRingProximal, 0.025f) - val leftRingDistal = makeBone(BodyPart.LEFT_RING_DISTAL, leftRingIntermediate, 0.025f) - - val leftLittleProximal = makeBone(BodyPart.LEFT_LITTLE_PROXIMAL, leftHand, 0.025f) - val leftLittleIntermediate = makeBone(BodyPart.LEFT_LITTLE_INTERMEDIATE, leftLittleProximal, 0.025f) - val leftLittleDistal = makeBone(BodyPart.LEFT_LITTLE_DISTAL, leftLittleIntermediate, 0.025f) - - // Right arm - val rightShoulder = makeBone(BodyPart.RIGHT_SHOULDER, neck, 0.175f) - val rightUpperArm = makeBone(BodyPart.RIGHT_UPPER_ARM, rightShoulder, 0.26f) - val rightLowerArm = makeBone(BodyPart.RIGHT_LOWER_ARM, rightUpperArm, 0.26f) - val rightHand = makeBone(BodyPart.RIGHT_HAND, rightLowerArm, 0.13f) - - // Right fingers - val rightThumbMetacarpal = makeBone(BodyPart.RIGHT_THUMB_METACARPAL, rightHand, 0.025f) - val rightThumbProximal = makeBone(BodyPart.RIGHT_THUMB_PROXIMAL, rightThumbMetacarpal, 0.025f) - val rightThumbDistal = makeBone(BodyPart.RIGHT_THUMB_DISTAL, rightThumbProximal, 0.025f) - - val rightIndexProximal = makeBone(BodyPart.RIGHT_INDEX_PROXIMAL, rightHand, 0.025f) - val rightIndexIntermediate = makeBone(BodyPart.RIGHT_INDEX_INTERMEDIATE, rightIndexProximal, 0.025f) - val rightIndexDistal = makeBone(BodyPart.RIGHT_INDEX_DISTAL, rightIndexIntermediate, 0.025f) - - val rightMiddleProximal = makeBone(BodyPart.RIGHT_MIDDLE_PROXIMAL, rightHand, 0.025f) - val rightMiddleIntermediate = makeBone(BodyPart.RIGHT_MIDDLE_INTERMEDIATE, rightMiddleProximal, 0.025f) - val rightMiddleDistal = makeBone(BodyPart.RIGHT_MIDDLE_DISTAL, rightMiddleIntermediate, 0.025f) - - val rightRingProximal = makeBone(BodyPart.RIGHT_RING_PROXIMAL, rightHand, 0.025f) - val rightRingIntermediate = makeBone(BodyPart.RIGHT_RING_INTERMEDIATE, rightRingProximal, 0.025f) - val rightRingDistal = makeBone(BodyPart.RIGHT_RING_DISTAL, rightRingIntermediate, 0.025f) - - val rightLittleProximal = makeBone(BodyPart.RIGHT_LITTLE_PROXIMAL, rightHand, 0.025f) - val rightLittleIntermediate = makeBone(BodyPart.RIGHT_LITTLE_INTERMEDIATE, rightLittleProximal, 0.025f) - val rightLittleDistal = makeBone(BodyPart.RIGHT_LITTLE_DISTAL, rightLittleIntermediate, 0.025f) + val skeletonBones = mutableMapOf() + iterateBoneHierarchy().forEach { (parent, child) -> + val parent = skeletonBones[parent] + + val length = when (child) { + BodyPart.HEAD, BodyPart.NECK -> 0.1f + + BodyPart.LEFT_SHOULDER, BodyPart.RIGHT_SHOULDER -> 0.175f + + BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM, + BodyPart.LEFT_LOWER_ARM, BodyPart.RIGHT_LOWER_ARM, + -> 0.26f + + BodyPart.LEFT_HAND, BodyPart.RIGHT_HAND -> 0.13f + + BodyPart.LEFT_THUMB_METACARPAL, BodyPart.LEFT_THUMB_PROXIMAL, BodyPart.LEFT_THUMB_DISTAL, + BodyPart.LEFT_INDEX_DISTAL, BodyPart.LEFT_INDEX_INTERMEDIATE, BodyPart.LEFT_INDEX_PROXIMAL, + BodyPart.LEFT_MIDDLE_DISTAL, BodyPart.LEFT_MIDDLE_INTERMEDIATE, BodyPart.LEFT_MIDDLE_PROXIMAL, + BodyPart.LEFT_RING_DISTAL, BodyPart.LEFT_RING_INTERMEDIATE, BodyPart.LEFT_RING_PROXIMAL, + BodyPart.LEFT_LITTLE_DISTAL, BodyPart.LEFT_LITTLE_INTERMEDIATE, BodyPart.LEFT_LITTLE_PROXIMAL, + + BodyPart.RIGHT_THUMB_METACARPAL, BodyPart.RIGHT_THUMB_PROXIMAL, BodyPart.RIGHT_THUMB_DISTAL, + BodyPart.RIGHT_INDEX_PROXIMAL, BodyPart.RIGHT_INDEX_INTERMEDIATE, BodyPart.RIGHT_INDEX_DISTAL, + BodyPart.RIGHT_MIDDLE_PROXIMAL, BodyPart.RIGHT_MIDDLE_INTERMEDIATE, BodyPart.RIGHT_MIDDLE_DISTAL, + BodyPart.RIGHT_RING_PROXIMAL, BodyPart.RIGHT_RING_INTERMEDIATE, BodyPart.RIGHT_RING_DISTAL, + BodyPart.RIGHT_LITTLE_PROXIMAL, BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_DISTAL, + -> 0.025f + + BodyPart.UPPER_CHEST, BodyPart.CHEST -> 0.16f + + BodyPart.WAIST -> 0.2f + + BodyPart.HIP -> 0.04f + + BodyPart.LEFT_HIP, BodyPart.RIGHT_HIP -> 0.13f + + BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG -> 0.42f + + BodyPart.LEFT_LOWER_LEG, BodyPart.RIGHT_LOWER_LEG -> 0.5f + + BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> 0.05f + + else -> 0.1f + } + val rot = when (child) { + BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> Quaternion.rotationAroundXAxis(FastMath.HALF_PI) + else -> Quaternion.IDENTITY + } + val head = parent?.tailPosition ?: Vector3.NULL + val offset = when (child) { + BodyPart.HEAD -> Vector3(0f, 0f, length) + BodyPart.LEFT_HAND, BodyPart.RIGHT_HAND -> Vector3(0f, 0f, -length) + BodyPart.LEFT_SHOULDER -> Vector3(-length, -0.08f, 0f) + BodyPart.RIGHT_SHOULDER -> Vector3(length, -0.08f, 0f) + BodyPart.LEFT_HIP -> Vector3(-length, 0f, 0f) + BodyPart.RIGHT_HIP -> Vector3(length, 0f, 0f) + else -> Vector3(0f, -length, 0f) + } + + val bone = BoneState( + bodyPart = child, + length = length, + rotation = rot, + headPosition = head, + tailPosition = head + offset, + parentBone = parent, + ) + skeletonBones[child] = bone + } SkeletonState( - bones = navigateHierarchy(head).associateBy { it.bodyPart }, - rootBone = head, + bones = skeletonBones, + rootBone = skeletonBones[BodyPart.HEAD]!!, ) } From 9c7ca73dbcde0872770fc9e7166f525fe585621d Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Wed, 8 Apr 2026 04:05:11 -0400 Subject: [PATCH 043/350] Fix naming --- .../java/dev/slimevr/skeleton/bodypartstructure.kt | 12 ++++++------ .../src/main/java/dev/slimevr/skeleton/module.kt | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt b/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt index 8b169b4587..efbfb8a986 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt @@ -2,7 +2,7 @@ package dev.slimevr.skeleton import solarxr_protocol.datatypes.BodyPart -val BODY_PART_CHILD_MAP = mapOf( +val BODY_PART_HIERARCHY_MAP = mapOf( BodyPart.HEAD to arrayOf(BodyPart.NECK), BodyPart.NECK to arrayOf(BodyPart.UPPER_CHEST, BodyPart.LEFT_SHOULDER, BodyPart.RIGHT_SHOULDER), @@ -64,12 +64,12 @@ val BODY_PART_CHILD_MAP = mapOf( BodyPart.RIGHT_LOWER_LEG to arrayOf(BodyPart.RIGHT_FOOT), ) -private suspend fun SequenceScope>.visitBone(parentBone: BodyPart?, bone: BodyPart) { +private suspend fun SequenceScope>.visitBodyPart(parentBone: BodyPart?, bone: BodyPart) { yield(Pair(parentBone, bone)) - val children = BODY_PART_CHILD_MAP[bone] ?: return - for (child in children) visitBone(bone, child) + val children = BODY_PART_HIERARCHY_MAP[bone] ?: return + for (child in children) visitBodyPart(bone, child) } -fun iterateBoneHierarchy() = sequence { - visitBone(null, BodyPart.HEAD) +fun iterateBodyPartHierarchy() = sequence { + visitBodyPart(null, BodyPart.HEAD) } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index dc3ecefea5..7b954c7c00 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -28,7 +28,7 @@ data class SkeletonState( val DEFAULT_SKELETON_STATE = run { val skeletonBones = mutableMapOf() - iterateBoneHierarchy().forEach { (parent, child) -> + iterateBodyPartHierarchy().forEach { (parent, child) -> val parent = skeletonBones[parent] val length = when (child) { From c5d8968d5864ad72db00621332c2d20804ce6399 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 8 Apr 2026 13:47:02 +0200 Subject: [PATCH 044/350] Tracker tap detection - Basic --- .../main/java/dev/slimevr/hid/behaviours.kt | 1 + .../main/java/dev/slimevr/solarxr/datafeed.kt | 2 + .../java/dev/slimevr/tracker/behaviours.kt | 79 +++++++++++++++++-- .../main/java/dev/slimevr/tracker/module.kt | 14 +++- .../main/java/dev/slimevr/udp/behaviours.kt | 8 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 1 + 6 files changed, 95 insertions(+), 10 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 2a8d14a45e..f03b345916 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -103,6 +103,7 @@ object HIDDeviceInfoBehaviour : HIDReceiverBehaviour { sensorType = packet.imuType, hardwareId = deviceState.address, origin = DeviceOrigin.HID, + server = receiver.serverContext ) receiver.serverContext.context.dispatch(VRServerActions.NewTracker(trackerId, newTracker)) receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, trackerId)) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 83ae997d9d..a3b880ea9b 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -53,6 +53,8 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas }, tps = if (trackerMask.tps == true) tracker.tps else null, temp = if (trackerMask.temp == true && tracker.imuTemp != null) Temperature(temp = tracker.imuTemp) else null, + rawAcceleration = if (trackerMask.rawAcceleration == true) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null, + linearAcceleration = if (trackerMask.linearAcceleration == true) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null // FIXME: temp value ) } diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 3a7ba0d7b0..5f05f45b19 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -2,35 +2,104 @@ package dev.slimevr.tracker import kotlin.time.TimeSource import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch + import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch +private const val NS_CONVERTER = 1.0e9f +private const val CLUMP_TIME_NS = 0.06f * NS_CONVERTER +private const val NEEDED_ACCEL_DELTA = 4.0f +private const val ALLOWED_BODY_ACCEL = 2.0f +private const val ALLOWED_BODY_ACCEL_SQUARED = ALLOWED_BODY_ACCEL * ALLOWED_BODY_ACCEL +private const val TAP_WINDOW_PER_TAP_S = 0.3f + +object TrackerTapDetectionBehaviour : TrackerBehaviour { + override fun observe(receiver: Tracker) { + val accelList = ArrayDeque>() + val tapTimestamps = ArrayDeque() + var waitForLowAccel = false + + // TODO: only enable this on the trackers that have taps assigned to them + receiver.context.state + .distinctUntilChangedBy { it.acceleration } + .onEach { current -> + val now = System.nanoTime() + val magnitude = current.acceleration.len() + + accelList.add(magnitude to now) + while (accelList.isNotEmpty() && now - accelList.first().second > CLUMP_TIME_NS) { + accelList.removeFirst() + } + + val max = accelList.maxOfOrNull { it.first } ?: 0f + val min = accelList.minOfOrNull { it.first } ?: 0f + val accelDelta = max - min + + if (accelDelta > NEEDED_ACCEL_DELTA && !waitForLowAccel) { + val othersOverThreshold = receiver.server.context.state.value.trackers.values + .count { it.context.state.value.id != current.id && it.context.state.value.acceleration.lenSq() > ALLOWED_BODY_ACCEL_SQUARED } + if (othersOverThreshold <= 1) { + tapTimestamps.add(now) + waitForLowAccel = true + } + } + + if (max < ALLOWED_BODY_ACCEL) { + waitForLowAccel = false + } + + if (tapTimestamps.isNotEmpty()) { + val totalWindowNs = (TAP_WINDOW_PER_TAP_S * tapTimestamps.size * NS_CONVERTER).toLong() + while (tapTimestamps.isNotEmpty() && now - tapTimestamps.first() > totalWindowNs) { + tapTimestamps.removeFirst() + } + + if (tapTimestamps.isNotEmpty()) { + val lastTapTime = tapTimestamps.last() + if (now - lastTapTime > (TAP_WINDOW_PER_TAP_S * NS_CONVERTER).toLong()) { + val count = tapTimestamps.size + if (count >= 2) { + println("Detected $count taps on ${receiver.context.state.value.id}") + // TODO trigger the tap action + } + tapTimestamps.clear() + } + } + } + } + .launchIn(receiver.context.scope) + } +} + object TrackerBasicBehaviour : TrackerBehaviour { override fun reduce(state: TrackerState, action: TrackerActions) = if (action is TrackerActions.Update) action.transform(state) else state } object TrackerTPSBehaviour : TrackerBehaviour { @OptIn(ExperimentalAtomicApi::class) - override fun observe(receiver: TrackerContext) { + override fun observe(receiver: Tracker) { val count = AtomicInt(0) - receiver.state.distinctUntilChangedBy { it.rawRotation }.onEach { + receiver.context.state.distinctUntilChangedBy { it.rawRotation }.onEach { count.incrementAndFetch() - }.launchIn(receiver.scope) + }.launchIn(receiver.context.scope) - receiver.scope.launch { + receiver.context.scope.launch { var mark = TimeSource.Monotonic.markNow() while (isActive) { delay(1000) val elapsed = mark.elapsedNow() val tps = count.exchange(0) * 1000L / elapsed.inWholeMilliseconds - receiver.dispatch(TrackerActions.Update { copy(tps = tps.toUShort()) }) + receiver.context.dispatch(TrackerActions.Update { copy(tps = tps.toUShort()) }) mark = TimeSource.Monotonic.markNow() } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 8aa0380842..eb41e3caba 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -1,5 +1,6 @@ package dev.slimevr.tracker +import dev.slimevr.VRServer import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.device.DeviceOrigin @@ -19,6 +20,7 @@ data class TrackerState( val bodyPart: BodyPart?, val customName: String?, val rawRotation: Quaternion, + val acceleration: Vector3, val deviceId: Int, val origin: DeviceOrigin, val tps: UShort, @@ -31,10 +33,11 @@ sealed interface TrackerActions { } typealias TrackerContext = Context -typealias TrackerBehaviour = Behaviour +typealias TrackerBehaviour = Behaviour class Tracker( val context: TrackerContext, + val server: VRServer, ) { companion object { fun create( @@ -44,12 +47,14 @@ class Tracker( sensorType: ImuType?, hardwareId: String, origin: DeviceOrigin, + server: VRServer ): Tracker { val trackerState = TrackerState( id = id, hardwareId = hardwareId, name = "Tracker #$id", rawRotation = Quaternion.IDENTITY, + acceleration = Vector3.NULL, bodyPart = null, origin = origin, deviceId = deviceId, @@ -60,10 +65,11 @@ class Tracker( imuTemp = null, ) - val behaviours = listOf(TrackerBasicBehaviour, TrackerTPSBehaviour) + val behaviours = listOf(TrackerBasicBehaviour, TrackerTPSBehaviour, TrackerTapDetectionBehaviour) val context = Context.create(initialState = trackerState, scope = scope, behaviours = behaviours) - behaviours.forEach { it.observe(context) } - return Tracker(context = context) + val tracker = Tracker(context = context, server) + behaviours.forEach { it.observe(tracker) } + return tracker } } } diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 50fdee1852..5a585faa86 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -204,6 +204,7 @@ object SensorInfoBehaviour : UDPConnectionBehaviour { deviceId = deviceState.id, origin = DeviceOrigin.UDP, scope = receiver.serverContext.context.scope, + server = receiver.serverContext ) receiver.serverContext.context.dispatch( @@ -229,7 +230,12 @@ object SensorRotationBehaviour : UDPConnectionBehaviour { receiver.packetEvents.onPacket { event -> val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = event.data.rotation) }) + tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = event.data.rotation, acceleration = event.data.acceleration) }) + } + + receiver.packetEvents.onPacket { event -> + val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket + tracker.context.dispatch(TrackerActions.Update { copy(acceleration = event.data.acceleration) }) } receiver.packetEvents.onPacket { event -> diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 1857317425..4a8fbf97d6 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -131,6 +131,7 @@ suspend fun handleFeederConnection( sensorType = ImuType.MPU9250, // TODO: prob need to make sensor type optional hardwareId = serial, origin = DeviceOrigin.FEEDER, + server = server ) server.context.dispatch(VRServerActions.NewTracker(trackerId, tracker)) From ada2ee3c81be86b02506d9dde68057969f617ee9 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Fri, 10 Apr 2026 19:39:28 +0200 Subject: [PATCH 045/350] Skeleton experiments --- .../main/java/dev/slimevr/serial/module.kt | 6 +- .../main/java/dev/slimevr/skeleton/module.kt | 269 +++++++++++++++--- .../main/java/dev/slimevr/solarxr/datafeed.kt | 32 ++- .../main/java/dev/slimevr/solarxr/firmware.kt | 9 +- .../main/java/dev/slimevr/solarxr/module.kt | 4 +- .../main/java/dev/slimevr/solarxr/vrchat.kt | 4 +- .../java/dev/slimevr/solarxr/ws-server.kt | 3 +- .../src/main/java/dev/slimevr/desktop/Main.kt | 11 +- .../main/java/dev/slimevr/desktop/ipc/ipc.kt | 4 +- .../java/dev/slimevr/desktop/ipc/linux.kt | 3 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 2 - .../java/dev/slimevr/desktop/ipc/windows.kt | 3 +- 12 files changed, 278 insertions(+), 72 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/serial/module.kt b/server/core/src/main/java/dev/slimevr/serial/module.kt index c40abd6c5a..d7a3beef0a 100644 --- a/server/core/src/main/java/dev/slimevr/serial/module.kt +++ b/server/core/src/main/java/dev/slimevr/serial/module.kt @@ -38,7 +38,6 @@ typealias SerialServerBehaviour = Behaviour Unit, @@ -80,7 +79,7 @@ class SerialServer( val state = context.state.value if (!state.availablePorts.containsKey(portLocation) || state.connections.containsKey(portLocation)) return val handle = openPortFactory(portLocation, ::onDataReceived, ::onPortDisconnected) ?: return - context.dispatch(SerialServerActions.RegisterConnection(portLocation, SerialConnection.Console.create(handle, scope))) + context.dispatch(SerialServerActions.RegisterConnection(portLocation, SerialConnection.Console.create(handle, context.scope))) } fun closeConnection(portLocation: String) { @@ -100,7 +99,7 @@ class SerialServer( return object : FlashingHandler by handler { override fun closeSerial() { handler.closeSerial() - scope.launch { context.dispatch(SerialServerActions.RemoveConnection(portLocation)) } + context.scope.launch { context.dispatch(SerialServerActions.RemoveConnection(portLocation)) } } } } @@ -119,7 +118,6 @@ class SerialServer( ) val server = SerialServer( context = context, - scope = scope, openPortFactory = openPort, openFlashingPortFactory = openFlashingPort, ) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 7b954c7c00..aeb5670fba 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -1,36 +1,28 @@ package dev.slimevr.skeleton import com.jme3.math.FastMath +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart +import kotlin.math.cos -data class BoneState( +data class BoneDefinition( val bodyPart: BodyPart, val length: Float, - val rotation: Quaternion, - val headPosition: Vector3, - val tailPosition: Vector3, - val parentBone: BoneState?, -) { - val localRotation: Quaternion - get() = parentBone?.let { it.rotation.inv() * rotation } ?: rotation - val localHeadPosition: Vector3 - get() = parentBone?.let { headPosition - it.tailPosition } ?: headPosition - val localTailPosition: Vector3 - get() = tailPosition - headPosition -} - -data class SkeletonState( - val bones: Map, - val rootBone: BoneState, + val localTailOffset: Vector3, + val parentPart: BodyPart?, ) -val DEFAULT_SKELETON_STATE = run { - val skeletonBones = mutableMapOf() - iterateBodyPartHierarchy().forEach { (parent, child) -> - val parent = skeletonBones[parent] - +val BONE_DEFINITIONS: Map = run { + val defs = mutableMapOf() + iterateBodyPartHierarchy().forEach { (parentPart, child) -> val length = when (child) { BodyPart.HEAD, BodyPart.NECK -> 0.1f @@ -71,11 +63,10 @@ val DEFAULT_SKELETON_STATE = run { else -> 0.1f } - val rot = when (child) { + val restRotation = when (child) { BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> Quaternion.rotationAroundXAxis(FastMath.HALF_PI) else -> Quaternion.IDENTITY } - val head = parent?.tailPosition ?: Vector3.NULL val offset = when (child) { BodyPart.HEAD -> Vector3(0f, 0f, length) BodyPart.LEFT_HAND, BodyPart.RIGHT_HAND -> Vector3(0f, 0f, -length) @@ -85,20 +76,230 @@ val DEFAULT_SKELETON_STATE = run { BodyPart.RIGHT_HIP -> Vector3(length, 0f, 0f) else -> Vector3(0f, -length, 0f) } - - val bone = BoneState( + defs[child] = BoneDefinition( bodyPart = child, length = length, - rotation = rot, - headPosition = head, - tailPosition = head + offset, - parentBone = parent, + localTailOffset = restRotation.inv().sandwich(offset), + parentPart = parentPart, + ) + } + defs +} + +data class BoneInput( + val rotation: Quaternion = Quaternion.IDENTITY, + val headPositionOverride: Vector3? = null, +) + +data class BonePose(val head: Vector3, val tail: Vector3) + +data class ComputedBone( + val bodyPart: BodyPart, + val length: Float, + val rotation: Quaternion, + val head: Vector3, + val tail: Vector3, + val parentBone: ComputedBone? = null, +) { + val localRotation: Quaternion + get() = parentBone?.let { it.rotation.inv() * rotation } ?: rotation + val localHeadPosition: Vector3 + get() = parentBone?.let { head - it.tail } ?: head + val localTailPosition: Vector3 + get() = tail - head +} + +data class SkeletonState(val inputs: Map) + +val DEFAULT_SKELETON_STATE = SkeletonState( + inputs = BONE_DEFINITIONS.keys.associateWith { part -> + BoneInput( + rotation = when (part) { + BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> Quaternion.rotationAroundXAxis(FastMath.HALF_PI) + else -> Quaternion.IDENTITY + } + ) + } +) + +fun computePoses(state: SkeletonState, rootHead: Vector3 = Vector3.NULL): Map { + val poses = mutableMapOf() + iterateBodyPartHierarchy().forEach { (parentPart, childPart) -> + val def = BONE_DEFINITIONS[childPart] ?: return@forEach + val input = state.inputs[childPart] ?: return@forEach + val fkHead = parentPart?.let { poses[it]?.tail } ?: rootHead + val head = input.headPositionOverride ?: fkHead + poses[childPart] = BonePose(head, head + input.rotation.sandwich(def.localTailOffset)) + } + return poses +} + +data class ComputedSkeletonState(val bones: Map) + +fun buildComputedBones(state: SkeletonState): Map { + val poses = computePoses(state) + val result = mutableMapOf() + iterateBodyPartHierarchy().forEach { (parentPart, childPart) -> + val def = BONE_DEFINITIONS[childPart] ?: return@forEach + val input = state.inputs[childPart] ?: return@forEach + val pose = poses[childPart] ?: return@forEach + result[childPart] = ComputedBone( + bodyPart = def.bodyPart, + length = def.length, + rotation = input.rotation, + head = pose.head, + tail = pose.tail, + parentBone = parentPart?.let { result[it] }, ) - skeletonBones[child] = bone } + return result +} + +sealed interface SkeletonActions { + data class SetBoneRotation(val bodyPart: BodyPart, val rotation: Quaternion) : SkeletonActions + data class SetBoneHeadPosition(val bodyPart: BodyPart, val position: Vector3?) : SkeletonActions +} + +typealias SkeletonContext = Context +typealias SkeletonBehaviour = Behaviour - SkeletonState( - bones = skeletonBones, - rootBone = skeletonBones[BodyPart.HEAD]!!, +class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour { + + override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState { + val inputs = state.inputs.toMutableMap() + return when (action) { + is SkeletonActions.SetBoneRotation -> { + val boneInput = inputs[action.bodyPart] ?: return state + inputs[action.bodyPart] = boneInput.copy(rotation = action.rotation) + state.copy(inputs = inputs) + } + is SkeletonActions.SetBoneHeadPosition -> { + val boneInput = inputs[action.bodyPart] ?: return state + inputs[action.bodyPart] = boneInput.copy(headPositionOverride = action.position) + state.copy(inputs = inputs) + } + } + } + + override fun observe(receiver: Skeleton) { + receiver.context.scope.launch { + val intervalMs = (1000f / inputHz).toLong() + val startTime = System.currentTimeMillis() + while (true) { + delay(intervalMs) + val elapsed = (System.currentTimeMillis() - startTime) / 1000f + receiver.context.dispatch( + SkeletonActions.SetBoneRotation( + BodyPart.CHEST, + Quaternion.fromRotationVector(Vector3(cos(elapsed), cos(elapsed), 0f)), + ) + ) + } + } + } +} + +interface SkeletonProcessor { + var enabled: Boolean + fun process(state: SkeletonState): SkeletonState +} + +class SmoothingProcessor(var smoothing: Float) : SkeletonProcessor { + override var enabled: Boolean = true + private var smoothedRotations: Map = emptyMap() + + override fun process(state: SkeletonState): SkeletonState { + val alpha = 1f - smoothing.coerceAtMost(0.99f) + smoothedRotations = state.inputs.mapValues { (bodyPart, input) -> + (smoothedRotations[bodyPart] ?: input.rotation).lerpR(input.rotation, alpha).unit() + } + return state.copy( + inputs = state.inputs.mapValues { (bodyPart, input) -> + input.copy(rotation = smoothedRotations[bodyPart] ?: input.rotation) + } + ) + } +} + +class PredictionProcessor(var predictionAmount: Float) : SkeletonProcessor { + override var enabled: Boolean = true + + private data class BoneVelocity( + val lastRotation: Quaternion, + val delta: Quaternion, ) + + private var velocities: Map = emptyMap() + + override fun process(state: SkeletonState): SkeletonState { + val newVelocities = mutableMapOf() + + val predicted = state.inputs.mapValues { (bodyPart, input) -> + val prev = velocities[bodyPart] + + if (prev == null) { + newVelocities[bodyPart] = BoneVelocity(input.rotation, Quaternion.IDENTITY) + return@mapValues input + } + + val velocity = if (input.rotation !== prev.lastRotation) { + BoneVelocity(input.rotation, input.rotation * prev.lastRotation.inv()) + } else { + prev + } + + newVelocities[bodyPart] = velocity + + val scaledDelta = Quaternion.IDENTITY.lerpR(velocity.delta, predictionAmount).unit() + input.copy(rotation = (scaledDelta * input.rotation).unit()) + } + + velocities = newVelocities + return state.copy(inputs = predicted) + } +} + +class ComputedSkeletonBehaviour(val processors: List = emptyList()) : SkeletonBehaviour { + override fun observe(receiver: Skeleton) { + receiver.context.scope.launch { + while (true) { + delay(10) + val targetState = receiver.context.state.value + val processed = processors + .filter { it.enabled } + .fold(targetState) { + state, processor -> processor.process(state) + } + receiver.computed.value = ComputedSkeletonState(buildComputedBones(processed)) + } + } + } +} + +class Skeleton( + val context: SkeletonContext, + val computed: MutableStateFlow, +) { + companion object { + fun create(scope: CoroutineScope): Skeleton { + val behaviours = listOf( + YouSpinMeRightRoundBehaviour(inputHz = 1f), + ComputedSkeletonBehaviour(processors = listOf( +// PredictionProcessor(predictionAmount = 0.3f), + SmoothingProcessor(smoothing = 1f) + )), + ) + + val context = Context.create( + initialState = DEFAULT_SKELETON_STATE, + scope = scope, + behaviours = behaviours, + ) + + val skeleton = Skeleton(context, MutableStateFlow(ComputedSkeletonState(buildComputedBones(context.state.value)))) + behaviours.forEach { it.observe(skeleton) } + + return skeleton + } + } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index a3b880ea9b..03e3c5697d 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -3,8 +3,8 @@ package dev.slimevr.solarxr import com.google.flatbuffers.FlatBufferBuilder import dev.slimevr.VRServer import dev.slimevr.device.DeviceState -import dev.slimevr.skeleton.BoneState -import dev.slimevr.skeleton.DEFAULT_SKELETON_STATE +import dev.slimevr.skeleton.ComputedBone +import dev.slimevr.skeleton.Skeleton import dev.slimevr.tracker.TrackerState import io.ktor.util.moveToByteArray import kotlinx.coroutines.cancelAndJoin @@ -90,26 +90,25 @@ private fun createDevice( ) } -private fun createBone( - bone: BoneState, -): Bone = Bone( +private fun createBone(bone: ComputedBone): Bone = Bone( bodyPart = bone.bodyPart, rotationG = bone.rotation.let { Quat(it.x, it.y, it.z, it.w) }, boneLength = bone.length, - headPositionG = bone.headPosition.let { Vec3f(it.x, it.y, it.z) }, + headPositionG = bone.head.let { Vec3f(it.x, it.y, it.z) }, ) fun createDatafeedFrame( - serverContext: VRServer, + server: VRServer, datafeedConfig: DataFeedConfig, + skeleton: Skeleton, index: Int = 0, ): DataFeedMessageHeader { - val serverState = serverContext.context.state.value + val serverState = server.context.state.value val trackers = serverState.trackers.values.map { it.context.state.value } val devices = serverState.devices.values.map { it.context.state.value } .map { device -> createDevice(device, trackers, datafeedConfig) } val bones = if (datafeedConfig.boneMask) { - DEFAULT_SKELETON_STATE.bones.values.map { createBone(it) } + skeleton.computed.value.bones.values.map { createBone(it) } } else { null } @@ -122,7 +121,7 @@ fun createDatafeedFrame( ) } -object DataFeedInitBehaviour : SolarXRConnectionBehaviour { +class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : SolarXRConnectionBehaviour { override fun reduce(state: SolarXRConnectionState, action: SolarXRConnectionActions) = when (action) { is SolarXRConnectionActions.SetConfig -> state.copy( dataFeedConfigs = action.configs, @@ -145,7 +144,12 @@ object DataFeedInitBehaviour : SolarXRConnectionBehaviour { fbb.finish( MessageBundle( dataFeedMsgs = listOf( - createDatafeedFrame(receiver.serverContext, config, index), + createDatafeedFrame( + server = server, + skeleton = skeleton, + datafeedConfig = config, + index = index + ), ), ).encode(fbb), ) @@ -167,7 +171,11 @@ object DataFeedInitBehaviour : SolarXRConnectionBehaviour { fbb.finish( MessageBundle( dataFeedMsgs = listOf( - createDatafeedFrame(serverContext = receiver.serverContext, datafeedConfig = config), + createDatafeedFrame( + server = server, + datafeedConfig = config, + skeleton = skeleton, + ), ), ).encode(fbb), ) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt index d4449b4ffd..478ee59d9c 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt @@ -1,5 +1,6 @@ package dev.slimevr.solarxr +import dev.slimevr.VRServer import dev.slimevr.firmware.FirmwareJobStatus import dev.slimevr.firmware.FirmwareManager import kotlinx.coroutines.flow.distinctUntilChanged @@ -13,7 +14,7 @@ import solarxr_protocol.rpc.FirmwareUpdateStopQueuesRequest import solarxr_protocol.rpc.OTAFirmwareUpdate import solarxr_protocol.rpc.SerialFirmwareUpdate -class FirmwareBehaviour(private val firmwareManager: FirmwareManager) : SolarXRConnectionBehaviour { +class FirmwareBehaviour(private val server: VRServer, private val firmwareManager: FirmwareManager) : SolarXRConnectionBehaviour { override fun observe(receiver: SolarXRConnection) { val scope = receiver.context.scope @@ -49,16 +50,16 @@ class FirmwareBehaviour(private val firmwareManager: FirmwareManager) : SolarXRC method.needmanualreboot, method.ssid, method.password, - receiver.serverContext, + server, ) } is OTAFirmwareUpdate -> { val deviceId = method.deviceId ?: return@on val part = method.firmwarePart ?: return@on - val device = receiver.serverContext.getDevice(deviceId.id.toInt()) ?: return@on + val device = server.getDevice(deviceId.id.toInt()) ?: return@on val deviceIp = device.context.state.value.address - firmwareManager.otaFlash(deviceIp, DeviceIdTable(id = deviceId), part, receiver.serverContext) + firmwareManager.otaFlash(deviceIp, DeviceIdTable(id = deviceId), part, server) } else -> return@on diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index e471f43b9f..53689576a0 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -5,6 +5,7 @@ import dev.slimevr.EventDispatcher import dev.slimevr.VRServer import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.skeleton.Skeleton import io.ktor.util.moveToByteArray import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -43,7 +44,6 @@ suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRConnection) { class SolarXRConnection( val context: SolarXRConnectionContext, - val serverContext: VRServer, val dataFeedDispatcher: EventDispatcher, val rpcDispatcher: EventDispatcher, private val onSend: suspend (ByteArray) -> Unit, @@ -60,7 +60,6 @@ class SolarXRConnection( companion object { fun create( - serverContext: VRServer, onSend: suspend (ByteArray) -> Unit, scope: CoroutineScope, behaviours: List, @@ -73,7 +72,6 @@ class SolarXRConnection( val conn = SolarXRConnection( context = context, - serverContext = serverContext, dataFeedDispatcher = EventDispatcher(), rpcDispatcher = EventDispatcher(), onSend = onSend, diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt index 53e184787b..602eaa9b5a 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt @@ -1,5 +1,6 @@ package dev.slimevr.solarxr +import dev.slimevr.VRServer import dev.slimevr.vrchat.VRCConfigActions import dev.slimevr.vrchat.VRCConfigManager import dev.slimevr.vrchat.computeRecommendedValues @@ -13,6 +14,7 @@ import solarxr_protocol.rpc.VRCConfigStateRequest class VrcBehaviour( private val vrcManager: VRCConfigManager, + private val server: VRServer, private val userHeight: () -> Double, ) : SolarXRConnectionBehaviour { override fun observe(receiver: SolarXRConnection) { @@ -20,7 +22,7 @@ class VrcBehaviour( val state = vrcManager.context.state.value val values = state.currentValues if (!state.isSupported || values == null) return VRCConfigStateChangeResponse(isSupported = false) - val recommended = computeRecommendedValues(receiver.serverContext, userHeight()) + val recommended = computeRecommendedValues(server, userHeight()) return VRCConfigStateChangeResponse( isSupported = true, validity = computeValidity(values, recommended), diff --git a/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt b/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt index c4e5593c9f..9cd9de5865 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt @@ -15,7 +15,7 @@ import solarxr_protocol.rpc.ResetRequest import java.nio.ByteBuffer const val SOLARXR_PORT = 21110 -suspend fun createSolarXRWebsocketServer(serverContext: VRServer, behaviours: List) { +suspend fun createSolarXRWebsocketServer(behaviours: List) { val engine = embeddedServer(Netty, port = SOLARXR_PORT) { install(WebSockets) @@ -23,7 +23,6 @@ suspend fun createSolarXRWebsocketServer(serverContext: VRServer, behaviours: Li webSocket { AppLogger.solarxr.info("[WS] New connection") val solarxrConnection = SolarXRConnection.create( - serverContext, scope = this, onSend = { send(Frame.Binary(fin = true, data = it)) 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 58dab4a0f3..8df0fe7974 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -12,6 +12,7 @@ import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resolveConfigDirectory +import dev.slimevr.skeleton.Skeleton import dev.slimevr.solarxr.DataFeedInitBehaviour import dev.slimevr.solarxr.FirmwareBehaviour import dev.slimevr.solarxr.HeightCalibrationBehaviour @@ -35,6 +36,8 @@ fun main(args: Array) = runBlocking { val heightCalibrationManager = HeightCalibrationManager.create(serverContext = server, scope = this) val provisioningManager = ProvisioningManager.create(serialServer, this) + val skeleton = Skeleton.create(this) + launch { createUDPTrackerServer(server, config) } @@ -43,14 +46,14 @@ fun main(args: Array) = runBlocking { } val solarXRBehaviours = listOf( - DataFeedInitBehaviour, + DataFeedInitBehaviour(server, skeleton), SerialBehaviour(serialServer), - FirmwareBehaviour(firmwareManager), - VrcBehaviour(vrcConfigManager, userHeight = { config.userConfig.context.state.value.data.userHeight.toDouble() }), + FirmwareBehaviour(server, firmwareManager), + VrcBehaviour(vrcConfigManager, server, userHeight = { config.userConfig.context.state.value.data.userHeight.toDouble() }), HeightCalibrationBehaviour(heightCalibrationManager), ProvisioningBehaviour(server, provisioningManager) ) - launch { createSolarXRWebsocketServer(server, solarXRBehaviours) } + launch { createSolarXRWebsocketServer(solarXRBehaviours) } launch { createIpcServers(server, solarXRBehaviours) } Unit diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt index 66060451b3..e33f885914 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt @@ -20,13 +20,13 @@ suspend fun createIpcServers(server: VRServer, behaviours: List { launch { createUnixDriverSocket(server) } launch { createUnixFeederSocket(server) } - launch { createUnixSolarXRSocket(server, behaviours) } + launch { createUnixSolarXRSocket(behaviours) } } Platform.WINDOWS -> { launch { createWindowsDriverPipe(server) } launch { createWindowsFeederPipe(server) } - launch { createWindowsSolarXRPipe(server, behaviours) } + launch { createWindowsSolarXRPipe(behaviours) } } else -> Unit diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index 74bc2bd974..38ddcd4ea4 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -33,9 +33,8 @@ suspend fun createUnixFeederSocket(server: VRServer) = acceptUnixClients(FEEDER_ ) } -suspend fun createUnixSolarXRSocket(server: VRServer, behaviours: List) = acceptUnixClients(SOLARXR_SOCKET_NAME) { channel -> +suspend fun createUnixSolarXRSocket(behaviours: List) = acceptUnixClients(SOLARXR_SOCKET_NAME) { channel -> handleSolarXRConnection( - server = server, messages = readFramedMessages(channel), send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, behaviours = behaviours, diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 4a8fbf97d6..8025a4c8ac 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -162,13 +162,11 @@ suspend fun handleFeederConnection( } suspend fun handleSolarXRConnection( - server: VRServer, messages: Flow, send: suspend (ByteArray) -> Unit, behaviours: List, ) = coroutineScope { val connection = SolarXRConnection.create( - serverContext = server, scope = this, onSend = send, behaviours = behaviours, diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt index 454fbd20a6..7af6a86dbb 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt @@ -36,9 +36,8 @@ suspend fun createWindowsFeederPipe(server: VRServer) = acceptWindowsClients(FEE ) } -suspend fun createWindowsSolarXRPipe(server: VRServer, behaviours: List) = acceptWindowsClients(SOLARXR_PIPE) { handle -> +suspend fun createWindowsSolarXRPipe(behaviours: List) = acceptWindowsClients(SOLARXR_PIPE) { handle -> handleSolarXRConnection( - server = server, messages = readFramedMessages(handle), send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, behaviours = behaviours, From 902ab1fee1b11c537258c7439cb778eee5965481 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Sat, 11 Apr 2026 08:30:45 +0200 Subject: [PATCH 046/350] Improved structure for skeleton? --- .../main/java/dev/slimevr/skeleton/module.kt | 191 +++++++----------- .../main/java/dev/slimevr/solarxr/datafeed.kt | 9 +- 2 files changed, 80 insertions(+), 120 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index aeb5670fba..89f57b6c16 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -8,21 +8,35 @@ import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart import kotlin.math.cos +import kotlin.math.sin -data class BoneDefinition( +data class BoneState( val bodyPart: BodyPart, val length: Float, - val localTailOffset: Vector3, - val parentPart: BodyPart?, -) + val rotation: Quaternion = Quaternion.IDENTITY, + val headPosition: Vector3 = Vector3.NULL, + val tailPosition: Vector3 = Vector3.NULL, + val parentBone: BodyPart? = null, +) { + val localTailPosition: Vector3 get() = tailPosition - headPosition + + fun localRotation(bones: Map): Quaternion { + val parent = parentBone?.let { bones[it] } ?: return rotation + return parent.rotation.inv() * rotation + } -val BONE_DEFINITIONS: Map = run { - val defs = mutableMapOf() - iterateBodyPartHierarchy().forEach { (parentPart, child) -> + fun localHeadPosition(bones: Map): Vector3 { + val parent = parentBone?.let { bones[it] } ?: return headPosition + return headPosition - parent.tailPosition + } +} + +private val BONE_TAIL_OFFSETS: Map = run { + val offsets = mutableMapOf() + iterateBodyPartHierarchy().forEach { (_, child) -> val length = when (child) { BodyPart.HEAD, BodyPart.NECK -> 0.1f @@ -76,80 +90,34 @@ val BONE_DEFINITIONS: Map = run { BodyPart.RIGHT_HIP -> Vector3(length, 0f, 0f) else -> Vector3(0f, -length, 0f) } - defs[child] = BoneDefinition( - bodyPart = child, - length = length, - localTailOffset = restRotation.inv().sandwich(offset), - parentPart = parentPart, - ) + offsets[child] = restRotation.inv().sandwich(offset) } - defs -} - -data class BoneInput( - val rotation: Quaternion = Quaternion.IDENTITY, - val headPositionOverride: Vector3? = null, -) - -data class BonePose(val head: Vector3, val tail: Vector3) - -data class ComputedBone( - val bodyPart: BodyPart, - val length: Float, - val rotation: Quaternion, - val head: Vector3, - val tail: Vector3, - val parentBone: ComputedBone? = null, -) { - val localRotation: Quaternion - get() = parentBone?.let { it.rotation.inv() * rotation } ?: rotation - val localHeadPosition: Vector3 - get() = parentBone?.let { head - it.tail } ?: head - val localTailPosition: Vector3 - get() = tail - head + offsets } -data class SkeletonState(val inputs: Map) +data class SkeletonState(val bones: Map) -val DEFAULT_SKELETON_STATE = SkeletonState( - inputs = BONE_DEFINITIONS.keys.associateWith { part -> - BoneInput( - rotation = when (part) { - BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> Quaternion.rotationAroundXAxis(FastMath.HALF_PI) - else -> Quaternion.IDENTITY - } - ) +val DEFAULT_SKELETON_STATE: SkeletonState = SkeletonState( + bones = BONE_TAIL_OFFSETS.entries.associate { (bodyPart, tailOffset) -> + val restRotation = when (bodyPart) { + BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> Quaternion.rotationAroundXAxis(FastMath.HALF_PI) + else -> Quaternion.IDENTITY + } + bodyPart to BoneState(bodyPart = bodyPart, length = tailOffset.len(), rotation = restRotation) } ) -fun computePoses(state: SkeletonState, rootHead: Vector3 = Vector3.NULL): Map { - val poses = mutableMapOf() - iterateBodyPartHierarchy().forEach { (parentPart, childPart) -> - val def = BONE_DEFINITIONS[childPart] ?: return@forEach - val input = state.inputs[childPart] ?: return@forEach - val fkHead = parentPart?.let { poses[it]?.tail } ?: rootHead - val head = input.headPositionOverride ?: fkHead - poses[childPart] = BonePose(head, head + input.rotation.sandwich(def.localTailOffset)) - } - return poses -} - -data class ComputedSkeletonState(val bones: Map) - -fun buildComputedBones(state: SkeletonState): Map { - val poses = computePoses(state) - val result = mutableMapOf() +fun buildBones(state: SkeletonState, rootHead: Vector3 = Vector3.NULL): Map { + val result = mutableMapOf() iterateBodyPartHierarchy().forEach { (parentPart, childPart) -> - val def = BONE_DEFINITIONS[childPart] ?: return@forEach - val input = state.inputs[childPart] ?: return@forEach - val pose = poses[childPart] ?: return@forEach - result[childPart] = ComputedBone( - bodyPart = def.bodyPart, - length = def.length, - rotation = input.rotation, - head = pose.head, - tail = pose.tail, - parentBone = parentPart?.let { result[it] }, + val bone = state.bones[childPart] ?: return@forEach + val tailOffset = BONE_TAIL_OFFSETS[childPart] ?: return@forEach + val parentBone = parentPart?.let { result[it] } + val head = parentBone?.tailPosition ?: rootHead + result[childPart] = bone.copy( + headPosition = head, + tailPosition = head + bone.rotation.sandwich(tailOffset), + parentBone = parentPart, ) } return result @@ -157,7 +125,6 @@ fun buildComputedBones(state: SkeletonState): Map { sealed interface SkeletonActions { data class SetBoneRotation(val bodyPart: BodyPart, val rotation: Quaternion) : SkeletonActions - data class SetBoneHeadPosition(val bodyPart: BodyPart, val position: Vector3?) : SkeletonActions } typealias SkeletonContext = Context @@ -166,17 +133,12 @@ typealias SkeletonBehaviour = Behaviour { - val boneInput = inputs[action.bodyPart] ?: return state - inputs[action.bodyPart] = boneInput.copy(rotation = action.rotation) - state.copy(inputs = inputs) - } - is SkeletonActions.SetBoneHeadPosition -> { - val boneInput = inputs[action.bodyPart] ?: return state - inputs[action.bodyPart] = boneInput.copy(headPositionOverride = action.position) - state.copy(inputs = inputs) + val bone = bones[action.bodyPart] ?: return state + bones[action.bodyPart] = bone.copy(rotation = action.rotation) + state.copy(bones = bones) } } } @@ -191,7 +153,13 @@ class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour receiver.context.dispatch( SkeletonActions.SetBoneRotation( BodyPart.CHEST, - Quaternion.fromRotationVector(Vector3(cos(elapsed), cos(elapsed), 0f)), + Quaternion.fromRotationVector(Vector3(cos(elapsed), sin(elapsed), 0f)), + ) + ) + receiver.context.dispatch( + SkeletonActions.SetBoneRotation( + BodyPart.LEFT_LOWER_LEG, + Quaternion.fromRotationVector(Vector3(cos(elapsed + 1000), sin(elapsed + 1000), 0f)), ) ) } @@ -210,12 +178,12 @@ class SmoothingProcessor(var smoothing: Float) : SkeletonProcessor { override fun process(state: SkeletonState): SkeletonState { val alpha = 1f - smoothing.coerceAtMost(0.99f) - smoothedRotations = state.inputs.mapValues { (bodyPart, input) -> - (smoothedRotations[bodyPart] ?: input.rotation).lerpR(input.rotation, alpha).unit() + smoothedRotations = state.bones.mapValues { (bodyPart, bone) -> + (smoothedRotations[bodyPart] ?: bone.rotation).lerpR(bone.rotation, alpha).unit() } return state.copy( - inputs = state.inputs.mapValues { (bodyPart, input) -> - input.copy(rotation = smoothedRotations[bodyPart] ?: input.rotation) + bones = state.bones.mapValues { (bodyPart, bone) -> + bone.copy(rotation = smoothedRotations[bodyPart] ?: bone.rotation) } ) } @@ -224,53 +192,46 @@ class SmoothingProcessor(var smoothing: Float) : SkeletonProcessor { class PredictionProcessor(var predictionAmount: Float) : SkeletonProcessor { override var enabled: Boolean = true - private data class BoneVelocity( - val lastRotation: Quaternion, - val delta: Quaternion, - ) + private data class BoneVelocity(val lastRotation: Quaternion, val delta: Quaternion) private var velocities: Map = emptyMap() override fun process(state: SkeletonState): SkeletonState { val newVelocities = mutableMapOf() - - val predicted = state.inputs.mapValues { (bodyPart, input) -> + val newBones = state.bones.mapValues { (bodyPart, bone) -> val prev = velocities[bodyPart] - if (prev == null) { - newVelocities[bodyPart] = BoneVelocity(input.rotation, Quaternion.IDENTITY) - return@mapValues input + newVelocities[bodyPart] = BoneVelocity(bone.rotation, Quaternion.IDENTITY) + return@mapValues bone } - - val velocity = if (input.rotation !== prev.lastRotation) { - BoneVelocity(input.rotation, input.rotation * prev.lastRotation.inv()) + val velocity = if (bone.rotation !== prev.lastRotation) { + BoneVelocity(bone.rotation, bone.rotation * prev.lastRotation.inv()) } else { prev } - newVelocities[bodyPart] = velocity - val scaledDelta = Quaternion.IDENTITY.lerpR(velocity.delta, predictionAmount).unit() - input.copy(rotation = (scaledDelta * input.rotation).unit()) + bone.copy(rotation = (scaledDelta * bone.rotation).unit()) } - velocities = newVelocities - return state.copy(inputs = predicted) + return state.copy(bones = newBones) } } -class ComputedSkeletonBehaviour(val processors: List = emptyList()) : SkeletonBehaviour { +class ComputedSkeletonBehaviour( + val hz: Float = 100f, + val processors: List = emptyList() +) : SkeletonBehaviour { override fun observe(receiver: Skeleton) { + val intervalMs = (1000f / hz).toLong() receiver.context.scope.launch { while (true) { - delay(10) + delay(intervalMs) val targetState = receiver.context.state.value val processed = processors - .filter { it.enabled } - .fold(targetState) { - state, processor -> processor.process(state) - } - receiver.computed.value = ComputedSkeletonState(buildComputedBones(processed)) + .filter { processor -> processor.enabled } + .fold(targetState) { state, processor -> processor.process(state) } + receiver.computed.value = buildBones(processed) } } } @@ -278,7 +239,7 @@ class ComputedSkeletonBehaviour(val processors: List = emptyL class Skeleton( val context: SkeletonContext, - val computed: MutableStateFlow, + val computed: MutableStateFlow>, ) { companion object { fun create(scope: CoroutineScope): Skeleton { @@ -296,7 +257,7 @@ class Skeleton( behaviours = behaviours, ) - val skeleton = Skeleton(context, MutableStateFlow(ComputedSkeletonState(buildComputedBones(context.state.value)))) + val skeleton = Skeleton(context, MutableStateFlow(buildBones(context.state.value))) behaviours.forEach { it.observe(skeleton) } return skeleton diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 03e3c5697d..a7ecf71002 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -3,7 +3,7 @@ package dev.slimevr.solarxr import com.google.flatbuffers.FlatBufferBuilder import dev.slimevr.VRServer import dev.slimevr.device.DeviceState -import dev.slimevr.skeleton.ComputedBone +import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.Skeleton import dev.slimevr.tracker.TrackerState import io.ktor.util.moveToByteArray @@ -12,7 +12,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import solarxr_protocol.MessageBundle -import solarxr_protocol.data_feed.Bone import solarxr_protocol.data_feed.DataFeedConfig import solarxr_protocol.data_feed.DataFeedMessageHeader import solarxr_protocol.data_feed.DataFeedUpdate @@ -90,11 +89,11 @@ private fun createDevice( ) } -private fun createBone(bone: ComputedBone): Bone = Bone( +private fun createBone(bone: BoneState): solarxr_protocol.data_feed.Bone = solarxr_protocol.data_feed.Bone( bodyPart = bone.bodyPart, rotationG = bone.rotation.let { Quat(it.x, it.y, it.z, it.w) }, boneLength = bone.length, - headPositionG = bone.head.let { Vec3f(it.x, it.y, it.z) }, + headPositionG = bone.headPosition.let { Vec3f(it.x, it.y, it.z) }, ) fun createDatafeedFrame( @@ -108,7 +107,7 @@ fun createDatafeedFrame( val devices = serverState.devices.values.map { it.context.state.value } .map { device -> createDevice(device, trackers, datafeedConfig) } val bones = if (datafeedConfig.boneMask) { - skeleton.computed.value.bones.values.map { createBone(it) } + skeleton.computed.value.values.map { createBone(it) } } else { null } From ac07e65b272ae16fdbe433f872048a780eb7c6cd Mon Sep 17 00:00:00 2001 From: loucass003 Date: Sat, 11 Apr 2026 08:35:26 +0200 Subject: [PATCH 047/350] Clenup dead code --- .../main/java/dev/slimevr/skeleton/module.kt | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 89f57b6c16..cc62758fba 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -19,19 +19,14 @@ data class BoneState( val rotation: Quaternion = Quaternion.IDENTITY, val headPosition: Vector3 = Vector3.NULL, val tailPosition: Vector3 = Vector3.NULL, - val parentBone: BodyPart? = null, + val parentBone: BoneState? = null, ) { - val localTailPosition: Vector3 get() = tailPosition - headPosition - - fun localRotation(bones: Map): Quaternion { - val parent = parentBone?.let { bones[it] } ?: return rotation - return parent.rotation.inv() * rotation - } - - fun localHeadPosition(bones: Map): Vector3 { - val parent = parentBone?.let { bones[it] } ?: return headPosition - return headPosition - parent.tailPosition - } + val localRotation: Quaternion + get() = parentBone?.let { it.rotation.inv() * rotation } ?: rotation + val localHeadPosition: Vector3 + get() = parentBone?.let { headPosition - it.tailPosition } ?: headPosition + val localTailPosition: Vector3 + get() = tailPosition - headPosition } private val BONE_TAIL_OFFSETS: Map = run { @@ -117,7 +112,7 @@ fun buildBones(state: SkeletonState, rootHead: Vector3 = Vector3.NULL): Map Date: Sat, 11 Apr 2026 11:35:54 +0200 Subject: [PATCH 048/350] Simple height calculation from proportions --- .../main/java/dev/slimevr/skeleton/module.kt | 132 ++++++++++++++++-- 1 file changed, 117 insertions(+), 15 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index cc62758fba..f282936a25 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -8,10 +8,13 @@ import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart import kotlin.math.cos import kotlin.math.sin +import kotlin.random.Random data class BoneState( val bodyPart: BodyPart, @@ -90,28 +93,57 @@ private val BONE_TAIL_OFFSETS: Map = run { offsets } -data class SkeletonState(val bones: Map) +private val BONE_TAIL_DIRECTIONS: Map = + BONE_TAIL_OFFSETS.mapValues { (_, offset) -> offset.unit() } -val DEFAULT_SKELETON_STATE: SkeletonState = SkeletonState( - bones = BONE_TAIL_OFFSETS.entries.associate { (bodyPart, tailOffset) -> +private val SPINE_CHAIN = listOf( + BodyPart.NECK, + BodyPart.UPPER_CHEST, + BodyPart.CHEST, + BodyPart.WAIST, + BodyPart.HIP, +) + +private val LEG_PAIRS = listOf( + BodyPart.LEFT_UPPER_LEG to BodyPart.RIGHT_UPPER_LEG, + BodyPart.LEFT_LOWER_LEG to BodyPart.RIGHT_LOWER_LEG, +) + +private fun computeUserHeight(bones: Map): Double { + val spineHeight = SPINE_CHAIN.sumOf { part -> + (bones[part]?.length ?: error("$part should exist")).toDouble() + } + val legHeight = LEG_PAIRS.sumOf { (left, right) -> + val leftLen = (bones[left]?.length ?: error("$left should exist")).toDouble() + val rightLen = (bones[right]?.length ?: error("$right should exist")).toDouble() + maxOf(leftLen, rightLen) + } + return spineHeight + legHeight +} + +data class SkeletonState(val bones: Map, val userHeight: Double) + +val DEFAULT_SKELETON_STATE: SkeletonState = run { + val bones = BONE_TAIL_OFFSETS.entries.associate { (bodyPart, tailOffset) -> val restRotation = when (bodyPart) { BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> Quaternion.rotationAroundXAxis(FastMath.HALF_PI) else -> Quaternion.IDENTITY } bodyPart to BoneState(bodyPart = bodyPart, length = tailOffset.len(), rotation = restRotation) } -) + SkeletonState(bones = bones, userHeight = computeUserHeight(bones)) +} fun buildBones(state: SkeletonState, rootHead: Vector3 = Vector3.NULL): Map { val result = mutableMapOf() iterateBodyPartHierarchy().forEach { (parentPart, childPart) -> val bone = state.bones[childPart] ?: return@forEach - val tailOffset = BONE_TAIL_OFFSETS[childPart] ?: return@forEach + val tailDirection = BONE_TAIL_DIRECTIONS[childPart] ?: return@forEach val parentBone = parentPart?.let { result[it] } val head = parentBone?.tailPosition ?: rootHead result[childPart] = bone.copy( headPosition = head, - tailPosition = head + bone.rotation.sandwich(tailOffset), + tailPosition = head + bone.rotation.sandwich(tailDirection * bone.length), parentBone = parentBone, ) } @@ -120,6 +152,7 @@ fun buildBones(state: SkeletonState, rootHead: Vector3 = Vector3.NULL): Map) : SkeletonActions } typealias SkeletonContext = Context @@ -135,6 +168,7 @@ class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour bones[action.bodyPart] = bone.copy(rotation = action.rotation) state.copy(bones = bones) } + is SkeletonActions.SetProportions -> state } } @@ -162,6 +196,50 @@ class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour } } +class RandomProportionsBehaviour(val intervalMs: Long) : SkeletonBehaviour { + private val targets = listOf( + BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG, + BodyPart.LEFT_LOWER_LEG, BodyPart.RIGHT_LOWER_LEG, + BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM, + ) + + override fun observe(receiver: Skeleton) { + receiver.context.scope.launch { + while (true) { + delay(intervalMs) + receiver.context.dispatch( + SkeletonActions.SetProportions( + targets.associateWith { Random.nextFloat() * 0.4f + 0.2f } + ) + ) + } + } + } +} + +class HeightLogBehaviour : SkeletonBehaviour { + override fun observe(receiver: Skeleton) { + receiver.context.scope.launch { + receiver.context.state + .map { state -> state.userHeight } + .distinctUntilChanged() + .collect { height -> println("User height changed: ${"%.2f".format(height)}m") } + } + } +} + +class ProportionsBehaviour : SkeletonBehaviour { + override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { + is SkeletonActions.SetProportions -> { + val newBones = state.bones.mapValues { (bodyPart, bone) -> + bone.copy(length = action.lengths[bodyPart] ?: bone.length) + } + state.copy(bones = newBones, userHeight = computeUserHeight(newBones)) + } + else -> state + } +} + interface SkeletonProcessor { var enabled: Boolean fun process(state: SkeletonState): SkeletonState @@ -170,15 +248,23 @@ interface SkeletonProcessor { class SmoothingProcessor(var smoothing: Float) : SkeletonProcessor { override var enabled: Boolean = true private var smoothedRotations: Map = emptyMap() + private var smoothedLengths: Map = emptyMap() override fun process(state: SkeletonState): SkeletonState { val alpha = 1f - smoothing.coerceAtMost(0.99f) smoothedRotations = state.bones.mapValues { (bodyPart, bone) -> (smoothedRotations[bodyPart] ?: bone.rotation).lerpR(bone.rotation, alpha).unit() } + smoothedLengths = state.bones.mapValues { (bodyPart, bone) -> + val prev = smoothedLengths[bodyPart] ?: bone.length + prev + (bone.length - prev) * alpha + } return state.copy( bones = state.bones.mapValues { (bodyPart, bone) -> - bone.copy(rotation = smoothedRotations[bodyPart] ?: bone.rotation) + bone.copy( + rotation = smoothedRotations[bodyPart] ?: bone.rotation, + length = smoothedLengths[bodyPart] ?: bone.length, + ) } ) } @@ -187,7 +273,12 @@ class SmoothingProcessor(var smoothing: Float) : SkeletonProcessor { class PredictionProcessor(var predictionAmount: Float) : SkeletonProcessor { override var enabled: Boolean = true - private data class BoneVelocity(val lastRotation: Quaternion, val delta: Quaternion) + private data class BoneVelocity( + val lastRotation: Quaternion, + val rotationDelta: Quaternion, + val lastLength: Float, + val lengthDelta: Float, + ) private var velocities: Map = emptyMap() @@ -196,17 +287,25 @@ class PredictionProcessor(var predictionAmount: Float) : SkeletonProcessor { val newBones = state.bones.mapValues { (bodyPart, bone) -> val prev = velocities[bodyPart] if (prev == null) { - newVelocities[bodyPart] = BoneVelocity(bone.rotation, Quaternion.IDENTITY) + newVelocities[bodyPart] = BoneVelocity(bone.rotation, Quaternion.IDENTITY, bone.length, 0f) return@mapValues bone } - val velocity = if (bone.rotation !== prev.lastRotation) { - BoneVelocity(bone.rotation, bone.rotation * prev.lastRotation.inv()) + val rotationDelta = if (bone.rotation !== prev.lastRotation) { + bone.rotation * prev.lastRotation.inv() } else { - prev + prev.rotationDelta } - newVelocities[bodyPart] = velocity - val scaledDelta = Quaternion.IDENTITY.lerpR(velocity.delta, predictionAmount).unit() - bone.copy(rotation = (scaledDelta * bone.rotation).unit()) + val lengthDelta = if (bone.length != prev.lastLength) { + bone.length - prev.lastLength + } else { + prev.lengthDelta + } + newVelocities[bodyPart] = BoneVelocity(bone.rotation, rotationDelta, bone.length, lengthDelta) + val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, predictionAmount).unit() + bone.copy( + rotation = (scaledDelta * bone.rotation).unit(), + length = bone.length + lengthDelta * predictionAmount, + ) } velocities = newVelocities return state.copy(bones = newBones) @@ -239,6 +338,9 @@ class Skeleton( companion object { fun create(scope: CoroutineScope): Skeleton { val behaviours = listOf( + ProportionsBehaviour(), + HeightLogBehaviour(), + RandomProportionsBehaviour(intervalMs = 3000L), YouSpinMeRightRoundBehaviour(inputHz = 1f), ComputedSkeletonBehaviour(processors = listOf( // PredictionProcessor(predictionAmount = 0.3f), From 6db666840bb0ead51a4bebfd34faba387b497627 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Sun, 12 Apr 2026 21:50:04 +0200 Subject: [PATCH 049/350] Implement scaled proportions --- .../src/main/java/dev/slimevr/config/user.kt | 1 + .../slimevr/heightcalibration/behaviours.kt | 12 +- .../dev/slimevr/heightcalibration/module.kt | 7 +- .../java/dev/slimevr/skeleton/behaviours.kt | 130 ++++++++ .../java/dev/slimevr/skeleton/bonegeometry.kt | 70 +++++ .../main/java/dev/slimevr/skeleton/module.kt | 283 +----------------- .../java/dev/slimevr/skeleton/processors.kt | 76 +++++ .../java/dev/slimevr/skeleton/proportions.kt | 79 +++++ .../dev/slimevr/solarxr/skeletonconfig.kt | 80 +++++ .../src/main/java/dev/slimevr/desktop/Main.kt | 10 +- 10 files changed, 466 insertions(+), 282 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors.kt create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/proportions.kt create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt diff --git a/server/core/src/main/java/dev/slimevr/config/user.kt b/server/core/src/main/java/dev/slimevr/config/user.kt index fd85b3bf6e..0796a6ff0e 100644 --- a/server/core/src/main/java/dev/slimevr/config/user.kt +++ b/server/core/src/main/java/dev/slimevr/config/user.kt @@ -18,6 +18,7 @@ private const val USER_CONFIG_VERSION = 1 @Serializable data class UserConfigData( val userHeight: Float = 1.6f, + val proportions: Map = emptyMap(), val version: Int = USER_CONFIG_VERSION, ) diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt index db57446f7d..1fb1a9178f 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt @@ -2,6 +2,9 @@ package dev.slimevr.heightcalibration +import dev.slimevr.config.UserConfig +import dev.slimevr.config.UserConfigActions +import dev.slimevr.skeleton.computeDefaultProportionsByBone import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.takeWhile @@ -62,6 +65,7 @@ object CalibrationBehaviour : HeightCalibrationBehaviourType { internal suspend fun runCalibrationSession( context: HeightCalibrationContext, + userConfig: UserConfig, hmdUpdates: kotlinx.coroutines.flow.Flow, controllerUpdates: kotlinx.coroutines.flow.Flow, clock: () -> Long = System::nanoTime, @@ -178,7 +182,13 @@ internal suspend fun runCalibrationSession( } dispatch(finalStatus, relativeY) - // TODO (when DONE): persist height to config, update user proportions, clear mounting reset flags + if (finalStatus == UserHeightCalibrationStatus.DONE) { + userConfig.context.dispatch( + UserConfigActions.Update { + copy(userHeight = relativeY, proportions = computeDefaultProportionsByBone(relativeY)) + } + ) + } } } } ?: dispatch(UserHeightCalibrationStatus.ERROR_TIMEOUT) diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt index b5fcbf87cb..fa80e92564 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt @@ -3,6 +3,7 @@ package dev.slimevr.heightcalibration import dev.slimevr.VRServer +import dev.slimevr.config.UserConfig import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import io.github.axisangles.ktmath.Quaternion @@ -40,6 +41,7 @@ val INITIAL_HEIGHT_CALIBRATION_STATE = HeightCalibrationState( class HeightCalibrationManager( val context: HeightCalibrationContext, val serverContext: VRServer, + private val userConfig: UserConfig, ) { private var sessionJob: Job? = null @@ -76,7 +78,7 @@ class HeightCalibrationManager( fun start() { sessionJob?.cancel() - sessionJob = context.scope.launch { runCalibrationSession(context, hmdUpdates, controllerUpdates) } + sessionJob = context.scope.launch { runCalibrationSession(context, userConfig, hmdUpdates, controllerUpdates) } } fun cancel() { @@ -88,6 +90,7 @@ class HeightCalibrationManager( companion object { fun create( serverContext: VRServer, + userConfig: UserConfig, scope: CoroutineScope, ): HeightCalibrationManager { val behaviours = listOf(CalibrationBehaviour) @@ -96,7 +99,7 @@ class HeightCalibrationManager( scope = scope, behaviours = behaviours, ) - return HeightCalibrationManager(context = context, serverContext = serverContext) + return HeightCalibrationManager(context = context, serverContext = serverContext, userConfig = userConfig) } } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt new file mode 100644 index 0000000000..67d47da12c --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -0,0 +1,130 @@ +package dev.slimevr.skeleton + +import dev.slimevr.config.UserConfig +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import solarxr_protocol.datatypes.BodyPart +import kotlin.math.cos +import kotlin.math.sin +import kotlin.random.Random + +class ProportionsBehaviour : SkeletonBehaviour { + override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { + is SkeletonActions.SetProportions -> { + val newBones = state.bones.mapValues { (bodyPart, bone) -> + bone.copy(length = action.lengths[bodyPart] ?: bone.length) + } + state.copy(bones = newBones, userHeight = computeUserHeight(newBones.mapValues { (_, bone) -> bone.length })) + } + else -> state + } +} + +class ScaledProportionsBehaviour(private val userConfig: UserConfig) : SkeletonBehaviour { + override fun observe(receiver: Skeleton) { + userConfig.context.state + .map { state -> state.data.proportions } + .distinctUntilChanged() + .onEach { proportions -> + if (proportions.isNotEmpty()) { + receiver.context.dispatch(SkeletonActions.SetProportions(expandProportions(proportions))) + } + } + .launchIn(receiver.context.scope) + } +} + +class HeightLogBehaviour : SkeletonBehaviour { + override fun observe(receiver: Skeleton) { + receiver.context.scope.launch { + receiver.context.state + .map { state -> state.userHeight } + .distinctUntilChanged() + .collect { height -> println("User height changed: ${"%.2f".format(height)}m") } + } + } +} + +class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour { + + override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState { + val bones = state.bones.toMutableMap() + return when (action) { + is SkeletonActions.SetBoneRotation -> { + val bone = bones[action.bodyPart] ?: return state + bones[action.bodyPart] = bone.copy(rotation = action.rotation) + state.copy(bones = bones) + } + else -> state + } + } + + override fun observe(receiver: Skeleton) { + receiver.context.scope.launch { + val intervalMs = (1000f / inputHz).toLong() + val startTime = System.currentTimeMillis() + while (true) { + delay(intervalMs) + val elapsed = (System.currentTimeMillis() - startTime) / 1000f + receiver.context.dispatch( + SkeletonActions.SetBoneRotation( + BodyPart.CHEST, + Quaternion.fromRotationVector(Vector3(cos(elapsed), sin(elapsed), 0f)), + ) + ) + receiver.context.dispatch( + SkeletonActions.SetBoneRotation( + BodyPart.LEFT_LOWER_LEG, + Quaternion.fromRotationVector(Vector3(cos(elapsed + 1000), sin(elapsed + 1000), 0f)), + ) + ) + } + } + } +} + +class RandomProportionsBehaviour(val intervalMs: Long) : SkeletonBehaviour { + private val targets = listOf( + BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG, + BodyPart.LEFT_LOWER_LEG, BodyPart.RIGHT_LOWER_LEG, + BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM, + ) + + override fun observe(receiver: Skeleton) { + receiver.context.scope.launch { + while (true) { + delay(intervalMs) + receiver.context.dispatch( + SkeletonActions.SetProportions( + targets.associateWith { Random.nextFloat() * 0.4f + 0.2f } + ) + ) + } + } + } +} + +class ComputedSkeletonBehaviour( + val hz: Float = 100f, + val processors: List = emptyList() +) : SkeletonBehaviour { + override fun observe(receiver: Skeleton) { + val intervalMs = (1000f / hz).toLong() + receiver.context.scope.launch { + while (true) { + delay(intervalMs) + val targetState = receiver.context.state.value + val processed = processors + .filter { processor -> processor.enabled } + .fold(targetState) { state, processor -> processor.process(state) } + receiver.computed.value = buildBones(processed) + } + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt b/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt new file mode 100644 index 0000000000..d8b1eea01a --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt @@ -0,0 +1,70 @@ +package dev.slimevr.skeleton + +import com.jme3.math.FastMath +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import solarxr_protocol.datatypes.BodyPart + +internal val BONE_TAIL_OFFSETS: Map = run { + val offsets = mutableMapOf() + iterateBodyPartHierarchy().forEach { (_, child) -> + val length = when (child) { + BodyPart.HEAD, BodyPart.NECK -> 0.1f + + BodyPart.LEFT_SHOULDER, BodyPart.RIGHT_SHOULDER -> 0.175f + + BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM, + BodyPart.LEFT_LOWER_ARM, BodyPart.RIGHT_LOWER_ARM, + -> 0.26f + + BodyPart.LEFT_HAND, BodyPart.RIGHT_HAND -> 0.13f + + BodyPart.LEFT_THUMB_METACARPAL, BodyPart.LEFT_THUMB_PROXIMAL, BodyPart.LEFT_THUMB_DISTAL, + BodyPart.LEFT_INDEX_DISTAL, BodyPart.LEFT_INDEX_INTERMEDIATE, BodyPart.LEFT_INDEX_PROXIMAL, + BodyPart.LEFT_MIDDLE_DISTAL, BodyPart.LEFT_MIDDLE_INTERMEDIATE, BodyPart.LEFT_MIDDLE_PROXIMAL, + BodyPart.LEFT_RING_DISTAL, BodyPart.LEFT_RING_INTERMEDIATE, BodyPart.LEFT_RING_PROXIMAL, + BodyPart.LEFT_LITTLE_DISTAL, BodyPart.LEFT_LITTLE_INTERMEDIATE, BodyPart.LEFT_LITTLE_PROXIMAL, + + BodyPart.RIGHT_THUMB_METACARPAL, BodyPart.RIGHT_THUMB_PROXIMAL, BodyPart.RIGHT_THUMB_DISTAL, + BodyPart.RIGHT_INDEX_PROXIMAL, BodyPart.RIGHT_INDEX_INTERMEDIATE, BodyPart.RIGHT_INDEX_DISTAL, + BodyPart.RIGHT_MIDDLE_PROXIMAL, BodyPart.RIGHT_MIDDLE_INTERMEDIATE, BodyPart.RIGHT_MIDDLE_DISTAL, + BodyPart.RIGHT_RING_PROXIMAL, BodyPart.RIGHT_RING_INTERMEDIATE, BodyPart.RIGHT_RING_DISTAL, + BodyPart.RIGHT_LITTLE_PROXIMAL, BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_DISTAL, + -> 0.025f + + BodyPart.UPPER_CHEST, BodyPart.CHEST -> 0.16f + + BodyPart.WAIST -> 0.2f + + BodyPart.HIP -> 0.04f + + BodyPart.LEFT_HIP, BodyPart.RIGHT_HIP -> 0.13f + + BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG -> 0.42f + + BodyPart.LEFT_LOWER_LEG, BodyPart.RIGHT_LOWER_LEG -> 0.5f + + BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> 0.05f + + else -> 0.1f + } + val restRotation = when (child) { + BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> Quaternion.rotationAroundXAxis(FastMath.HALF_PI) + else -> Quaternion.IDENTITY + } + val offset = when (child) { + BodyPart.HEAD -> Vector3(0f, 0f, length) + BodyPart.LEFT_HAND, BodyPart.RIGHT_HAND -> Vector3(0f, 0f, -length) + BodyPart.LEFT_SHOULDER -> Vector3(-length, -0.08f, 0f) + BodyPart.RIGHT_SHOULDER -> Vector3(length, -0.08f, 0f) + BodyPart.LEFT_HIP -> Vector3(-length, 0f, 0f) + BodyPart.RIGHT_HIP -> Vector3(length, 0f, 0f) + else -> Vector3(0f, -length, 0f) + } + offsets[child] = restRotation.inv().sandwich(offset) + } + offsets +} + +internal val BONE_TAIL_DIRECTIONS: Map = + BONE_TAIL_OFFSETS.mapValues { (_, offset) -> offset.unit() } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index f282936a25..7daeabccae 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -1,20 +1,14 @@ package dev.slimevr.skeleton import com.jme3.math.FastMath +import dev.slimevr.config.UserConfig import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart -import kotlin.math.cos -import kotlin.math.sin -import kotlin.random.Random data class BoneState( val bodyPart: BodyPart, @@ -32,94 +26,6 @@ data class BoneState( get() = tailPosition - headPosition } -private val BONE_TAIL_OFFSETS: Map = run { - val offsets = mutableMapOf() - iterateBodyPartHierarchy().forEach { (_, child) -> - val length = when (child) { - BodyPart.HEAD, BodyPart.NECK -> 0.1f - - BodyPart.LEFT_SHOULDER, BodyPart.RIGHT_SHOULDER -> 0.175f - - BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM, - BodyPart.LEFT_LOWER_ARM, BodyPart.RIGHT_LOWER_ARM, - -> 0.26f - - BodyPart.LEFT_HAND, BodyPart.RIGHT_HAND -> 0.13f - - BodyPart.LEFT_THUMB_METACARPAL, BodyPart.LEFT_THUMB_PROXIMAL, BodyPart.LEFT_THUMB_DISTAL, - BodyPart.LEFT_INDEX_DISTAL, BodyPart.LEFT_INDEX_INTERMEDIATE, BodyPart.LEFT_INDEX_PROXIMAL, - BodyPart.LEFT_MIDDLE_DISTAL, BodyPart.LEFT_MIDDLE_INTERMEDIATE, BodyPart.LEFT_MIDDLE_PROXIMAL, - BodyPart.LEFT_RING_DISTAL, BodyPart.LEFT_RING_INTERMEDIATE, BodyPart.LEFT_RING_PROXIMAL, - BodyPart.LEFT_LITTLE_DISTAL, BodyPart.LEFT_LITTLE_INTERMEDIATE, BodyPart.LEFT_LITTLE_PROXIMAL, - - BodyPart.RIGHT_THUMB_METACARPAL, BodyPart.RIGHT_THUMB_PROXIMAL, BodyPart.RIGHT_THUMB_DISTAL, - BodyPart.RIGHT_INDEX_PROXIMAL, BodyPart.RIGHT_INDEX_INTERMEDIATE, BodyPart.RIGHT_INDEX_DISTAL, - BodyPart.RIGHT_MIDDLE_PROXIMAL, BodyPart.RIGHT_MIDDLE_INTERMEDIATE, BodyPart.RIGHT_MIDDLE_DISTAL, - BodyPart.RIGHT_RING_PROXIMAL, BodyPart.RIGHT_RING_INTERMEDIATE, BodyPart.RIGHT_RING_DISTAL, - BodyPart.RIGHT_LITTLE_PROXIMAL, BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_DISTAL, - -> 0.025f - - BodyPart.UPPER_CHEST, BodyPart.CHEST -> 0.16f - - BodyPart.WAIST -> 0.2f - - BodyPart.HIP -> 0.04f - - BodyPart.LEFT_HIP, BodyPart.RIGHT_HIP -> 0.13f - - BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG -> 0.42f - - BodyPart.LEFT_LOWER_LEG, BodyPart.RIGHT_LOWER_LEG -> 0.5f - - BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> 0.05f - - else -> 0.1f - } - val restRotation = when (child) { - BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> Quaternion.rotationAroundXAxis(FastMath.HALF_PI) - else -> Quaternion.IDENTITY - } - val offset = when (child) { - BodyPart.HEAD -> Vector3(0f, 0f, length) - BodyPart.LEFT_HAND, BodyPart.RIGHT_HAND -> Vector3(0f, 0f, -length) - BodyPart.LEFT_SHOULDER -> Vector3(-length, -0.08f, 0f) - BodyPart.RIGHT_SHOULDER -> Vector3(length, -0.08f, 0f) - BodyPart.LEFT_HIP -> Vector3(-length, 0f, 0f) - BodyPart.RIGHT_HIP -> Vector3(length, 0f, 0f) - else -> Vector3(0f, -length, 0f) - } - offsets[child] = restRotation.inv().sandwich(offset) - } - offsets -} - -private val BONE_TAIL_DIRECTIONS: Map = - BONE_TAIL_OFFSETS.mapValues { (_, offset) -> offset.unit() } - -private val SPINE_CHAIN = listOf( - BodyPart.NECK, - BodyPart.UPPER_CHEST, - BodyPart.CHEST, - BodyPart.WAIST, - BodyPart.HIP, -) - -private val LEG_PAIRS = listOf( - BodyPart.LEFT_UPPER_LEG to BodyPart.RIGHT_UPPER_LEG, - BodyPart.LEFT_LOWER_LEG to BodyPart.RIGHT_LOWER_LEG, -) - -private fun computeUserHeight(bones: Map): Double { - val spineHeight = SPINE_CHAIN.sumOf { part -> - (bones[part]?.length ?: error("$part should exist")).toDouble() - } - val legHeight = LEG_PAIRS.sumOf { (left, right) -> - val leftLen = (bones[left]?.length ?: error("$left should exist")).toDouble() - val rightLen = (bones[right]?.length ?: error("$right should exist")).toDouble() - maxOf(leftLen, rightLen) - } - return spineHeight + legHeight -} data class SkeletonState(val bones: Map, val userHeight: Double) @@ -131,7 +37,7 @@ val DEFAULT_SKELETON_STATE: SkeletonState = run { } bodyPart to BoneState(bodyPart = bodyPart, length = tailOffset.len(), rotation = restRotation) } - SkeletonState(bones = bones, userHeight = computeUserHeight(bones)) + SkeletonState(bones = bones, userHeight = computeUserHeight(bones.mapValues { (_, bone) -> bone.length })) } fun buildBones(state: SkeletonState, rootHead: Vector3 = Vector3.NULL): Map { @@ -158,193 +64,20 @@ sealed interface SkeletonActions { typealias SkeletonContext = Context typealias SkeletonBehaviour = Behaviour -class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour { - - override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState { - val bones = state.bones.toMutableMap() - return when (action) { - is SkeletonActions.SetBoneRotation -> { - val bone = bones[action.bodyPart] ?: return state - bones[action.bodyPart] = bone.copy(rotation = action.rotation) - state.copy(bones = bones) - } - is SkeletonActions.SetProportions -> state - } - } - - override fun observe(receiver: Skeleton) { - receiver.context.scope.launch { - val intervalMs = (1000f / inputHz).toLong() - val startTime = System.currentTimeMillis() - while (true) { - delay(intervalMs) - val elapsed = (System.currentTimeMillis() - startTime) / 1000f - receiver.context.dispatch( - SkeletonActions.SetBoneRotation( - BodyPart.CHEST, - Quaternion.fromRotationVector(Vector3(cos(elapsed), sin(elapsed), 0f)), - ) - ) - receiver.context.dispatch( - SkeletonActions.SetBoneRotation( - BodyPart.LEFT_LOWER_LEG, - Quaternion.fromRotationVector(Vector3(cos(elapsed + 1000), sin(elapsed + 1000), 0f)), - ) - ) - } - } - } -} - -class RandomProportionsBehaviour(val intervalMs: Long) : SkeletonBehaviour { - private val targets = listOf( - BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG, - BodyPart.LEFT_LOWER_LEG, BodyPart.RIGHT_LOWER_LEG, - BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM, - ) - - override fun observe(receiver: Skeleton) { - receiver.context.scope.launch { - while (true) { - delay(intervalMs) - receiver.context.dispatch( - SkeletonActions.SetProportions( - targets.associateWith { Random.nextFloat() * 0.4f + 0.2f } - ) - ) - } - } - } -} - -class HeightLogBehaviour : SkeletonBehaviour { - override fun observe(receiver: Skeleton) { - receiver.context.scope.launch { - receiver.context.state - .map { state -> state.userHeight } - .distinctUntilChanged() - .collect { height -> println("User height changed: ${"%.2f".format(height)}m") } - } - } -} - -class ProportionsBehaviour : SkeletonBehaviour { - override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { - is SkeletonActions.SetProportions -> { - val newBones = state.bones.mapValues { (bodyPart, bone) -> - bone.copy(length = action.lengths[bodyPart] ?: bone.length) - } - state.copy(bones = newBones, userHeight = computeUserHeight(newBones)) - } - else -> state - } -} - -interface SkeletonProcessor { - var enabled: Boolean - fun process(state: SkeletonState): SkeletonState -} - -class SmoothingProcessor(var smoothing: Float) : SkeletonProcessor { - override var enabled: Boolean = true - private var smoothedRotations: Map = emptyMap() - private var smoothedLengths: Map = emptyMap() - - override fun process(state: SkeletonState): SkeletonState { - val alpha = 1f - smoothing.coerceAtMost(0.99f) - smoothedRotations = state.bones.mapValues { (bodyPart, bone) -> - (smoothedRotations[bodyPart] ?: bone.rotation).lerpR(bone.rotation, alpha).unit() - } - smoothedLengths = state.bones.mapValues { (bodyPart, bone) -> - val prev = smoothedLengths[bodyPart] ?: bone.length - prev + (bone.length - prev) * alpha - } - return state.copy( - bones = state.bones.mapValues { (bodyPart, bone) -> - bone.copy( - rotation = smoothedRotations[bodyPart] ?: bone.rotation, - length = smoothedLengths[bodyPart] ?: bone.length, - ) - } - ) - } -} - -class PredictionProcessor(var predictionAmount: Float) : SkeletonProcessor { - override var enabled: Boolean = true - - private data class BoneVelocity( - val lastRotation: Quaternion, - val rotationDelta: Quaternion, - val lastLength: Float, - val lengthDelta: Float, - ) - - private var velocities: Map = emptyMap() - - override fun process(state: SkeletonState): SkeletonState { - val newVelocities = mutableMapOf() - val newBones = state.bones.mapValues { (bodyPart, bone) -> - val prev = velocities[bodyPart] - if (prev == null) { - newVelocities[bodyPart] = BoneVelocity(bone.rotation, Quaternion.IDENTITY, bone.length, 0f) - return@mapValues bone - } - val rotationDelta = if (bone.rotation !== prev.lastRotation) { - bone.rotation * prev.lastRotation.inv() - } else { - prev.rotationDelta - } - val lengthDelta = if (bone.length != prev.lastLength) { - bone.length - prev.lastLength - } else { - prev.lengthDelta - } - newVelocities[bodyPart] = BoneVelocity(bone.rotation, rotationDelta, bone.length, lengthDelta) - val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, predictionAmount).unit() - bone.copy( - rotation = (scaledDelta * bone.rotation).unit(), - length = bone.length + lengthDelta * predictionAmount, - ) - } - velocities = newVelocities - return state.copy(bones = newBones) - } -} - -class ComputedSkeletonBehaviour( - val hz: Float = 100f, - val processors: List = emptyList() -) : SkeletonBehaviour { - override fun observe(receiver: Skeleton) { - val intervalMs = (1000f / hz).toLong() - receiver.context.scope.launch { - while (true) { - delay(intervalMs) - val targetState = receiver.context.state.value - val processed = processors - .filter { processor -> processor.enabled } - .fold(targetState) { state, processor -> processor.process(state) } - receiver.computed.value = buildBones(processed) - } - } - } -} - class Skeleton( val context: SkeletonContext, val computed: MutableStateFlow>, ) { companion object { - fun create(scope: CoroutineScope): Skeleton { + fun create(scope: CoroutineScope, userConfig: UserConfig): Skeleton { val behaviours = listOf( ProportionsBehaviour(), + ScaledProportionsBehaviour(userConfig), HeightLogBehaviour(), - RandomProportionsBehaviour(intervalMs = 3000L), - YouSpinMeRightRoundBehaviour(inputHz = 1f), + YouSpinMeRightRoundBehaviour(inputHz = 10f), ComputedSkeletonBehaviour(processors = listOf( -// PredictionProcessor(predictionAmount = 0.3f), - SmoothingProcessor(smoothing = 1f) +// PredictionProcessor(predictionAmount = 0.3f), + SmoothingProcessor(smoothing = 0.3f) )), ) @@ -360,4 +93,4 @@ class Skeleton( return skeleton } } -} +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt new file mode 100644 index 0000000000..51edb41d21 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt @@ -0,0 +1,76 @@ +package dev.slimevr.skeleton + +import io.github.axisangles.ktmath.Quaternion +import solarxr_protocol.datatypes.BodyPart + +interface SkeletonProcessor { + var enabled: Boolean + fun process(state: SkeletonState): SkeletonState +} + +class SmoothingProcessor(var smoothing: Float) : SkeletonProcessor { + override var enabled: Boolean = true + private var smoothedRotations: Map = emptyMap() + private var smoothedLengths: Map = emptyMap() + + override fun process(state: SkeletonState): SkeletonState { + val alpha = 1f - smoothing.coerceAtMost(0.99f) + smoothedRotations = state.bones.mapValues { (bodyPart, bone) -> + (smoothedRotations[bodyPart] ?: bone.rotation).lerpR(bone.rotation, alpha).unit() + } + smoothedLengths = state.bones.mapValues { (bodyPart, bone) -> + val prev = smoothedLengths[bodyPart] ?: bone.length + prev + (bone.length - prev) * alpha + } + return state.copy( + bones = state.bones.mapValues { (bodyPart, bone) -> + bone.copy( + rotation = smoothedRotations[bodyPart] ?: bone.rotation, + length = smoothedLengths[bodyPart] ?: bone.length, + ) + } + ) + } +} + +class PredictionProcessor(var predictionAmount: Float) : SkeletonProcessor { + override var enabled: Boolean = true + + private data class BoneVelocity( + val lastRotation: Quaternion, + val rotationDelta: Quaternion, + val lastLength: Float, + val lengthDelta: Float, + ) + + private var velocities: Map = emptyMap() + + override fun process(state: SkeletonState): SkeletonState { + val newVelocities = mutableMapOf() + val newBones = state.bones.mapValues { (bodyPart, bone) -> + val prev = velocities[bodyPart] + if (prev == null) { + newVelocities[bodyPart] = BoneVelocity(bone.rotation, Quaternion.IDENTITY, bone.length, 0f) + return@mapValues bone + } + val rotationDelta = if (bone.rotation !== prev.lastRotation) { + bone.rotation * prev.lastRotation.inv() + } else { + prev.rotationDelta + } + val lengthDelta = if (bone.length != prev.lastLength) { + bone.length - prev.lastLength + } else { + prev.lengthDelta + } + newVelocities[bodyPart] = BoneVelocity(bone.rotation, rotationDelta, bone.length, lengthDelta) + val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, predictionAmount).unit() + bone.copy( + rotation = (scaledDelta * bone.rotation).unit(), + length = bone.length + lengthDelta * predictionAmount, + ) + } + velocities = newVelocities + return state.copy(bones = newBones) + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt new file mode 100644 index 0000000000..3eb029f323 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt @@ -0,0 +1,79 @@ +package dev.slimevr.skeleton + +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.SkeletonBone + +// Sum of default bone lengths for height-contributing bones (from Drillis and Contini 1966). +// Used to normalize HEIGHT_SCALED_BONE_RATIOS. +private const val DEFAULT_HEIGHT = 0.1f + 0.16f + 0.16f + 0.20f + 0.04f + 0.42f + 0.50f // = 1.58f + +// Maps each solarxr SkeletonBone to the BodyPart(s) it controls in the skeleton. +// Symmetric bones (legs, arms) map to both left and right sides. +val SKELETON_BONE_TO_BODY_PARTS: Map> = mapOf( + SkeletonBone.HEAD to listOf(BodyPart.HEAD), + SkeletonBone.NECK to listOf(BodyPart.NECK), + SkeletonBone.UPPER_CHEST to listOf(BodyPart.UPPER_CHEST), + SkeletonBone.CHEST to listOf(BodyPart.CHEST), + SkeletonBone.WAIST to listOf(BodyPart.WAIST), + SkeletonBone.HIP to listOf(BodyPart.HIP), + SkeletonBone.UPPER_LEG to listOf(BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG), + SkeletonBone.LOWER_LEG to listOf(BodyPart.LEFT_LOWER_LEG, BodyPart.RIGHT_LOWER_LEG), + SkeletonBone.FOOT_LENGTH to listOf(BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT), + SkeletonBone.UPPER_ARM to listOf(BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM), + SkeletonBone.LOWER_ARM to listOf(BodyPart.LEFT_LOWER_ARM, BodyPart.RIGHT_LOWER_ARM), +) + +// Per-bone fraction of total standing height, derived from Drillis and Contini (1966). +// Includes spine, legs, and arms, all bones whose length scales with user height. +// Non-height bones (HEAD, FOOT_LENGTH) are absent; they keep fixed defaults from DEFAULT_SKELETON_STATE. +private val HEIGHT_SCALED_BONE_RATIOS: Map = mapOf( + SkeletonBone.NECK to 0.1f / DEFAULT_HEIGHT, + SkeletonBone.UPPER_CHEST to 0.16f / DEFAULT_HEIGHT, + SkeletonBone.CHEST to 0.16f / DEFAULT_HEIGHT, + SkeletonBone.WAIST to 0.20f / DEFAULT_HEIGHT, + SkeletonBone.HIP to 0.04f / DEFAULT_HEIGHT, + SkeletonBone.UPPER_LEG to 0.42f / DEFAULT_HEIGHT, + SkeletonBone.LOWER_LEG to 0.50f / DEFAULT_HEIGHT, + SkeletonBone.UPPER_ARM to 0.26f / DEFAULT_HEIGHT, + SkeletonBone.LOWER_ARM to 0.26f / DEFAULT_HEIGHT, +) + +// Subset of HEIGHT_SCALED_BONE_RATIOS whose lengths actually sum to standing height (spine + legs). +// Arms are excluded: they scale with height but are not part of the height measurement. +// For symmetric bones (UPPER_LEG, LOWER_LEG) computeUserHeight takes the larger side. +private val HEIGHT_CONTRIBUTING_BONES: Set = + HEIGHT_SCALED_BONE_RATIOS.keys - setOf(SkeletonBone.UPPER_ARM, SkeletonBone.LOWER_ARM) + +// Sums the HEIGHT_CONTRIBUTING_BONES lengths to derive standing height. +// For symmetric bones (legs) the larger side is used. +fun computeUserHeight(lengths: Map): Double = + HEIGHT_CONTRIBUTING_BONES.sumOf { bone -> + val bodyParts = SKELETON_BONE_TO_BODY_PARTS[bone] ?: return@sumOf 0.0 + bodyParts.maxOfOrNull { lengths[it] ?: 0f }?.toDouble() ?: 0.0 + } + +// Returns proportions keyed by SkeletonBone.name for config storage. +// Only height-scaled bones are included. +fun computeDefaultProportionsByBone(height: Float): Map = + HEIGHT_SCALED_BONE_RATIOS.mapKeys { (bone, _) -> bone.name } + .mapValues { (_, ratio) -> height * ratio } + +// Returns proportions for all tracked bones: height-scaled + default lengths for the rest. +fun computeAllDefaultProportionsByBone(height: Float): Map { + val heightScaled = computeDefaultProportionsByBone(height) + val nonScaled = SKELETON_BONE_TO_BODY_PARTS + .filter { (bone, _) -> bone !in HEIGHT_SCALED_BONE_RATIOS } + .mapNotNull { (bone, bodyParts) -> + val length = bodyParts + .mapNotNull { DEFAULT_SKELETON_STATE.bones[it]?.length } + .average().takeIf { !it.isNaN() }?.toFloat() ?: return@mapNotNull null + bone.name to length + }.toMap() + return heightScaled + nonScaled +} + +fun expandProportions(proportions: Map): Map = + proportions.entries.flatMap { (boneName, length) -> + val bone = SkeletonBone.entries.firstOrNull { it.name == boneName } ?: return@flatMap emptyList() + (SKELETON_BONE_TO_BODY_PARTS[bone] ?: return@flatMap emptyList()).map { bodyPart -> bodyPart to length } + }.toMap() diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt new file mode 100644 index 0000000000..de54cfdf00 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt @@ -0,0 +1,80 @@ +package dev.slimevr.solarxr + +import dev.slimevr.config.UserConfig +import dev.slimevr.config.UserConfigActions +import dev.slimevr.skeleton.SKELETON_BONE_TO_BODY_PARTS +import dev.slimevr.skeleton.Skeleton +import dev.slimevr.skeleton.computeAllDefaultProportionsByBone +import dev.slimevr.skeleton.computeDefaultProportionsByBone +import dev.slimevr.skeleton.computeUserHeight +import dev.slimevr.skeleton.expandProportions +import solarxr_protocol.rpc.ChangeSettingsRequest +import solarxr_protocol.rpc.ChangeSkeletonConfigRequest +import solarxr_protocol.rpc.SkeletonConfigRequest +import solarxr_protocol.rpc.SkeletonConfigResponse +import solarxr_protocol.rpc.SkeletonPart +import solarxr_protocol.rpc.SkeletonResetAllRequest + +private const val MIN_HEIGHT = 1.0f + +class SkeletonBehaviour( + private val userConfig: UserConfig, + private val skeleton: Skeleton, +) : SolarXRConnectionBehaviour { + + private fun buildConfigResponse(): SkeletonConfigResponse { + val proportions = userConfig.context.state.value.data.proportions + val bones = skeleton.context.state.value.bones + val skeletonParts = SKELETON_BONE_TO_BODY_PARTS.mapNotNull { (skeletonBone, bodyParts) -> + val length = proportions[skeletonBone.name] + ?: bodyParts.mapNotNull { bones[it]?.length }.average().takeIf { !it.isNaN() }?.toFloat() + ?: return@mapNotNull null + SkeletonPart(bone = skeletonBone, value = length) + } + val expanded = expandProportions(proportions) + val userHeight = if (expanded.isNotEmpty()) { + computeUserHeight(expanded).toFloat() + } else { + skeleton.context.state.value.userHeight.toFloat() + } + return SkeletonConfigResponse(skeletonParts = skeletonParts, userHeight = userHeight) + } + + override fun observe(receiver: SolarXRConnection) { + receiver.rpcDispatcher.on { + receiver.sendRpc(buildConfigResponse()) + } + + receiver.rpcDispatcher.on { req -> + req.modelSettings?.skeletonHeight?.let { skeletonHeight -> + val hmdHeight = skeletonHeight.hmdHeight ?: return@let + val floorHeight = skeletonHeight.floorHeight ?: 0f + val height = hmdHeight - floorHeight + if (height >= MIN_HEIGHT) { + userConfig.context.dispatch( + UserConfigActions.Update { + copy(userHeight = height, proportions = computeDefaultProportionsByBone(height)) + } + ) + } + } + } + + receiver.rpcDispatcher.on { + val height = userConfig.context.state.value.data.userHeight + if (height >= MIN_HEIGHT) { + val defaults = computeAllDefaultProportionsByBone(height) + userConfig.context.dispatch(UserConfigActions.Update { copy(proportions = defaults) }) + } + receiver.sendRpc(buildConfigResponse()) + } + + receiver.rpcDispatcher.on { req -> + val bone = req.bone ?: return@on + userConfig.context.dispatch( + UserConfigActions.Update { copy(proportions = proportions + (bone.name to req.value)) } + ) + receiver.sendRpc(buildConfigResponse()) + } + } +} 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 8df0fe7974..5ec21502a8 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -18,6 +18,7 @@ import dev.slimevr.solarxr.FirmwareBehaviour import dev.slimevr.solarxr.HeightCalibrationBehaviour import dev.slimevr.solarxr.ProvisioningBehaviour import dev.slimevr.solarxr.SerialBehaviour +import dev.slimevr.solarxr.SkeletonBehaviour import dev.slimevr.solarxr.VrcBehaviour import dev.slimevr.solarxr.createSolarXRWebsocketServer import dev.slimevr.udp.createUDPTrackerServer @@ -33,10 +34,10 @@ fun main(args: Array) = runBlocking { val firmwareManager = FirmwareManager.create(serialServer = serialServer, scope = this) val vrcConfigManager = createDesktopVRCConfigManager(config = config, scope = this) - val heightCalibrationManager = HeightCalibrationManager.create(serverContext = server, scope = this) + val heightCalibrationManager = HeightCalibrationManager.create(serverContext = server, userConfig = config.userConfig, scope = this) val provisioningManager = ProvisioningManager.create(serialServer, this) - val skeleton = Skeleton.create(this) + val skeleton = Skeleton.create(this, config.userConfig) launch { createUDPTrackerServer(server, config) @@ -49,9 +50,10 @@ fun main(args: Array) = runBlocking { DataFeedInitBehaviour(server, skeleton), SerialBehaviour(serialServer), FirmwareBehaviour(server, firmwareManager), - VrcBehaviour(vrcConfigManager, server, userHeight = { config.userConfig.context.state.value.data.userHeight.toDouble() }), + VrcBehaviour(vrcConfigManager, server, userHeight = { skeleton.context.state.value.userHeight }), HeightCalibrationBehaviour(heightCalibrationManager), - ProvisioningBehaviour(server, provisioningManager) + ProvisioningBehaviour(server, provisioningManager), + SkeletonBehaviour(config.userConfig, skeleton), ) launch { createSolarXRWebsocketServer(solarXRBehaviours) } launch { createIpcServers(server, solarXRBehaviours) } From e2fa7c8963ff99b675d4583ca5e28f69edb46891 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Sun, 12 Apr 2026 21:58:19 +0200 Subject: [PATCH 050/350] Remove random proportions --- .../java/dev/slimevr/skeleton/behaviours.kt | 23 +------------------ .../main/java/dev/slimevr/skeleton/module.kt | 4 ++-- 2 files changed, 3 insertions(+), 24 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 67d47da12c..d6e5ce4c8f 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -89,27 +89,6 @@ class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour } } -class RandomProportionsBehaviour(val intervalMs: Long) : SkeletonBehaviour { - private val targets = listOf( - BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG, - BodyPart.LEFT_LOWER_LEG, BodyPart.RIGHT_LOWER_LEG, - BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM, - ) - - override fun observe(receiver: Skeleton) { - receiver.context.scope.launch { - while (true) { - delay(intervalMs) - receiver.context.dispatch( - SkeletonActions.SetProportions( - targets.associateWith { Random.nextFloat() * 0.4f + 0.2f } - ) - ) - } - } - } -} - class ComputedSkeletonBehaviour( val hz: Float = 100f, val processors: List = emptyList() @@ -127,4 +106,4 @@ class ComputedSkeletonBehaviour( } } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 7daeabccae..59e84925a0 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -76,7 +76,7 @@ class Skeleton( HeightLogBehaviour(), YouSpinMeRightRoundBehaviour(inputHz = 10f), ComputedSkeletonBehaviour(processors = listOf( -// PredictionProcessor(predictionAmount = 0.3f), +// PredictionProcessor(predictionAmount = 0.3f), SmoothingProcessor(smoothing = 0.3f) )), ) @@ -93,4 +93,4 @@ class Skeleton( return skeleton } } -} \ No newline at end of file +} From 4bf1e706d391fd1465bfc1cae31da3eaa8bab89c Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 13 Apr 2026 12:32:29 +0200 Subject: [PATCH 051/350] Started implementing checklist / refactored most ipc logic to have two way communication with core --- .../src/main/java/dev/slimevr/behaviours.kt | 8 +- .../main/java/dev/slimevr/config/settings.kt | 1 + .../java/dev/slimevr/driver/behaviours.kt | 57 +++++++ .../main/java/dev/slimevr/driver/module.kt | 63 +++++++ .../java/dev/slimevr/feeder/behaviours.kt | 70 ++++++++ .../main/java/dev/slimevr/feeder/module.kt | 59 +++++++ .../src/main/java/dev/slimevr/hid/module.kt | 34 ---- .../src/main/java/dev/slimevr/hid/packets.kt | 134 --------------- .../main/java/dev/slimevr/solarxr/datafeed.kt | 44 +---- .../main/java/dev/slimevr/solarxr/firmware.kt | 4 +- .../dev/slimevr/solarxr/heightcalibration.kt | 4 +- .../src/main/java/dev/slimevr/solarxr/ipc.kt | 37 ++++ .../main/java/dev/slimevr/solarxr/module.kt | 57 ++++--- .../java/dev/slimevr/solarxr/provisioning.kt | 4 +- .../main/java/dev/slimevr/solarxr/serial.kt | 4 +- .../dev/slimevr/solarxr/skeletonconfig.kt | 4 +- .../dev/slimevr/solarxr/trackingchecklist.kt | 66 +++++++ .../main/java/dev/slimevr/solarxr/vrchat.kt | 4 +- .../slimevr/trackingchecklist/behaviours.kt | 144 ++++++++++++++++ .../dev/slimevr/trackingchecklist/module.kt | 78 +++++++++ .../main/java/dev/slimevr/vrchat/module.kt | 11 ++ .../src/main/java/dev/slimevr/vrserver.kt | 22 ++- server/desktop/build.gradle.kts | 5 + .../src/main/java/dev/slimevr/desktop/Main.kt | 13 +- .../main/java/dev/slimevr/desktop/hid/hid.kt | 36 ++-- .../java/dev/slimevr/desktop/hid/packets.kt | 151 ++++++++++++++++ .../main/java/dev/slimevr/desktop/ipc/ipc.kt | 8 +- .../java/dev/slimevr/desktop/ipc/linux.kt | 8 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 161 ++++-------------- .../java/dev/slimevr/desktop/ipc/windows.kt | 8 +- .../dev/slimevr/desktop/ipc}/ws-server.kt | 43 ++--- 31 files changed, 916 insertions(+), 426 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/driver/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/driver/module.kt create mode 100644 server/core/src/main/java/dev/slimevr/feeder/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/feeder/module.kt create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/ipc.kt create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt create mode 100644 server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt rename server/{core/src/main/java/dev/slimevr/solarxr => desktop/src/main/java/dev/slimevr/desktop/ipc}/ws-server.kt (53%) diff --git a/server/core/src/main/java/dev/slimevr/behaviours.kt b/server/core/src/main/java/dev/slimevr/behaviours.kt index 3ce833d22f..98d6bc2c2f 100644 --- a/server/core/src/main/java/dev/slimevr/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/behaviours.kt @@ -5,9 +5,15 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach object BaseBehaviour : VRServerBehaviour { - override fun reduce(state: VRServerState, action: VRServerActions) = when (action) { + override fun reduce(state: VRServerState, action: VRServerActions): VRServerState = when (action) { is VRServerActions.NewTracker -> state.copy(trackers = state.trackers + (action.trackerId to action.context)) is VRServerActions.NewDevice -> state.copy(devices = state.devices + (action.deviceId to action.context)) + is VRServerActions.DriverConnected -> state.copy(drivers = state.drivers + (action.bridge.id to action.bridge)) + is VRServerActions.DriverDisconnected -> state.copy(drivers = state.drivers - action.bridgeId) + is VRServerActions.FeederConnected -> state.copy(feeders = state.feeders + (action.bridge.id to action.bridge)) + is VRServerActions.FeederDisconnected -> state.copy(feeders = state.feeders - action.bridgeId) + is VRServerActions.SolarXRConnected -> state.copy(solarxr = state.solarxr + (action.connection.id to action.connection)) + is VRServerActions.SolarXRDisconnected -> state.copy(solarxr = state.solarxr - action.connectionId) } override fun observe(receiver: VRServer) { diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 563dcc0b94..9a0606949d 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -19,6 +19,7 @@ private const val SETTINGS_CONFIG_VERSION = 1 data class SettingsConfigState( val trackerPort: Int = 6969, val mutedVRCWarnings: List = listOf(), + val mutedChecklistSteps: Set = emptySet(), val version: Int = SETTINGS_CONFIG_VERSION, ) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt new file mode 100644 index 0000000000..747cc644c7 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -0,0 +1,57 @@ +package dev.slimevr.driver + +import dev.slimevr.VRServer +import dev.slimevr.device.DeviceOrigin +import dev.slimevr.tracker.TrackerActions +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +class DriverBaseBehaviour(private val server: VRServer) : DriverBridgeBehaviour { + override fun reduce(state: DriverBridgeState, action: DriverBridgeActions): DriverBridgeState = when (action) { + is DriverBridgeActions.UpdateProtocolVersion -> state.copy(protocolVersion = action.version) + } + + override fun observe(receiver: DriverBridge) { + receiver.inbound.on { event -> + receiver.context.dispatch(DriverBridgeActions.UpdateProtocolVersion(event.protocolVersion)) + } + + receiver.inbound.on { event -> + server.getTracker(event.trackerId)?.context?.dispatch( + TrackerActions.Update { copy(rawRotation = event.rotation) }, + ) + } + + // Should be safe: StateFlow never delivers two emissions concurrently to the same collector. + val subscribedTrackers = mutableSetOf() + + server.context.state + .onEach { state -> + state.trackers.values.forEach { tracker -> + val ts = tracker.context.state.value + if (ts.origin == DeviceOrigin.DRIVER) return@forEach + if (subscribedTrackers.add(ts.id)) { + receiver.outbound.emit( + DriverBridgeOutbound.TrackerAdded( + trackerId = ts.id, + serial = ts.hardwareId, + name = ts.customName ?: ts.name, + ), + ) + tracker.context.state + .onEach { trackerState -> + receiver.outbound.emit( + DriverBridgeOutbound.TrackerPosition( + trackerId = trackerState.id, + rotation = trackerState.rawRotation, + position = trackerState.position, + ), + ) + } + .launchIn(receiver.context.scope) + } + } + } + .launchIn(receiver.context.scope) + } +} diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt new file mode 100644 index 0000000000..af58efa607 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -0,0 +1,63 @@ +package dev.slimevr.driver + +import dev.slimevr.EventDispatcher +import dev.slimevr.VRServer +import dev.slimevr.VRServerActions +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.CoroutineScope + +data class DriverBridgeState( + val protocolVersion: Int, +) + +sealed interface DriverBridgeActions { + data class UpdateProtocolVersion(val version: Int) : DriverBridgeActions +} + +sealed interface DriverBridgeInbound { + data class Version(val protocolVersion: Int) : DriverBridgeInbound + data class TrackerPosition(val trackerId: Int, val rotation: Quaternion, val position: Vector3?) : DriverBridgeInbound +} + +sealed interface DriverBridgeOutbound { + data class TrackerAdded(val trackerId: Int, val serial: String, val name: String) : DriverBridgeOutbound + data class TrackerPosition(val trackerId: Int, val rotation: Quaternion, val position: Vector3?) : DriverBridgeOutbound +} + +typealias DriverBridgeContext = Context +typealias DriverBridgeBehaviour = Behaviour + +class DriverBridge( + val id: Int, + val context: DriverBridgeContext, + val serverContext: VRServer, + val inbound: EventDispatcher = EventDispatcher(), + val outbound: EventDispatcher = EventDispatcher(), +) { + fun disconnect() { + serverContext.context.dispatch(VRServerActions.DriverDisconnected(id)) + } + + companion object { + fun create(id: Int, serverContext: VRServer, scope: CoroutineScope): DriverBridge { + val behaviours = listOf(DriverBaseBehaviour(serverContext)) + + val context = Context.create( + initialState = DriverBridgeState(protocolVersion = 0), + scope = scope, + behaviours = behaviours, + ) + + val bridge = DriverBridge(id = id, context = context, serverContext = serverContext) + + behaviours.forEach { it.observe(bridge) } + + serverContext.context.dispatch(VRServerActions.DriverConnected(bridge)) + + return bridge + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt b/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt new file mode 100644 index 0000000000..42cb1052e5 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt @@ -0,0 +1,70 @@ +package dev.slimevr.feeder + +import dev.slimevr.VRServer +import dev.slimevr.VRServerActions +import dev.slimevr.device.Device +import dev.slimevr.device.DeviceActions +import dev.slimevr.device.DeviceOrigin +import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerActions +import solarxr_protocol.datatypes.hardware_info.ImuType + +class FeederBaseBehaviour(private val server: VRServer) : FeederBridgeBehaviour { + override fun reduce(state: FeederBridgeState, action: FeederBridgeActions): FeederBridgeState = when (action) { + is FeederBridgeActions.UpdateProtocolVersion -> state.copy(protocolVersion = action.version, firmware = action.firmware) + } + + override fun observe(receiver: FeederBridge) { + receiver.inbound.on { event -> + receiver.context.dispatch(FeederBridgeActions.UpdateProtocolVersion(event.protocolVersion, event.firmware)) + } + + receiver.inbound.on { event -> + handleTrackerAdded(event.serial) + } + + receiver.inbound.on { event -> + server.getTracker(event.trackerId)?.context?.dispatch( + TrackerActions.Update { copy(rawRotation = event.rotation) }, + ) + } + } + + private fun handleTrackerAdded(serial: String) { + val scope = server.context.scope + val existingTracker = server.context.state.value.trackers.values + .find { tracker -> tracker.context.state.value.hardwareId == serial } + + val device = if (existingTracker != null) { + server.getDevice(existingTracker.context.state.value.deviceId) + ?: error("could not find existing device for serial $serial") + } else { + val deviceId = server.nextHandle() + val newDevice = Device.create( + scope = scope, + id = deviceId, + address = serial, + macAddress = serial, + origin = DeviceOrigin.FEEDER, + protocolVersion = 0, + ) + server.context.dispatch(VRServerActions.NewDevice(deviceId, newDevice)) + + val trackerId = server.nextHandle() + val tracker = Tracker.create( + scope = scope, + id = trackerId, + deviceId = deviceId, + sensorType = ImuType.MPU9250, + hardwareId = serial, + origin = DeviceOrigin.FEEDER, + server = server, + ) + server.context.dispatch(VRServerActions.NewTracker(trackerId, tracker)) + + newDevice + } + + device.context.dispatch(DeviceActions.Update { copy(protocolVersion = 0) }) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/feeder/module.kt b/server/core/src/main/java/dev/slimevr/feeder/module.kt new file mode 100644 index 0000000000..0b69fda251 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/feeder/module.kt @@ -0,0 +1,59 @@ +package dev.slimevr.feeder + +import dev.slimevr.EventDispatcher +import dev.slimevr.VRServer +import dev.slimevr.VRServerActions +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.CoroutineScope + +data class FeederBridgeState( + val protocolVersion: Int, + val firmware: String?, +) + +sealed interface FeederBridgeActions { + data class UpdateProtocolVersion(val version: Int, val firmware: String?) : FeederBridgeActions +} + +sealed interface FeederBridgeInbound { + data class Version(val protocolVersion: Int, val firmware: String?) : FeederBridgeInbound + data class TrackerAdded(val serial: String) : FeederBridgeInbound + data class TrackerPosition(val trackerId: Int, val rotation: Quaternion, val position: Vector3?) : FeederBridgeInbound +} + +typealias FeederBridgeContext = Context +typealias FeederBridgeBehaviour = Behaviour + +class FeederBridge( + val id: Int, + val context: FeederBridgeContext, + val serverContext: VRServer, + val inbound: EventDispatcher = EventDispatcher(), +) { + fun disconnect() { + serverContext.context.dispatch(VRServerActions.FeederDisconnected(id)) + } + + companion object { + fun create(id: Int, serverContext: VRServer, scope: CoroutineScope): FeederBridge { + val behaviours = listOf(FeederBaseBehaviour(serverContext)) + + val context = Context.create( + initialState = FeederBridgeState(protocolVersion = 0, firmware = null), + scope = scope, + behaviours = behaviours, + ) + + val bridge = FeederBridge(id = id, context = context, serverContext = serverContext) + + behaviours.forEach { it.observe(bridge) } + + serverContext.context.dispatch(VRServerActions.FeederConnected(bridge)) + + return bridge + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/hid/module.kt b/server/core/src/main/java/dev/slimevr/hid/module.kt index db61b99b0e..e78a43ee78 100644 --- a/server/core/src/main/java/dev/slimevr/hid/module.kt +++ b/server/core/src/main/java/dev/slimevr/hid/module.kt @@ -5,23 +5,8 @@ import dev.slimevr.VRServer import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.device.Device -import dev.slimevr.device.DeviceActions import dev.slimevr.tracker.Tracker import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import solarxr_protocol.datatypes.TrackerStatus - -const val HID_TRACKER_RECEIVER_VID = 0x1209 -const val HID_TRACKER_RECEIVER_PID = 0x7690 -const val HID_TRACKER_PID = 0x7692 - -// --- State and actions --- data class HIDTrackerRecord( val hidId: Int, @@ -68,7 +53,6 @@ class HIDReceiver( companion object { fun create( serialNumber: String, - data: Flow, serverContext: VRServer, scope: CoroutineScope, ): HIDReceiver { @@ -96,24 +80,6 @@ class HIDReceiver( behaviours.forEach { it.observe(receiver) } - data - .onEach { report -> parseHIDPackets(report).forEach { dispatcher.emit(it) } } - .launchIn(scope) - - scope.launch { - try { - awaitCancellation() - } finally { - withContext(NonCancellable) { - for (record in context.state.value.trackers.values) { - serverContext.getDevice(record.deviceId)?.context?.dispatch( - DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, - ) - } - } - } - } - return receiver } } diff --git a/server/core/src/main/java/dev/slimevr/hid/packets.kt b/server/core/src/main/java/dev/slimevr/hid/packets.kt index 86ca277fb7..2ad4b34360 100644 --- a/server/core/src/main/java/dev/slimevr/hid/packets.kt +++ b/server/core/src/main/java/dev/slimevr/hid/packets.kt @@ -1,22 +1,11 @@ package dev.slimevr.hid import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Quaternion.Companion.fromRotationVector import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.BoardType import solarxr_protocol.datatypes.hardware_info.ImuType import solarxr_protocol.datatypes.hardware_info.McuType -import java.nio.ByteBuffer -import java.nio.ByteOrder -import kotlin.math.PI -import kotlin.math.cos -import kotlin.math.sin -import kotlin.math.sqrt - -private const val HID_PACKET_SIZE = 16 - -private val AXES_OFFSET = fromRotationVector(-PI.toFloat() / 2f, 0f, 0f) sealed interface HIDPacket { val hidId: Int @@ -77,126 +66,3 @@ data class HIDRotationButton( val rssi: Int, ) : HIDPacket -private fun readLE16Signed(data: ByteArray, offset: Int): Int = data[offset + 1].toInt() shl 8 or data[offset].toUByte().toInt() - -private fun decodeQ15Quat(data: ByteArray, offset: Int): Quaternion { - val scale = 1f / 32768f - val x = readLE16Signed(data, offset).toShort().toFloat() * scale - val y = readLE16Signed(data, offset + 2).toShort().toFloat() * scale - val z = readLE16Signed(data, offset + 4).toShort().toFloat() * scale - val w = readLE16Signed(data, offset + 6).toShort().toFloat() * scale - return AXES_OFFSET * Quaternion(w, x, y, z) -} - -private fun decodeExpMapQuat(data: ByteArray, offset: Int): Quaternion { - val buf = ByteBuffer.wrap(data, offset, 4).order(ByteOrder.LITTLE_ENDIAN).int.toUInt() - val vx = ((buf and 1023u).toFloat() / 1024f) * 2f - 1f - val vy = ((buf shr 10 and 2047u).toFloat() / 2048f) * 2f - 1f - val vz = ((buf shr 21 and 2047u).toFloat() / 2048f) * 2f - 1f - val d = vx * vx + vy * vy + vz * vz - val invSqrtD = 1f / sqrt(d + 1e-6f) - val a = (PI.toFloat() / 2f) * d * invSqrtD - val s = sin(a) - val k = s * invSqrtD - return AXES_OFFSET * Quaternion(cos(a), k * vx, k * vy, k * vz) -} - -private fun decodeAccel(data: ByteArray, offset: Int): Vector3 { - val scale = 1f / 128f - return Vector3( - readLE16Signed(data, offset).toShort().toFloat() * scale, - readLE16Signed(data, offset + 2).toShort().toFloat() * scale, - readLE16Signed(data, offset + 4).toShort().toFloat() * scale, - ) -} - -private fun decodeBattery(raw: Int): Float = if (raw == 128) 1f else (raw and 127).toFloat() - -private fun decodeBatteryVoltage(raw: Int): Float = (raw.toFloat() + 245f) / 100f - -private fun parseSingleHIDPacket(data: ByteArray, i: Int): HIDPacket? { - val packetType = data[i].toUByte().toInt() - val hidId = data[i + 1].toUByte().toInt() - - return when (packetType) { - 255 -> { - val addr = ByteBuffer.wrap(data, i + 2, 8).order(ByteOrder.LITTLE_ENDIAN).long and 0x0000_FFFF_FFFF_FFFFL - HIDDeviceRegister(hidId, "%012X".format(addr)) - } - - 0 -> { - val batt = data[i + 2].toUByte().toInt() - val battV = data[i + 3].toUByte().toInt() - val brdId = data[i + 5].toUByte().toInt() - val mcuId = data[i + 6].toUByte().toInt() - val imuId = data[i + 8].toUByte().toInt() - val fwDate = data[i + 11].toUByte().toInt() shl 8 or data[i + 10].toUByte().toInt() - val fwMajor = data[i + 12].toUByte().toInt() - val fwMinor = data[i + 13].toUByte().toInt() - val fwPatch = data[i + 14].toUByte().toInt() - val rssi = data[i + 15].toUByte().toInt() - val fwYear = 2020 + (fwDate shr 9 and 127) - val fwMonth = fwDate shr 5 and 15 - val fwDay = fwDate and 31 - HIDDeviceInfo( - hidId = hidId, - imuType = ImuType.fromValue(imuId.toUShort()) ?: ImuType.Other, - boardType = BoardType.fromValue(brdId.toUShort()) ?: BoardType.UNKNOWN, - mcuType = McuType.fromValue(mcuId.toUShort()) ?: McuType.Other, - firmware = "%04d-%02d-%02d %d.%d.%d".format(fwYear, fwMonth, fwDay, fwMajor, fwMinor, fwPatch), - batteryLevel = decodeBattery(batt), - batteryVoltage = decodeBatteryVoltage(battV), - rssi = -rssi, - ) - } - - 1 -> HIDRotation( - hidId = hidId, - rotation = decodeQ15Quat(data, i + 2), - acceleration = decodeAccel(data, i + 10), - ) - - 2 -> HIDRotationBattery( - hidId = hidId, - rotation = decodeExpMapQuat(data, i + 5), - acceleration = decodeAccel(data, i + 9), - batteryLevel = decodeBattery(data[i + 2].toUByte().toInt()), - batteryVoltage = decodeBatteryVoltage(data[i + 3].toUByte().toInt()), - rssi = -data[i + 15].toUByte().toInt(), - ) - - 3 -> HIDStatus( - hidId = hidId, - status = TrackerStatus.fromValue((data[i + 2].toUByte().toInt() + 1).toUByte()) ?: TrackerStatus.OK, - rssi = -data[i + 15].toUByte().toInt(), - ) - - 4 -> { - val scaleMag = 1000f / 1024f - HIDRotationMag( - hidId = hidId, - rotation = decodeQ15Quat(data, i + 2), - magnetometer = Vector3( - readLE16Signed(data, i + 10).toShort().toFloat() * scaleMag, - readLE16Signed(data, i + 12).toShort().toFloat() * scaleMag, - readLE16Signed(data, i + 14).toShort().toFloat() * scaleMag, - ), - ) - } - - 7 -> HIDRotationButton( - hidId = hidId, - button = data[i + 2].toUByte().toInt(), - rotation = decodeExpMapQuat(data, i + 5), - acceleration = decodeAccel(data, i + 9), - rssi = -data[i + 15].toUByte().toInt(), - ) - - else -> null - } -} - -fun parseHIDPackets(data: ByteArray): List { - if (data.size % HID_PACKET_SIZE != 0) return emptyList() - return (0 until data.size / HID_PACKET_SIZE).mapNotNull { parseSingleHIDPacket(data, it * HID_PACKET_SIZE) } -} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index a7ecf71002..cc8943dbc8 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -1,17 +1,14 @@ package dev.slimevr.solarxr -import com.google.flatbuffers.FlatBufferBuilder import dev.slimevr.VRServer import dev.slimevr.device.DeviceState import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.Skeleton import dev.slimevr.tracker.TrackerState -import io.ktor.util.moveToByteArray import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import solarxr_protocol.MessageBundle import solarxr_protocol.data_feed.DataFeedConfig import solarxr_protocol.data_feed.DataFeedMessageHeader import solarxr_protocol.data_feed.DataFeedUpdate @@ -120,15 +117,15 @@ fun createDatafeedFrame( ) } -class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : SolarXRConnectionBehaviour { - override fun reduce(state: SolarXRConnectionState, action: SolarXRConnectionActions) = when (action) { - is SolarXRConnectionActions.SetConfig -> state.copy( +class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : SolarXRBridgeBehaviour { + override fun reduce(state: SolarXRBridgeState, action: SolarXRBridgeActions) = when (action) { + is SolarXRBridgeActions.SetConfig -> state.copy( dataFeedConfigs = action.configs, datafeedTimers = action.timers, ) } - override fun observe(receiver: SolarXRConnection) { + override fun observe(receiver: SolarXRBridge) { receiver.dataFeedDispatcher.on { event -> val datafeeds = event.dataFeeds ?: return@on @@ -136,49 +133,22 @@ class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : Sola val timers = datafeeds.mapIndexed { index, config -> receiver.context.scope.launch { - val fbb = FlatBufferBuilder(1024) val minTime = config.minimumTimeSinceLast.toLong() while (isActive) { - fbb.clear() - fbb.finish( - MessageBundle( - dataFeedMsgs = listOf( - createDatafeedFrame( - server = server, - skeleton = skeleton, - datafeedConfig = config, - index = index - ), - ), - ).encode(fbb), - ) - receiver.send(fbb.dataBuffer().moveToByteArray()) + receiver.sendDataFeed(createDatafeedFrame(server = server, skeleton = skeleton, datafeedConfig = config, index = index)) delay(minTime) } } } receiver.context.dispatch( - SolarXRConnectionActions.SetConfig(datafeeds, timers = timers), + SolarXRBridgeActions.SetConfig(datafeeds, timers = timers), ) } receiver.dataFeedDispatcher.on { event -> val config = event.config ?: return@on - - val fbb = FlatBufferBuilder(1024) - fbb.finish( - MessageBundle( - dataFeedMsgs = listOf( - createDatafeedFrame( - server = server, - datafeedConfig = config, - skeleton = skeleton, - ), - ), - ).encode(fbb), - ) - receiver.send(fbb.dataBuffer().moveToByteArray()) + receiver.sendDataFeed(createDatafeedFrame(server = server, datafeedConfig = config, skeleton = skeleton)) } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt index 478ee59d9c..12b1c4a2c9 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt @@ -14,8 +14,8 @@ import solarxr_protocol.rpc.FirmwareUpdateStopQueuesRequest import solarxr_protocol.rpc.OTAFirmwareUpdate import solarxr_protocol.rpc.SerialFirmwareUpdate -class FirmwareBehaviour(private val server: VRServer, private val firmwareManager: FirmwareManager) : SolarXRConnectionBehaviour { - override fun observe(receiver: SolarXRConnection) { +class FirmwareBehaviour(private val server: VRServer, private val firmwareManager: FirmwareManager) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { val scope = receiver.context.scope var prevJobs: Map = firmwareManager.context.state.value.jobs diff --git a/server/core/src/main/java/dev/slimevr/solarxr/heightcalibration.kt b/server/core/src/main/java/dev/slimevr/solarxr/heightcalibration.kt index ff0786af49..d44433b910 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/heightcalibration.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/heightcalibration.kt @@ -10,8 +10,8 @@ import solarxr_protocol.rpc.UserHeightRecordingStatusResponse class HeightCalibrationBehaviour( private val heightCalibrationManager: HeightCalibrationManager, -) : SolarXRConnectionBehaviour { - override fun observe(receiver: SolarXRConnection) { +) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { heightCalibrationManager.start() } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/ipc.kt b/server/core/src/main/java/dev/slimevr/solarxr/ipc.kt new file mode 100644 index 0000000000..a05ebcd948 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/ipc.kt @@ -0,0 +1,37 @@ +package dev.slimevr.solarxr + +import com.google.flatbuffers.FlatBufferBuilder +import dev.slimevr.VRServer +import io.ktor.util.moveToByteArray +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import solarxr_protocol.MessageBundle +import java.nio.ByteBuffer + +suspend fun handleSolarXRBridge( + server: VRServer, + messages: Flow, + send: suspend (ByteArray) -> Unit, + behaviours: List, +) = coroutineScope { + val bridge = SolarXRBridge.create( + id = server.nextHandle(), + serverContext = server, + scope = this, + behaviours = behaviours, + ) + + bridge.outbound.on { bundle -> + val fbb = FlatBufferBuilder(256) + fbb.finish(bundle.encode(fbb)) + send(fbb.dataBuffer().moveToByteArray()) + } + + try { + messages.collect { bytes -> + onSolarXRMessage(ByteBuffer.wrap(bytes), bridge) + } + } finally { + bridge.disconnect() + } +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 53689576a0..24b8ced701 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -1,34 +1,33 @@ package dev.slimevr.solarxr -import com.google.flatbuffers.FlatBufferBuilder import dev.slimevr.EventDispatcher import dev.slimevr.VRServer +import dev.slimevr.VRServerActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.skeleton.Skeleton -import io.ktor.util.moveToByteArray import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import solarxr_protocol.MessageBundle import solarxr_protocol.data_feed.DataFeedConfig import solarxr_protocol.data_feed.DataFeedMessage +import solarxr_protocol.data_feed.DataFeedMessageHeader import solarxr_protocol.rpc.RpcMessage import solarxr_protocol.rpc.RpcMessageHeader import java.nio.ByteBuffer -data class SolarXRConnectionState( +data class SolarXRBridgeState( val dataFeedConfigs: List, val datafeedTimers: List, ) -sealed interface SolarXRConnectionActions { - data class SetConfig(val configs: List, val timers: List) : SolarXRConnectionActions +sealed interface SolarXRBridgeActions { + data class SetConfig(val configs: List, val timers: List) : SolarXRBridgeActions } -typealias SolarXRConnectionContext = Context -typealias SolarXRConnectionBehaviour = Behaviour +typealias SolarXRBridgeContext = Context +typealias SolarXRBridgeBehaviour = Behaviour -suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRConnection) { +suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRBridge) { val messageBundle = MessageBundle.fromByteBuffer(message) messageBundle.dataFeedMsgs?.forEach { @@ -42,43 +41,47 @@ suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRConnection) { } } -class SolarXRConnection( - val context: SolarXRConnectionContext, +class SolarXRBridge( + val id: Int, + val context: SolarXRBridgeContext, + val serverContext: VRServer, val dataFeedDispatcher: EventDispatcher, val rpcDispatcher: EventDispatcher, - private val onSend: suspend (ByteArray) -> Unit, + val outbound: EventDispatcher = EventDispatcher(), ) { - suspend fun send(bytes: ByteArray) = onSend(bytes) + suspend fun sendRpc(message: RpcMessage) = outbound.emit(MessageBundle(rpcMsgs = listOf(RpcMessageHeader(message = message)))) - suspend fun sendRpc(message: RpcMessage) { - val fbb = FlatBufferBuilder(256) - fbb.finish( - MessageBundle(rpcMsgs = listOf(RpcMessageHeader(message = message))).encode(fbb), - ) - onSend(fbb.dataBuffer().moveToByteArray()) + suspend fun sendDataFeed(frame: DataFeedMessageHeader) = outbound.emit(MessageBundle(dataFeedMsgs = listOf(frame))) + + fun disconnect() { + serverContext.context.dispatch(VRServerActions.SolarXRDisconnected(id)) } companion object { fun create( - onSend: suspend (ByteArray) -> Unit, + id: Int, + serverContext: VRServer, scope: CoroutineScope, - behaviours: List, - ): SolarXRConnection { + behaviours: List, + ): SolarXRBridge { val context = Context.create( - initialState = SolarXRConnectionState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), + initialState = SolarXRBridgeState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), scope = scope, behaviours = behaviours, ) - val conn = SolarXRConnection( + val bridge = SolarXRBridge( + id = id, context = context, + serverContext = serverContext, dataFeedDispatcher = EventDispatcher(), rpcDispatcher = EventDispatcher(), - onSend = onSend, ) - behaviours.forEach { it.observe(conn) } - return conn + behaviours.forEach { it.observe(bridge) } + + + return bridge } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/provisioning.kt b/server/core/src/main/java/dev/slimevr/solarxr/provisioning.kt index 3b36785d7f..ccf13ff4ca 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/provisioning.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/provisioning.kt @@ -13,8 +13,8 @@ import solarxr_protocol.rpc.WifiProvisioningStatusResponse class ProvisioningBehaviour( private val server: VRServer, private val provisioningManager: ProvisioningManager, -) : SolarXRConnectionBehaviour { - override fun observe(receiver: SolarXRConnection) { +) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { event -> val ssid = event.ssid ?: return@on diff --git a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt index c34a8d1b47..13fe9bc32b 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt @@ -22,8 +22,8 @@ import solarxr_protocol.rpc.SerialTrackerGetWifiScanRequest import solarxr_protocol.rpc.SerialTrackerRebootRequest import solarxr_protocol.rpc.SerialUpdateResponse -class SerialBehaviour(private val serialServer: SerialServer) : SolarXRConnectionBehaviour { - override fun observe(receiver: SolarXRConnection) { +class SerialBehaviour(private val serialServer: SerialServer) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { val scope = receiver.context.scope // We assume that you can only subscribe to one serial console at a time diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt index de54cfdf00..e46be4bc1a 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt @@ -20,7 +20,7 @@ private const val MIN_HEIGHT = 1.0f class SkeletonBehaviour( private val userConfig: UserConfig, private val skeleton: Skeleton, -) : SolarXRConnectionBehaviour { +) : SolarXRBridgeBehaviour { private fun buildConfigResponse(): SkeletonConfigResponse { val proportions = userConfig.context.state.value.data.proportions @@ -40,7 +40,7 @@ class SkeletonBehaviour( return SkeletonConfigResponse(skeletonParts = skeletonParts, userHeight = userHeight) } - override fun observe(receiver: SolarXRConnection) { + override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { receiver.sendRpc(buildConfigResponse()) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt b/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt new file mode 100644 index 0000000000..51f99cf2f5 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt @@ -0,0 +1,66 @@ +package dev.slimevr.solarxr + +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions +import dev.slimevr.trackingchecklist.ChecklistStep +import dev.slimevr.trackingchecklist.TrackingChecklist +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import solarxr_protocol.rpc.IgnoreTrackingChecklistStepRequest +import solarxr_protocol.rpc.TrackingChecklistRequest +import solarxr_protocol.rpc.TrackingChecklistResponse +import solarxr_protocol.rpc.TrackingChecklistStep +import solarxr_protocol.rpc.TrackingChecklistStepId + +class TrackingChecklistBehaviour( + private val checklist: TrackingChecklist, + private val settings: Settings, +) : SolarXRBridgeBehaviour { + + private fun parseMutedSteps(): Set = + settings.context.state.value.data.mutedChecklistSteps + .mapNotNull { name -> TrackingChecklistStepId.entries.firstOrNull { stepId -> stepId.name == name } } + .toSet() + + private fun buildResponse(): TrackingChecklistResponse { + val state = checklist.context.state.value + return TrackingChecklistResponse( + steps = state.steps.map { (id, step) -> toProtocolStep(id, step) }, + ignoredSteps = (state.ignoredSteps + parseMutedSteps()).toList(), + ) + } + + private fun toProtocolStep(id: TrackingChecklistStepId, step: ChecklistStep): TrackingChecklistStep = + TrackingChecklistStep( + id = id, + valid = step.valid, + enabled = step.enabled, + optional = step.optional, + ignorable = step.ignorable, + visibility = step.visibility, + extraData = step.extraData, + ) + + override fun observe(receiver: SolarXRBridge) { + combine( + checklist.context.state, + settings.context.state.map { state -> state.data.mutedChecklistSteps }, + ) { _, _ -> buildResponse() } + .onEach { response -> receiver.sendRpc(response) } + .launchIn(receiver.context.scope) + + receiver.rpcDispatcher.on { + receiver.sendRpc(buildResponse()) + } + + receiver.rpcDispatcher.on { req -> + val stepId = req.stepId ?: return@on + val name = stepId.name + settings.context.dispatch(SettingsActions.Update { + copy(mutedChecklistSteps = if (req.ignore) mutedChecklistSteps + name else mutedChecklistSteps - name) + }) + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt index 602eaa9b5a..b6766ec5d6 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt @@ -16,8 +16,8 @@ class VrcBehaviour( private val vrcManager: VRCConfigManager, private val server: VRServer, private val userHeight: () -> Double, -) : SolarXRConnectionBehaviour { - override fun observe(receiver: SolarXRConnection) { +) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { fun buildCurrentResponse(): VRCConfigStateChangeResponse { val state = vrcManager.context.state.value val values = state.currentValues diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt new file mode 100644 index 0000000000..8efeabc827 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -0,0 +1,144 @@ +@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + +package dev.slimevr.trackingchecklist + +import dev.slimevr.VRServer +import dev.slimevr.VRServerState +import dev.slimevr.device.DeviceOrigin +import dev.slimevr.device.DeviceState +import dev.slimevr.skeleton.Skeleton +import dev.slimevr.tracker.TrackerState +import dev.slimevr.vrchat.VRCConfigManager +import dev.slimevr.vrchat.VRCConfigState +import dev.slimevr.vrchat.computeRecommendedValues +import dev.slimevr.vrchat.computeValidity +import dev.slimevr.vrchat.isVRCConfigValid +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.DeviceId +import solarxr_protocol.datatypes.TrackerId +import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.rpc.TrackingChecklistStepId +import solarxr_protocol.rpc.TrackingChecklistSteamVRDisconnected +import solarxr_protocol.rpc.TrackingChecklistTrackerError +import solarxr_protocol.rpc.TrackingChecklistUnassignedHMD + +// Flat-maps a server state flow into a combined flow of all context states for a given collection. +// Re-emits whenever any item's state changes or the collection itself changes. +private inline fun allContextStates( + server: VRServer, + crossinline select: (VRServerState) -> Collection, + crossinline stateOf: (C) -> Flow, +): Flow> = server.context.state.flatMapLatest { serverState -> + val items = select(serverState) + if (items.isEmpty()) return@flatMapLatest flowOf(emptyList()) + combine(items.map { item -> stateOf(item) }) { states -> states.toList() } +} + +private fun trackerStatesFlow(server: VRServer): Flow> = + allContextStates(server, { state -> state.trackers.values }) { tracker -> tracker.context.state } + +private fun deviceStatesFlow(server: VRServer): Flow> = + allContextStates(server, { state -> state.devices.values }) { device -> device.context.state } + +private fun trackerIdOf(tracker: TrackerState): TrackerId = + TrackerId(trackerNum = tracker.id.toUByte(), deviceId = DeviceId(tracker.deviceId.toUByte())) + +class SteamVRCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { + override fun observe(receiver: TrackingChecklist) { + server.context.state + .map { state -> + val connected = state.drivers.isNotEmpty() + ChecklistStep( + valid = connected, + enabled = true, + ignorable = true, + extraData = if (!connected) TrackingChecklistSteamVRDisconnected() else null, + ) + } + .distinctUntilChanged() + .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.STEAMVR_DISCONNECTED, step)) } + .launchIn(receiver.context.scope) + } +} + +class HMDCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { + private fun computeStep(trackers: List): ChecklistStep { + val hasSteamVR = trackers.any { tracker -> tracker.origin == DeviceOrigin.DRIVER } + val hmdTracker = trackers.firstOrNull { tracker -> tracker.origin == DeviceOrigin.DRIVER && tracker.position != null } + val isAssigned = hmdTracker?.bodyPart == BodyPart.HEAD + return ChecklistStep( + valid = isAssigned, + enabled = hasSteamVR, + ignorable = true, + extraData = if (!isAssigned) TrackingChecklistUnassignedHMD( + trackerId = hmdTracker?.let { tracker -> trackerIdOf(tracker) }, + ) else null, + ) + } + + override fun observe(receiver: TrackingChecklist) { + trackerStatesFlow(server) + .map { trackers -> computeStep(trackers) } + .distinctUntilChanged() + .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.UNASSIGNED_HMD, step)) } + .launchIn(receiver.context.scope) + } +} + +class TrackerErrorCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { + private fun computeStep(trackers: List, devices: List): ChecklistStep { + val errorDeviceIds = devices + .filter { device -> device.status == TrackerStatus.ERROR || device.status == TrackerStatus.TIMED_OUT } + .map { device -> device.id } + .toSet() + val errorTrackers = trackers.filter { tracker -> tracker.deviceId in errorDeviceIds } + return ChecklistStep( + valid = errorTrackers.isEmpty(), + enabled = trackers.isNotEmpty(), + extraData = if (errorTrackers.isNotEmpty()) TrackingChecklistTrackerError( + trackersId = errorTrackers.map { tracker -> trackerIdOf(tracker) }, + ) else null, + ) + } + + override fun observe(receiver: TrackingChecklist) { + combine(trackerStatesFlow(server), deviceStatesFlow(server)) { trackers, devices -> + computeStep(trackers, devices) + } + .distinctUntilChanged() + .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.TRACKER_ERROR, step)) } + .launchIn(receiver.context.scope) + } +} + +class VRChatSettingsCheckBehaviour( + private val server: VRServer, + private val skeleton: Skeleton, + private val vrcConfigManager: VRCConfigManager, +) : TrackingChecklistBehaviourType { + private fun computeStep(vrc: VRCConfigState, userHeight: Double): ChecklistStep { + val values = vrc.currentValues + if (!vrc.isSupported || values == null) return ChecklistStep(valid = true, enabled = false) + val recommended = computeRecommendedValues(server, userHeight) + val validity = computeValidity(values, recommended) + return ChecklistStep(valid = isVRCConfigValid(validity, vrc.mutedWarnings), enabled = true) + } + + override fun observe(receiver: TrackingChecklist) { + combine( + skeleton.context.state.map { state -> state.userHeight }, + vrcConfigManager.context.state, + ) { userHeight, vrc -> computeStep(vrc, userHeight) } + .distinctUntilChanged() + .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.VRCHAT_SETTINGS, step)) } + .launchIn(receiver.context.scope) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt new file mode 100644 index 0000000000..fad0891dba --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt @@ -0,0 +1,78 @@ +package dev.slimevr.trackingchecklist + +import dev.slimevr.VRServer +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context +import dev.slimevr.skeleton.Skeleton +import dev.slimevr.vrchat.VRCConfigManager +import kotlinx.coroutines.CoroutineScope +import solarxr_protocol.rpc.TrackingChecklistExtraData +import solarxr_protocol.rpc.TrackingChecklistStepId +import solarxr_protocol.rpc.TrackingChecklistStepVisibility + +data class ChecklistStep( + val valid: Boolean, + val enabled: Boolean, + val optional: Boolean = false, + val ignorable: Boolean = false, + val visibility: TrackingChecklistStepVisibility = TrackingChecklistStepVisibility.WHEN_INVALID, + val extraData: TrackingChecklistExtraData? = null, +) + +data class TrackingChecklistState( + val steps: Map = emptyMap(), + val ignoredSteps: Set = emptySet(), +) + +sealed interface TrackingChecklistActions { + data class UpdateStep(val id: TrackingChecklistStepId, val step: ChecklistStep) : TrackingChecklistActions + data class SetIgnored(val id: TrackingChecklistStepId, val ignored: Boolean) : TrackingChecklistActions +} + +typealias TrackingChecklistContext = Context +typealias TrackingChecklistBehaviourType = Behaviour + +object ChecklistBaseBehaviour : TrackingChecklistBehaviourType { + override fun reduce(state: TrackingChecklistState, action: TrackingChecklistActions): TrackingChecklistState = when (action) { + is TrackingChecklistActions.UpdateStep -> state.copy(steps = state.steps + (action.id to action.step)) + is TrackingChecklistActions.SetIgnored -> state.copy( + ignoredSteps = if (action.ignored) state.ignoredSteps + action.id else state.ignoredSteps - action.id, + ) + } +} + +class TrackingChecklist( + val context: TrackingChecklistContext, +) { + fun setIgnored(stepId: TrackingChecklistStepId, ignored: Boolean) { + context.dispatch(TrackingChecklistActions.SetIgnored(stepId, ignored)) + } + + companion object { + fun create( + scope: CoroutineScope, + server: VRServer, + skeleton: Skeleton, + vrcConfigManager: VRCConfigManager?, + ): TrackingChecklist { + val stepBehaviours: List = buildList { + add(SteamVRCheckBehaviour(server)) + add(HMDCheckBehaviour(server)) + add(TrackerErrorCheckBehaviour(server)) + vrcConfigManager?.let { add(VRChatSettingsCheckBehaviour(server, skeleton, it)) } + } + + val behaviours = listOf(ChecklistBaseBehaviour) + stepBehaviours + + val context = Context.create( + initialState = TrackingChecklistState(), + scope = scope, + behaviours = behaviours, + ) + + val checklist = TrackingChecklist(context) + behaviours.forEach { it.observe(checklist) } + return checklist + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/vrchat/module.kt b/server/core/src/main/java/dev/slimevr/vrchat/module.kt index 6225ad2c4e..a25a70d33c 100644 --- a/server/core/src/main/java/dev/slimevr/vrchat/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrchat/module.kt @@ -106,6 +106,17 @@ fun computeRecommendedValues(server: VRServer, userHeight: Double): VRCConfigRec ) } +fun isVRCConfigValid(validity: VRCConfigValidity, mutedWarnings: List): Boolean = + (validity.legacyModeOk || "legacyModeOk" in mutedWarnings) && + (validity.shoulderTrackingOk || "shoulderTrackingOk" in mutedWarnings) && + (validity.shoulderWidthCompensationOk || "shoulderWidthCompensationOk" in mutedWarnings) && + (validity.userHeightOk || "userHeightOk" in mutedWarnings) && + (validity.calibrationRangeOk || "calibrationRangeOk" in mutedWarnings) && + (validity.calibrationVisualsOk || "calibrationVisualsOk" in mutedWarnings) && + (validity.trackerModelOk || "trackerModelOk" in mutedWarnings) && + (validity.spineModeOk || "spineModeOk" in mutedWarnings) && + (validity.avatarMeasurementTypeOk || "avatarMeasurementTypeOk" in mutedWarnings) + fun computeValidity(values: VRCConfigValues, recommended: VRCConfigRecommendedValues): VRCConfigValidity = VRCConfigValidity( legacyModeOk = values.legacyMode == recommended.legacyMode, shoulderTrackingOk = values.shoulderTrackingDisabled == recommended.shoulderTrackingDisabled, diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index 1aec22fd43..1d968c5ece 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -1,8 +1,11 @@ package dev.slimevr +import dev.slimevr.driver.DriverBridge +import dev.slimevr.feeder.FeederBridge import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.device.Device +import dev.slimevr.solarxr.SolarXRBridge import dev.slimevr.tracker.Tracker import kotlinx.coroutines.CoroutineScope import kotlin.concurrent.atomics.AtomicInt @@ -12,11 +15,20 @@ import kotlin.concurrent.atomics.incrementAndFetch data class VRServerState( val trackers: Map, val devices: Map, + val drivers: Map, + val feeders: Map, + val solarxr: Map, ) sealed interface VRServerActions { data class NewTracker(val trackerId: Int, val context: Tracker) : VRServerActions data class NewDevice(val deviceId: Int, val context: Device) : VRServerActions + data class DriverConnected(val bridge: DriverBridge) : VRServerActions + data class DriverDisconnected(val bridgeId: Int) : VRServerActions + data class FeederConnected(val bridge: FeederBridge) : VRServerActions + data class FeederDisconnected(val bridgeId: Int) : VRServerActions + data class SolarXRConnected(val connection: SolarXRBridge) : VRServerActions + data class SolarXRDisconnected(val connectionId: Int) : VRServerActions } typealias VRServerContext = Context @@ -36,7 +48,13 @@ class VRServer( fun create(scope: CoroutineScope): VRServer { val behaviours = listOf(BaseBehaviour) val context = Context.create( - initialState = VRServerState(trackers = mapOf(), devices = mapOf()), + initialState = VRServerState( + trackers = emptyMap(), + devices = emptyMap(), + drivers = emptyMap(), + feeders = emptyMap(), + solarxr = emptyMap(), + ), scope = scope, behaviours = behaviours, ) @@ -45,4 +63,4 @@ class VRServer( return server } } -} +} \ No newline at end of file diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index 456f085b86..968abedbdb 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -84,6 +84,11 @@ dependencies { } implementation("org.hid4java:hid4java:0.8.0") implementation("io.klogging:klogging:0.11.7") + + val ktor_version = "3.4.1" + implementation("io.ktor:ktor-server-core-jvm:$ktor_version") + implementation("io.ktor:ktor-server-netty-jvm:$ktor_version") + implementation("io.ktor:ktor-server-websockets-jvm:$ktor_version") } tasks.shadowJar { 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 5ec21502a8..ce00f7f92f 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -19,8 +19,10 @@ import dev.slimevr.solarxr.HeightCalibrationBehaviour import dev.slimevr.solarxr.ProvisioningBehaviour import dev.slimevr.solarxr.SerialBehaviour import dev.slimevr.solarxr.SkeletonBehaviour +import dev.slimevr.solarxr.TrackingChecklistBehaviour import dev.slimevr.solarxr.VrcBehaviour -import dev.slimevr.solarxr.createSolarXRWebsocketServer +import dev.slimevr.desktop.ipc.createSolarXRWebsocketServer +import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.createUDPTrackerServer import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -38,6 +40,12 @@ fun main(args: Array) = runBlocking { val provisioningManager = ProvisioningManager.create(serialServer, this) val skeleton = Skeleton.create(this, config.userConfig) + val trackingChecklist = TrackingChecklist.create( + scope = this, + server = server, + skeleton = skeleton, + vrcConfigManager = vrcConfigManager, + ) launch { createUDPTrackerServer(server, config) @@ -54,8 +62,9 @@ fun main(args: Array) = runBlocking { HeightCalibrationBehaviour(heightCalibrationManager), ProvisioningBehaviour(server, provisioningManager), SkeletonBehaviour(config.userConfig, skeleton), + TrackingChecklistBehaviour(trackingChecklist, config.settings), ) - launch { createSolarXRWebsocketServer(solarXRBehaviours) } + launch { createSolarXRWebsocketServer(server, solarXRBehaviours) } launch { createIpcServers(server, solarXRBehaviours) } Unit diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index 5c5bad1ddc..02b6edfa4a 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -2,19 +2,17 @@ package dev.slimevr.desktop.hid import dev.slimevr.AppLogger import dev.slimevr.VRServer +import dev.slimevr.device.DeviceActions import dev.slimevr.hid.HIDReceiver -import dev.slimevr.hid.HID_TRACKER_PID -import dev.slimevr.hid.HID_TRACKER_RECEIVER_PID -import dev.slimevr.hid.HID_TRACKER_RECEIVER_VID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import solarxr_protocol.datatypes.TrackerStatus import org.hid4java.HidDevice import org.hid4java.HidManager import org.hid4java.HidServicesSpecification @@ -23,6 +21,10 @@ import org.hid4java.jna.HidDeviceInfoStructure private const val HID_POLL_INTERVAL_MS = 3000L +private const val HID_TRACKER_RECEIVER_VID = 0x1209 +private const val HID_TRACKER_RECEIVER_PID = 0x7690 +private const val HID_TRACKER_PID = 0x7692 + private fun isCompatibleDevice(vid: Int, pid: Int) = vid == HID_TRACKER_RECEIVER_VID && (pid == HID_TRACKER_RECEIVER_PID || pid == HID_TRACKER_PID) private val hidSpec = HidServicesSpecification().apply { isAutoStart = false } @@ -87,7 +89,13 @@ fun createDesktopHIDManager(serverContext: VRServer, scope: CoroutineScope) { val deviceJob = Job(scope.coroutineContext[Job]) val deviceScope = CoroutineScope(scope.coroutineContext + deviceJob) - val dataFlow = channelFlow { + val receiver = HIDReceiver.create( + serialNumber = serial, + serverContext = serverContext, + scope = deviceScope, + ) + + deviceScope.launch { try { while (isActive) { val data = withContext(Dispatchers.IO) { @@ -98,25 +106,25 @@ fun createDesktopHIDManager(serverContext: VRServer, scope: CoroutineScope) { } } when { - data == null -> return@channelFlow + data == null -> return@launch // read error, device gone - data.isNotEmpty() -> send(data) + data.isNotEmpty() -> parseHIDPackets(data).forEach { receiver.packetEvents.emit(it) } else -> delay(1) // no data yet, yield without busy-spinning } } } finally { withContext(NonCancellable + Dispatchers.IO) { hidDevice.close() } + withContext(NonCancellable) { + for (record in receiver.context.state.value.trackers.values) { + serverContext.getDevice(record.deviceId)?.context?.dispatch( + DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, + ) + } + } } } - - val receiver = HIDReceiver.create( - serialNumber = serial, - data = dataFlow, - serverContext = serverContext, - scope = deviceScope, - ) deviceJob.complete() active[path] = ActiveReceiver(deviceJob, receiver) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt new file mode 100644 index 0000000000..96d2ee9331 --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt @@ -0,0 +1,151 @@ +package dev.slimevr.desktop.hid + +import dev.slimevr.hid.HIDDeviceInfo +import dev.slimevr.hid.HIDDeviceRegister +import dev.slimevr.hid.HIDPacket +import dev.slimevr.hid.HIDRotation +import dev.slimevr.hid.HIDRotationBattery +import dev.slimevr.hid.HIDRotationButton +import dev.slimevr.hid.HIDRotationMag +import dev.slimevr.hid.HIDStatus +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Quaternion.Companion.fromRotationVector +import io.github.axisangles.ktmath.Vector3 +import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.datatypes.hardware_info.BoardType +import solarxr_protocol.datatypes.hardware_info.ImuType +import solarxr_protocol.datatypes.hardware_info.McuType +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt + +private const val HID_PACKET_SIZE = 16 + +private val AXES_OFFSET = fromRotationVector(-PI.toFloat() / 2f, 0f, 0f) + +private fun readLE16Signed(data: ByteArray, offset: Int): Int = data[offset + 1].toInt() shl 8 or data[offset].toUByte().toInt() + +private fun decodeQ15Quat(data: ByteArray, offset: Int): Quaternion { + val scale = 1f / 32768f + val x = readLE16Signed(data, offset).toShort().toFloat() * scale + val y = readLE16Signed(data, offset + 2).toShort().toFloat() * scale + val z = readLE16Signed(data, offset + 4).toShort().toFloat() * scale + val w = readLE16Signed(data, offset + 6).toShort().toFloat() * scale + return AXES_OFFSET * Quaternion(w, x, y, z) +} + +private fun decodeExpMapQuat(data: ByteArray, offset: Int): Quaternion { + val buf = ByteBuffer.wrap(data, offset, 4).order(ByteOrder.LITTLE_ENDIAN).int.toUInt() + val vx = ((buf and 1023u).toFloat() / 1024f) * 2f - 1f + val vy = ((buf shr 10 and 2047u).toFloat() / 2048f) * 2f - 1f + val vz = ((buf shr 21 and 2047u).toFloat() / 2048f) * 2f - 1f + val d = vx * vx + vy * vy + vz * vz + val invSqrtD = 1f / sqrt(d + 1e-6f) + val a = (PI.toFloat() / 2f) * d * invSqrtD + val s = sin(a) + val k = s * invSqrtD + return AXES_OFFSET * Quaternion(cos(a), k * vx, k * vy, k * vz) +} + +private fun decodeAccel(data: ByteArray, offset: Int): Vector3 { + val scale = 1f / 128f + return Vector3( + readLE16Signed(data, offset).toShort().toFloat() * scale, + readLE16Signed(data, offset + 2).toShort().toFloat() * scale, + readLE16Signed(data, offset + 4).toShort().toFloat() * scale, + ) +} + +private fun decodeBattery(raw: Int): Float = if (raw == 128) 1f else (raw and 127).toFloat() + +private fun decodeBatteryVoltage(raw: Int): Float = (raw.toFloat() + 245f) / 100f + +private fun parseSingleHIDPacket(data: ByteArray, i: Int): HIDPacket? { + val packetType = data[i].toUByte().toInt() + val hidId = data[i + 1].toUByte().toInt() + + return when (packetType) { + 255 -> { + val addr = ByteBuffer.wrap(data, i + 2, 8).order(ByteOrder.LITTLE_ENDIAN).long and 0x0000_FFFF_FFFF_FFFFL + HIDDeviceRegister(hidId, "%012X".format(addr)) + } + + 0 -> { + val batt = data[i + 2].toUByte().toInt() + val battV = data[i + 3].toUByte().toInt() + val brdId = data[i + 5].toUByte().toInt() + val mcuId = data[i + 6].toUByte().toInt() + val imuId = data[i + 8].toUByte().toInt() + val fwDate = data[i + 11].toUByte().toInt() shl 8 or data[i + 10].toUByte().toInt() + val fwMajor = data[i + 12].toUByte().toInt() + val fwMinor = data[i + 13].toUByte().toInt() + val fwPatch = data[i + 14].toUByte().toInt() + val rssi = data[i + 15].toUByte().toInt() + val fwYear = 2020 + (fwDate shr 9 and 127) + val fwMonth = fwDate shr 5 and 15 + val fwDay = fwDate and 31 + HIDDeviceInfo( + hidId = hidId, + imuType = ImuType.fromValue(imuId.toUShort()) ?: ImuType.Other, + boardType = BoardType.fromValue(brdId.toUShort()) ?: BoardType.UNKNOWN, + mcuType = McuType.fromValue(mcuId.toUShort()) ?: McuType.Other, + firmware = "%04d-%02d-%02d %d.%d.%d".format(fwYear, fwMonth, fwDay, fwMajor, fwMinor, fwPatch), + batteryLevel = decodeBattery(batt), + batteryVoltage = decodeBatteryVoltage(battV), + rssi = -rssi, + ) + } + + 1 -> HIDRotation( + hidId = hidId, + rotation = decodeQ15Quat(data, i + 2), + acceleration = decodeAccel(data, i + 10), + ) + + 2 -> HIDRotationBattery( + hidId = hidId, + rotation = decodeExpMapQuat(data, i + 5), + acceleration = decodeAccel(data, i + 9), + batteryLevel = decodeBattery(data[i + 2].toUByte().toInt()), + batteryVoltage = decodeBatteryVoltage(data[i + 3].toUByte().toInt()), + rssi = -data[i + 15].toUByte().toInt(), + ) + + 3 -> HIDStatus( + hidId = hidId, + status = TrackerStatus.fromValue((data[i + 2].toUByte().toInt() + 1).toUByte()) ?: TrackerStatus.OK, + rssi = -data[i + 15].toUByte().toInt(), + ) + + 4 -> { + val scaleMag = 1000f / 1024f + HIDRotationMag( + hidId = hidId, + rotation = decodeQ15Quat(data, i + 2), + magnetometer = Vector3( + readLE16Signed(data, i + 10).toShort().toFloat() * scaleMag, + readLE16Signed(data, i + 12).toShort().toFloat() * scaleMag, + readLE16Signed(data, i + 14).toShort().toFloat() * scaleMag, + ), + ) + } + + 7 -> HIDRotationButton( + hidId = hidId, + button = data[i + 2].toUByte().toInt(), + rotation = decodeExpMapQuat(data, i + 5), + acceleration = decodeAccel(data, i + 9), + rssi = -data[i + 15].toUByte().toInt(), + ) + + else -> null + } +} + +fun parseHIDPackets(data: ByteArray): List { + if (data.size % HID_PACKET_SIZE != 0) return emptyList() + return (0 until data.size / HID_PACKET_SIZE).mapNotNull { parseSingleHIDPacket(data, it * HID_PACKET_SIZE) } +} \ No newline at end of file diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt index e33f885914..766bd3402b 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt @@ -3,7 +3,7 @@ package dev.slimevr.desktop.ipc import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform import dev.slimevr.VRServer -import dev.slimevr.solarxr.SolarXRConnectionBehaviour +import dev.slimevr.solarxr.SolarXRBridgeBehaviour import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -15,18 +15,18 @@ const val DRIVER_PIPE = "\\\\.\\pipe\\SlimeVRDriver" const val FEEDER_PIPE = "\\\\.\\pipe\\SlimeVRInput" const val SOLARXR_PIPE = "\\\\.\\pipe\\SlimeVRRpc" -suspend fun createIpcServers(server: VRServer, behaviours: List) = coroutineScope { +suspend fun createIpcServers(server: VRServer, behaviours: List) = coroutineScope { when (CURRENT_PLATFORM) { Platform.LINUX, Platform.OSX -> { launch { createUnixDriverSocket(server) } launch { createUnixFeederSocket(server) } - launch { createUnixSolarXRSocket(behaviours) } + launch { createUnixSolarXRSocket(server, behaviours) } } Platform.WINDOWS -> { launch { createWindowsDriverPipe(server) } launch { createWindowsFeederPipe(server) } - launch { createWindowsSolarXRPipe(behaviours) } + launch { createWindowsSolarXRPipe(server, behaviours) } } else -> Unit diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index 38ddcd4ea4..d524d2aba5 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -2,7 +2,8 @@ package dev.slimevr.desktop.ipc import dev.slimevr.VRServer import dev.slimevr.getSocketDirectory -import dev.slimevr.solarxr.SolarXRConnectionBehaviour +import dev.slimevr.solarxr.SolarXRBridgeBehaviour +import dev.slimevr.solarxr.handleSolarXRBridge import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn @@ -33,8 +34,9 @@ suspend fun createUnixFeederSocket(server: VRServer) = acceptUnixClients(FEEDER_ ) } -suspend fun createUnixSolarXRSocket(behaviours: List) = acceptUnixClients(SOLARXR_SOCKET_NAME) { channel -> - handleSolarXRConnection( +suspend fun createUnixSolarXRSocket(server: VRServer, behaviours: List) = acceptUnixClients(SOLARXR_SOCKET_NAME) { channel -> + handleSolarXRBridge( + server = server, messages = readFramedMessages(channel), send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, behaviours = behaviours, diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 8025a4c8ac..dbc4731f78 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -1,27 +1,20 @@ package dev.slimevr.desktop.ipc import dev.slimevr.VRServer -import dev.slimevr.VRServerActions +import dev.slimevr.driver.DriverBridge +import dev.slimevr.driver.DriverBridgeInbound +import dev.slimevr.driver.DriverBridgeOutbound +import dev.slimevr.feeder.FeederBridge +import dev.slimevr.feeder.FeederBridgeInbound import dev.slimevr.desktop.platform.Position import dev.slimevr.desktop.platform.ProtobufMessage import dev.slimevr.desktop.platform.TrackerAdded import dev.slimevr.desktop.platform.Version -import dev.slimevr.device.Device -import dev.slimevr.device.DeviceActions -import dev.slimevr.device.DeviceOrigin -import dev.slimevr.solarxr.SolarXRConnection -import dev.slimevr.solarxr.SolarXRConnectionBehaviour -import dev.slimevr.solarxr.onSolarXRMessage -import dev.slimevr.tracker.Tracker -import dev.slimevr.tracker.TrackerActions import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import solarxr_protocol.datatypes.hardware_info.ImuType -import java.nio.ByteBuffer const val PROTOCOL_VERSION = 5 @@ -36,57 +29,29 @@ suspend fun handleDriverConnection( send(ProtobufMessage.ADAPTER.encode(msg)) } - sendMsg(ProtobufMessage(version = Version(protocol_version = PROTOCOL_VERSION))) - - // Should be safe, only accessed inside state.collect below. StateFlow never delivers - // two emissions concurrently to the same collector. - val subscribedTrackers = mutableSetOf() + val bridge = DriverBridge.create(id = server.nextHandle(), serverContext = server, scope = this) - val observerJob = launch { - server.context.state.collect { state -> - state.trackers.values.forEach { tracker -> - val trackerState = tracker.context.state.value - if (trackerState.origin == DeviceOrigin.DRIVER) return@forEach - if (subscribedTrackers.add(trackerState.id)) { - sendMsg( - ProtobufMessage( - tracker_added = TrackerAdded( - tracker_id = trackerState.id, - tracker_serial = trackerState.hardwareId, - tracker_name = trackerState.customName ?: trackerState.name, - ), - ), - ) - tracker.context.state.collect { ts -> - sendMsg( - ProtobufMessage( - position = Position( - tracker_id = ts.id, - qx = ts.rawRotation.x, - qy = ts.rawRotation.y, - qz = ts.rawRotation.z, - qw = ts.rawRotation.w, - ), - ), - ) - } - } - } - } + bridge.outbound.on { event -> + sendMsg(ProtobufMessage(tracker_added = TrackerAdded(tracker_id = event.trackerId, tracker_serial = event.serial, tracker_name = event.name))) } + bridge.outbound.on { event -> + sendMsg(ProtobufMessage(position = Position(tracker_id = event.trackerId, qx = event.rotation.x, qy = event.rotation.y, qz = event.rotation.z, qw = event.rotation.w))) + } + + sendMsg(ProtobufMessage(version = Version(protocol_version = PROTOCOL_VERSION))) try { messages.collect { bytes -> val msg = ProtobufMessage.ADAPTER.decode(bytes) - msg.user_action?.let { - // TODO: dispatch user actions (reset, etc.) to VRServer + msg.version?.let { ver -> + bridge.inbound.emit(DriverBridgeInbound.Version(ver.protocol_version)) } - msg.version?.let { - // TODO: store remote protocol version if needed + msg.position?.let { pos -> + bridge.inbound.emit(DriverBridgeInbound.TrackerPosition(trackerId = pos.tracker_id, rotation = Quaternion(w = pos.qw, x = pos.qx, y = pos.qy, z = pos.qz), position = null)) } } } finally { - observerJob.cancel() + bridge.disconnect() } } @@ -95,84 +60,24 @@ suspend fun handleFeederConnection( messages: Flow, send: suspend (ByteArray) -> Unit, ) = coroutineScope { - send(ProtobufMessage.ADAPTER.encode(ProtobufMessage(version = Version(protocol_version = PROTOCOL_VERSION)))) + val bridge = FeederBridge.create(id = server.nextHandle(), serverContext = server, scope = this) - messages.collect { bytes -> - val msg = ProtobufMessage.ADAPTER.decode(bytes) - - if (msg.tracker_added != null) { - val serial = msg.tracker_added.tracker_serial - val protocolVersion = msg.version?.protocol_version ?: 0 - val firmware = msg.version?.toString() - - // Check for existing tracker with same hardwareId (reconnect case) - val existingTracker = server.context.state.value.trackers.values - .find { it.context.state.value.hardwareId == serial } - - val device = if (existingTracker != null) { - server.getDevice(existingTracker.context.state.value.deviceId) ?: error("could not find existing device") - } else { - val deviceId = server.nextHandle() - val newDevice = Device.create( - scope = this, - id = deviceId, - address = serial, - macAddress = serial, // FIXME: prob not correct - origin = DeviceOrigin.FEEDER, - protocolVersion = protocolVersion, - ) - server.context.dispatch(VRServerActions.NewDevice(deviceId, newDevice)) - - val trackerId = server.nextHandle() - val tracker = Tracker.create( - scope = this, - id = trackerId, - deviceId = deviceId, - sensorType = ImuType.MPU9250, // TODO: prob need to make sensor type optional - hardwareId = serial, - origin = DeviceOrigin.FEEDER, - server = server - ) - server.context.dispatch(VRServerActions.NewTracker(trackerId, tracker)) + send(ProtobufMessage.ADAPTER.encode(ProtobufMessage(version = Version(protocol_version = PROTOCOL_VERSION)))) - newDevice + try { + messages.collect { bytes -> + val msg = ProtobufMessage.ADAPTER.decode(bytes) + msg.version?.let { ver -> + bridge.inbound.emit(FeederBridgeInbound.Version(protocolVersion = ver.protocol_version, firmware = ver.toString())) + } + msg.tracker_added?.let { ta -> + bridge.inbound.emit(FeederBridgeInbound.TrackerAdded(serial = ta.tracker_serial)) + } + msg.position?.let { pos -> + bridge.inbound.emit(FeederBridgeInbound.TrackerPosition(trackerId = pos.tracker_id, rotation = Quaternion(w = pos.qw, x = pos.qx, y = pos.qy, z = pos.qz), position = null)) } - - device.context.dispatch( - DeviceActions.Update { copy(firmware = firmware, protocolVersion = protocolVersion) }, - ) - } - - if (msg.position != null) { - server.getTracker(msg.position.tracker_id)?.context?.dispatch( - TrackerActions.Update { - copy( - rawRotation = Quaternion( - w = msg.position.qw, - x = msg.position.qx, - y = msg.position.qy, - z = msg.position.qz, - ), - // TODO: add position data - ) - }, - ) } - } -} - -suspend fun handleSolarXRConnection( - messages: Flow, - send: suspend (ByteArray) -> Unit, - behaviours: List, -) = coroutineScope { - val connection = SolarXRConnection.create( - scope = this, - onSend = send, - behaviours = behaviours, - ) - - messages.collect { bytes -> - onSolarXRMessage(ByteBuffer.wrap(bytes), connection) + } finally { + bridge.disconnect() } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt index 7af6a86dbb..db7ae0414c 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt @@ -7,7 +7,8 @@ import com.sun.jna.platform.win32.WinError import com.sun.jna.platform.win32.WinNT import com.sun.jna.ptr.IntByReference import dev.slimevr.VRServer -import dev.slimevr.solarxr.SolarXRConnectionBehaviour +import dev.slimevr.solarxr.SolarXRBridgeBehaviour +import dev.slimevr.solarxr.handleSolarXRBridge import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn @@ -36,8 +37,9 @@ suspend fun createWindowsFeederPipe(server: VRServer) = acceptWindowsClients(FEE ) } -suspend fun createWindowsSolarXRPipe(behaviours: List) = acceptWindowsClients(SOLARXR_PIPE) { handle -> - handleSolarXRConnection( +suspend fun createWindowsSolarXRPipe(server: VRServer, behaviours: List) = acceptWindowsClients(SOLARXR_PIPE) { handle -> + handleSolarXRBridge( + server = server, messages = readFramedMessages(handle), send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, behaviours = behaviours, diff --git a/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ws-server.kt similarity index 53% rename from server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt rename to server/desktop/src/main/java/dev/slimevr/desktop/ipc/ws-server.kt index 9cd9de5865..272d8fbec4 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/ws-server.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ws-server.kt @@ -1,7 +1,9 @@ -package dev.slimevr.solarxr +package dev.slimevr.desktop.ipc import dev.slimevr.AppLogger import dev.slimevr.VRServer +import dev.slimevr.solarxr.SolarXRBridgeBehaviour +import dev.slimevr.solarxr.handleSolarXRBridge import io.ktor.server.application.install import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty @@ -10,40 +12,31 @@ import io.ktor.server.websocket.WebSockets import io.ktor.server.websocket.webSocket import io.ktor.websocket.Frame import kotlinx.coroutines.awaitCancellation -import solarxr_protocol.MessageBundle -import solarxr_protocol.rpc.ResetRequest -import java.nio.ByteBuffer +import kotlinx.coroutines.flow.flow const val SOLARXR_PORT = 21110 -suspend fun createSolarXRWebsocketServer(behaviours: List) { + +suspend fun createSolarXRWebsocketServer(server: VRServer, behaviours: List) { val engine = embeddedServer(Netty, port = SOLARXR_PORT) { install(WebSockets) routing { webSocket { AppLogger.solarxr.info("[WS] New connection") - val solarxrConnection = SolarXRConnection.create( - scope = this, - onSend = { - send(Frame.Binary(fin = true, data = it)) + handleSolarXRBridge( + server = server, + messages = flow { + for (frame in incoming) { + when (frame) { + is Frame.Binary -> emit(frame.data) + is Frame.Close -> AppLogger.solarxr.info("[WS] Connection closed") + else -> {} + } + } }, + send = { bytes -> send(Frame.Binary(fin = true, data = bytes)) }, behaviours = behaviours, ) - - for (frame in incoming) { - when (frame) { - is Frame.Binary -> onSolarXRMessage( - frame.buffer, - solarxrConnection, - ) - - is Frame.Close -> { - AppLogger.solarxr.info("[WS] Connection closed") - } - - else -> {} - } - } } } } @@ -53,4 +46,4 @@ suspend fun createSolarXRWebsocketServer(behaviours: List Date: Mon, 13 Apr 2026 13:13:42 +0200 Subject: [PATCH 052/350] Fix test code --- .../src/test/java/dev/slimevr/TestServer.kt | 20 ++++++++++++++++ .../HeightCalibrationTest.kt | 6 +++-- .../java/dev/slimevr/solarxr/DataFeedTest.kt | 24 +++++++++++++------ 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 8de6c05f67..d8f5911622 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -1,9 +1,15 @@ package dev.slimevr import dev.llelievr.espflashkotlin.FlasherSerialInterface +import dev.slimevr.config.DefaultUserBehaviour +import dev.slimevr.config.UserConfig +import dev.slimevr.config.UserConfigData +import dev.slimevr.config.UserConfigState +import dev.slimevr.context.Context import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialServer import kotlinx.coroutines.CoroutineScope +import java.nio.file.Files fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create( openPort = { loc, _, _ -> SerialPortHandle(loc, "Fake $loc", {}, {}) }, @@ -25,3 +31,17 @@ fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create( ) fun buildTestVrServer(scope: CoroutineScope): VRServer = VRServer.create(scope) + +fun buildTestUserConfig(scope: CoroutineScope): UserConfig { + val tempDir = Files.createTempDirectory("slimevr-test").toFile() + tempDir.deleteOnExit() + val behaviours = listOf(DefaultUserBehaviour) + val context = Context.create( + initialState = UserConfigState(data = UserConfigData(), name = "test"), + scope = scope, + behaviours = behaviours, + ) + val userConfig = UserConfig(context, scope = scope, userConfigDir = tempDir) + behaviours.forEach { it.observe(userConfig) } + return userConfig +} diff --git a/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt index ba87b0867f..810b1bcb04 100644 --- a/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt @@ -2,6 +2,7 @@ package dev.slimevr.heightcalibration +import dev.slimevr.buildTestUserConfig import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.Job @@ -51,8 +52,9 @@ private fun TestScope.launchSession( controllerFlow: MutableSharedFlow, ): Job { val scope = this + val userConfig = buildTestUserConfig(backgroundScope) return launch { - runCalibrationSession(context, hmdFlow, controllerFlow, clock = { scope.currentTime * 1_000_000L }) + runCalibrationSession(context, userConfig, hmdFlow, controllerFlow, clock = { scope.currentTime * 1_000_000L }) } } @@ -290,7 +292,7 @@ class HeightCalibrationSessionTest { val context = makeContext(this) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) - val job = launch { runCalibrationSession(context, hmdFlow, controllerFlow) } + val job = launch { runCalibrationSession(context, buildTestUserConfig(backgroundScope), hmdFlow, controllerFlow) } advanceTimeBy(TIMEOUT_MS + 1) diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt index cd561cff49..c68b79f640 100644 --- a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -1,21 +1,31 @@ package dev.slimevr.solarxr +import dev.slimevr.buildTestUserConfig import dev.slimevr.buildTestVrServer +import dev.slimevr.skeleton.Skeleton import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest +import solarxr_protocol.MessageBundle import solarxr_protocol.data_feed.DataFeedConfig import solarxr_protocol.data_feed.PollDataFeed import solarxr_protocol.data_feed.StartDataFeed import kotlin.test.Test import kotlin.test.assertEquals -private fun testConn(backgroundScope: kotlinx.coroutines.CoroutineScope, onSend: suspend (ByteArray) -> Unit) = SolarXRConnection.create( - buildTestVrServer(backgroundScope), - onSend = onSend, - scope = backgroundScope, - behaviours = listOf(DataFeedInitBehaviour), -) +private fun testConn(backgroundScope: kotlinx.coroutines.CoroutineScope, onSend: suspend (ByteArray) -> Unit): SolarXRBridge { + val server = buildTestVrServer(backgroundScope) + val userConfig = buildTestUserConfig(backgroundScope) + val skeleton = Skeleton.create(backgroundScope, userConfig) + val bridge = SolarXRBridge.create( + id = 1, + serverContext = server, + scope = backgroundScope, + behaviours = listOf(DataFeedInitBehaviour(server, skeleton)), + ) + bridge.outbound.on { _ -> onSend(ByteArray(0)) } + return bridge +} private fun config(intervalMs: Int) = DataFeedConfig(minimumTimeSinceLast = intervalMs.toUShort()) @@ -91,4 +101,4 @@ class DataFeedTest { } // TODO: need more tests for the content of a datafeed + check if the masks work -} +} \ No newline at end of file From a43ba532e12e9c63ed138ad405a84a6bb5ee2347 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 13 Apr 2026 14:49:37 +0200 Subject: [PATCH 053/350] Tracker config + save --- .../java/dev/slimevr/config/behaviours.kt | 4 ++ .../main/java/dev/slimevr/config/settings.kt | 38 +++++++++++++++++ .../java/dev/slimevr/feeder/behaviours.kt | 4 +- .../main/java/dev/slimevr/feeder/module.kt | 5 ++- .../main/java/dev/slimevr/hid/behaviours.kt | 8 ++-- .../src/main/java/dev/slimevr/hid/module.kt | 6 ++- .../main/java/dev/slimevr/solarxr/datafeed.kt | 4 +- .../dev/slimevr/solarxr/trackerassignment.kt | 29 +++++++++++++ .../main/java/dev/slimevr/tracker/config.kt | 42 +++++++++++++++++++ .../main/java/dev/slimevr/tracker/module.kt | 13 +++++- .../main/java/dev/slimevr/udp/behaviours.kt | 9 ++-- .../main/java/dev/slimevr/udp/connection.kt | 4 +- .../src/main/java/dev/slimevr/udp/server.kt | 1 + .../src/main/java/dev/slimevr/desktop/Main.kt | 6 ++- .../main/java/dev/slimevr/desktop/hid/hid.kt | 4 +- .../main/java/dev/slimevr/desktop/ipc/ipc.kt | 7 ++-- .../java/dev/slimevr/desktop/ipc/linux.kt | 4 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 4 +- .../java/dev/slimevr/desktop/ipc/windows.kt | 4 +- 19 files changed, 172 insertions(+), 24 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt create mode 100644 server/core/src/main/java/dev/slimevr/tracker/config.kt diff --git a/server/core/src/main/java/dev/slimevr/config/behaviours.kt b/server/core/src/main/java/dev/slimevr/config/behaviours.kt index c7d6a503c2..ffd4d8a264 100644 --- a/server/core/src/main/java/dev/slimevr/config/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/config/behaviours.kt @@ -11,6 +11,10 @@ object DefaultSettingsBehaviour : SettingsBehaviour { override fun reduce(state: SettingsState, action: SettingsActions) = when (action) { is SettingsActions.Update -> state.copy(data = action.transform(state.data)) is SettingsActions.LoadProfile -> action.newState + is SettingsActions.UpdateTracker -> { + val existing = state.data.trackers[action.hardwareId] ?: TrackerConfig() + state.copy(data = state.data.copy(trackers = state.data.trackers + (action.hardwareId to action.transform(existing)))) + } } } diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 9a0606949d..0522da7c8f 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -5,21 +5,58 @@ import dev.slimevr.context.Context import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin +import io.github.axisangles.ktmath.Quaternion +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import solarxr_protocol.datatypes.BodyPart import java.io.File private const val SETTINGS_CONFIG_VERSION = 1 +private object BodyPartSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("BodyPart", PrimitiveKind.STRING) + override fun serialize(encoder: Encoder, value: BodyPart) = encoder.encodeString(value.name) + override fun deserialize(decoder: Decoder): BodyPart = + BodyPart.entries.firstOrNull { it.name == decoder.decodeString() } ?: BodyPart.NONE +} + +@Serializable +private data class QuaternionSurrogate(val w: Float, val x: Float, val y: Float, val z: Float) + +private object QuaternionSerializer : KSerializer { + override val descriptor = QuaternionSurrogate.serializer().descriptor + override fun serialize(encoder: Encoder, value: Quaternion) = + encoder.encodeSerializableValue(QuaternionSurrogate.serializer(), QuaternionSurrogate(value.w, value.x, value.y, value.z)) + override fun deserialize(decoder: Decoder): Quaternion { + val s = decoder.decodeSerializableValue(QuaternionSurrogate.serializer()) + return Quaternion(s.w, s.x, s.y, s.z) + } +} + +@Serializable +data class TrackerConfig( + @Serializable(with = BodyPartSerializer::class) + val bodyPart: BodyPart? = null, + val customName: String? = null, + @Serializable(with = QuaternionSerializer::class) + val mountingOrientation: Quaternion? = null, +) + @Serializable data class SettingsConfigState( val trackerPort: Int = 6969, val mutedVRCWarnings: List = listOf(), val mutedChecklistSteps: Set = emptySet(), + val trackers: Map = emptyMap(), val version: Int = SETTINGS_CONFIG_VERSION, ) @@ -44,6 +81,7 @@ data class SettingsState( sealed interface SettingsActions { data class Update(val transform: SettingsConfigState.() -> SettingsConfigState) : SettingsActions data class LoadProfile(val newState: SettingsState) : SettingsActions + data class UpdateTracker(val hardwareId: String, val transform: TrackerConfig.() -> TrackerConfig) : SettingsActions } typealias SettingsContext = Context diff --git a/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt b/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt index 42cb1052e5..24a13500ca 100644 --- a/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt @@ -2,6 +2,7 @@ package dev.slimevr.feeder import dev.slimevr.VRServer import dev.slimevr.VRServerActions +import dev.slimevr.config.Settings import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin @@ -9,7 +10,7 @@ import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import solarxr_protocol.datatypes.hardware_info.ImuType -class FeederBaseBehaviour(private val server: VRServer) : FeederBridgeBehaviour { +class FeederBaseBehaviour(private val server: VRServer, private val settings: Settings) : FeederBridgeBehaviour { override fun reduce(state: FeederBridgeState, action: FeederBridgeActions): FeederBridgeState = when (action) { is FeederBridgeActions.UpdateProtocolVersion -> state.copy(protocolVersion = action.version, firmware = action.firmware) } @@ -59,6 +60,7 @@ class FeederBaseBehaviour(private val server: VRServer) : FeederBridgeBehaviour hardwareId = serial, origin = DeviceOrigin.FEEDER, server = server, + settings = settings, ) server.context.dispatch(VRServerActions.NewTracker(trackerId, tracker)) diff --git a/server/core/src/main/java/dev/slimevr/feeder/module.kt b/server/core/src/main/java/dev/slimevr/feeder/module.kt index 0b69fda251..8e19bdbe12 100644 --- a/server/core/src/main/java/dev/slimevr/feeder/module.kt +++ b/server/core/src/main/java/dev/slimevr/feeder/module.kt @@ -3,6 +3,7 @@ package dev.slimevr.feeder import dev.slimevr.EventDispatcher import dev.slimevr.VRServer import dev.slimevr.VRServerActions +import dev.slimevr.config.Settings import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import io.github.axisangles.ktmath.Quaternion @@ -38,8 +39,8 @@ class FeederBridge( } companion object { - fun create(id: Int, serverContext: VRServer, scope: CoroutineScope): FeederBridge { - val behaviours = listOf(FeederBaseBehaviour(serverContext)) + fun create(id: Int, serverContext: VRServer, settings: Settings, scope: CoroutineScope): FeederBridge { + val behaviours = listOf(FeederBaseBehaviour(serverContext, settings)) val context = Context.create( initialState = FeederBridgeState(protocolVersion = 0, firmware = null), diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index f03b345916..4f217afc52 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -2,6 +2,7 @@ package dev.slimevr.hid import dev.slimevr.AppLogger import dev.slimevr.VRServerActions +import dev.slimevr.config.Settings import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin @@ -9,7 +10,7 @@ import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import solarxr_protocol.datatypes.TrackerStatus -object HIDRegistrationBehaviour : HIDReceiverBehaviour { +class HIDRegistrationBehaviour(private val settings: Settings) : HIDReceiverBehaviour { override fun reduce(state: HIDReceiverState, action: HIDReceiverActions) = when (action) { is HIDReceiverActions.DeviceRegistered -> state.copy( trackers = state.trackers + @@ -57,7 +58,7 @@ object HIDRegistrationBehaviour : HIDReceiverBehaviour { } } -object HIDDeviceInfoBehaviour : HIDReceiverBehaviour { +class HIDDeviceInfoBehaviour(private val settings: Settings) : HIDReceiverBehaviour { override fun reduce(state: HIDReceiverState, action: HIDReceiverActions): HIDReceiverState = when (action) { is HIDReceiverActions.TrackerRegistered -> { val existing = state.trackers[action.hidId] ?: return state @@ -103,7 +104,8 @@ object HIDDeviceInfoBehaviour : HIDReceiverBehaviour { sensorType = packet.imuType, hardwareId = deviceState.address, origin = DeviceOrigin.HID, - server = receiver.serverContext + server = receiver.serverContext, + settings = settings, ) receiver.serverContext.context.dispatch(VRServerActions.NewTracker(trackerId, newTracker)) receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, trackerId)) diff --git a/server/core/src/main/java/dev/slimevr/hid/module.kt b/server/core/src/main/java/dev/slimevr/hid/module.kt index e78a43ee78..65c2d37e73 100644 --- a/server/core/src/main/java/dev/slimevr/hid/module.kt +++ b/server/core/src/main/java/dev/slimevr/hid/module.kt @@ -2,6 +2,7 @@ package dev.slimevr.hid import dev.slimevr.EventDispatcher import dev.slimevr.VRServer +import dev.slimevr.config.Settings import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.device.Device @@ -54,11 +55,12 @@ class HIDReceiver( fun create( serialNumber: String, serverContext: VRServer, + settings: Settings, scope: CoroutineScope, ): HIDReceiver { val behaviours = listOf( - HIDRegistrationBehaviour, - HIDDeviceInfoBehaviour, + HIDRegistrationBehaviour(settings), + HIDDeviceInfoBehaviour(settings), HIDRotationBehaviour, HIDBatteryBehaviour, HIDStatusBehaviour, diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index cc8943dbc8..2a8537990a 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -42,7 +42,9 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas imuType = tracker.sensorType, bodyPart = tracker.bodyPart, displayName = tracker.name, - isImu = tracker.sensorType != null + customName = tracker.customName, + mountingOrientation = tracker.mountingOrientation?.let { Quat(it.x, it.y, it.z, it.w) }, + isImu = tracker.sensorType != null, ) } else { null diff --git a/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt b/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt new file mode 100644 index 0000000000..3b94b917dc --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt @@ -0,0 +1,29 @@ +package dev.slimevr.solarxr + +import dev.slimevr.VRServer +import dev.slimevr.tracker.TrackerActions +import io.github.axisangles.ktmath.Quaternion +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.AssignTrackerRequest + +class AssignTrackerBehaviour( + private val server: VRServer, +) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { + receiver.rpcDispatcher.on { req -> + val trackerId = req.trackerId ?: return@on + val tracker = server.getTracker(trackerId.trackerNum.toInt()) + ?: return@on + + val bodyPart = req.bodyPosition?.takeIf { it != BodyPart.NONE } + val mountingOrientation = req.mountingOrientation?.let { Quaternion(it.w, it.x, it.y, it.z) } + tracker.context.dispatch(TrackerActions.Update { + copy( + bodyPart = bodyPart, + customName = req.displayName, + mountingOrientation = mountingOrientation, + ) + }) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/config.kt b/server/core/src/main/java/dev/slimevr/tracker/config.kt new file mode 100644 index 0000000000..bbd0bc44d5 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/tracker/config.kt @@ -0,0 +1,42 @@ +package dev.slimevr.tracker + +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions +import dev.slimevr.config.TrackerConfig +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import solarxr_protocol.datatypes.BodyPart + +private fun restoreFromConfig(state: TrackerState, config: TrackerConfig): TrackerState = state.copy( + bodyPart = config.bodyPart?.takeIf { it != BodyPart.NONE } ?: state.bodyPart, + customName = config.customName ?: state.customName, + mountingOrientation = config.mountingOrientation ?: state.mountingOrientation, +) + +private fun stateToConfig(state: TrackerState): TrackerConfig = TrackerConfig( + bodyPart = state.bodyPart, + customName = state.customName, + mountingOrientation = state.mountingOrientation, +) + +class TrackerConfigBehaviour( + private val settings: Settings, + private val hardwareId: String, +) : TrackerBehaviour { + override fun observe(receiver: Tracker) { + val config = settings.context.state.value.data.trackers[hardwareId] + if (config != null) { + receiver.context.dispatch(TrackerActions.Update { restoreFromConfig(this, config) }) + } + + receiver.context.state + .map { stateToConfig(it) } + .distinctUntilChanged() + .onEach { config -> + settings.context.dispatch(SettingsActions.UpdateTracker(hardwareId) { config }) + } + .launchIn(receiver.context.scope) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index eb41e3caba..908dc9cb8f 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -1,6 +1,7 @@ package dev.slimevr.tracker import dev.slimevr.VRServer +import dev.slimevr.config.Settings import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.device.DeviceOrigin @@ -19,6 +20,7 @@ data class TrackerState( val sensorType: ImuType?, val bodyPart: BodyPart?, val customName: String?, + val mountingOrientation: Quaternion?, val rawRotation: Quaternion, val acceleration: Vector3, val deviceId: Int, @@ -47,7 +49,8 @@ class Tracker( sensorType: ImuType?, hardwareId: String, origin: DeviceOrigin, - server: VRServer + server: VRServer, + settings: Settings, ): Tracker { val trackerState = TrackerState( id = id, @@ -56,6 +59,7 @@ class Tracker( rawRotation = Quaternion.IDENTITY, acceleration = Vector3.NULL, bodyPart = null, + mountingOrientation = null, origin = origin, deviceId = deviceId, customName = null, @@ -65,7 +69,12 @@ class Tracker( imuTemp = null, ) - val behaviours = listOf(TrackerBasicBehaviour, TrackerTPSBehaviour, TrackerTapDetectionBehaviour) + val behaviours = listOf( + TrackerBasicBehaviour, + TrackerTPSBehaviour, + TrackerTapDetectionBehaviour, + TrackerConfigBehaviour(settings, hardwareId) + ) val context = Context.create(initialState = trackerState, scope = scope, behaviours = behaviours) val tracker = Tracker(context = context, server) behaviours.forEach { it.observe(tracker) } diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 5a585faa86..031b6f52f5 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -2,6 +2,7 @@ package dev.slimevr.udp import dev.slimevr.AppLogger import dev.slimevr.VRServerActions +import dev.slimevr.config.Settings import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin @@ -176,7 +177,7 @@ object DeviceStatsBehaviour : UDPConnectionBehaviour { } } -object SensorInfoBehaviour : UDPConnectionBehaviour { +class SensorInfoBehaviour(private val settings: Settings) : UDPConnectionBehaviour { override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { is UDPConnectionActions.AssignTracker -> state.copy(trackerIds = state.trackerIds + action.trackerId) else -> state @@ -196,15 +197,17 @@ object SensorInfoBehaviour : UDPConnectionBehaviour { tracker.context.dispatch(action) } else { val deviceState = device.context.state.value + val hardwareId = "${deviceState.address}:${event.data.sensorId}" val trackerId = receiver.serverContext.nextHandle() val newTracker = Tracker.create( id = trackerId, - hardwareId = "${deviceState.address}:${event.data.sensorId}", + hardwareId = hardwareId, sensorType = event.data.imuType, deviceId = deviceState.id, origin = DeviceOrigin.UDP, scope = receiver.serverContext.context.scope, - server = receiver.serverContext + server = receiver.serverContext, + settings = settings, ) receiver.serverContext.context.dispatch( diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index f368c705db..558f26e3bb 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -2,6 +2,7 @@ package dev.slimevr.udp import dev.slimevr.EventDispatcher import dev.slimevr.VRServer +import dev.slimevr.config.Settings import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.device.Device @@ -83,6 +84,7 @@ class UDPConnection( remoteIp: String, remotePort: Int, serverContext: VRServer, + settings: Settings, scope: CoroutineScope, ): UDPConnection { val behaviours = listOf( @@ -91,7 +93,7 @@ class UDPConnection( TimeoutBehaviour, PingBehaviour, DeviceStatsBehaviour, - SensorInfoBehaviour, + SensorInfoBehaviour(settings), SensorRotationBehaviour, BundledPacketBehaviour, FlagsBehaviour, diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index 3e5c8fc0c8..a1a95cfe91 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -65,6 +65,7 @@ suspend fun createUDPTrackerServer( remotePort = port, socket = socket, serverContext = serverContext, + settings = configContext.settings, scope = udpScope, ) 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 ce00f7f92f..f8a9c0b110 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -13,6 +13,7 @@ import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resolveConfigDirectory import dev.slimevr.skeleton.Skeleton +import dev.slimevr.solarxr.AssignTrackerBehaviour import dev.slimevr.solarxr.DataFeedInitBehaviour import dev.slimevr.solarxr.FirmwareBehaviour import dev.slimevr.solarxr.HeightCalibrationBehaviour @@ -51,7 +52,7 @@ fun main(args: Array) = runBlocking { createUDPTrackerServer(server, config) } launch { - createDesktopHIDManager(server, this) + createDesktopHIDManager(server, config.settings, this) } val solarXRBehaviours = listOf( @@ -63,9 +64,10 @@ fun main(args: Array) = runBlocking { ProvisioningBehaviour(server, provisioningManager), SkeletonBehaviour(config.userConfig, skeleton), TrackingChecklistBehaviour(trackingChecklist, config.settings), + AssignTrackerBehaviour(server), ) launch { createSolarXRWebsocketServer(server, solarXRBehaviours) } - launch { createIpcServers(server, solarXRBehaviours) } + launch { createIpcServers(server, config.settings, solarXRBehaviours) } Unit } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index 02b6edfa4a..d7a4c915b5 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -2,6 +2,7 @@ package dev.slimevr.desktop.hid import dev.slimevr.AppLogger import dev.slimevr.VRServer +import dev.slimevr.config.Settings import dev.slimevr.device.DeviceActions import dev.slimevr.hid.HIDReceiver import kotlinx.coroutines.CoroutineScope @@ -51,7 +52,7 @@ private fun enumerateCompatibleDevices(): Map { private data class ActiveReceiver(val job: Job, val receiver: HIDReceiver) -fun createDesktopHIDManager(serverContext: VRServer, scope: CoroutineScope) { +fun createDesktopHIDManager(serverContext: VRServer, settings: Settings, scope: CoroutineScope) { val active = mutableMapOf() scope.launch { @@ -92,6 +93,7 @@ fun createDesktopHIDManager(serverContext: VRServer, scope: CoroutineScope) { val receiver = HIDReceiver.create( serialNumber = serial, serverContext = serverContext, + settings = settings, scope = deviceScope, ) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt index 766bd3402b..f4f282d5c6 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt @@ -3,6 +3,7 @@ package dev.slimevr.desktop.ipc import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform import dev.slimevr.VRServer +import dev.slimevr.config.Settings import dev.slimevr.solarxr.SolarXRBridgeBehaviour import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -15,17 +16,17 @@ const val DRIVER_PIPE = "\\\\.\\pipe\\SlimeVRDriver" const val FEEDER_PIPE = "\\\\.\\pipe\\SlimeVRInput" const val SOLARXR_PIPE = "\\\\.\\pipe\\SlimeVRRpc" -suspend fun createIpcServers(server: VRServer, behaviours: List) = coroutineScope { +suspend fun createIpcServers(server: VRServer, settings: Settings, behaviours: List) = coroutineScope { when (CURRENT_PLATFORM) { Platform.LINUX, Platform.OSX -> { launch { createUnixDriverSocket(server) } - launch { createUnixFeederSocket(server) } + launch { createUnixFeederSocket(server, settings) } launch { createUnixSolarXRSocket(server, behaviours) } } Platform.WINDOWS -> { launch { createWindowsDriverPipe(server) } - launch { createWindowsFeederPipe(server) } + launch { createWindowsFeederPipe(server, settings) } launch { createWindowsSolarXRPipe(server, behaviours) } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index d524d2aba5..8640ff4878 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -1,6 +1,7 @@ package dev.slimevr.desktop.ipc import dev.slimevr.VRServer +import dev.slimevr.config.Settings import dev.slimevr.getSocketDirectory import dev.slimevr.solarxr.SolarXRBridgeBehaviour import dev.slimevr.solarxr.handleSolarXRBridge @@ -26,9 +27,10 @@ suspend fun createUnixDriverSocket(server: VRServer) = acceptUnixClients(DRIVER_ ) } -suspend fun createUnixFeederSocket(server: VRServer) = acceptUnixClients(FEEDER_SOCKET_NAME) { channel -> +suspend fun createUnixFeederSocket(server: VRServer, settings: Settings) = acceptUnixClients(FEEDER_SOCKET_NAME) { channel -> handleFeederConnection( server = server, + settings = settings, messages = readFramedMessages(channel), send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, ) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index dbc4731f78..6672ef25af 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -1,6 +1,7 @@ package dev.slimevr.desktop.ipc import dev.slimevr.VRServer +import dev.slimevr.config.Settings import dev.slimevr.driver.DriverBridge import dev.slimevr.driver.DriverBridgeInbound import dev.slimevr.driver.DriverBridgeOutbound @@ -57,10 +58,11 @@ suspend fun handleDriverConnection( suspend fun handleFeederConnection( server: VRServer, + settings: Settings, messages: Flow, send: suspend (ByteArray) -> Unit, ) = coroutineScope { - val bridge = FeederBridge.create(id = server.nextHandle(), serverContext = server, scope = this) + val bridge = FeederBridge.create(id = server.nextHandle(), serverContext = server, settings = settings, scope = this) send(ProtobufMessage.ADAPTER.encode(ProtobufMessage(version = Version(protocol_version = PROTOCOL_VERSION)))) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt index db7ae0414c..5cc2ae45c3 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt @@ -7,6 +7,7 @@ import com.sun.jna.platform.win32.WinError import com.sun.jna.platform.win32.WinNT import com.sun.jna.ptr.IntByReference import dev.slimevr.VRServer +import dev.slimevr.config.Settings import dev.slimevr.solarxr.SolarXRBridgeBehaviour import dev.slimevr.solarxr.handleSolarXRBridge import kotlinx.coroutines.Dispatchers @@ -29,9 +30,10 @@ suspend fun createWindowsDriverPipe(server: VRServer) = acceptWindowsClients(DRI ) } -suspend fun createWindowsFeederPipe(server: VRServer) = acceptWindowsClients(FEEDER_PIPE) { handle -> +suspend fun createWindowsFeederPipe(server: VRServer, settings: Settings) = acceptWindowsClients(FEEDER_PIPE) { handle -> handleFeederConnection( server = server, + settings = settings, messages = readFramedMessages(handle), send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, ) From b17624d87ad5cc38be1d8f3a28a4442be02613da Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 13 Apr 2026 16:35:15 +0200 Subject: [PATCH 054/350] Fix tracker config not loading back properly + fligtlist checks --- .../src/main/java/dev/slimevr/config/io.kt | 1 + .../java/dev/slimevr/config/serializers.kt | 47 +++++++++++++++++++ .../main/java/dev/slimevr/config/settings.kt | 24 +++------- .../main/java/dev/slimevr/solarxr/datafeed.kt | 2 +- .../main/java/dev/slimevr/tracker/config.kt | 23 ++++----- .../main/java/dev/slimevr/tracker/module.kt | 16 ++++++- .../slimevr/trackingchecklist/behaviours.kt | 17 ++++--- .../main/java/dev/slimevr/udp/behaviours.kt | 13 +++-- .../src/main/java/dev/slimevr/udp/packets.kt | 9 +++- 9 files changed, 103 insertions(+), 49 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/config/serializers.kt diff --git a/server/core/src/main/java/dev/slimevr/config/io.kt b/server/core/src/main/java/dev/slimevr/config/io.kt index fad6525972..d32f41769c 100644 --- a/server/core/src/main/java/dev/slimevr/config/io.kt +++ b/server/core/src/main/java/dev/slimevr/config/io.kt @@ -41,6 +41,7 @@ suspend inline fun loadFileWithBackup(file: File, default: T, crossi try { deserialize(file.readText()) } catch (e: Exception) { + e.printStackTrace() System.err.println("Failed to load ${file.absolutePath}: ${e.message}") if (file.exists()) { try { diff --git a/server/core/src/main/java/dev/slimevr/config/serializers.kt b/server/core/src/main/java/dev/slimevr/config/serializers.kt new file mode 100644 index 0000000000..2091dc254b --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/config/serializers.kt @@ -0,0 +1,47 @@ +package dev.slimevr.config + +import io.github.axisangles.ktmath.Quaternion +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.nullable +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive +import solarxr_protocol.datatypes.BodyPart + +object BodyPartSerializer : KSerializer { + @OptIn(ExperimentalSerializationApi::class) + override val descriptor = PrimitiveSerialDescriptor("BodyPart", PrimitiveKind.STRING).nullable + + override fun serialize(encoder: Encoder, value: BodyPart?) { + val jsonEncoder = encoder as JsonEncoder + if (value == null) jsonEncoder.encodeJsonElement(JsonNull) + else jsonEncoder.encodeJsonElement(JsonPrimitive(value.name)) + } + + override fun deserialize(decoder: Decoder): BodyPart? { + val element = (decoder as JsonDecoder).decodeJsonElement() + if (element is JsonNull) return null + return BodyPart.entries.firstOrNull { it.name == element.jsonPrimitive.content } + } +} + +@Serializable +private data class QuaternionSurrogate(val w: Float, val x: Float, val y: Float, val z: Float) + +object QuaternionSerializer : KSerializer { + override val descriptor = QuaternionSurrogate.serializer().descriptor + override fun serialize(encoder: Encoder, value: Quaternion) = + encoder.encodeSerializableValue(QuaternionSurrogate.serializer(), QuaternionSurrogate(value.w, value.x, value.y, value.z)) + override fun deserialize(decoder: Decoder): Quaternion { + val s = decoder.decodeSerializableValue(QuaternionSurrogate.serializer()) + return Quaternion(s.w, s.x, s.y, s.z) + } +} diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 0522da7c8f..16e2b8f06a 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -6,13 +6,19 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import io.github.axisangles.ktmath.Quaternion +import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.descriptors.PrimitiveKind import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.nullable import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject @@ -22,25 +28,7 @@ import java.io.File private const val SETTINGS_CONFIG_VERSION = 1 -private object BodyPartSerializer : KSerializer { - override val descriptor = PrimitiveSerialDescriptor("BodyPart", PrimitiveKind.STRING) - override fun serialize(encoder: Encoder, value: BodyPart) = encoder.encodeString(value.name) - override fun deserialize(decoder: Decoder): BodyPart = - BodyPart.entries.firstOrNull { it.name == decoder.decodeString() } ?: BodyPart.NONE -} -@Serializable -private data class QuaternionSurrogate(val w: Float, val x: Float, val y: Float, val z: Float) - -private object QuaternionSerializer : KSerializer { - override val descriptor = QuaternionSurrogate.serializer().descriptor - override fun serialize(encoder: Encoder, value: Quaternion) = - encoder.encodeSerializableValue(QuaternionSurrogate.serializer(), QuaternionSurrogate(value.w, value.x, value.y, value.z)) - override fun deserialize(decoder: Decoder): Quaternion { - val s = decoder.decodeSerializableValue(QuaternionSurrogate.serializer()) - return Quaternion(s.w, s.x, s.y, s.z) - } -} @Serializable data class TrackerConfig( diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 2a8537990a..ac7c282396 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -34,7 +34,7 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas trackerNum = tracker.id.toUByte(), deviceId = DeviceId(device.id.toUByte()), ), - status = if (trackerMask.status == true) device.status else null, + status = if (trackerMask.status == true) tracker.status else null, rotation = if (trackerMask.rotation == true) tracker.rawRotation.let { Quat(it.x, it.y, it.z, it.w) } else null, position = if (trackerMask.position == true && tracker.position != null) tracker.position.let { Vec3f(it.x, it.y, it.z) } else null, info = if (trackerMask.info == true) { diff --git a/server/core/src/main/java/dev/slimevr/tracker/config.kt b/server/core/src/main/java/dev/slimevr/tracker/config.kt index bbd0bc44d5..18f6f745d9 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/config.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/config.kt @@ -3,19 +3,19 @@ package dev.slimevr.tracker import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions import dev.slimevr.config.TrackerConfig -import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.BodyPart -private fun restoreFromConfig(state: TrackerState, config: TrackerConfig): TrackerState = state.copy( +fun restoreFromConfig(state: TrackerState, config: TrackerConfig): TrackerState = state.copy( bodyPart = config.bodyPart?.takeIf { it != BodyPart.NONE } ?: state.bodyPart, customName = config.customName ?: state.customName, mountingOrientation = config.mountingOrientation ?: state.mountingOrientation, ) -private fun stateToConfig(state: TrackerState): TrackerConfig = TrackerConfig( +private fun stateToConfig(state: TrackerState) = TrackerConfig( bodyPart = state.bodyPart, customName = state.customName, mountingOrientation = state.mountingOrientation, @@ -26,17 +26,12 @@ class TrackerConfigBehaviour( private val hardwareId: String, ) : TrackerBehaviour { override fun observe(receiver: Tracker) { - val config = settings.context.state.value.data.trackers[hardwareId] - if (config != null) { - receiver.context.dispatch(TrackerActions.Update { restoreFromConfig(this, config) }) - } - receiver.context.state - .map { stateToConfig(it) } - .distinctUntilChanged() - .onEach { config -> - settings.context.dispatch(SettingsActions.UpdateTracker(hardwareId) { config }) + .distinctUntilChangedBy { stateToConfig(it) } + .drop(1) + .onEach { state -> + settings.context.dispatch(SettingsActions.UpdateTracker(hardwareId) { stateToConfig(state) }) } .launchIn(receiver.context.scope) } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 908dc9cb8f..1a77a39e21 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -2,6 +2,7 @@ package dev.slimevr.tracker import dev.slimevr.VRServer import dev.slimevr.config.Settings +import dev.slimevr.config.TrackerConfig import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.device.DeviceOrigin @@ -9,8 +10,11 @@ import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.ImuType + + data class TrackerIdNum(val id: Int, val trackerNum: Int) data class TrackerState( @@ -28,6 +32,7 @@ data class TrackerState( val tps: UShort, val imuTemp: Float?, val position: Vector3?, + val status: TrackerStatus, ) sealed interface TrackerActions { @@ -52,7 +57,9 @@ class Tracker( server: VRServer, settings: Settings, ): Tracker { - val trackerState = TrackerState( + val trackerConfigs = settings.context.state.value.data.trackers + val savedConfig = trackerConfigs[hardwareId] + val baseState = TrackerState( id = id, hardwareId = hardwareId, name = "Tracker #$id", @@ -67,13 +74,18 @@ class Tracker( position = null, tps = 0u, imuTemp = null, + status = TrackerStatus.DISCONNECTED, ) + val trackerState = if (savedConfig != null) + restoreFromConfig(baseState, savedConfig) + else + baseState val behaviours = listOf( TrackerBasicBehaviour, + TrackerConfigBehaviour(settings, hardwareId), TrackerTPSBehaviour, TrackerTapDetectionBehaviour, - TrackerConfigBehaviour(settings, hardwareId) ) val context = Context.create(initialState = trackerState, scope = scope, behaviours = behaviours) val tracker = Tracker(context = context, server) diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index 8efeabc827..cc61629d60 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -16,6 +16,7 @@ import dev.slimevr.vrchat.isVRCConfigValid import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn @@ -71,6 +72,7 @@ class SteamVRCheckBehaviour(private val server: VRServer) : TrackingChecklistBeh class HMDCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { private fun computeStep(trackers: List): ChecklistStep { + // FIXME: Most likely incomplete val hasSteamVR = trackers.any { tracker -> tracker.origin == DeviceOrigin.DRIVER } val hmdTracker = trackers.firstOrNull { tracker -> tracker.origin == DeviceOrigin.DRIVER && tracker.position != null } val isAssigned = hmdTracker?.bodyPart == BodyPart.HEAD @@ -94,12 +96,10 @@ class HMDCheckBehaviour(private val server: VRServer) : TrackingChecklistBehavio } class TrackerErrorCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { - private fun computeStep(trackers: List, devices: List): ChecklistStep { - val errorDeviceIds = devices - .filter { device -> device.status == TrackerStatus.ERROR || device.status == TrackerStatus.TIMED_OUT } - .map { device -> device.id } + private fun computeStep(trackers: List): ChecklistStep { + val errorTrackers = trackers + .filter { tracker -> tracker.status == TrackerStatus.ERROR && tracker.bodyPart != null } .toSet() - val errorTrackers = trackers.filter { tracker -> tracker.deviceId in errorDeviceIds } return ChecklistStep( valid = errorTrackers.isEmpty(), enabled = trackers.isNotEmpty(), @@ -110,9 +110,8 @@ class TrackerErrorCheckBehaviour(private val server: VRServer) : TrackingCheckli } override fun observe(receiver: TrackingChecklist) { - combine(trackerStatesFlow(server), deviceStatesFlow(server)) { trackers, devices -> - computeStep(trackers, devices) - } + trackerStatesFlow(server) + .map { trackers -> computeStep(trackers) } .distinctUntilChanged() .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.TRACKER_ERROR, step)) } .launchIn(receiver.context.scope) @@ -141,4 +140,4 @@ class VRChatSettingsCheckBehaviour( .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.VRCHAT_SETTINGS, step)) } .launchIn(receiver.context.scope) } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 031b6f52f5..9ba50f9ec7 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -151,6 +151,9 @@ object TimeoutBehaviour : UDPConnectionBehaviour { receiver.getDevice()?.context?.dispatch( DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, ) + state.trackerIds.mapNotNull { receiver.serverContext.getTracker(it.id) }.forEach { tracker -> + tracker.context.dispatch(TrackerActions.Update { copy(status = TrackerStatus.DISCONNECTED) }) + } } else { delay(timeUntilTimeout + 1) } @@ -188,16 +191,18 @@ class SensorInfoBehaviour(private val settings: Settings) : UDPConnectionBehavio val device = receiver.getDevice() ?: error("invalid state - a device should exist at this point") - device.context.dispatch(DeviceActions.Update { copy(status = event.data.status) }) - val tracker = receiver.getTracker(event.data.sensorId) - val action = TrackerActions.Update { copy(sensorType = event.data.imuType) } + val action = TrackerActions.Update { copy(sensorType = event.data.imuType, status = event.data.status) } if (tracker != null) { tracker.context.dispatch(action) } else { val deviceState = device.context.state.value - val hardwareId = "${deviceState.address}:${event.data.sensorId}" + val mac = deviceState.macAddress ?: run { + AppLogger.udp.warn("[${deviceState.address}] No MAC address available, falling back to IP for hardware ID") + deviceState.address + } + val hardwareId = "$mac:${event.data.sensorId}" val trackerId = receiver.serverContext.nextHandle() val newTracker = Tracker.create( id = trackerId, diff --git a/server/core/src/main/java/dev/slimevr/udp/packets.kt b/server/core/src/main/java/dev/slimevr/udp/packets.kt index 5ffc2bffa3..efe0ae3811 100644 --- a/server/core/src/main/java/dev/slimevr/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/udp/packets.kt @@ -309,9 +309,16 @@ data class SensorInfo( val trackerDataType: Int? = null, ) : SensorSpecificPacket { companion object { + private fun statusFromUDP(raw: UByte): TrackerStatus = when (raw.toInt()) { + 0 -> TrackerStatus.DISCONNECTED + 1 -> TrackerStatus.OK + 2 -> TrackerStatus.ERROR + else -> TrackerStatus.DISCONNECTED + } + fun read(src: Source) = with(src) { val id = readU8() - val stat = TrackerStatus.fromValue((readUByte() + 1u).toUByte()) ?: TrackerStatus.DISCONNECTED + val stat = statusFromUDP(readUByte()) val imu = if (remaining > 0) ImuType.fromValue(readUByte().toUShort()) ?: ImuType.Other else ImuType.Other val conf = if (remaining >= 2) readShort().toUShort() else null val calib = if (remaining > 0) readU8() != 0 else null From a097c262613b6ec3ea328db287de5bcda7571639 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 13 Apr 2026 10:44:58 -0500 Subject: [PATCH 055/350] Implement rest calibration checklist warning --- .../main/java/dev/slimevr/hid/behaviours.kt | 6 +++-- .../main/java/dev/slimevr/tracker/module.kt | 2 ++ .../slimevr/trackingchecklist/behaviours.kt | 25 +++++++++++++++++++ .../dev/slimevr/trackingchecklist/module.kt | 1 + .../main/java/dev/slimevr/udp/behaviours.kt | 8 +++++- 5 files changed, 39 insertions(+), 3 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 4f217afc52..0fa4bb32f9 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -94,7 +94,8 @@ class HIDDeviceInfoBehaviour(private val settings: Settings) : HIDReceiverBehavi if (existingTracker != null) { receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, existingTracker.context.state.value.id)) - existingTracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType) }) + // HID does not have a rest calibration signal + existingTracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType, completedRestCalibration = true) }) } else { val trackerId = receiver.serverContext.nextHandle() val newTracker = Tracker.create( @@ -114,7 +115,8 @@ class HIDDeviceInfoBehaviour(private val settings: Settings) : HIDReceiverBehavi device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) }) } else { - tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType) }) + // HID does not have a rest calibration signal + tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType, completedRestCalibration = true) }) } } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 1a77a39e21..c4b3c5824d 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -33,6 +33,7 @@ data class TrackerState( val imuTemp: Float?, val position: Vector3?, val status: TrackerStatus, + val completedRestCalibration: Boolean, ) sealed interface TrackerActions { @@ -75,6 +76,7 @@ class Tracker( tps = 0u, imuTemp = null, status = TrackerStatus.DISCONNECTED, + completedRestCalibration = false ) val trackerState = if (savedConfig != null) restoreFromConfig(baseState, savedConfig) diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index cc61629d60..95ae6db0c8 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -26,6 +26,7 @@ import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.TrackerId import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.rpc.TrackingChecklistNeedCalibration import solarxr_protocol.rpc.TrackingChecklistStepId import solarxr_protocol.rpc.TrackingChecklistSteamVRDisconnected import solarxr_protocol.rpc.TrackingChecklistTrackerError @@ -95,6 +96,30 @@ class HMDCheckBehaviour(private val server: VRServer) : TrackingChecklistBehavio } } +class TrackerRestCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { + private fun computeStep(trackers: List): ChecklistStep { + val uncalibratedTrackers = trackers.filter { tracker -> + (tracker.origin == DeviceOrigin.UDP || tracker.origin == DeviceOrigin.HID) + && tracker.status == TrackerStatus.OK && !tracker.completedRestCalibration + } + return ChecklistStep( + valid = uncalibratedTrackers.isEmpty(), + enabled = trackers.isNotEmpty(), + extraData = if (!uncalibratedTrackers.isEmpty()) TrackingChecklistNeedCalibration( + trackersId = uncalibratedTrackers.map { tracker -> trackerIdOf(tracker) }, + ) else null, + ) + } + + override fun observe(receiver: TrackingChecklist) { + trackerStatesFlow(server) + .map { trackers -> computeStep(trackers) } + .distinctUntilChanged() + .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.TRACKERS_REST_CALIBRATION, step)) } + .launchIn(receiver.context.scope) + } +} + class TrackerErrorCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { private fun computeStep(trackers: List): ChecklistStep { val errorTrackers = trackers diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt index fad0891dba..3104b28552 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt @@ -58,6 +58,7 @@ class TrackingChecklist( val stepBehaviours: List = buildList { add(SteamVRCheckBehaviour(server)) add(HMDCheckBehaviour(server)) + add(TrackerRestCheckBehaviour(server)) add(TrackerErrorCheckBehaviour(server)) vrcConfigManager?.let { add(VRChatSettingsCheckBehaviour(server, skeleton, it)) } } diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 9ba50f9ec7..bf8e84d7ef 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -192,7 +192,13 @@ class SensorInfoBehaviour(private val settings: Settings) : UDPConnectionBehavio ?: error("invalid state - a device should exist at this point") val tracker = receiver.getTracker(event.data.sensorId) - val action = TrackerActions.Update { copy(sensorType = event.data.imuType, status = event.data.status) } + val action = TrackerActions.Update { + copy( + sensorType = event.data.imuType, + status = event.data.status, + completedRestCalibration = event.data.hasCompletedRestCalibration ?: tracker?.context?.state?.value?.completedRestCalibration ?: false + ) + } if (tracker != null) { tracker.context.dispatch(action) From 7d04273b0e0dd67dddb29aab1af0f2cf9c807a1e Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 13 Apr 2026 20:36:36 +0200 Subject: [PATCH 056/350] Better readme --- server/README.md | 424 ++++++++++++++++++++++------------------ server/architecture.dot | 99 ++++++++++ server/architecture.svg | 238 ++++++++++++++++++++++ 3 files changed, 570 insertions(+), 191 deletions(-) create mode 100644 server/architecture.dot create mode 100644 server/architecture.svg diff --git a/server/README.md b/server/README.md index 1bd19f1a8f..51d936fe97 100644 --- a/server/README.md +++ b/server/README.md @@ -1,293 +1,281 @@ -# SlimeVR Server — Design Guidelines +for a# SlimeVR Server — Architecture -This document explains the architectural choices made in the server rewrite and how to extend the system correctly. +This document explains how the server is structured, why it's structured that way, and how to extend it correctly. --- -## Core Principle: Reducers and State +## Mental Model -Every major part of this server — a tracker, a device, a UDP connection, a SolarXR session — manages state the same way: immutable data, typed actions, and pure reducer functions that transform one into the other. +Every significant part of the server — a tracker, a UDP connection, a SolarXR session, a config file — is a **module**. Every module is a list of behaviours sharing one context: -This is not accidental. It gives us: -- **Predictability**: state only changes through known, enumerated actions -- **Observability**: any code can `collect` the `StateFlow` and react to changes -- **Concurrency safety**: `StateFlow.update` is atomic; two concurrent dispatches never corrupt state +![Architecture diagram](architecture.svg) + +That's the entire system. Everything else is an application of this pattern. --- -## The Context System +## Why This Pattern + +The server handles many concurrent connections, each with independent state that many other components need to observe. A shared mutable object works fine until two coroutines update it at the same time, or you need to know *when* something changed, or you want to add a feature without touching existing code. + +The reducer pattern solves all three: -The `Context` type (`context/context.kt`) is the building block of every module: +- **Thread safety**: `StateFlow.update` is atomic. Concurrent dispatches serialize without locks. +- **Observability**: `context.state` is a hot `StateFlow` — it always holds the current value and any code can `collect` it and react to changes without being coupled to the producer. +- **Extensibility**: adding a feature means adding a `Behaviour` to a list. Nothing else changes. + +The cost is that you cannot mutate state directly. Everything goes through `dispatch`. That constraint is the point. + +--- + +## The Context + +`Context` (`context/context.kt`) holds two things: the current state as a `StateFlow`, and the coroutine scope that defines this module's lifetime. ```kotlin class Context( - val state: StateFlow, // current state, readable by anyone - val scope: CoroutineScope, // lifetime of this module + val state: StateFlow, + val scope: CoroutineScope, ) { - fun dispatch(action: A) - fun dispatchAll(actions: List) + fun dispatch(action: A) + fun dispatchAll(actions: List) } ``` -`Context.create` wires everything together: -1. Takes an `initialState` and a list of **behaviours** -2. On each `dispatch`, folds all behaviours' `reduce` over the current state -3. Publishes the new state on the `StateFlow` +`Context.create` takes an initial state and a list of behaviours. On each `dispatch`, it folds every behaviour's `reduce` over the current state in order and publishes the result. Behaviours that don't care about an action return the state unchanged. -**Never mutate state directly.** Always go through `dispatch`. +`dispatchAll(actions)` applies a list of actions in one atomic `StateFlow.update`. Use it when you need multiple state changes to be visible as one — intermediate states are never published, so no observer fires between them. --- -## Behaviours: Splitting Concerns +## Behaviours -A `Behaviour` is an interface with two methods, both with no-op defaults: +A behaviour is one feature of a module. It has two methods, both optional: ```kotlin interface Behaviour { - fun reduce(state: S, action: A): S = state - fun observe(receiver: C) {} + fun reduce(state: S, action: A): S = state // pure, no side effects + fun observe(receiver: C) {} // side effects go here } ``` -- **`reduce`**: Pure function. Handles the actions it cares about, returns the rest unchanged. Override only if the behaviour needs to modify state. -- **`observe`**: Called once at construction. Launches coroutines, registers event listeners, subscribes to other state flows. Override only if the behaviour has side effects. +`reduce` is a pure function — it cannot launch coroutines, call external services, or read from a clock. It just maps `(state, action) → state`. -The type parameter `C` is what the observer receives. For modules where the behaviour only needs the context, `C = Context`. For modules where behaviours need access to the full service object (its `send` method, dispatchers, etc.), `C` is the service class itself: +`observe` is called once at construction, after the context exists. This is where you launch coroutines, subscribe to flows, register event listeners. All coroutines launched here should use `receiver.context.scope` so they die when the module dies — no manual cleanup, no leaks. -```kotlin -// Observer receives only the context -typealias DeviceBehaviour = Behaviour +**Why behaviours instead of methods on the module class?** -// Observer receives the full connection object -typealias UDPConnectionBehaviour = Behaviour +The natural alternative is one class with methods for each feature — ping handling, handshake logic, sensor registration, timeout detection, all in the same file. That works until the class has 20 features and every change has global blast radius. Behaviours make the feature list explicit: you read `create()`, you see exactly what the module does. Adding or removing a feature is one line in that list, touching nothing else. + +**Why `observe` is separate from `reduce`:** + +`reduce` must stay pure so state transitions are testable and predictable. Side effects in `observe` are isolated — a behaviour that logs packets doesn't affect state, a behaviour that saves config doesn't affect packet handling. + +### Scoping Behaviours to Module Lifetime + +Every behaviour's coroutines run inside `receiver.context.scope`. When a UDP connection drops, its scope is cancelled, which cancels every coroutine every behaviour launched for that connection. No teardown code needed anywhere. + +This is why `observe` receives the module (or context) rather than taking a raw `CoroutineScope` parameter — it enforces that behaviours cannot outlive their module. + +### Stateless vs. Stateful Behaviours + +Behaviours with no constructor dependencies are `object`s — singletons, zero allocation: + +```kotlin +object PacketBehaviour : UDPConnectionBehaviour { ... } ``` -Every module follows the same construction pattern: +Behaviours that need external services are `class`es, constructed at the call site: ```kotlin -val behaviours = listOf(BehaviourA, BehaviourB, BehaviourC) +class TrackerConfigBehaviour( + private val settings: Settings, + private val hardwareId: String, +) : TrackerBehaviour { ... } +``` -val context = Context.create( - initialState = ..., - scope = scope, - behaviours = behaviours, -) +When a behaviour is conditional (depends on a nullable service), use `buildList`: -val module = MyModule(context, ...) -behaviours.forEach { it.observe(module) } // or it.observe(context) for basic modules +```kotlin +val behaviours: List = buildList { + add(AlwaysPresentBehaviour) + optionalService?.let { add(OptionalServiceBehaviour(it)) } +} ``` -This is where observers are started. Order matters for reducers (applied top-to-bottom), but rarely matters for observers. +### The Receiver Type ---- +The type parameter `C` controls what `observe` receives. For simple modules where behaviours only need to dispatch or subscribe to state, `C` is the context itself. For modules where behaviours also need to call methods (send bytes, emit events), `C` is the module class: + +```kotlin +// behaviours only dispatch/observe state +typealias GlobalConfigBehaviour = Behaviour + +// behaviours also call receiver.send(), receiver.packetEvents.on(...) +typealias UDPConnectionBehaviour = Behaviour +``` -## Behaviour File Layout +### File Layout -Behaviours live in their own `behaviours.kt` file, separate from the module they belong to, within the same package: +Behaviours live in a `behaviours.kt` file in the same package as the module, separate from the module class itself: ``` -udp/ -├── behaviours.kt ← PacketBehaviour, PingBehaviour, HandshakeBehaviour, … -├── connection.kt ← UDPConnection class, state, actions, typealias -└── packets.kt ← packet type definitions +tracker/ +├── module.kt ← Tracker class, TrackerState, TrackerActions, typealiases +├── behaviours.kt ← TrackerBasicBehaviour, TrackerTPSBehaviour, ... +└── config.kt ← TrackerConfigBehaviour (its own file — distinct concern) ``` -Group behaviours that share the same receiver type in a single file. Behaviours with dependencies on external services (e.g. `SerialBehaviour`, `FirmwareBehaviour`) are standalone classes — one per file is fine when they have distinct concerns. +`module.kt` is the standard name for the file containing the module class, state, actions, and typealiases. `behaviours.kt` holds the implementations. For modules with many distinct feature areas, split behaviours by feature instead of one file — `solarxr/` uses this: `datafeed.kt`, `firmware.kt`, `provisioning.kt`, etc. each live alongside `module.kt`. --- -## Stateless vs. Stateful Behaviours +## Actions -**Stateless behaviours** (no dependencies at construction time) are `object`s: +Actions are `sealed interface`s. This matters because the compiler enforces exhaustive `when` in reducers — you cannot forget to handle a new action type. Named actions also mean you can grep for all places a specific transition can occur. ```kotlin -object PacketBehaviour : UDPConnectionBehaviour { - override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { - is UDPConnectionActions.LastPacket -> state.copy(...) - else -> state - } - override fun observe(receiver: UDPConnection) { ... } +sealed interface UDPConnectionActions { + data class Handshake(val deviceId: Int) : UDPConnectionActions + data class LastPacket(val packetNum: Long?, val time: Long) : UDPConnectionActions + data object Disconnected : UDPConnectionActions } ``` -**Behaviours with dependencies** are classes, constructed at the call site: +The choice between a flexible `Update` action and a named action is about who else needs to react: + +- **`Update`** — when only the caller cares about the change. It carries a lambda that transforms state directly. No other behaviour pattern-matches it, so there's no coupling. +- **Named action** — when other behaviours need to react to this specific event. `Handshake` in `UDPConnectionActions` signals to multiple behaviours that a connection is now established; each one matches it independently. ```kotlin -class FirmwareBehaviour(private val firmwareManager: FirmwareManager) : SolarXRConnectionBehaviour { - override fun observe(receiver: SolarXRConnection) { ... } -} +// Nothing else needs to react — use Update +tracker.context.dispatch(TrackerActions.Update { copy(tps = newTps) }) -// At the call site: -listOf( - DataFeedInitBehaviour, - FirmwareBehaviour(firmwareManager), - SerialBehaviour(serialServer), -) +// Multiple behaviours react to this — use a named action +connection.context.dispatch(UDPConnectionActions.Handshake(deviceId = id)) ``` ---- - -## Actions - -Actions are `sealed interface`s with `data class` variants. This means: - -- The compiler enforces exhaustive `when` expressions in reducers -- No stringly-typed dispatch -- Refactors are caught at compile time - -Use `data class Update(val transform: State.() -> State)` when you need a flexible "update anything" action (see `TrackerActions`, `DeviceActions`). Use specific named actions when the action has semantic meaning that other behaviours need to pattern-match on (see `UDPConnectionActions.Handshake`). +If you find yourself dispatching an `Update` that other behaviours start matching against, it's time to promote it to a named action. --- -## The PacketDispatcher Pattern +## EventDispatcher -`PacketDispatcher` routes incoming messages to typed listeners without a giant `when` block: +`EventDispatcher` routes events to typed listeners. Each behaviour registers its own listener in `observe`: ```kotlin -dispatcher.on { packet -> /* only called for SensorInfo */ } -dispatcher.onAny { packet -> /* called for everything */ } -dispatcher.emit(packet) // routes to correct listeners +receiver.packetEvents.on { packet -> ... } // only SensorInfo +receiver.packetEvents.onAny { packet -> ... } // every packet ``` -Use this wherever you have a stream of heterogeneous messages (UDP packets, SolarXR messages). Each behaviour registers its own listener in its `observe` — the dispatcher is passed as part of the module struct. +**Why not a central `when` block?** ---- +A central switch means every new packet type requires editing the dispatch hub. With `EventDispatcher`, each behaviour owns its subscription. Adding a new packet type means adding a class to `packets.kt` and a listener in a behaviour — the routing hub never changes. -## Coroutines and Lifetime +It also means behaviours are self-contained: `SensorInfoBehaviour` is the only place that knows what to do with `SensorInfo`. No shared routing code. -- Every module is given a `CoroutineScope` at creation. Cancelling that scope tears down all coroutines the module launched. -- Observers should use `receiver.context.scope.launch { ... }` so their work is scoped to the module. -- Blocking I/O goes on `Dispatchers.IO`. State updates and logic stay on the default dispatcher. -- **Avoid `runBlocking`** inside observers or handlers — it blocks the coroutine thread. The one acceptable use is synchronous listener registration before a scope is started. +`EventDispatcher` dispatches by the runtime type of the event by default. When events are wrapped (e.g. `PacketEvent` where the actual packet is the inner value), pass a `keyOf` lambda to tell the dispatcher which type to route by — otherwise all events would be bucketed under `PacketEvent` regardless of the inner packet type. --- -## State vs. Out-of-Band Data +## Coroutine Scope and Lifetime -Not everything belongs in `StateFlow`. Two good examples: +Every module receives a `CoroutineScope` at creation. That scope defines when the module lives and dies: -- `VRServer.handleCounter` is an `AtomicInteger` — not in state — because nothing needs to react to it changing, and `incrementAndGet()` is faster and simpler than a dispatch round-trip. -- `UDPTrackerServer` has no `Context` at all. Its connection map is a plain `MutableMap` internal to the server loop. Nothing outside the loop reads it, so there is no reason to wrap it in a state machine. +- Cancelling the scope cancels all coroutines all behaviours launched inside it +- No `close()`, `stop()`, or `dispose()` methods needed anywhere +- A disconnected client, a dropped UDP connection, a closed serial port — all cleaned up by scope cancellation -Rule of thumb: put data in state if **any other code needs to react to it changing**. If it's purely an implementation detail owned by one place, keep it plain. +Blocking I/O goes on `Dispatchers.IO`. State updates and logic stay on the default dispatcher. Never use `runBlocking` inside an observer — it blocks the coroutine thread pool. --- -## Adding a New Module +## State vs. Plain Data -To add a new major section of the server (say, a HID device connection): +Not everything needs to be in a `StateFlow`. The rule: **put data in state only if other code needs to react to it changing**. -1. **Define the state**: -```kotlin -data class HIDConnectionState( - val deviceId: Int?, - val connected: Boolean, -) -``` +- `VRServer.handleCounter` is an `AtomicInt` — nothing reacts to it, so a dispatch round-trip would be waste. +- `UDPTrackerServer` has no `Context` at all. Its connection map is a `MutableMap` internal to the server loop. Nothing outside reads it. +- Tracker config *is* in state because the SolarXR layer and the config autosave both react to body part assignments changing. -2. **Define sealed actions**: -```kotlin -sealed interface HIDConnectionActions { - data class Connected(val deviceId: Int) : HIDConnectionActions - data object Disconnected : HIDConnectionActions -} -``` +--- -3. **Create type aliases** (keeps signatures readable): -```kotlin -typealias HIDConnectionContext = Context -typealias HIDConnectionBehaviour = Behaviour -``` +## Cross-Module Communication -4. **Define the module class** (holds context + extra runtime state): -```kotlin -class HIDConnection( - val context: HIDConnectionContext, - val serverContext: VRServer, - private val onSend: suspend (ByteArray) -> Unit, -) { - suspend fun send(bytes: ByteArray) = onSend(bytes) -} -``` +Modules don't call each other directly. A behaviour in one module subscribes to another module's `StateFlow` and dispatches into its own context (or another module's context) when something relevant changes. + +Two common patterns: -5. **Write behaviours** in a separate `behaviours.kt` file: +**React to another module's state:** ```kotlin -object HIDHandshakeBehaviour : HIDConnectionBehaviour { - override fun reduce(state: HIDConnectionState, action: HIDConnectionActions) = when (action) { - is HIDConnectionActions.Connected -> state.copy(deviceId = action.deviceId, connected = true) - is HIDConnectionActions.Disconnected -> state.copy(connected = false) - } - override fun observe(receiver: HIDConnection) { - // launch coroutines, subscribe to events, etc. - } -} +// Inside TrackerConfigBehaviour.observe() +// When tracker state changes, persist the relevant fields to Settings +receiver.context.state + .distinctUntilChangedBy { stateToConfig(it) } + .drop(1) + .onEach { state -> + settings.context.dispatch(SettingsActions.UpdateTracker(hardwareId) { stateToConfig(state) }) + } + .launchIn(receiver.context.scope) ``` -6. **Write a `companion object { fun create() }`**: +**Dispatch into VRServer from a connection:** ```kotlin -companion object { - fun create(serverContext: VRServer, scope: CoroutineScope, send: suspend (ByteArray) -> Unit): HIDConnection { - val behaviours = listOf(HIDHandshakeBehaviour, ...) - val context = Context.create(initialState = ..., scope = scope, behaviours = behaviours) - val conn = HIDConnection(context, serverContext, send) - behaviours.forEach { it.observe(conn) } - return conn - } +// Inside HandshakeBehaviour.observe() — a UDP connection registers a new device +receiver.packetEvents.on { event -> + val device = Device.create(...) + receiver.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId, device)) } ``` +The key rule: behaviours own the subscription lifetime via `launchIn(receiver.context.scope)`. When the module dies, the subscription stops — no dangling listeners in foreign modules. + --- -## Adding a New Behaviour to an Existing Module +## IPC and Transport -Find the `create` function, add your behaviour to the `behaviours` list. That's it. The behaviour's `reduce` and `observe` are automatically picked up. +The server exposes four connection points: -Example: adding battery tracking to a HID connection requires only adding a `HIDBatteryBehaviour` to the list — nothing else changes. +| Transport | Client | Protocol | +|---|---|---| +| Unix socket / named pipe `SlimeVRDriver` | OpenVR driver | Protobuf (Wire) | +| Unix socket / named pipe `SlimeVRInput` | External feeder | Protobuf (Wire) | +| Unix socket / named pipe `SlimeVRRpc` | SolarXR (IPC path) | FlatBuffers | +| WebSocket port 21110 | GUI / third-party (SolarXR) | FlatBuffers | ---- +**Why separate transport from protocol?** -## Adding a New UDP Packet Type +Platform files (`linux.kt`, `windows.kt`) own reading frames off a socket and produce a `Flow` + a `send` function. Protocol handlers (`protocol.kt`, `ipc.kt`) are plain `suspend fun`s that take those two things and know nothing about Unix sockets or named pipes. -1. Add the packet class and its `read` function in `udp/packets.kt` -2. In a behaviour's `observe`, register a listener: -```kotlin -receiver.packetEvents.on { event -> - // handle it -} -``` -3. In `udp/server.kt`, route the new packet type to `emit`. +The same `handleSolarXRBridge` function runs on Linux sockets, Windows pipes, and WebSocket. The WebSocket adapter in `ws-server.kt` converts Ktor frames to the same `Flow` + `send` abstraction. The handler never changes. ---- +**Why events for IPC message routing?** -## IPC +Same reason as UDP packets. The SolarXR protocol has dozens of RPC message types. Behaviours that handle firmware updates, skeleton config, data feeds, etc. each register their own listener on the `rpcDispatcher`. Adding a new RPC handler doesn't touch existing ones. -There are three IPC sockets, each serving a distinct client: +### Wire Framing (named sockets) -| Socket | Client | Payload encoding | -|---|---|---| -| `SlimeVRDriver` | OpenVR driver | Protobuf (Wire) | -| `SlimeVRInput` | External feeder | Protobuf (Wire) | -| `SlimeVRRpc` | SolarXR RPC | FlatBuffers (solarxr-protocol) | +All three named sockets use the same framing: a **LE u32 length** prefix (including the 4-byte header itself) followed by the raw payload bytes. -### Wire framing +### UDP Packet Pipeline -All three sockets share the same framing: a **LE u32 length** prefix (which includes the 4-byte header itself) followed by the raw payload bytes. +The UDP receive loop runs on `Dispatchers.IO`. It reads a datagram, parses the packet type and payload via `readPacket`, wraps the result in a `PacketEvent`, and pushes it into a `Channel>` per connection. -### Transport / protocol split +A dedicated coroutine per connection drains the channel and calls `EventDispatcher.emit`. Pre-handshake packets are filtered at the channel drain step — a single guard in one place, not spread across every behaviour. -Platform files (`linux.kt`, `windows.kt`) own the transport layer — accepting connections, reading frames, and producing a `Flow` + a `send` function. The protocol handlers in `protocol.kt` are plain `suspend fun`s that consume those two abstractions and know nothing about Unix sockets or named pipes. +--- -This means the same handler runs on Linux (Unix domain sockets) and Windows (named pipes) without any changes. +## Config -### Connection lifetime +Config is not just file I/O — it is a live state machine. The server supports runtime profile switching, so config data lives in a `StateFlow` like everything else. Code that needs to react to config changes (e.g. tracker body part assignment saving to disk) subscribes to the settings state flow. -Each client runs in its own `launch` block. When the socket disconnects, the coroutine scope is cancelled and everything inside cleans up automatically. +The config system has three layers: -### What each handler does +- **`AppConfig`** — global state: which user profile and settings profile are active +- **`UserConfig`** — per-user data (body proportions, etc.) +- **`Settings`** — per-profile settings (tracker assignments, port, VRC warnings); persists to disk -- **Driver** (`handleDriverConnection`): on connect, sends the protocol version and streams `TrackerAdded` + `Position` messages for every non-driver tracker. Receives user actions from the driver (resets, etc.). -- **Feeder** (`handleFeederConnection`): receives `TrackerAdded` messages to create new devices and trackers, then `Position` updates to drive their rotation. -- **SolarXR** (`handleSolarXRConnection`): creates a `SolarXRConnection` and forwards all incoming FlatBuffers messages to it. +Each is a full module with its own `Context`, behaviours, and JSON autosave. Migrations live in the module's `parseAndMigrate` function — add a `version < N` branch there. --- @@ -295,22 +283,76 @@ Each client runs in its own `launch` block. When the socket disconnects, the cor | Location | Purpose | |---|---| -| `server/core` | Protocol-agnostic business logic (trackers, devices, config, SolarXR) | -| `server/desktop` | Platform-specific entry point, IPC socket wiring, platform abstractions | -| `context/context.kt` | The `Context` / `Behaviour` primitives — do not add domain logic here | -| `udp/` | Everything specific to the SlimeVR UDP wire protocol | -| `solarxr/` | SolarXR WebSocket server + FlatBuffers message handling | -| `config/` | JSON config read/write with autosave; no business logic | -| `firmware/` | OTA update and serial flash logic; interacts with devices over the network, independent of the UDP tracker protocol | +| `server/core` | Protocol-agnostic business logic | +| `server/desktop` | Platform entry point, IPC wiring, HID, serial flash | +| `context/context.kt` | `Context` and `Behaviour` primitives — no domain logic here | +| `udp/` | UDP wire protocol: connection state, packets, behaviours, server loop | +| `hid/` | HID tracker receiver: registration, rotation, battery, status | +| `solarxr/` | `SolarXRBridge` + per-feature behaviour files (one per RPC area) | +| `config/` | 3-tier config: `AppConfig`, `UserConfig`, `Settings` | +| `firmware/` | OTA and serial flash; independent of UDP tracker protocol | +| `trackingchecklist/` | Checklist steps as behaviours observing server/tracker/VRC state | +| `skeleton/` | Skeleton solving, proportions, bone geometry | +| `vrchat/` | VRChat config monitoring and recommended-value computation | + +--- + +## Extending the Server + +### Adding a New Module + +1. **Define state and actions** in `module.kt`: +```kotlin +data class MyState(val connected: Boolean) + +sealed interface MyActions { + data object Connected : MyActions + data object Disconnected : MyActions +} + +typealias MyContext = Context +typealias MyBehaviour = Behaviour +``` + +2. **Define the module class** (holds context + anything behaviours need to call): +```kotlin +class MyModule(val context: MyContext, val server: VRServer) { + companion object { + fun create(scope: CoroutineScope, server: VRServer): MyModule { + val behaviours = listOf(MyCoreBehaviour, ...) + val context = Context.create(initialState = MyState(false), scope = scope, behaviours = behaviours) + val module = MyModule(context, server) + behaviours.forEach { it.observe(module) } + return module + } + } +} +``` + +3. **Write behaviours** in `behaviours.kt` (or per-feature files for large modules). + +### Adding a Behaviour to an Existing Module + +Add it to the `behaviours` list in `create()`. The behaviour's `reduce` and `observe` are automatically wired in. Nothing else changes. + +### Adding a New UDP Packet Type + +1. Add the packet class and its `read` function in `udp/packets.kt` +2. Add an entry to the `PacketType` enum and a branch in `readPacket()` +3. Register a listener in a behaviour's `observe`: +```kotlin +receiver.packetEvents.on { event -> + receiver.context.dispatch(...) +} +``` --- ## Style Conventions -- **Limit OOP to strictly necessary cases.** Prefer plain functions, function types, and data classes. Avoid classes and inheritance unless there is a genuine need for encapsulated mutable state or polymorphism. A single-method interface should almost always be a function type instead (`() -> Unit`, `suspend (String) -> Unit`, etc.). When in doubt, write a function. -- **Prefer plain functions over extension functions.** Only use extension functions when the receiver type is genuinely the primary subject and the function would be confusing without it. -- Behaviours are `object`s (no dependencies) or `class`es (with dependencies), defined in a dedicated `behaviours.kt` file in the same package as the module they belong to. -- Module creation lives in `companion object { fun create(...) }`. -- State data classes use `copy(...)` inside reducers and `Update { copy(...) }` actions — never expose a `MutableStateFlow` directly. -- **Never use `var` in a state data class** — state must be immutable, all fields `val`. Using `var` in any data class is almost certainly a design mistake; if you need mutable fields, prefer a plain class or rethink the structure. -- Use `sealed interface` for action types, not `sealed class`, to avoid the extra constructor overhead. +- **Prefer plain functions over classes.** A single-method interface should almost always be a function type (`() -> Unit`, `suspend (ByteArray) -> Unit`). A class with no mutable state should be a plain function or `object`. When in doubt, write a function. +- **Prefer plain functions over extension functions.** Only use extensions when the receiver type is genuinely the primary subject. +- **State data classes are all `val`.** Mutable fields in a state class are a design mistake — use `var` in a plain class if you need local mutability, never in state. +- **Use `sealed interface` for actions**, not `sealed class` — no constructor overhead. +- **Module creation lives in `companion object { fun create(...) }`.** +- **Never expose `MutableStateFlow` directly.** Expose `StateFlow` via `context.state`. diff --git a/server/architecture.dot b/server/architecture.dot new file mode 100644 index 0000000000..f958e58ce0 --- /dev/null +++ b/server/architecture.dot @@ -0,0 +1,99 @@ +digraph module_architecture { + rankdir=TB + nodesep=0.8 + ranksep=0.8 + forcelabels=true + fontname="monospace" + node [fontname="monospace", fontsize=11, style="filled,rounded", shape=box, margin="0.3,0.2"] + edge [fontname="monospace", fontsize=10] + + ext [ + label="external events\n(packets · messages)" + shape=ellipse fillcolor="#dae8fc" color="#6c8ebf" + ] + + subgraph cluster_A { + label="Module A" + style="rounded" bgcolor="#fafafa" color="#999999" penwidth=1.5 + + subgraph cluster_A_beh { + label="Behaviours" + style="dashed" color="#aaaaaa" bgcolor="#fffff8" + + bA1 [label="BehaviourA1\n────────────────────\nobserve(receiver)\n subscribes to flows\n calls dispatch()\n────────────────────\nreduce(state, action)\n pure: State → State" + fillcolor="#fff9db" color="#d6ae00"] + + bA2 [label="BehaviourA2\n────────────────────\nobserve(receiver)\n ...\n────────────────────\nreduce(state, action)\n ..." + fillcolor="#fff9db" color="#d6ae00"] + + bAmore [label="···\n────────────────────\nobserve(receiver)\n ...\n────────────────────\nreduce(state, action)\n ..." + fillcolor="#fff9db" color="#d6ae00"] + } + + foldA [ + label="reduce fold\n──────────────────────────────────\nstate = BehaviourA1.reduce(state, a)\nstate = BehaviourA2.reduce(state, a)\nstate = ···" + fillcolor="#d5e8d4" color="#82b366" + ] + + sfA [ + label="StateFlow" + shape=cylinder fillcolor="#dae8fc" color="#6c8ebf" + ] + } + + subgraph cluster_B { + label="Module B" + style="rounded" bgcolor="#fafafa" color="#999999" penwidth=1.5 + subgraph cluster_B_beh { + label="Behaviours" style="dashed" color="#aaaaaa" bgcolor="#fffff8" + bB [label="observe()\n subscribes to flows\n and external events\nreduce(state, action)" fillcolor="#fff9db" color="#d6ae00"] + } + sfB [label="StateFlow" shape=cylinder fillcolor="#dae8fc" color="#6c8ebf"] + bB -> sfB [style=invis] + } + + subgraph cluster_C { + label="Module C" + style="rounded" bgcolor="#fafafa" color="#999999" penwidth=1.5 + subgraph cluster_C_beh { + label="Behaviours" style="dashed" color="#aaaaaa" bgcolor="#fffff8" + bC [label="observe()\n subscribes to flows\n and external events\nreduce(state, action)" fillcolor="#fff9db" color="#d6ae00"] + } + sfC [label="StateFlow" shape=cylinder fillcolor="#dae8fc" color="#6c8ebf"] + bC -> sfC [style=invis] + } + + subgraph cluster_D { + label="Module D" + style="rounded" bgcolor="#fafafa" color="#999999" penwidth=1.5 + subgraph cluster_D_beh { + label="Behaviours" style="dashed" color="#aaaaaa" bgcolor="#fffff8" + bD [label="observe()\n subscribes to flows\n and external events\nreduce(state, action)" fillcolor="#fff9db" color="#d6ae00"] + } + sfD [label="StateFlow" shape=cylinder fillcolor="#dae8fc" color="#6c8ebf"] + bD -> sfD [style=invis] + } + + // Module A internal flow + ext -> bA1 [xlabel="subscribe" color="#6c8ebf" fontcolor="#6c8ebf" weight=1] + ext -> bA2 [color="#6c8ebf" weight=5] + ext -> bAmore [color="#6c8ebf" weight=1] + + bA1 -> foldA [xlabel="dispatch(action)" color="#d6ae00" fontcolor="#d6ae00"] + bA2 -> foldA [color="#d6ae00"] + bAmore -> foldA [color="#d6ae00"] + + foldA -> sfA [xlabel="new state" color="#82b366" fontcolor="#82b366"] + + // Within-module reaction -- routes through interior via centre behaviour + sfA -> bA2 [ + xlabel="collect() -> dispatch()" + style=dashed color="#888888" fontcolor="#888888" + constraint=false + ] + + // Cross-module fan-out + sfA -> bB [xlabel="collect()" style=dashed color="#b85450" fontcolor="#b85450" minlen=2 weight=1] + sfA -> bC [xlabel="collect()" style=dashed color="#b85450" fontcolor="#b85450" minlen=2 weight=5] + sfA -> bD [xlabel="collect()" style=dashed color="#b85450" fontcolor="#b85450" minlen=2 weight=1] +} diff --git a/server/architecture.svg b/server/architecture.svg new file mode 100644 index 0000000000..5318860812 --- /dev/null +++ b/server/architecture.svg @@ -0,0 +1,238 @@ + + + + + + +module_architecture + + +cluster_A + +Module A + + +cluster_A_beh + +Behaviours + + +cluster_B + +Module B + + +cluster_B_beh + +Behaviours + + +cluster_C + +Module C + + +cluster_C_beh + +Behaviours + + +cluster_D + +Module D + + +cluster_D_beh + +Behaviours + + + +ext + +external events +(packets · messages) + + + +bA1 + +BehaviourA1 +──────────────────── +observe(receiver) +  subscribes to flows +  calls dispatch() +──────────────────── +reduce(state, action) +  pure: State → State + + + +ext->bA1 + + +subscribe + + + +bA2 + +BehaviourA2 +──────────────────── +observe(receiver) +  ... +──────────────────── +reduce(state, action) +  ... + + + +ext->bA2 + + + + + +bAmore + +··· +──────────────────── +observe(receiver) +  ... +──────────────────── +reduce(state, action) +  ... + + + +ext->bAmore + + + + + +foldA + +reduce fold +────────────────────────────────── +state = BehaviourA1.reduce(state, a) +state = BehaviourA2.reduce(state, a) +state = ··· + + + +bA1->foldA + + +dispatch(action) + + + +bA2->foldA + + + + + +bAmore->foldA + + + + + +sfA + + +StateFlow<StateA> + + + +foldA->sfA + + +new state + + + +sfA->bA2 + + +collect() -> dispatch() + + + +bB + +observe() +  subscribes to flows +  and external events +reduce(state, action) + + + +sfA->bB + + +collect() + + + +bC + +observe() +  subscribes to flows +  and external events +reduce(state, action) + + + +sfA->bC + + +collect() + + + +bD + +observe() +  subscribes to flows +  and external events +reduce(state, action) + + + +sfA->bD + + +collect() + + + +sfB + + +StateFlow<StateB> + + + + +sfC + + +StateFlow<StateC> + + + + +sfD + + +StateFlow<StateD> + + + + From fa46f7d5fb184c89a66d22d04328a2f919c7fd52 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 13 Apr 2026 21:54:54 +0200 Subject: [PATCH 057/350] Better readme --- server/README.md | 2 +- server/architecture.dot | 96 +++++++---- server/architecture.png | Bin 0 -> 145722 bytes server/architecture.svg | 366 ++++++++++++++++++++++------------------ 4 files changed, 265 insertions(+), 199 deletions(-) create mode 100644 server/architecture.png diff --git a/server/README.md b/server/README.md index 51d936fe97..dcc3593946 100644 --- a/server/README.md +++ b/server/README.md @@ -1,4 +1,4 @@ -for a# SlimeVR Server — Architecture +# SlimeVR Server — Architecture This document explains how the server is structured, why it's structured that way, and how to extend it correctly. diff --git a/server/architecture.dot b/server/architecture.dot index f958e58ce0..fa6c53d9ee 100644 --- a/server/architecture.dot +++ b/server/architecture.dot @@ -1,5 +1,6 @@ digraph module_architecture { rankdir=TB + splines=false nodesep=0.8 ranksep=0.8 forcelabels=true @@ -20,7 +21,7 @@ digraph module_architecture { label="Behaviours" style="dashed" color="#aaaaaa" bgcolor="#fffff8" - bA1 [label="BehaviourA1\n────────────────────\nobserve(receiver)\n subscribes to flows\n calls dispatch()\n────────────────────\nreduce(state, action)\n pure: State → State" + bA1 [label="BehaviourA1\n────────────────────\nobserve(receiver)\n subscribes to flows\n calls dispatch()\n────────────────────\nreduce(state, action)\n pure: State -> State" fillcolor="#fff9db" color="#d6ae00"] bA2 [label="BehaviourA2\n────────────────────\nobserve(receiver)\n ...\n────────────────────\nreduce(state, action)\n ..." @@ -39,61 +40,84 @@ digraph module_architecture { label="StateFlow" shape=cylinder fillcolor="#dae8fc" color="#6c8ebf" ] + } subgraph cluster_B { label="Module B" style="rounded" bgcolor="#fafafa" color="#999999" penwidth=1.5 + subgraph cluster_B_beh { - label="Behaviours" style="dashed" color="#aaaaaa" bgcolor="#fffff8" - bB [label="observe()\n subscribes to flows\n and external events\nreduce(state, action)" fillcolor="#fff9db" color="#d6ae00"] + label="Behaviours" + style="dashed" color="#aaaaaa" bgcolor="#fffff8" + + bBmore [label="···\n────────────────────\nobserve(receiver)\n ...\n────────────────────\nreduce(state, action)\n ..." + fillcolor="#fff9db" color="#d6ae00"] } - sfB [label="StateFlow" shape=cylinder fillcolor="#dae8fc" color="#6c8ebf"] - bB -> sfB [style=invis] + + foldB [ + label="reduce fold" + fillcolor="#d5e8d4" color="#82b366" + ] + + sfB [ + label="StateFlow" + shape=cylinder fillcolor="#dae8fc" color="#6c8ebf" + ] } subgraph cluster_C { label="Module C" style="rounded" bgcolor="#fafafa" color="#999999" penwidth=1.5 + subgraph cluster_C_beh { - label="Behaviours" style="dashed" color="#aaaaaa" bgcolor="#fffff8" - bC [label="observe()\n subscribes to flows\n and external events\nreduce(state, action)" fillcolor="#fff9db" color="#d6ae00"] - } - sfC [label="StateFlow" shape=cylinder fillcolor="#dae8fc" color="#6c8ebf"] - bC -> sfC [style=invis] - } + label="Behaviours" + style="dashed" color="#aaaaaa" bgcolor="#fffff8" - subgraph cluster_D { - label="Module D" - style="rounded" bgcolor="#fafafa" color="#999999" penwidth=1.5 - subgraph cluster_D_beh { - label="Behaviours" style="dashed" color="#aaaaaa" bgcolor="#fffff8" - bD [label="observe()\n subscribes to flows\n and external events\nreduce(state, action)" fillcolor="#fff9db" color="#d6ae00"] + bCmore [label="···\n────────────────────\nobserve(receiver)\n ...\n────────────────────\nreduce(state, action)\n ..." + fillcolor="#fff9db" color="#d6ae00"] } - sfD [label="StateFlow" shape=cylinder fillcolor="#dae8fc" color="#6c8ebf"] - bD -> sfD [style=invis] + + foldC [ + label="reduce fold" + fillcolor="#d5e8d4" color="#82b366" + ] + + sfC [ + label="StateFlow" + shape=cylinder fillcolor="#dae8fc" color="#6c8ebf" + ] + } - // Module A internal flow - ext -> bA1 [xlabel="subscribe" color="#6c8ebf" fontcolor="#6c8ebf" weight=1] - ext -> bA2 [color="#6c8ebf" weight=5] - ext -> bAmore [color="#6c8ebf" weight=1] + // ext -> all modules + ext -> bA1 [color="#6c8ebf" fontcolor="#6c8ebf" weight=1] + ext -> bA2 [color="#6c8ebf" weight=1] + ext -> bAmore [color="#6c8ebf" weight=1] + ext -> bBmore [color="#6c8ebf" weight=1] + ext -> bCmore [color="#6c8ebf" weight=1] + // Module A internal flow bA1 -> foldA [xlabel="dispatch(action)" color="#d6ae00" fontcolor="#d6ae00"] - bA2 -> foldA [color="#d6ae00"] - bAmore -> foldA [color="#d6ae00"] - + bA2 -> foldA [xlabel="dispatch(action)" color="#d6ae00" fontcolor="#d6ae00"] + bAmore -> foldA [xlabel="dispatch(action)" color="#d6ae00" fontcolor="#d6ae00"] foldA -> sfA [xlabel="new state" color="#82b366" fontcolor="#82b366"] - // Within-module reaction -- routes through interior via centre behaviour - sfA -> bA2 [ - xlabel="collect() -> dispatch()" - style=dashed color="#888888" fontcolor="#888888" - constraint=false - ] + // Module B internal flow + bBmore -> foldB [xlabel="dispatch(action)" color="#d6ae00" fontcolor="#d6ae00"] + foldB -> sfB [color="#82b366"] + + // Module C internal flow + bCmore -> foldC [xlabel="dispatch(action)" color="#d6ae00" fontcolor="#d6ae00"] + foldC -> sfC [color="#82b366"] + // Within-module feedback (gray) + bA1 -> sfA [xlabel="collect()" dir=back style=dashed color="#888888" fontcolor="#888888" constraint=true] + bBmore -> sfB [xlabel="collect()" dir=back style=dashed color="#888888" fontcolor="#888888" constraint=true] + bCmore -> sfC [xlabel="collect()" dir=back style=dashed color="#888888" fontcolor="#888888" constraint=true] + - // Cross-module fan-out - sfA -> bB [xlabel="collect()" style=dashed color="#b85450" fontcolor="#b85450" minlen=2 weight=1] - sfA -> bC [xlabel="collect()" style=dashed color="#b85450" fontcolor="#b85450" minlen=2 weight=5] - sfA -> bD [xlabel="collect()" style=dashed color="#b85450" fontcolor="#b85450" minlen=2 weight=1] + // Cross-module: routed via waypoints at rank 4 — exits downward, travels below all modules + bBmore -> sfA [xlabel="collect()" dir=back style=dashed color="#FF0000" fontcolor="#FF0000" constraint=false] + bCmore -> sfA [xlabel="collect()" dir=back style=dashed color="#FF0000" fontcolor="#FF0000" constraint=false] + bCmore -> sfB [xlabel="collect()" dir=back style=dashed color="#FF0000" fontcolor="#FF0000" constraint=false] } diff --git a/server/architecture.png b/server/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..c3368f27ff7eae7182f1650ea2399e2fed5b7085 GIT binary patch literal 145722 zcmY(qbzGHSusw_-B7(GZC@mZ*>HI3)(%p@8H!4VnfPl2LbR6lDZjg{VbRCeAZg}@W z?!CYF@elYQ>}U4unOSRQuM_fCUgF7PlE+9$NKd3BMU{|{9%>*VA!9sx2!0|wQGgD< zJT#J#5JkE{{QK6J7mI}S3`t7#wTgSn)~uVej`@e{z1_o1;t0=#mogRBQaj~O>E&hb zKab#$ocs^>&ALs#jK=x>zWaR@e~k?ZAEla1l|6VAa44VTRlP#hQ{|Z;{wizZRkN@O z({Zkbqltcxrx1L-JIf0`2cL^}F z&o}tL|HZ*cq$(V~cl-A%TX>OlMCsdX0~#C-tre0GDZZm6mCxm(p`z@Z4*ZGIcoDP; zHBoK0Ek;~&GgVsslf^8)di^F`i$ApGYocI=D6(H=7aZ4fqHfI`ZDt)CWeXMBJ@!-G&M=3|bSjD4 zt*p^0^QFqCdvo5tKT$3wpCjY1yC!aUJ6d?hBZ_*T!wZY`j@1mwlobc{XtL!p&2p`Q z<&J>akH6Wk?UH|goHa|Wv8j%_>9=Atu8ttYHyb^DO7B|#bkqU%Lu-IuC&H-wYq^#l zd86%-1cE&(N$ylqkhEvfvJp4@TzU^X z@Lnv1JScaM5oMR6N~H31)}&(7qq)*22usAsdlEGo*k#kUS+(de)y;Fh>M-@UZreSD zWsWOZj%6zH*024xj2gF_G)q)rj1a~*=D{}P!8WDAHU-}>V4J#g^xCt&VIUJ;6r%gN z`eV^?(w?tKq8aAEYs0hMWec;fk1R2U8bLRtpS;)bzWkG7&oQXMBTqv@PZog}LiJP$7Xrm1_sX3NBbY{i7Hd(b&%+u`%7%ja#E&#@acuiAtZB|{K) zb#Hz9D2A}hBrv2i<}fON_^~Jvg`igEgDecxOpJ5t2c1UQkwOvN$#U*Gt_`-R#I~pu z)yQ^%$WeU`KRC)4oWm37=o&xxiD!>)?A@pHbpBo`nPR{r{tTa%$8|=XN1X9!Y5D6$ zRqVD)Osq$dw?mB6p_gwJdeCDQ#3@G;i`~WfJ*9jOkq?jjuOA^_v(EY(J7Wb99ZC>A z4&Xh8%x;v$EIKZ@PXG1LG4UjGPjO|R!DJW2WS^CnCGoWy4{gT{e@GqB%JycFA*+Sq z`t8^9a-s}LGQYna^C$_U(j`M;Qjy7-GZ_2c%=_N*p^mT0GzU54aRyZDq zcdV0BWZCh^&i^I4YBQ`0>Ll6^lA>tzeBNeCPe1x;Xp;`MNyFrHSx?^<@;j4z5MCk5 zE@r>A9w(b+jDgyEifkAt(FW`=@Rt8NBTFyFYx}rjcE!A&q2E>c^LFcI6{+_FD#T;X z@)UCW&FHf@`Bxky8RqukRdYx-KKCM>Q6Ev9(7DG?aO!1~HR96Jzw&Re=5|d!qR}Sw z$Twl^kbKtq*$=~ywcmoZw@g^{Mlx7K04-eqo@z|;YmbZs4-|?}r&kO;eLpjo1|5JIo?_2t@LMsh{N3f$!Lrc>;y%c28+c;rhb zNmepb6OR4`$>8fnY~;4r4=;_gl`OZ^&^tVczYw}JF#Ub2wvZ#*7JeH?vWbrgZqHL4 zMa?*$P|Cg^4dhdn^ ztbQK&1|#yp1|>!FbcZ#U;^)=ehF>C@O+ja3iTzB61!Q<&&4n^W&bn%X@=H>J$ z$V@P7H{Ofs!6#A5WvMa_UOhckTw8e!Z+|F+P zuXIQAQa^D%Lgk%gdm{rsRb~9}7NhXDtYKu(lED1%8(O?PUA&<%GQ5b6jXzkwe16z( z5M#v1=7LJ1l5qB&aZgb+GDeqMKe<%|_RGPP+A!KH6A&B_%B6FaELFYKm;ZL;ze z+z@Y(8S_<=;+ciPnwA0_qC;;V0aK;)=uE{l`MNX3_|BLXkL#-KX>+R&e-& zs}+$DA{-KDRmyISF2W}YD~Ec#-$uQ#+#$O6?Z;0EF)2)RX{YY}It+ORs;=+{tz>_Q zR^Faq>-uCNsU(<|TNw)V6&wKHKBULLU5oEuIM+X9T)hbII6LPN=W&;I9XV-M@3e{C6%U;$iekTbAS| z#>!Z7K|y6-`@cLr)6hth_}w62t@h-KmR*}R=Eby^xP|RzUZruc235;PBg!W(pHiUT zk@BRKZ*6M1+h2IBbdDt-d;40ho@*t>XK4|zyGr!OU-e@mK(6p*2FpT4**l_3438;y zR^Lvr62yk&sMZ$czs#H@_Fnjc^x@P;nSVx_fm?)hM4YnEl znmGyc=p))-Da)D*lT6RS#_nzHPvN~wqwsZNjJ@?WuBxrwwq@6{?7Cpu-yJ;L=PR|r zDM{`EJ#*s}u3NiQ*r8=chC=FT)$cROs)vVfaTD_{u=7+U{5}V9Qy7{%3D*j77jD88 z1z8lF=!3g|Nac-?S!|li7^8|@V>1yF*l*MxFbo_Jk;@@g7&N*w;>S8An$x^f@fOc7 z1yI5eE2yT0Np*wAF{%C(Odj+kq66` zr{Af*4#d7jeZ$Az3LTZg;O@kjhgvXHcV9S;xCjojhFa??e6VY|g80q?s=KJwqEWU? zo1Sa&Cyg|y{Hj^+EaVdR>1%(1nrJ)GhDws)OF`@DQE~RTrBElh({L|)|9L$P;uJ>j zBBH_UWvcIv?&g8OBuKd>s;SY|YOeXW!U^*A_Z)WIzL zpTAc6S6K2)Kn-*gYKSs1=qgSItkhGt3$KQJ9!Clyt4Z^3fA31~4QATOmo?+}IP{#sHo4(RE3L zqu6Ecl28i3Kr73RUAt+u!|JEXq<3PXgCrlyADsw~in8Lb>Z*^IyEg@5-~mgcWAFS! ze{^_^d41}Pjt++(oI4ekYeK{&aB&jVLPTS_xG1t~ZQgIPl$c%ZD`ipNT8WYO&q}@D zS3{IbixCn$c48=no4pZ{)Kres*&m9%VD5%+oD!bWk4)ogMC4M4<1k~r>nu#nq>)bq z96p=7x$Fg2qURSv*!g0DyDd%ct4$2Gt(G193N-7aLYs3`Z}fY& zI3+l%T0cc7Y+`u@t?x5GFY*|{-uz$+N%kjx0ZJ&@u6QF7144;C&J5L+6dHE zrpk<~6zW93`jgRo{UM2Jvt4~OEmd41hSzGbL14~(j*0>{(ut=HL+k@QuRh<^fRj2hu4E>QCuo@ zy-jsB`|A-UUze^E`YJ_blhwiWo6p*Fm!9#e?zM-PT4`SA<#<))K79}8+zx&h>3!)c z_{7_=yH;o^^oz%beusyizogXrc-P~AbfRI!ms`)}p8o0B1$|>`57~tYW!O%MtI+-q zsm1q~CIgw?LdSEY8(Tc=#c;7)+p7X;_E}8d>xo91*cVyLh|1HAaYvbu+^w`xQ+=M7 zG?Wn(iYeel;D?EMbR8v>0YiMx>Vw=}%}G@`T^iuq>d~?^NE$cieeO3B!fo_I_ISWe zr&n#>`^el5unXZqQs#P+_e0mC)riyb627)W!aSs*Yhw4&jGddeSC9~q~z7hHh z@$)pnyJ{4qhcq9+Z+hU($GU0rvpp4Y?@s$A?D!4&-7XjOYmXW!SEo|91|F?T(y!<WYVvn-Me{o zB3K?l&&7R^yJ@=;<#yw#H54J6`rBx2#3c+6z`esr`Tuo>`XC}8-xxqTMUo@S3QE2} zN@43fPIDqBS09k&yiU*Ed}X&|R!vOd`%|Uf$?yFFPoR8tZ|*Ce-nbv(uAKkDrT#9kMg`74t~D;v#C*8K&JGnMy*7z?-J$rxiJZ#LzRccweno(%0bre6|-bvSAryYwd=j2p8j>N$8FFc#2Vr*0hFTM)K=RD^oH z?`2!FB%sF9o0Z$$n**P?kNp@W_sfL|otMspjvwC1)!ymHRll0Gz-0PJ2SzyDq)UjH zyLnUO@;vXR<-)cpDgCmmdh)P!KG)fgw^3~(&ahg`-NHZ622ogX%1FrnLzKL5P6Da2 z^70^bH8rE;rQ`$0^MH@{2%f_9_7C>Y;^=3DYMLF4Fr%&_>U&ri_-rojcMmYR|F%gS zU^(FbB9B~Vx(xOH))KP{e_L7R z+6zl8Qul&HzIa)>a9%yzd1O}~VOlu;_lU*&qn-C|-q!(=_Xc#|iy2nH2Bv1l`p%!` zARqxe$Q#6Uh&n=6?O?~k`4nZ+=6q{F)}e!w`*X7X&ji~WY;v2RV9{2Now*iu;S0qy z_mxs48ayo$T{d{VOYBM@;ixHP7Kc{n*gWMitKl-zteo; zrsL-9c%eKsXwookeDCkIcFE#K7p2LV@Ebon?jfPGN1T;1Dkc5TxRO78I@q83HEHa8 z+e@&0P0K+v1O0}J79T;keIQc-PNl1SYHQ}GtBWmBcE~Bl*Sw0Se#;N zfk6-H8cV@O<0h`iTYfV5Bl|RL)R8K9@W<|1MWOse6+YJ7Iq&du*h^w8sdJ|_3;RTQ zeR-lh7nP#-ly=Jb7zIs2Un}Mo?Pm<7^YVBYZkw=uym@woTJ>*0C491>$eaa&07Q&4j$T6ss+3Vg*oU+`h?iTf8Fa0h!Se_ z>>sK+X|R%YZL1)DMb$Ph?&stf92FO=5e*HxTLgk1vh0jtRf&v2v*N5|+WF$X{oP=poI zcHrlgl0?Onx*hbS@7b$~xmlPdwB~*E;_Ixee$SS2h7aWotDh*cvJjRAfB45l`wioA z5;Y)Zb>|Whk?fmS@RRFLw0%Q{tA8rRB@($3iVNYW^JhuJY$i!#EV@!{XPakHext_c zS}wPxG*%sNV00>V_yU z-wSl{D{8R*Z%h|VMa8W)+e)JU|09Gp@>b|d7n{A(&6H526Vl-T)*-0?;HQEtiQ7sQyEsX95hb{2E1j( zz!Oc*7eN};4JoVW?6s={rGCGk_lO6gdi3QyL68Jo%qY3b%f;sF=*_vBB-#V^8iNi2 z-+jvYBn>Wt$4bg3IRFqc;;#_AD7of}MgM#Z4R$k|D!dBF5~T32wJe5~14Ue4Of145w|bKz?WwU_;iP)ffcw9TgJa>`eU%Y0LOu^zI@`S>@- zL+Qo?OZU}OO!q$dog(deZ{}Pscg^`|u_G6G&C*V8xl)`S+{o{&^vZxPL5uGpub)&j z@G5xP4gP`RT8(Phe!2DlN7XS2x#{@y>5MW&x16MK;JBi%+SW7DnXyAzTl=iXZfA9F ze!gSqdXpcokMewzGPuL&a>)&=tk@$MQ9LhCFXorr+^(ICQr!hQ`AeDu8u_dDWBG6T z(Y@+wu4LwydL(xJrhJZ%oE=6h315|FhEm?F>FF?YBl%s8&Uzm<`gJ{3pZ|GK&wm!h zzq2+We0A^Sa){-gy}3INU|9btd_++VJ6+Ff7I3&gzqyt}Xw z?R$NY&}{wk^MCF=WFN`Bd$ z8an8p3B~CA^GBPvcwsn#**+MiBOCwUESpF_B*H;4H21 zn9I$$T2n()+2DWb=v{^n+E$kNdc1%o@rAT6iFYN&0m`N<)V%*R!K)$B`#fy^C%n&0 zwe76oDwT4fME}~t=q&kWM@{HDvGlDL7iCbJRGY_U?P-MF5~$xP8t~|goA&F@S??1G zw{nvy#u;LY_)@C{<@5`9!d7YHHmhCpF_l^gZ`AJfX)~zWczsPb+okq%pGx7#loyc1 zCWsnm%22(~Dca8IVzb=FR~Tls0atSELMHc;J&)$w%HUEzco!g6$s?|jwVQ-DQgYF8 z^}c)QdCq?CIp47u08^1TQcn1+2qu;t&3_I1KKMFQuCR9JGyR)G)EWEHdS1UP((Xgk zy*=0sn!ef~S{H|IVwAd2b4>i2Po8%gyaLqL@mzVm>V^l&sw{bk%wOXZq`rk9W}B}; z16D?DC0wB1NPkcirr2dta20IMd|W@v=1_0uAlNvYir41)b0M zTrA&UKYmEJ19h8l-QP#g|6agrnT9ogOi;%c`Bu9b1A5kxcL{()0+SPZz0@bK;n!2) z#53dgUXn&&!&ndS`mfdBk_wr%S|)^U9Ig+Wn+@&1*^QhClW)1AVg}(M1vy=M^OMO_ zMgPr?@?r1dOmvYy{wkh@1J*D5?%LDtQ^ZAzUZC&oo%iZsPasY9zVA(s!uN*u&RGdC zwAZOP9}B&mXYtnmYo&3>xX>qxw11|p+18=nr*HO8`I)>yO+@6g`cyU-&z2&YlsZ-V z-d`+AU(^dyH-|1=Kf{v@BvemaofEg+XD-fXDn1LwH{FEI*Mdz)aYJS16IBTiKo72# z+Zz~xN9NAPW$qEJZzdiJd28~{+>BvfuhzSLPEHtN@x2rT-KY^J$2MX7`(kFhZT?1l{6~^iP4svFx%D%GtW^-RQy1g3=?D0#7xI^&hyEbi@C^@Pg`e?S|0Drc(!u=Nhmh?)|?>n@Fzf$`d&)VJDi~xoaNbF85rrsEjRbBz2q_E=c=6w__g!5Xip#5A7Zy(JicTGq;+2?Be1&7#SMwW8!t272BFC|J2U( z1qVdf{Mk8t?;>cb$rRb`W({+0ZwCw6fIacMo=Ts;Uu{rEi|<3!^V4y4VG-CO9(uCO z{Gl1a@0Y8wlnsf3{MEP==;h{cHALz*7?mIjDb13D&`r;Y+~t;W^$p(5QKQ}8!(}Yo z#}C(cHy2C&>XH?F|E?v>_`Ozr!Xj|MBjSI>qUkzxa~ZSicfmwvHKo8ovN)ATY3gqO zewvnv&Z!rJEfI0Psn)4ILb=sVC%d)q>gI*BW`^&@4p_F1a@!DJxvS4l9EA~fbZ(?#HhN#xxi5l?`bbhj=uzD-v|p#JLfu!C6y zQ%Tdk*r|TyE;$3=1}Bp7X!$&k*#ZzU1=H};2xLA%&SX#1F5T=GG zNo#?4=vc`EgQe?MrKF3;3afjEbGEI*xSG-3v=3Zl$;9A*eB$Wr(nY0nL+ zH=QB-p6=fCPoEo=__Xl~T>tg)UdK!K>$wsJCDou@tJYnP6J*Bqs!Td4o?T8fwb3T%zshlPqmx^kq<7MZ)daD!QM`LW?B%bVCE=~9z&WA z+VhlrI>^ooU5s|s`;2s6_$5pjW<8{0u>3z-fVt7mz*!0RiMXSp zq8{$MewS4(3mL)Ky8EG4iuXR)rw9vpU_*QQdpmN(C6e}p_Ip_Vb{-Hw!O#P=+V7mn z5>YU>V12tc7w{Qx7-|m-WNprCC-X{@NnOXuvxIr7-itq(Tc9tCg(Ee7JKd&=)1}OC z0%z|0oFtA3ku3WacT>tf&)HBVaLaXaUHnNB(&Ev#MoMleeqeS!Syl~OCt?p%nHXa) z&5Su|Ue*HB0F9dOk?B{*rTzMEeYB3%o1JNsPmWUKZPMjNexmbZLMzKtH(3hST32Zi z6GT9#iKRsXZb^ONbWL^8yiis8c`ub-b=_mArm{d-_tc9x>AjUT0x(5)Jje;N{Qi;y z%mTsFf5&!Kvcc8F-(=L}?!Li=eV0m;nyr7cN#jK#ECE>id*Sx_(xNQi*CmLlEaJNP zZm1bMB8a^dd2x&#kyEquQ7d%6;r8zcW|8kvcvqn%w6$K8mgF&Tx7`g;(f?54BC8Y7xmtxo&Uza2%YF4AdI8fz7Zap_Eub7tXrl%)k7%6RpXcrRi z?v@;Tfu14C)^<5sPNMser`AG_Gv`bQF;H$OiWCx7u(LGeAX=*kJkzN#qab+NhB$x6 z(7W@0$Hj`fcA`L$z3~@2j1b>`F7&Yga}Z(#j`S#8%-y#O2a{|ktAstFTJOha#JCyE z+(9SPcW1UG6k3}u3}R>4ApB{VG;dxBMvyL^cvvuy3_Q?2u7Ym4+%Yi-A zV>*```w`|lhfcg?Sv`V(S$3ETkEBBwVb1oim2?yjpaKK^@s?43J}KH!h~C@o&<VHSVUwVCLVktx}FyfhsTkpC@>;z^hocHWoL?Q!yd} z^BH;!K>+lIRh-t1^0tg+@l?0Ofs6<`r&OQ&mSSseWm}-8-bE} zqg#R;?XfUEo?k+;@e^XYs?WhqOHHb4)^bQU2GDbWdqMMw56ln6JHJEr5Kf6_>2I@E z61=Sn|8wcZrUU=)Gu+Ju>Y>jvEE@W!Lg!CmC;RtCSm6IA@YHrrVP+GjL7GGeQ z_CSiZc6VJ4Ek4|o2wd}lXN~9X+Q=AOfq?dXf2)KU^9RP*zpN`;XR9!+WAD z7{vQQJd<4AO>K>*$T+=^8+FQleB}&7FNbkp+}=O+W8B>ziT@$SDEH8(OiuD7MiXV| z9yCxCyZ8IxV6To`ybCYd5?oe~cxR{k1YN9CVn(NKWM@=;1`ECDcOd;|(hBWeyoblj z=UHwXLTmFAY)~&k#PRS~J@T1{vaIwd5{c!rs6)G7JPx4ZsDAQmM(Dqx|B&)VdwV~f zaf}ftYW@*HesGHrvbq1RKg=S)_2_k#VHM?;xq2T`wB*ND9jv%j4+z5m?L?kJZCJmv z|1}%ve@CXlS_aq&qn-N&I%<%9^SuUZ2my2$=a!q%-6>W2rGYx=%BF3LN2@BJ)xlej z{%HpP;}v*J(6f$%qIl z_KUkxrpb~j?^HwoGj06d@ehn)PNH^OC~GN0SVgP#+EerBRTpA%Ws^Z7v;fOZ;@n2B zfa5eAqYxTe4~lq|wTd6?31?z@vUG9()~vrv7wF(Gi-5+Uhn4~Y?H?j>jLK<*N@Mr4 zOl3SWu!3gIoMoe-;xq(#ja|Zu1>u2IliLJVz0myR^tPB@vWmtemfNeKLFRwwx>5wH z>c$p&MZL@W;)@GnB4X;eKuA>Bk~d4oSt36Cg2OMY8$X~k)A`AR^*@w3=06qY-(oA1 zu)iWND(cN@VvqWxl)iR*sT~bXx=W>Zw91yp6eK3f#w=b~PmH*dd8(^E*P+z@a6Y%( z*&m%FeU>P@=)xZv|1uG%8M+4?jmNRYsj^2b9|m=;ll&c><(8iK$pB_42tCJrmfxmA zj56LF6ci@L#w?e!xS0nTnm6R5;7`ps_TX}pE3{4Tl z4WSYAe~r*ooJ5TXvPwhJ+E;&6e4CU|4T&mHqC9xqI9Wummw~I0JvqwPotsNRg7sw4 zSwo|?>^*1YCzZH!3_dCZIz*3(+}ZW?vV{SLbqsZ|!y>*hb zWq#o*d=cDj@-7>vzh3~!OYdU=<)p8^KmgFy;>)$Qk>T`A(l9bvl@q0?n1za7`JnvP zS6g=%hi1iiLNEQ?9|aAxj`Zq~{0aS@m@we{M3#dr*zuph2S|zd&izmQ6_J28>}-m7 z#nh{NXz&>j;p9r#QS|jMV+C$dJ{}gvH8SAo{%IflbMkbzoTcrxW=2qjH`Xc&#Tub$ z%&LPdtO@3YY;FC=bhAnCP4sD)dfZFqcEiy)!`n>Zzl&;3+En?`Y0h4tga9}7v=j>E zWddP=({I$qR*Z#3+T2k(jhv5BEqBpJrYN(ES-O9A6K!ztY>kIh(00Lrbc+rKZ(S`* zjkad5ju0~fg01lIKw>iDEI*S5*^o}$uX1$z6*_m~ruf~QqM!a55`j}= zV{IkzulwmVloDk`Y;Zt>#<3fX@K-(VtBy|BYOrZ}vDAzGG= zokU10JDd%d=;q|F#a9c+z4vWq8LTilAo3cy+-%N>lDw+=t3*M2xTHrDr{$`Hus5GBq=7snKZB5PT0+Wwg| zkiGe|8J>d>A}7gmvoky7(sbc`m`Fqs8VH;Wif+larHe zpg;i~)|1L*k>J$)!gmNn63vzVmkj+3`OZoH+tk)pTZUaN_eQey$=-YiVwSTl;x`P5 z(yz#ZS=Xa}2@+)j+1zzBD-aF(XDx;t6%o9mNI)*_`Y^Oi1r&R2rfqPCMc1jGL!L5C zvrbhsc3w~!>}kXqaJwkOu^&qiqj&8EwF2qjgD#`+PsU_kwJx8_OC%i9ud%p2- z`wOJUw(6V^lM?L8@)$n)>C*@l5(Gm-xfxZ#roTNj1~YGN{?+ok2qn(&VnqmbxVE;z zJy}-6ufDpRj$2XBtd(=aD>Dm@Ru)Q&Y(JiU)KYC}Rx)!2Y{Od(C_)Bm!O?5MAkEHs ztuklw5hKCBAA_Hm86mff>Wl0%Y}@#;=sXGZrL(RCjl{xuKylj|&0{(#Bt>!Dc%?92@FjG69iFS#H(m0;P0701(eVSPpg&7h$U+C!!?yp2Z?r zHPQ2@10|(7`)dOvVR&z_CUpS$@_5+Cq;9)mQ|{=?ifO}A9T2VzzumIfz%>O$$AW|1 z0Y(|lP7r~okS$-6{@bW?wQSK*!-033hrB8TCLi>q+Nt5I!(_2WF&&vJDOVwJn1}5X z<#^c~Ntzf@BQ)F)j_6Z&MRo#SwG^7pzF4qx8ZHO^Xn{R%Sdkff3YRxAX2mOghQfZEgyNd=^Mg9uM0l(T;?>qh-l~fZG+3`KA^iuml)}QW-ccAoxGZ1k zl(EXSnatA=kYXjHXCsA4E;Z*o)jWL+&)9Z+VyCC@#=UEOR-iyAF&RF}I z!UPS0o;?=xrI3|&RY9Z>lNb|r6wBatJQ~~=OFj5|?lBqyuc>(TGDVlsmO}7DL_;;G zUPIz(jvlkb>d2E)0KGCSaKe-tq{k*j^P6s`!>TUuZ55!*xi)Xk5-uYfGR!(<)%{OfPZ1}oylZisg>93R#)0ibVm=ud5&|}<8$>ueL?SoBBTRia@U%i zLG3C>7!iAg7o@~wsl@8^KtB3$agT|^XphENqPYwA1t8PW8o*d7foLO0%rg>&NUX4@ za`KQc+L!=iG$f zIl(O}il1hcWkpuD36+REb~O8+cEb$hV_%by-sGsm)O*3bjwg|B$YvIYE+)h2M`b!fp@*+K* zZV#Ezo^=1VC%3dV@lJA)tICsP;MRu($TsgUu5j+Q!QQ0%gT+kQ4L{#GHxNI^m9alD zMxCr`jyhuMa|fC!$U}W%HoU8)>qex83%R3*^obrobB$#q(Aq{CRrZ51A$hKbsCv}O zxrjK!oVC3Q`O4aQ3lya{0B;Z=%fbwb2SOqUp20iG*b1ji&M28RrJ=Gh7C$EDXS#tH zKoqRPz87}0d28e(u1MOw*BAUH$q~O2-$$UV9&PNLO)P`b*F$bixj2EvX~>mWv)!u& zs2doduz_J_v2GTEoEpJ30?(z*|6JmZn-qI>RVv1}Nx4nH`SA+W^Vf1%XkXq8z`<0kA2SiYxbb456} zzD7ug5f|ThcrLCc;@IUs-Ldx%fkPhKfyg+%NB`jguawk&iwRSUCnmR4Q)=0t2C%*8 zra+d>(=`CnBRcQ-CEe;IYj84&m*w_8_#{A?r$a{IYs6JWbi&=#vy|uFA%My+gq{(R z+=W}#JMikL9r{;oE%hZuSu`wQ`~!`D96wP4<}Ug|zq-E(x5Ak77gNMgK-{Q$J`}OG z`eSNrjFpu^wE9U8k*mtul7XXU{}iiilf*AIa6=xXrluYqAG5Qwzkv`?6NIJa&)yDq zZrI4mhw*-la+{&buGB2y*JZ7kx3gsoEy{{9+GLux%XNMC&kSeatbGn4QD5hr;$OJ; zO^C(9K4xnB=GvCIm%Pn7sqv;63_>x3kfXXp^zheFlM_b7tu{7<95q()kx4S$JKl3>vse?J^O_}FIH2x*PC?uopkE0LV% zdN6?@CoO}U^CEk?Af+o41d`pf$6;J`YG1@bU5A>gdSI%VWCMlh^AmyCeLTVhgHw?lDrm)|E3xK##Mw zrqZIZM@|%3XLwj=jZ3^TU#^~QOKXu(}&LB=?aF1i^M&dyoHD;T&o&Y zE92niKG=c|;f8gTX+Xxt$CcF8CBy?ezafM%ym+`h_^8uY)^ihmY2(iUR>%;TKs_Dq z0eJfLo~u+2+)JD&RFy3+AQ3`X7L#0_lF(TsU7ct6AT=E9cRZx$tp{L5F+0+dl?=<* z|2z;;sviuq;YQK{ zt+$wn&2uIza0NW1gNf+$c%9^BS`dH8Wi$QYKPf4+cM~NY@B_IMTg0ALOhIEX)zF;2 zje3qIwgiFn869PM$E&UyRd2{OZTuQ1Tp0{H!3)cChmjWKr>5IDXG3Ei$B!1)whie< zQT()K-;36|@56D|2`an zK&thtR4I%V(tUCkzZ)?*S^w`MP zk3R9*s;R(--)jF?HDT!702L>3H!~+Z5%dcvh>^9eGsy_N#}}bemg3uDpHfe0XQmHh zwbRiZ%z4)(auf4^)=-5*8)im2zBw-j^T7|aF!x7;pV(srzv zrcN#0DlT*9e-J=Oxou70gQ=6-uFQ4oRMIk>YKYHpbZ_8>(1M1$J^tznpi;}~kMah_ z1Sf)3Wli>a;SeD_&>_xTxxc!ANem`&XPSf2ta;~NhoPwh9e1nf zY;0^><%C|?fY~b!~(Ee_~3!RR_^dStP^*|grfK$GaceqhqwKvxn#1s^{4Gk%M%=H-1 zDQjnl(gp`AOk#3uvMtgdIa>;v%?|LI>JOtM>*j6^!Yf5=FHyqgcj0MXJI@nk9Tz_l zD(UJ9l26?R-uJmhRsh{)`S}R|uPW|i6%wvO3+O1LWYWEbbR*>+UU#o?0qX0xyjkeL zYqo2$0F8JY|B8h~X8$5jYtr{yI(mG9{lM8lxkfk(t-LBL(Mr?B4mEB_nd`Pz{7Y>! z=WM*&dJ}$QBmBQaWqkapqMAW}nHd7t**!*;$^;I7iTtRnR8&@Ot8>}RrA|Otp27{J zc~H~$FE7q{#{@%J+6ECVhqk(88u&Cq19VQ3wKo-u@>`a0ck*EJ&^h8hBgsjnjK~~t zw;hkVxK?YzYQ3u98O=-XJSa3)g2LlL{p?@__ndntePepj;(q~PWp`f%Ze(gh@Ubui zceU}s(W_1srFv~{PT$zr7#<#u9o7Lv8RA_8IQ}5swp{r&H8nw2DQRhi{^sKrdXU*1 zMCB-ubwkm#_A#fiF%^~48ymdkXhJ_~l-DaA6wB_Z?UUvkLE*C=1*FrdAzyJZv5xi% z8(7s|;*j^l_b|KWRB)QsTF149Z4aAHJ6QIIqkO--D{XY`J-KP87ivip^rEvZ)o)H? z@jL4qjoDVYyqdoeyg1%A9!TQ!K1iPDfOJhk!2JB%?9iG|Blze^EBF|ddh~$Dkr)Ir z^ai;rg}7cD^=uYzzbvq>$u70k%n~c)hpCb={y_Ux1%RYH9!t~)Y*THpk!tkSv zK-u1_TITDoa9()7VAe#>E4WZjpqO^-i{b*)HCD>=2RwiHv$d;033POHe1w5vvi>XM z91~vWh(2_%neQD*L#U`eAMF2$AolzXL{*i+veH=P6LxB_bWUqmSJ1CtZ^_8W4#@|} z6_u1)^Of_uySu-Ymn+UMK6*Z#U$ui}#~}@SuTV}759h9V) zmn==EnLBeav@*^o6z&Tj)Vzj9MwE_sX8R5|M(y&i_m;bO?HAtfR5mvHy!+%j+S}K6 zwirZdVPUb*9ZlVEeZEOVO1k+nZIG_?ds5AcQDhFj)1s2#+@P8)x3fc|l{E@XF~ci#U0iLTGq6 z4GT-umoNCOZEfXc7V|JW4<1eSj$R~s|ND_NYsz}YQi}d0$T=hmUdWzjQ;W-1A?4B0 zO(IXHUJ@r|}bI7BcLszbgf>$t78C26-=( zgOd#{zL`vawmq%ahFVLIP+oa|M9oBYaABQrv*7L+8Ns!)vjfCUQc5aAAzkPkb0cYF zWMs10PuRu9Ws)SHh-fV&s9N|1oEbMkrTve8$E23pF#XTy>*w<1p6J}q+qd`j_cPsg z=X7f9KLi9kz{SIpl9r~TruJ{~M|C@Oit(GLEKp(m=rkt)&;X-@a*wk>=Hh&i3_@35i zZSXfRGYRDf$JM{Gui{=l(=?pa?lH*0Zl1_WCihF74eNl#oH?)dV0th|mzJ^+6BC=5 zn^$i>=mW7E5)py&^Y`yKfI-GKHc#F|vz{m#mf(NUU6FKT1E;VhkQ7u6eTwrs@^H_s zee(NqF)P@fkdu?s`SG^Q+qc+zdwV9PrsYm>`QF~%byzjaW3eun#j(~^2KJjD*~J%a zu-;HW0+AMWcIFsc9)TdS$&iSmc<}IHg;B?2J&)C=YDF3?KRcv9Ym#c1Dn%Erqez)((`27XFS&^h=}e=^&GPk*kzmgaS_S|4Ctk}@(P`6?r&`obSSmiS(OhNcqZ zJ#=R)X{NXIh-;^RT5hXP@b1JEcgaR({u%v80h_ndesrP<#+H^ne;3<8U@n0#WWv5F zfBsknVo}Z+#z447DIY%l`d~-H*`L_*gprll{K3*)000$4xKXIy9VyTa%d1bQP_;6?TB7S=7acJ*5o_MV{)<(});qM&OwOPUbeeRQ$ zvu+b1$E0e?D{_Wa(J8B!E|&K^f8a6who9#8v|x_XYv*j#rV=$sq|~CnDSeGF-+No; zZ9kMWfCAIgNg$j+ZFX>W9&?i%Xz%QdiH{GAj>c&Ve9|^BuwCc!+SCT{l-(w-dQti6|2n~>Q1Z*UclPMlbaJ#Qu={9?JZ** zNVBE*hj?-kPl6vQ9jtuWxm<2}6nNnjMpCz z>K7SLMRLSAF%gk3wtQ{cAvdeXcs@;vt}H^4NYvki2R~i?k81e1&psE&4hui;>FDahwt4B?G7-M)+fngjNW=kouRGqK_{!s8 zfIE@RxKnkPo}S*=&aPCZM2E|Eh6m%x6DfK5hu?sUGBc^pPj>w}qTU14{vq{o_44c> zJ2%(J)U<;-O5i?+4YBK)zNRK|M@I+fqvsAaApiqS%*@y=M`&iVAIr$d0GtHu2nqCC z^?%Mdbz4?N01eAz=D?H{Up(zkr3kHt<}``mX0|CK z0W>Zl3aOWJli0*8{7`&Qb@4?orY;+Bj^{XO7QooQpip=GpmFiDUg@;yor0U1`1sFpWB z=-et%So;5TgwAewH$RVVF)Wd#SpOtV%S2N`%gqhaj|_AikFcKA`Wv|!x*cdA~Qjj&L6V2Byqep}gjh_fcTv}XIq|xZ;>5rdsJ6BiP{eiqY_sVKVf+dDtHo`8 z5FkuyMbD_IkwL@&I_q&bERmyNjjB~I{6#+=Bzzr&pTGVc1;g(8diNC6Jinx*v$ge& zTO&9|fM=e^Tlw$0e*OA9Hm34J%Le*DT1jc->9(G{{pH^0i!Wck5E!#@aERt8RelX9 zheF%N#_;v^^@XlamkD5JSMVCe1Uv{ZYh`Us@5t9&p=^xX!Wrs|2km2HQ$moeLk3E3 z9oS9FpswcGnHGVK%%Zger}9HLYQ~~>B~`eYFNsL=YwTo^UZ-&tO~<}g^L=_Efx2Wc z>w>yf3J39pbol2tI$mtmMcV(z)>p=5xqaKJsEDKjA_yWOBGTO`Eg=$;(kUq+9V*== zU6P7)H%NDPOLuqQ`MUT2oO{l_ANcM4Vej{Sp0(zhbBr;^TyJKFzt>&Mr;ue!2=;!j z+$e5b__JXwBp5{IbWX&;x~iPP(4D~7Jyqp!ot}log!OwVBrNOok?Wr$A`~*J^tH9g zAoDd&Og(#RXx%m~iyIG)i5aK%m_A!h4jd``5V~2Wv>fQ|jZ$TQK+4J*uH76^ zRB_X{`c%ODQy=jZC*X{JDZWYS9W+UmKo^i50KKg|rQL}c+Qii5490@s{|d-fR$aY& z`tp!%vP>J|{rmvNEG9kE=0$XL$brK;9t{S=g*+`6*SWnW#R2k47_8$%o@__VEu3rEpx~hrMX7jz4mexqI$s>fix4+zrgNwV< z!PLFX0?YgR_e(gQl$4Z^Sox=CmJ1^;-@bo+S|4+?S+TX-$u_M47%n5Hy6yB}O{vHb zSBmd~nZRGz%-nlXxq$E6P2ayegQsSDT|hwK@@)EQc446rU^B22t&`n_$f&3tKn81V zYH!N9&(;fW-n`jUVy13?wCOZh@Y(|n|7&XMQ;@zIhKC~n>to~KxYg8LvCKvBI&nUI z`qXM`D()VcFumabC7_bjimM?NGk`W-^_YBqF)@!o&V5Eodc(oNfkG;NB6Hmz3M)2& zzsGagwB`G(5m)|%93fCjKCbjVokUCP`d*skk(F0gS+smdJ(E{R*cmsuZt`TOE! z#z8b9P8=>SuAwXyWM&hU0w7a*U9ss)E!p!M8(9vl6=Oq%CavAwfspALjYsG=tIte_ zGL>A5idc{_9}h@U-9OdU(D<%m-V?9aTT*iQ_FNb?Ojb#$)nTj3Q!G^rg0%8*%*c<3 zEBtjR#ZE0U@yFfy*HsNqvj?It68$g`bcbenwBT8WLQYeAyD!k~*}1vrFJ9D7PbZ*1 zV*N8Wm;8cDv+ic~@hmDeHFZl{+iZh3wjTkDPXu6bA8Q(F>f_V390n#PGCsb9WRXC| zPpfijjysJ;O?$iTbp1dSfOa9+EF^~1(&G}P+RMw!^~s6|*v+?UYF)j_A{=(Ri$My+lkb?-ahE7jWap}FJDthL(j3%ONkE5|9_^odN2~TQA z>zVfY`t`=OiOI~Sh^;s0X<_()^C$n=rNd#F#`y;8a(Xl@5g5`#6f*PT{=4jSkMlHOVhc2maKfl$?1a6k7Crl zySLQ+BR6-uc&w6ujEoGHUzywG?Q-)1?~(=az~jQD%iN66%k4$uk=!qEpc&!Q0nX%< znrUlWa?SY24*|zbad*e!x57s}(9Fz?n}>(m!6gENLkcUV=HcXw2ABagmXd}>C`*2B zX~|~rG?4F-{b<^SProa+wy!S)@`9MSIQ$P~TA$ifZ6+Hd8MH;k#OfLwZ%Ii>{rdF_ z(qZlR_}Q6g);{46B15jN__lCuTpHBSXlVY&kxPd-+$-epmAd2 zoT55?y7G9|w*zp@E7#kq2bbTT%fd&k1R)=MC{*s-rSg`dniaI~4>`gzQvoo_AM^uV zEEVmx74yz(q0EW6F9lWj-Q9QB`jA;rd*k<)^_tc7kF<7*b$C_nLQs$@b=rBIxqLf8 z6zUFsACv6=yf^r<-^$t>y-Pcg;|c(us%TlF>=u%czLg9#Esit&v;-0Okk6SL&|;=y z-q+>j<&1b;HkV7sDf_i7z2Tv3)pl6NW?;b|fuyM>FzI7t$Y=FcO_iF@z0>cMS5PPb z8770*Wn;V~r;+Kzv;mf~MOKPu&{(K-u`}ib66r{_lS77S#vTyLkB93@pi#WYxIY%$ z?$kxx?KG(_2cb4 z5Vc)SuECSo8KUfNPW36(wDTEF4|7YY;UJZKzFHMZZ%v?lHZ>Q%KFb>)LELqoz1qe7a)IDo~~vf{>s3eaO0eYflRm zh2Ki-0G(iReXL>K!Vmr3{;s8l@;A)M_gO&)=tMCOMnFWaIgfVFiD5Yk*llim-_?3A ziEP`lV9|ZFO0k&CpKdr_&q}cWP}bs&K(mgCsi0is3!qu$?~-cb+w@i5V}Ljf9HH;o z?|a(y0VQHqz=|D#ElR|&qFY+-y||4N8WM6X8olc}I~Nx=K7K14S$3OE!pDyvr=+BO ze@h8y&s5&J&J6`1tgYQzG+oVkNKVZqL6$ry!?&K$(P0{J2L6%9rnX$_(rx?p9D)W> z#R~K>iCxYaR7y-2;Gi-7p%g@>xR~+J%QuW>mQhj~wXgNDmby=kP*j$g3zf4>U5=B} z8wZAswk=Ea4)5^QofFbCFTX#lw|TcjH7`LFpD zTAh-S$fd_>ImV+HG(Y2Mlkxn^;s+g=5ZDhg;^}RUd-J-k`L6q;($dlZQ433N%nY1pFtaf;#H{PBWcC`mw=K8dMdn^uJ8SJ zA8IXSR=Hbf96n$uZ!~%B4-~GS;$DlH-V!hQkt9PE$;E?Xw)Dcq3x$O9_AM;jMV~-l zT+|YZz1sY_j>jt^UL}MU6TRtjV*HgRC_BXqp#Qu{iNH#b>u_Ns*MQBJ;zK@l|7l*t zHf-~AI7N;s$8Kn10VxI>8#`yj>}uC|vt1YRq3D}86wjU^LrGFCo1dS5F~-7bI;ITt zFLU$&3J<}fM=E~J0|WcfXDt;K6`|452pr46Sbn-E4MLeprppW31Z~@sGYDP@NWhUG z;UaC2C0t96Jk;8XfBPjr{DjQA`Sp zjCx4_y@F{^&uw=%_P?OV@w7Sb>B&t8so3Q5w2RnV=1hO3BhWaWKY!k|}FOQ<~DKg1XZ?w*Cr`I8XML)O)i57mKBa0E$~4%gV|!vCgg?w6(Xln5lDrfTnB;TV)kBZHv16*C}C7n@eGJgKl=uHy3HZuBpR?e$S;qKvhU40*wCp$WIs8xT(=#iZ^xyS}#95^C(r?_flJI}W z&B);9!t=1UkYw_srNMN8W@G0IKJ|7yqc;V629JTP&PmOErDq8r)!?_HXcDGc^uzcQ zFMZiSfwMdMOZJ;wG54OFl>{gekW6h4EKe!dJ_%$Z@DR00MwIw1x=qZ{aeJ76JL-sIbDAp_#< z+s$`HXk%k@y1!EY{go^9p%TretIwe)T9vgmhbTG&Kc)SEer#`l*Q~t?GKH*^Rzi`8m@+^l(|FeKOe7bJ zZkYp>ebZD%#UouKd#l?X1@jAN*&CSgsy8!!{RN#K=y|DG^4H5E|nU$JxOC^$a83;t`wBK~+2msDIF4$u)H+WY#p zn-tNw8BC|}ol6=RP*&J%>5b%SzOS?mhDIR(ToAr;l8=JbRw)svQ=0}bk2yMqHq*~a%l-WOPTZg`>T?Po=&d4h=wo9`x;&8d- zhE`N#e}AayWVxz&4ZN^`C&tYBdM203d_IIKv_M_U$~Z_!NN6@Ia(WF!MMb|u4-*>7 z-^8At9c?xD_67$91?lOot*voIHvYU7hkdp`D!_h<{1F1|W$w zPETL?Fai}Le}(`AQYEI7rbb4&m6hDUS3c$7-n|83V6`?RS$x~*2QB6sK9H#F15&*A zJvRdPJ+qR3{`6X3H-?nEeQ*%Qq%@W|w2b-m-_T92ekH&!&#}`gM6MU1ZTvH-qJ-w& zE5mah;~*9)RRPUWp$DdtX-VY)6&@#j?KKC^^Iic$_o-*C^zhrQQ#3UVe{z8uS9i&kipblCk9J33IW%WUK@c}%8HHH+NlNHcb3}{ zB+WA7z||YNC!mXwJ>eQo0Gqfrcu6aFn4Ea7^fq)Qd%iGM;z$VEE?_iuZaG1>*OQE@1U z+_^rY_;6>S@Nq-KLY}kB18A&4o%D?IPOKKdHu?QB74c<&0BF?={|MTn?7VJttlC5y zS`-$%nGW@L&OrOu*4w|O>elP8LWsaNNdv{{pN>&zw91#%vU?!N3*JsQx2&u(Q5S3u zleLW(Qjz^>)U!P@j|EM;1ELVDq&R6R96=HLKWlo8(Asy*U1vY?M`>st6q`=;X4TQt z`u%yc3vGE*LemZ%n1YuiwGNQviNObgH(5u& zl}QwP52_31BOS@Fr4q@#_puY2i%SXloz|mR>M&)s?Fe!CCpiBV_1k#0 zdOnhuR&5TRPupM>qoSM9(QnF~%HBtpX`ebu5_;AzeqyIUAg?FPZKLq8>rf9WDh1TX-#kIhX3mn{#p{WfBx?&WPKp)hWu*DV^?M{M7|WTU|QgJ8P}n7CwgbEb_B!#&z49 z*6hmoHzFw8x@&Xen&=J=6l(sv_-w{MK)cJBTz*dYVS$Lu89pY}xjAFYK)a2}=3qVo zX_R_2oL|P8gUR!xjrHy9U7+Z2PCMYZ;)JWt+bTcL^LAfn5Am!R`c$}l-4ZhD#eAJdHEKc*QlIG9_~o8_VR7IBP5-6Q(Y z1pUgC#lIOPO@PCgJxKEV5A=Iq4DF9c5@p&bv=CuukS&Q#P4!OpYh!&q?xRN;589O# zBbVFM{Qs{_*pj$N@skJ-^sDSU;unXqR~BDWlVO*}v#Vv&Q}gmp{@4VXT9!t*_TGAKl8|tf6`V>VLTcFNy1HWey#iq#p-*d~A2q>lG z-0$Q+C`Gj-QwD`9Y*Bt%>~C&qX%s&o6sI9$O^o!jT*@nACM9hNzRokiKGyPhezpl% zO0Ka8MRSO_*Xl-HAthbZ#tYMS%tx}eQqXpdEXt(91IP~M7peA z;x>{aM54hP==n+Vydq?zLF-Lgn5NE8(t&1=9VG5M+l9g&4UL^`wCvr1F?~O?%;wWq zYH!+&E(0cYyY@2uEf&$rmz)?}RMyzE!dl~-V&(t($sJwaVKOpU0C zL%Zvxl632kuw8#+%J`fovy^X)_=P10mg1e>%i}U7riLE@0xJiZ$3;PV=xKf%TkbL> z{dXJuE&q3^Kj~r~T5nJJFW{n+?}H$OLeO{?$`}N@*c}7ube(NsMA(ThEUkG-Q7!&i zA@zAdJ7a9tVjXsqs&_Y>hzYLqHj{8M7ZlE^0Uho{lq(d6!uj4@~J3(By{9*+O;gT0eh7E8)Geo=Nw^?(GUB^rvo^& z&q16Tu(3_T7VS!hR40cfCl+5(fjmWP@h1l>N&D@!Dkp01^_BXs{wS3d?D1zwuFF%M zY5)Q-+BzD70QErNMt#lqC45awL7VPHB(e)B8SCD%sO@*MMGD3|1O!<&*72oBY*r{l zwN7jX1ua}fkaC}yfaaCj5H~Z)=Xhspi;+?TTp-Rgp56HiA89q64=1?Bkf98v;P5X? zy?S-8IWR#u%bJv#dEaBr?k5eL!kg_jY1^G+vVsTJ`eRtca#RaMe)3kJbv>4 z>iR2adp=0p{h?MEpZ`nQdEy(t+Kx~f=7FA~86Ki8VKFl^f4p>L2@jWNQym@=ZA8*` z1wDCTDJ~t}a6WN|1R6zCUqn@g%Pf~lZFjVMk0vK3=vhooEJZK4)cJnbdq6Ts8)W@R zQQ&txuT?!A^ckR;Gi}=OuXpUqs!s339lX?6Z8}c7y1Enepgn@oBYP@tQ|Fl=VV2w%SquU;C0yZ;3N5}F>>)4VPbX(LQ zX58>V_a)80x)zWcelO;&L{r(`h%&*wd-E-y9~%;y=;WC7o(B8~&u6AUf)l7#`3wgS znd$HQW>GLU;G5yScwj8{r|RiW$7B4LfZ=|1#30hUJZHn_ zw$-Q*&7Gf**PUsLi)CA@9m5uZj!$p@@PASeYc!S(KbTlQomq}pQYW-%AnW<>*8U}M zu#AK0CY`pL*2DniHD(qC!N3^F!g!5L;Nr;c?yaA4oya&m-0$=9KG+QgxVyLFeoCo^ zOYKG;-ka>kb!oO|?3NEvR+Lh%zOEFuSUN)RXUq5{+p~J>M>PXxH3xfps2ncU<&B(i z?)#pIx_PlSXts9w_Af`^^am4~IoJrnPy_U^zNjdY>y_Io5fdcwoiF^=|j?5(S9(sc}r63saxF2!$)^}u!xPH zWNLn25)pVMIj4t*#d30TVBWJ+F|iTc*!d^=)Ljn7%m3;4dcB!T{}KOPByh8Q*k~wI z`*dF^Q?07F{;49>uQe}m>cyTYEilV{EN0R*Fk=HQ-O$yhI6dP{`vl6?0v98=U>8z3 zc=AfZueQMUBl0r`iEi4gRVwEGiNUBe1?HiqftgKJ=7T}QA~H zG^Dn!A>1RD%&BhmD2Oopa{ zDo36sAsW(@I1e8_{4Vo+e0Bo?5wC%GgZ}c}yFuI|aNv!Wvv_1?8h3Pd;`2Hh6wG%J z4-O6G8x9hHwHpO>t+mv=SAoW6f0-eGi0hTK^gV>5iI}%=l!KmwqYNx>QfLIspP{YE znxQiNedY%)muO2ya_w&nS!^WxvvKXW&VFMZ1r2)q>+%m-seLr-la&2fcz9el#%&O` z`_NEq?Iu4knN%r7g?B}qTyW62I0nKn2@VLJ@%F1I&~Z~N(Br|y#RVIiUr*2D&bGyc zINjy|RdBKXnVs!3Emp!FR*)V4olg`+C{9cAs|2+_sq*b}T_jEt!C`G}&5p{7x)BS? z7#4@V*PU4!U6C(gM#Y)(B<_D!jkGPAvG}kul6w0J`pgp4lTOBy3p>#6{9Gj=h2j6JuGbzE;)rJpNknuOV;(5-lLBqzt+Nn1w zwIlA#xS+w8Kk?7XkE=Rp_kI-O0;@ONTsptM!c=Y~#l1tR?sDP2`N`h?Hk1BT{m$I8 z=)reXVdo2UQoE!2#Ml4Krz{q9U)GNmt#LSWKgg07Zz?mKz3&U{!oc&6hJDV|IzqN3+DIz+Ys3evCp&cGg$pLLMF-{w+K_-+WGw z_v&1{$=_FRqSOG|w^1xc6z#*tb3YE6TPTNQF*#^0|)rAvHES-O^ue|&NRaoT-(fjm-SGi_2+ zQp!~LJ6`)Fb27%i#uKbfXWF_`#Y~0nShMmRTp9RGdN;*Vdx;z{U2GnymD7ENgm`Sc zV4g%)^>BO|HI@H%abw&MIm$cxg%myg(NXn}*u$o*)Y?ndLUQxzlOk$(E(8e86=K%7 zFW6Sn_Tb%Gg+@cgyE0?@7Mjjf50KLzmN9) zd2dRuK_uJO^T}Uv?YbC*>{cpUG@E`1m|FPM|J#IElVCYhcFm)^`1CmZR!?rhw19XP14Q7)Ac{9bcU-siunpK}*xVyWT z8+mwmz}(w=E$zAJ#uOR+o~*N%zd8j@&PqhyQhoOfBw~0m_2k(JeL{7RAAcf3JPms> z+>@g|{i2w;LK@W9`iY5H@ClZV9_YtMwNGDN(t-MIExvK7W@18<;B**FTL#w2?h7jNvZ4dWRZ8G#V%ii}ygafFxkg5yVsyNV28dAq~S$F>!Uf2*~@ zFwXDXsmaP6lhc8=Zl^zc8(8tH; zWQlL7WaQ1KPb@=2H5*x*o0~)H)n#`5hp3-E9h4jGE%JyM8$+LTQ&1$Zsdg~oHj@o) z`hY>0$6J*q)%q&$I~Ko3-U`)vKdvk{x#e)Uu04`V37%XD@YrJV9-q>kpC2a129 z9BV?Yk)feaTpSH}gojN8lFJSOu)i^c9*ANR zjVYlEiwS-O)Af2UF0tg^{Dp0$V#2Pc;1zWcInw~2sXHh-$=n0smCVn+E^x^n>uo$w zvaet=@xr7Uj8hdNGwu9>C^9-G!SskJqwQ)q*W|W*hZP<`))J_jq80EZdIw zWtR2)#nJR4{H4KIG=v_L*_0)u*RKNuOe14sQytCsp)2|;94b|if@AQCKo$PbxX?5S z3bb@}j8kU6c`*B$HJx!rnKZQZKi`XPD}8CHgwo>XpePu^2+X7X0xEY+|xmw_F(Rd+F}_`Z&N`ie(n)W>p7f5WF2Judz*6G!OH6o$L!f8KF(PTxl1W z)FH}Vp)w6ji}b0=W)NM6?msaAo0~A07Yw$vneb`iCtO^fV5sI7aJVMv9sBcVt>0sw z5O7hr9UmvdzwQ=K2~3pFrKeK+xBhOb^TOLgX>6>O_-Co{@Mrx>pb^gcbbBhqRxc3k z3296E;F!W+Xxg$GDNk?uo;f(!2`nBkkeTm%al6=fv}vNOJI`uG>J%gA-T53b-Q4CT z#^)mUd;bHXXY>@huuF;u8?Su4;kg^{2Bkr7dIa|YFKrYF{3MuiPF~n zb^Kbe4}*mu72G8A^ElR9Q|)dja}PPKT3DtH7A3XkZ@f5mYxk7&iY}Q$wuZMV|Yi|H0%%`)zarFPZdtaQu+5v0+L{VS-as zKvi|p-)WRaR6;@v9$8S(t-r$N;o;#SIXOA_6CVK;uyt^lls}st0$a6W37;oV^R73V zwZZAuqWvD(K6JS%FD<3F+ZB}iWn?z@ zJlpL2I1d1>{^Z)Q`qi_ksp~Kfg8`lr{@xw7p3`PNwX)Sucn(ZJYjkFxV9Ho#cu7)f z0oaq@=t}YoqzSGKaI+o%E@yqd_0meMkGMEKdjf748~gw3IZ3GC`Z3EXU0hkI-CYp3 z8g~{F3DldZBZflGF_d+ds-&c%GHyhnaJSu%J>-2`^&Ej-$C;mf=PinTOYN7K&ny_T zMo#;U2)QG=Yh69B-2qHi(9Ns0+#{`EV%FrB4v4!uSGS#Yz)%F>Uv*Lk?8@#281u^ z`k$?>W@O^Z_HWNiswJaJO7IdBdAtJo_#MyhO@0D?Gi5u+a(;1PT4rKybXSZnUqhA( zbTkkwuT>$s117-j1Bm(3U(9W649QB*uyL^$UcSmx&DewRv6EAJ+AGjLGN<8w<>r3S<-_T~-kg9i`L9&_i8 zc42qkZ+KVNeJ9mw$T&oFp%FAq)~X@ABZ+n#)2?2c<5tMht^Dc%=oqCX$Ri?+6Q`=zKT z{p;7yMsqc>f#bq~VbE|ta~%-*0vLTGsS8v_FyG_>sV0&d_T zv2k%K=j*JjL!D|@2unD~He?if$k5dPIcS)aETVp(ae8%DW8)R@SP~Hgu4YO~9642o znDA`nN$0?>EGIYm!KBM!#~6QnOh(9KAKTU@1~z>d&BF-@80M=HD$E{n(+b~eX&9{i z7O}YBFg1338U|3^6=qjwk9Yci4!!{g0aO@%CnDPuXL0ATZ!IG20gQIJm;w$J?K86* zZ-s>^?CkWx@L|XrX$#DLvwTerMsj1qh&iq9tPN!~jE$WJN;2qoB`B6#MQt8{Ik7HL zz#)c*OEQ+-zcZ#Jsh2syiO=!)t$dDJfWJQ&RYRci7MP4%r%A?qbrsn2zDMl1%>bLL z*%dolZUh%(Ed5A$z~)*&e|VqQtuSAnR6{zI=fZ*a={OoKM+r4t&~Wda>Bg z0`zAacG$MPoxykGnv>Iw9!A7sC|H2c4AZSNO=qy96CdHHMrJxg)RIj8pB{x={(>iH z#4!8%=lsNwnkH+X$S1(xondeDu6BE(@zDC~XDlUOIkayuFfmj*>zX$sU{y$n4Akk9 zlM`4QkOSIP%sPj|o>Nnka@vzVeVQsLe5l*={W>^GU{IwI#E3|)BPDQQ_he487!4up zDxp%m9x~}Vts%Ox?B*fvs1e&>Zdz_LnJC34PA~=U4q=gUr#mX%NXeE0l$3g|t#jh7 zs-p+jFuCbHC1Z+njYqpXi?^Y(r`Z{OfJQh{Gy3aKy6iJZ4d~3BX{N2C1xuw%@yMkg zXKp}6$A^spW|H~7%tMIJQ4*BG%Y)T+kc{=(FUd)Trv3cIc zj~--qNJvnEf?_7X9n!klwH5enQ1tW7&`H9rrMcc_GOoR-sH3#qu8vCmsRZ-9YBZEj zX2TPBU?e}8j`g&eU0P~~ve44S`$6gIcuq=MRkagpyQt_xNdW;Qm_8uouwxTWmNuol zt9dH~lP&?1?_^$v z`}ZI}zb&Y4$bW`Zmf(0WhlJW(EPKo6?a}|Jn~U@3KImb=lfuCa@C`VlNmSS+7Q{K?*y#zNad z_#rPyb$8~Pp6(uwZz0Byu?P$_CNvzJ?T%>9PcdC;-^8@+qK_`GGoxW1HI%q8zfWZ8 zsdjpvl1fBDPyaignu)1>cMpai(eUrLmrR=)6-{nVI?&ney@z`Z$cc;Th%e`+u!PvxlhGR?q3;gvT`x*9Y$ioIfO5pxO8)7?^a8OV;;fb)3oVRz>b|8a>(@ z3mIBBtYTKm7XRr4#=GAx`Cmg)=FQ*Tbx@$(${(nm?I@9fDAXmJpuZoTkE4t)NI(-Dc%qU zDrYUOkIJ+&hPF1~sh>w2O<(19j&vpDl$@{s`SZKdt|ZTRv74CC<-IOJHmH4I@hl`i|H_-dYRQK774muqxcgU4a zSR}kLwUqh);696kn-Vlt?M1jAM-#Po4cr(aPgvA>#X=zD8`oyA)^qv|Zrw0ExXJu` zqoiB^WH+$vZ1dKH?c4zf={L6f#9hkPot-Cg_$VoV4(h|`56qLns^(KW98DhCUR_`! zoOEM_>HAyL-Hb=mpfWkPa^K`T8|4qDIb(zO(b}0^qI~f}e{V5s!hVf#Vj_EEW5CxO z`R2{{`Ly)8x@xu2(b24Cl;tZMul@F(F?116|9P05Q0Ap7axq%UmjH)wHn~#zgYx6` zhb*gYCR$f8I(HwB&CiDU^KjySya3Y|co4^Kx-kenp+3vJ5rznm=pVkPe~4C$A+&!j z1T%++__8NZ`vT5-vF3&&EE#OZ4iWi0KlxtGHD$V}}YOj)rXtk?1M*Nsg|DSK^F zGUa##vg#!d{o<`!y6_+EnCq?O0WFmr6jGqNMr0;RmB5}F+1YVCn+dCzBb`>@b)P4CG&9%Sh2+uE z5)*Yt=t(I5$AQ)Yrlh4luCctckaS zR2Y%dRaXR5Hod%ziLKAfyP$KZf)REJ7{`D|g&~=~w%$YM*4Bz^)NGhJ6{e&h1d(xa zhS=>Xm&AX76aj6=EYmy15n`$QsWLO0Q|0WHwmUKs6tXY}`o8ot-{p=J9O^nB+zc=Z zYPNlQO~iXBRPTwYeXvSryVqHAvYdQGGi`TuiL^fLat-v-_Wm(mUK%@AC^)t{J1YWGT19@cUA)USqUk%4I-auYgMA`TNE3^C8HzdpSWOn zIRE@jQgSj#HP=D5yzLh+Yua ztRA8J3kSp*ez+bUrr_#36|booPcG*+#`0@k~*66A)NxU zisw*9mU>O_?(SV2p~Uj?=!-Mik(#S(poT(#-~J7(wk=eBqx)B z=Cn9&7790WeT%^RbUcDcnIQUu_y8$T4`x89;penIeqAP3E?hQ^cX39ctIUOgW2Ey{ zg&M&O6Q@VdXg1gr;8y|L^^yeAd@P{fPMj zKy2YXTSvtanh6$pTxbjkr}0ni3?a9z7Yeqi23%2;@(AYiNFsfi-?t%(Q)bdl8W=d-J~R?9Cz!BogV7A~Jm!8@k1*mge) zG6;|7A#E_SX(^10lDTY@x)vw6bvtgWp(68N^F11<(+rE*OtZv$Cw2IUy@+P*!H z(ePzN#pbC}%sAxuz_sicxY#A;`k1k2Xt;GOo|@vA;V6{)I>3Q_)&@|PZ3j4A#?0c)N zg>Em!eW!{l_D-lc0==ARCyJL*h8%aL;Kmu^JXGTrNZUx7n>?~dO666m7fu- zdBfdEs{^gz_701Uecx`;>d6y`gBX9>H)zB?AZ(%3pWcyKC5XmfHv2QwaOp?5?%t(# zy6ZEQJZhlVnaypT_5hoBr7|JjKY_wMY<&80_>}wAt^@Pz53r*i9jrSWC zJw&sYNQ;lcu9_q~S=bObw(rL09SsrEQ^PHJ>XB3sx)<5S2vTFb%Oyvo~3f3B$$f)pC1b+H@;+dDgp3N@!sF)&POstR>lwTewA18TqPw*-xd z4>dOkmYkZJm|LE~#XZq1uCcM$nd&v(V21`;3uB&y^`6bD`ICTqV6qR%80Z2rGV)?a zl#qtT@2ArnNh)S>o!?(2vU3zk$itljchH#u4E`x@xSkScYFE!qzbPN@SA&c9iDuFA z2g;!}93M9$Ll!VRK7^Xu>S+R+obkjl3ovfj-v}VVU>qkWK72_fB<*~D!?}V+WvI_+ z6E0V&T2(9}_;8N`NU!X`084PRYV9Q;!f$cS&6C0RT&uhScSD8xN5+ILR+Wn5H$0=G z`Q%8I2t{T?+#TsY&e!{62=f+Bki~E zp=hdRcO9OehZ__VQa<^hKuh8rV4FdH>7%$QDG@uH(b%Oc z8~C^@lKh2iyqggT-QROwP*SFS|9%G#Z&*!51K<`6&M`;Fb1a2p)p2IVVP?r_NaNf} z?!8HAz`R?S2k2WI7LmjHSL!WAc{Oxedj}79yAZ4e$)Lc3ta|J^DjW@cLk4vx2ajA0 zg$I+&qCP%JQa*vZ6gm6ccrU08aT`R$63Bax+-{fe#@wnu={__rPI;GXP5UR!?WJ9` z`o7^C4CSWAQ%2gMh>*7182a+Ld{y6Qknf@#4kD6N%N?`*fZdLd>T_FL-I+>EIsy8y z=~`hm!Y~8Oh!Tc)z(`#8^>a&jVGHt#ySLmUv1|bNzZ$s4HqeLhdY|NfGlVBpWnU;x zf6mOPIaZSm?MSH6;kqH0wT!=?%?JL=VL^^gOsuY@t!+Z|CgSaNI3I4e2^6a@$#XJ~11i$cNGTdF z^2gtAm<3|-c#EZyGWWbR`2J=5K5`JC_sQn2-P&?$LZWpq)IANkl#+8cIybDk)p^=* za4cl7+f+x@D=okZhN>%jR=g77FT~W=cHFkG%>87`ZxuBW!Z~$(&VD3;N=93GfPJi2 zP}g+fZak6Dol7sZD0R11`q>YC{%d|d`xH!9gp@RVsOB8P$-!e|oM^Z4R6P3@zB=un zA7(4^j$23T8oi-s=t_X|lz7qWcr#rImwGy=Q`V+|{$A-09sS^iZmxak z=InN4DWo=!SEU--+pv2u3IPICx_4U2tZXEQd$m`V`*sZcF@BmvpECzWi=0s z-9MpOKi*!?ON?+4Q}?PC*8ib7BdLkDxzwaTZpp5y*j~lvIdf5iEKk+Vl5es1yu+ss zKKk6b7i+zU5%1S>97Kx5W89W`=`a7^%tcj)>cDlv_5@O>(LcHCM};lerIYG(XJy$M<4xJ2q(wjt(X5VppNDh6IEpxcVeXs;wh~8;_4K*?>yoOh=fPXjm zeiCvyHp}nMrrx`nM9TiraGJIFC(>(rBn+*eK3~y2pYyevKtBY?q9@@OxR8}IMas^8 z&9(NH>#N1Yd;I$*LtUOShzpp^I|&H<3AqOQZcx9-x$Rm&WIm(j7qCk@B68~FTb+^E z+amHv>Z{l-A1x(Qd7RzuAqF0j$Pe0IjUQ@mrBVtLP#sFE2TRPA!E9t|WOVl^C$+hM zYzIxm3yu@vMc-aE08sV(d=FAnlH%)?!q%neblbo1!?rYt*fTKJ zJ*v{~zACwggY&tjhM-_{DA+14V?h8M*=tJz8RxhDP2L1{{IYjQ>^TdPg5q2PzU|8d z#AfnK;7T$qZf$7gIo@>h;$exL>W3z$&a`R)5M(JYyr&S0%3^qs~=rK!zT>ZT?c zDyiyM2T===1nqg2XmzzLcO9O&42;zlV@P@Nwi|PM=H-Q~#vW0MtNRHrxvN|`Y(Lz; zsT>!WRlj!6$Jh7u>(?Zdl%E;2g^M}TimRiD(i?vczPDg{CH?*KYjAf>t8TBPYbI76 z)?{eH5A6PCb_N=fpv)`PZpSLF1c-LQeVeDeeJa`dcFAW*=F_6~ zFN^RQq8;i@lE5Sx@w}5CZ*klg9_5$y7*wx^MpG2jeeq+|5}1JeRA~B{---!tU#z`& zk3Z2JRwgMN5N~WpcIEm>iJf!g9^PW@Pf;^|q|`x3uwEIVT!>Kz+BL22*~w6F0STp3LXb|SL20DB8w6ozZ4wJlyvdZrocRj%Q-W&@k7pzz$aWmIhqxafJL%KX+r|y&9w}F_)N1- zGdBCba1b%SMo?pl{}d+Y`-pvUXF;_DbM-*_3*$aoCXS?1b?p%|<#=h7PnxeD{z z`bWi136q&;-!9)>PHiJSb)6U6AqiM`hh=Pb+5#y)+`0V2JnVsWDM&M;J3|ddhl_E_lIm^;%>7H*5vgK!1D%zy)M0B>T^z zL%_v@gXy+Ff!%=8);JZAA2=37B#zi)aUkR%zg8zEuo&rsA`cf69Q)cJ_;Yjav-URr zz{hqpQ{pz;@@_x;f8Jt~c*31~9v_BzqW_-o$K+3Zmqp34r!q+Dl)G_&FvWe^K4Mf^ z+`a`8o^dj~5rq(*%%yU9-Hh^#%BtB>H3Y959p@?2VQa2iC_E^1`i+UZ^En^{JI}B7 zX|b4$bRaJ)`CjZc-I6f8eie@@7~jK6U)seMx!O3^j(irA)GXxiHS?u= zC2JC>Xg+*Y{WJYJ9x%sLuYZwndMqYCOQ+|UOv}(;+}&uTULrbWP}IiCtdU;^R|8M{ zuy-0#9t1U%*>Blwr@L0_lkv9-xPE)_P)TR9qg+Xam|x}Xr#o)x_dq2)BeU*b@2wlj zQ;w4IGsB=k;{6+Ut^~URpi%K3xD%}2WD-mJLPo*nFI!tY@CZJDSbZJz7C;UJ3#c$` zF^G_*zeLfvgi1&1k$4W=i=$Jy-sB9B3F z-H=fcSM92k!F8&@aYp(O%?(F+R97ZYS2TxOe`>L3o;_mam-r^9bxJYi)@x-t9L?gh zMCkU%u~~M5JmzQqSEEzL6syv&V5*KUH!(&0okZyI{_jA)joTakl=xRoWbECKv`Q+P z7EWqqOE2e4^RkYpbNGv zx-XaAH*pCI(8t{Gu)nR_zg+K<<@d*gS2iahbW__&7P5693$GY5u0;2&MwhyIqXnZt zy#aaw?3*;TA98VGE#{MO9!B#j6cqB(;fYApk%}7@GM-wD+_Xs%T~}i&a~Jqpsd3z5 z@HwBZA}$|RP4rHH3flEvn%lmRou?nq=iXY`Jw?3Ns7BA%IygT!ohTwO<5*Ao!3=;7 zAob8tk2{K9{>J^SkaEt7_uEx7(BeHt=wxdiGLr0g#~0n#Q5doT%f@O_wX;qlF4;>^ zZKlbwlyvsv<%r%}QSzcJ&IP^7ciuUmP#mbL1pIY;_V>oQI~CQ=j4HWzt(V_Z@;G>i zwKIn|kqq~-z1}evbAgR9!F7)-%41PguJZ+s9|Y%PneL0s2}8VFA^yPnjg@@GNcnYE zJHzva+%=*4$e@(~kin9yoOZ8wKWHPEF8?0khDHem?)8rFqffSIB=etXoiuUwR_Wl? zIp0mGS4TgsOU2Dp;zA%5U$^Qxg#U!0rtYno9jGyq(8*#oTq}{^OZIrQ=H>Wcq{fi&weTi*Kw()M_6N zG;CdfsBJj0WP5ja94M+sFEVA{dzpnfv5esRpau@qJ>41l6&d3Kv|a*RK(B~w23kOC)xd3A}f)2lpfc|)Lo;_c%RntTp)e0Q6UB{#|^ zD&ky8O}@#yhtBApg!>W+4z=4JX&8=C7=C56y#W)jd3H_PF1P>U0`I$lxBDkC!`CU|#dg(>T>OdJa;PKxQy)}1&}^T-_d$(C45Bhf)({9_P zd5Vo)Mq`uIB4Jk)3vZ18Qd-%7G0mEPV1H3^c_-uAb_5rb&9e&DRqoz^l-%;ys8wB9 zyxnkG0TjMv6yV|)p?KofcT19YLoX)Bgies}NI&%xuKo-;e{|fVOu07`?S45x;Ca7C zzMvN;InoBr1g|J} zQjlhNh{;8Gr+6`_oG-MJr<(&uS0ODl>!Ml#zgE90zqaM7_Ikb+i1>5vOE5Dl^5kdh49p9@J3fD%`w~Sg>#;lt!0s-{!iH4? z#6m`V5HVL2-cBsIeC586d{#ACZaMH6ZSYfvpIao1QIVUK`-t@D(b9ft*IY>2b|jF| z#Y3f%+~U7znZLQ3vt|}0z4CzIon|bS6C5yg4h9ZFfrINCtnl|!IoKZOyeD^@tG~G? zCdeJ#K-?w!z1kN*%WY;lJC{WLY^g6=$b;(8ztC z!puWR#;}obGz-_FYmm< zolMuNf6YZM5@-9Q-p^jtg4^wc7Lj`Ify#9n(_tG5?7c1L+{s3TCQ?k!0gB4WOC=L7Xo0uZZH^ZsEkloHHscZUclsFB^SePRj<5r>B5)!bb%2s>WjB zP2Ar%3V{n5#mC(M)Hz*k)iK(^eaJ0@`Pyr-O73?uOy%U(z>j^wF)Tw^cozX~;To{# z$v^)Dl>r5ZMz8(V#wUj>c`YPL&?s=B?PHz0^4p^JNo_#WoM3Z(Ne}{o$quis*asB^ zPttH{{4F^^^N}*u_gTIGOMU^?Fd+vLklomt-fe6Sk=k028YJ`EU@URCGs{(ZhPNlF zT}$)IQ3gl|Al*W1?%jx;e8QKP7pO$8S2kDlD=w_$V4V|nYEIqz^eEQdXhDzwoh{``qFWB_H1On2u1UZ(1+AVN`cwlVV`TB`cJkBi&$6{bAw!s0q zy=kqcpHDv>m}10&&V&V`++hDdu{H(pSHG1Q3)e__>AY3Imc<*ld!7{v=vLMQ9Dv(D z4xO<0`_^ZTRNnW#g9!|i2O|JLzGVX35bR8+&5g^fw18)v5t~DmzMg>dTV4uMoS=dp zseD9NK$s)IS(XwJ^E8Tv8~<(#G8+&?D_07nuT4SfkP(-coAlVkOF zZ{Y+S5JXc{7?S|3F~RC*0)pkRg~J)eV%QjfEesf<=ilv)YowE0n%61<0_g38vs@14 znhhOphCl7+{`i5i-{Sn*{no}hk*%Cw$ZE`fsJTl`QcFTg$EM0iSJrCR%S+s*G&pK2{!iE+7<#k=JO?zR!Tb5 zJnhf50)VWGfQvtxSuD4(S>O1)yyC}M8b_?Bvv1=E)1b$POPhYe7rUR;pRV4N*44tp zMla0OLI8Vy(VJf{ljD7KOm$3I*`0KX9kXa>PBnU=_;lF^8lDvXk`mVmt0}wQE9m`+ z6O*>tZ)WD(3B-}01?e2cjB)eXQ|iip`cj^r9{-GnEgzK5s+v>CH8J+Zi0BVr-)snA zr=E$>%wne@x;kDFN1FQhJYweRTM0_DlB583rpOaGC z^3DCFE47>)=iR||x3lRqUA5I>dt1Z$6i+t2=2e>#gU2$hR=|Tdqcc|85FL2XM@fqs zoudY=vol4wvt2>MkOL9s^}qP-VY7ZU5_)Ka(1}2b%iYHAde5&5{ort$wI97$_g*8G z@3`A%=D9(j1B+}^O1l>0(~R{w-|OdKy*HIK=*nrmFv7*di~d>}`}OOu!f=(Z)XL_( z^dc{O%Wz|6&_y>1grO4QSliL7O!8Y)WT9&jQ|CgF}!E1k@M}qTI?xxbkLKePi&4y55LK#wtQpkE%t;6 zIpBS~dnvL%rUH!^OhnvE#vYWyh*(YCQ~;bp(Ogt-Mc>)M<^+Q*&P2Myl|r#NO!3J% zHKy*RzW4yd9TDVlT~JrW$Egvo4Uu_tk0`~2lC5+fpQA(bc@6RLwnGN@e%h?TPOzDm z)X;bl68FQqp@E)LPVS*53KOTaP7fFoOWm5}w&ZycQFOzZ{_DGQ+^h2i_iZEH>+OoK zzjt4F{=&qazfMv$(!JYm;5(e62>$34ozjG3V}tQ+;78i}drCM|y#8g|Ejus@)t1rG zS}owF4B7`C%O;m@rRf~+k9Wo*sxSGash_dixCE}Rzh?`#reL>1g=1gmGvihjFV<=H zDJmj9qb^+^zAJrs_ZGG4_dHU5QIWyV--psEY~C|7$2(JXATCbHqbDgz3ot7-yK{~w$IlU73^Cl48TDlHRHR~b$CL^ zTEHGi^6H4b6C>EH8|Rv`0Ko_2y;Fq8Pj;??%3-_8N?6QyieuB}=XEsh7U(Z7T@em0 zCI}NVx7Y?LSe7K9>cojzi7QEv-CL@z zM+(|@sR#t7(~gyaVb}Hq8~g9vzcN!IU1wIrAw2<`ASqmYfGLC02Djz6X$s7{CYmnr z_kh>}pt|PR8AAmHIX(j>UBV6)?~M3l7j^wJ%I9WnsM%!irkk<20-Z#*UElWo+!R*q zAv!uwW>(w9o|#zTt*`${#m&z8#7A~eaIot;IInP$-Sx(*_B#Ac4k{&|D;r!@6_n9v zPnRW0PT+Rt$YrhI)m)=~bxzp`=`_udzG?0YLtBZ$spJ%r-k*b3=boNsE4~VGVdNin zUw@M(wYlU2D=eyM;gd`^pxen^;URLS@53+*HieBYI*Wzv#%C*ByCqXyoH+MOd1Yrd zQTo?Y=R~A|$qax+k~b>e6%qo%@iRa%UnjL;R960>H-H}cwo$spup5#&IjND>+K|$# z7B8Q2#@dg44SFN>Kx7xwv-cvz=9_l*i1w9xY-Z)0w&a52mrK;Nm3@d?c*9<@A6R+Z zb&q_}nn){-ZjCEZ_Y>P!Uf7g5>Cpk@OT#8-fBt!zvBdxfP&-tWXO z7iq`%%9;-KV3muA`F`3^Up7E6vwj_w5M$&Lvi56}Z)=+fndsMOS6`F z+RLu|L2_{sRQ^3JEy0T&X+|(Z2^SK2Pcv^>xTc*n&_Iag)kIH#AQfYqYgt%QvuF-# z#m&$dgeuzlir`wQX?xi7XJ4WGW*dVQMbTOvRF9I{x?Uy1g&T<72tnpBkEpzc#+bQ| zeMO|nzQ@lV)|&Kle4(DD?aN*EQh7Bsn=CU?@dO+{SeSGnexRZc{jig>u6AqJ#q85V z<~hOS*=I<9Es8YTJ#CUSkET=--g8#Bbt<^^h1XB?^q5KSI>~x^8PhYC;q%2uFFTp> zj!)}%cNd%-l<^<=y(n6|c=F^4o&*pc!KMS(BqKbg_;!eZUUzO6q4na$_;sSmFuBY~Wf3W&4K7v3{K2nS-8kIr2oP-PvdaM8s5n zA_{sYt`$rV2lnq0J$)vjNpIB#SPEEhD6rS~J0M&FtrO1CN`w&S}wXS=M;Hj8@d;>Avm?Uc{V?DRJXe-bP}aNGI}N^ zYT6Hsw6ys!uY#Njq~#$$aabFaTn1yn<@rg83a)Q5_xRex?ZUwVHy6dCJ`v3859jwHb1-c!X;f$(yeqzhX zbeOloYPGu~f)Kv4a^&Jv>HPR28j@L1fC#jomfw6KZOh@93P|m6GHU=(O|gQ03i2cL z^Y`P6T3$BKK(a#xUFSL;2bnUMcH(pdI`U=Mv>FK%w4%X=ae2Nbwz6)aukSYMRs;>b z&Um^)PVgsq&X{Xxb%Ua)Ipwb#2aLjqv1Uu+kpeJ`+b8dvR^lTm4Y@xqZL9X-&U^H| zSd}H7om)Q>SV)a0WSiAKKk#un0#8i=r(An20C?JPcPh|1?FB;_>BD{b2| z61{NcV&$6)7*X(a{&X;N1G1775cum~nv?rpeE1D=g+OKg$6nvQz3=;ebVRoYxgc== zEVjDzbl`~+E>LOqz17n+_L9l&c(T^zzT$H4`>N-QC-&##egZt%G4J%SWWf#wGDq2E zH75kGI*vmnYAf^D&+1+H4tn+()An7KoMLk^nI|b2u8o;EhdeQTpz=m}nW2l%=W`#O`xUSq@dZz<9#eBQvwmwk}> zSh%?8_-vK2GbY60jPX{4zK{n>i#DRER1pMuVRBSjrIFlhd)aHo3`0Hx1xxdS1^A^Kvl%(cwEaPrFpm?iHPk|fBtb|*wIooX6AJyycDyd zs%kGVbl{-8p95phykkH2JyF8@4$NNCBx5d%DW^iD59qx>Xx9X!=l=wGuE!EB{eM7D zy(5QJQGmP?YWPI#?b!*$IU^IQA9)ejlU5QJW=)hVzExF4cZ*iDH@jEEzEAhlJlgY7 zR-IrmIdc9coA3tswV!t8oH}od)vNr=-Xb7B*>sXQ4D2fe=^A=!IgzPKkAmAW`j1|B z|1V(@$eq9=BWwjJjUb2b`I8m^E&+hx{EPc&eM_1Lf@XU?i(?5ItHSD6OZcRTI3a}# zspGt|NBz7FE|7UUa~apKeU?dyI^my%x)R>0x-0cR%9{HLswpt5g2cW0iM;X}P0vqG zPB5sw1l@kp0@btrt*%MkJFCWOX(mP1fA6;TLEv&|vgpnb;dnpZVEU|F9PcS7L%|v} zjoEmufa>Td1epLIttF9pp*++P6iJxQg$xFeK_q7m8CLv7S*gjx-)7uu)R{k#!mX(k zSFfWB@@#z|oy<}&zi2uZYs^;h@nU}q@)UamsYUSaj?ag}#(4K?caF{NT>wNJkBQ@G zeH$W@V_r9Z*Y3F_sb@oJtk2>jbd7*Xfb|YIpK-g~cM%Xz>7x=KH7iMS9;V#9Sf`2P z^zvTc-lBBH)}~5DYV^~J(0nlF8T}v6i0N~FtiyYz%1Vb}faCFye4;C|BP&# zhE9K~Sk$_;T1pvY@qd>~Z5@=A>R9@sTF%YO8x@l>yLWpt-0?VziAWd>l5_&!!ja3? z@5fXgjl~1yNi};b`S<4(%*_``=rUJyC3{OjY|dNUPz-)e&MGTH7yVEtuFpmv-(SYx-qEz0qsILiu$%xmkA{Ha4M!~= z$Li(gPfDB1laszl0~hnTdBA~Gm!3QO8b#RpsOxN}pkZ$Vs0OgCbaQJnWH*^oVe91d zf~LmnOY5YOYSX@V-9ZUA1F)}d=Q){U)Akg{98S#f^BMbghw*Vt-HKEn3e^ocbelZl zBWg^O`XX{o?YA6Ab7R*I2fwQnPmsv+4<6K-^+72YO>D1IA`U~u_ttBgJr4>Ng)1X8 z6x17d(?Am6eT~QiDI?}bV0Yf4fQ)dV0y{^43fN9Y@gog&3mtpcw=Cz3k9Sz4tqxo#ajzkUGmNnYo!4&ozq?%@Pp0cx^er z?=(;t>P%4*ULSi}C-D1C-tAALlZjUrm-lvDZ-4LMQabj@DNq$m8bComBfL9lu_bnl z`u^8&EM_X^gMDvS0+=+un0PqY*h=!?!1@r22>LSZ>_2Wu7;r;sSw?!IFfc}kjm_#_3eC^e{19Ys5~Pi}C+ zvY>Z1Do3Xrv>&6Qw-gg2&K;>{KV1{cR6lG?(R^}))BV@d8X*GpUDz!q&{kFhW_hpy z!JGJFw7w5ljfssO1t2yTmrjLubVNni37}rU`xtmBP&7eO(gGwdRdp?2B`X-&0RM%O z%K;wZxt8|hD$dk-E6D9ZSxt>m_c{(pdFayN1n1&K&YcgB0sdxdn3$r%RCdKrP@q*< z5Q6Tsh;_4`5T_-BeJ||PpE6iy@Ax%V#TNP_NvoLGc>|wL2lma}bo07}Wmt{>0LnD8Rv$MpdUf2A6ddj`|%A)LipANRO;^BoN+x zKYAGhCDdx%a^ohf3cl^`E5Zb0@V7#;+-(VBAygDX+J%I6f z)Z-*={S$=1Nrs;a1X6`b5K zs-Cp4UM?0MXr^Oi5YaBKp((K!L36 z?5~vKqhOQ33$TS9mP_D!5k#0I1Co=%do7!;^jj=d&0Z&|$A0a#nmmQpbKNtA%OjIF z(ZDvtsl?+j7FH)4%4vRRs8J|XnfjoWs;*G@oU$eneK!9MK+^3o{+(dist=27w|xU?;yJJ+sz2`s#~vP>*pOB`;)ArL zHwxIHV$agkM}P>Ci_C%qi-U8uu|bz*H!2W%2|O$NB%rM4(Ic1pN3of4sB(idll~qD zdhCCQYh_p*4)Zk#Vj+)^XaJfX0)c=dZN0_DLi#rTe)(VK)OUkDTQvUfTLhB!4Gjg* zDp|iNuC9TlG=SG+Mnu9=@c(>7AgNU0xFO31YeM#msz1LgW8y}DmEiuX8P z?Vnb`{^anb64^rdt-<4(#mlLfZtSnazviTSTJ#Ahjp|SFLA^QG-2x+Dvyk7y1Y=2E zUa&|YW@aHsmI&RHF-nIf3$FjCK|`h@`x(FR3XzITHym4iCf(_i;b2j(8nKd&2YS|s zZ(LkMXe8^rE1FlxTS(`rkaqaL-ooff~q)EFoh5LG1C1fvl@13;Aqoa5d}PnK(bdI6B(7W zqj;$ljE;@P?Qr@MmJfvuk&Y(>)&6-S`PEZ~xF+!eK!HwxK`ep3C$IMJtT<{~ahXNa_v_ zOlbqI*+G{U85S|ygXSgHA9w2?;YdAJT*RpQ>x`4iCkIx&#XT37jUF)EZ%6?OYxjF| z7JcVfq|vR@!274vbKzMQXtzQ%M<*4Koc#Pc;p;l6_aYc?@Le@cQB(mDRJ-288)&p5 zyiaT7#R&Oseh+s?@?KpVQNb7q&lnSv<&k##Pi#(XHQiUezvS&Wody^gCO@J+t5DOD zR;r|!gP4d0uY5*j^7@Rz^Aa1Gj>6~o+9z%#=)rsS_lOssC4KbkE_>Wi&%t325-vXI z<<%?#U3Wwv>`_35r~c=nG7s#UUlvA2cc3zco+9*5RpPwl8|uCqCbFwiDJiysU)9Rmr@!aPutpKG-WI1;=nm zcQh|0WmZi%HO*J_m?po!qM$ps$O57gC%a27Gt^SG&DlpRPC-#{OH6FJytM^iu3ogQ z92n^EZuayGDuW2@q3Lkq%<=T8QrTfeXIV{!e@Zcqz+x>ntPQ_17QY(iN)!4Grp#nW zMvJK*_ww}^v$A@~>zDSYXjJAAd3hWF%5 zecRj|+P3PD_*s_r&Mc%>#Cj-o&FI+wMeq+*2pb;UaGeq_SCx6VPJ`ZAqlFVyBKK5g zQfWk&2hL*E^3-TXe>oi^nd7x4ZvEqSgzIIWO(O$?k%!fP0W9~byoN6eOh=I8eUZzV7=czSm7?h)>UFoj}3^>&6TuKGjx2O<<$ zzCtBXP{$2RVLsO37#;12sO^W{Jg$kKx{Sv+v5S=BR>8iNH^|1G|wY@A`j# zQJA{8oZP^}42`jybl`*c)H+@0Kjb8zqhTzoK5!A{{1_g|aan`3-bRNq=^3gz;5EqDY> z@_!ya_<6?=j1!@E=&ZO)74r^@fWU8Ric2R~NF-aSC_p4TLS$wG9O{mUfOsMN(mf+= z?~gp6#A-7!uW`M*5xQM0UHe!!TE3|yMwt%5NWQq`xC;#L6eoj(FMHpXt@e!v$Is2t zu&~%MjiePch%+$uOAYXDqyOp3uMT(FSH$yr71@cN2> zH#lE$U_8zJB=+FbKe+(w@5#}`Oqan~i}!f4N}1CO|>9p1zZ?v?}50i{HjcGn)L#i<4#C!UPY z)V+Xv&iw*sfY|cYplC|d)?{hG#319LxB~-qIGMuor(Y^Dt63$#e=Ebu*Ao>meXp+e zP1AD%WqdbzaQ^hesMa(NJzKEHt400u{q^qG5~X}qveKE)3wK72DgHA8+V!M+yH{I0 zPvBa-TA!7jWQEns`sB@&lWx&W2xo4sO}66=M#aXnT0GjDhj?9&2E@N+#|~#CSz4@V zP_&%FTUg$3!*)}|knI-n)2Ynkvp9P)dk^~f){qoIpY2(n$xt6!RCEYAh z&{Bd-UE@`!Z*NHCN8TqcE<>13hlJE}dj5Lobb|Y$Brt6f5a=^i!HM#AA5EDjS5i;5 zII+ET0WsH*$t>B2+JjMx?}-~Fy21R_Zcl%|(3KCG<$N^}jan1;c)rknDI-`V9V`AUtgIuBblH!R1-EDh$$}~)PUQv5NhkQV^)t*P@17&jPg-`0 zOPOx)#1MYyJW5c%p8k|QFE!~%C!#D_6cE$yDp3_FK_&9r+ZOpDeC4P8$3a>cK#7Rl zp1eSN5@W-X#=hQ&1wUG}~UoT?xI`l%%&hS{A5!=K}p-2DgtxHWX{k{%OZ>N!j7(yEGGTT?`K_*p)PY8#Cr+09&#B4RKC<`@(0KMnYQp<82#jT`n&=zQU-dV@qJ zr<0pmjnijp2nL~E76EQ)a~WPPVgmVkg||qXRcmq+Q{hD!sVe$5`M7Fviq^^jvtEqr z!pu+jqZ=x?7Njw$`;bOpW^50j%zZn3KyNM^WFTXpGP3V=#Tc2SB6)R%5s?d?Yvx?T zdr3Z9Y`v1F7{_jog6qj``vMNTH#(00j~qw4hSGYcCuB32xyy#=M<-v0i!+Q+E+9~QBw zzgZ2>GfSB0F_C)0G)YohlebS6a}f!TQTuudoR{QSY+K=2%hUbMBeCZTX|?jJO>p{9 zP9zpfi8RITydrmw&F${Ah>Ylc`Ck^l!`~Oz8{B19Ip6(wll5uA4U)Q2im5W+rZ&E+ zv`~dd^g0`(>F&(y=OJYQtvZGV_zKG7m;gE+h)*oTV*0kSdG?DwPW`ifJ7C%-fJ~|c zABzu?aYKsYfBkZ+{j|&T(ca!M1w{pGLKu9t2akN>{|zsNL(jkLv^yj6Xq?y{#;xJURMg*|Q@fH4rd>W( z+!TfrbraPKYzPLbH+bsn?1w;Zl7RF`gjajp>&3hhGSQh*&@+>8l@FJBvy-Wf3iv$k zbnpRZo%2bMl@mRzvWg0!ooOk;stWsS^Pc1AGP66X7#G#ki?5EmrCX!<{MnlXwV!U^ zC#*_jm;N(ge5g>rrHxjSdf6BIf5)U)V&h9S;FDaoB$%3ldnu*w) z)>OZ?V8(PpSOxa>dzsl&d3qd4rZ{2h=!~+m-<(2L_Wyf0LB1BCCY!*4lEPah98Dco zwjS;BrPui4>^cnxYV%^zuO^On$Fg`hFA`rE@wDxG=NLAW5}mj;=Hj8{?05LmDo4M% zI)aJf)QBDNI=b@;&oc(HBu)l}-&I%9b#Pt)q_pO%*ze5Te&5$=pd9IM#hrKe;Mgm4 zRv9nP5l%2t(FE(HioxL-P!8=#W8H0SnU5{47+4)g8=M;po&36TIaK%sOmwabiAh4b z_Q%_#l%vZ?zk{!*SXe5lpbQ%#+JSfgy2<_L{MSo2kuXWhXDA9N3MhcRY%386UbZ4F z1!Wc{5m9c~o&(+HIte~u@Y3jvf?ZLJ5j%&OJVuaYO^o4ett7S{C-?~%I?BzM_+Ij? zvcu!j5$5OkFm*}>>7r}AzwvVp?0hm@6%w}+9~8toQ!8n^d35@;&hhxv`^|ItgmVBU zEl*2L}hkjy%eblLbHC z<;m(L3o7cgxc0b$%c6iQZ&U`AevMK1M~~P5vU;hS0OYF^48|9(S8BnHPtyXu?)c%b zi{lsS+%N_Q8NV88a@4fPj|j88Kf!c3IP0jy5IR)3cn9!6OJO4~07sFjgi3qnCN9KU zJn3K!PF3+f1tpLV&W^karRY0dWUkD2=bplSNsbDBk6uj(&6L$U-JTS-fW}@>^58z} zj>xm+-75HbsG#=oBOTn!+bVbknKsbc9#;twk(uYwy8Q0Hr$?{a}AqhxYYO$iD4WzbJ4}+J5+1 z7?_y_Dp2Z{XkKBUb!AXCQ}Xg6jh>(p^OHWoa5BZm$M&#n_doHDQBjhP|A9|iGxjC! z9L)}|)ZpmEdl|)AUhpnX8ZHwpKY+3-k z59}kTXJ{k-{ypv}nX&$M^*oo$){idk!mNTWaUB|?!S=2hct}l^rzV>%SlZ0#BPVR#|$!KvFc~lS(%_dGH1z zvNIhTh#xUZg4s=%3%1ha54(JG|tbup!!gudm*qLpM;}U zX#d41Qc=L484r@U-<>9Ud2+Q$rIE;axr?UFE}$zJvpNT)Vy!INjfQ0AHbPVF5RV*Da2~ zjq*E|<(%+HkEguo7zO_qfa0FkkX|&mFQyv>RM*SqcGWp=^3?yp3hXHCHn1@*-gcnd zRhWtR(#7oG=Otb*nt-bhVVaEz3i&qf>09s=7q8SCVX= z@9;HY4Vr)5aWdQL=;$6L^*XVomDNawZ~&W;Tr!vUY>iD8sH1M{@M~9S31=1iHeQwDh*%){u?=`EX)$ViLkiO9Qaw8--{hNzJl~_}gGEzmL_dwg_ zc~8GMmC09?Rex{!T=%Tp_!3bkSCNuYqv=6~X?P({XGC6!5q}Z!gm6Kz;#f95F&BZ1 zmB%oEo$X@Mohc3H*id%%im%n-@Ls@P1Ch1`)0|Vyq*wPqjv0a-GO2d2ES?hdZps_)1iE@pI9^O>;X(RMWk`vuv^Z6Yh zkGAt?D5U3;!H2L|x52B=IQa&BAaXcI6%sOh=LfW91+@4xSS46**&n?5;8p_ghp6^{ zLvt}bxqO{z`QUY0y-ww}hq=L%rJjRZFX_|n_d?%1v)ET83%A%ECeW(cMU;PKBrz`c zk2#m0%>NP#<9f-q8=7QX4X_nWwd;!80qISDP zjVZuycPxa$$dmcS5+pdV!m#gyYbIX)ufShF0DtD^@+m%1R^PMOIqdr7xOvD+}MKc#jcs;;)zw-LPG$S=G1^2Kl`{j&mW^nC)9ZmMy12_JqVZl}XJEfTl>E9p6)07;Q zXvh{P8|}bo$)*MQ)WjjxJ6K0VvqE9NslRN}mKdABSLFowyS$bh)TehyLHRS5tlEF^ zDx=ib)JZ4=uIB6N8ww9JRn{T%Pd#5lx7;_=gIVqR6zC1YZ zX8XxHPrs}OYWm9?d7$@~q2mc3%v(#srnrE-G^)a>*4h}Gr_-q;(qukA;O}4R>9ibZZ zw)=Mob8!M4l?fTPmYZ<*P4n~u$R=j%`Un>Es5{d{e$XLQj=}Es##ewCJ6wZ6-t6nn z4>zcBx>wngZH=>wHB8RDoGJLpO7(|J3gNP+hFy$)e`HTXu@j`d!l5I~t0rF+FR-*2EIP*`{*F8`z(aZUTSH_IBOoCmU(DkP#))`G z>Odfu?L+ip_vJAcmh4T8<<;UlcE-V!=FF+V?SE%6%%PV56KS$h|7&fbi$F>+Ihgxc ztyO&o(Ysv4?)=ODma!4V*(&7&U}+I@O%Q(X`t+2rPFSDe?fdt!GnatiaoC+B1?n+V z)Sef{Bmo4T!dvYlL8W}66k`OSZhcImD+V~aTHM;Y1ZYEwQCh3X_N~hxuRrwVyYZ?J zJoLL8lLXcUUD8_TGrSz5{cytUiA!}5}`d|Vjsif%P2T+C3` zvvzbpZ_XSb|B`_-^-tRa1L44V_h|S_7#UxNy2vx@!+=wD5~&*Np7U=$;omR)^IrkZ zem*5kEGRMw>G^NT!%426sFi2~whg9)l)G)c{r-BnIYWZ9|J+=6Pha2IfBWN~mq-Zz ztsMT`4hR}1p1Oo-;>=ZgesP4VVPN11!8SS8hiJEkroWgj{iWeADBa4rfXzHyxITVI z9M_9N&Jz24r3I@Ps!Z}tS}$C-Nq9UQa`F-{_KXC}o~NXv4%JT0zT`F^nECw7p8 z9B>rx*O&AGF2D=yGz7{sTcs%A#Ac=>;P2KH2(PT7zXkRcga=gU{NeC01c4)%U3DiU z_}ItE4Fi}laND!pnWZO-;hF;hmBRl9Juo9lD{4w_QGtj3*RV&v=E3@Vg6dL*nN1c1 z3#;%cC_K7=50yXN=B&I0 zvWofG<>lodSwa*#xKJ+}aPklup^R5f8bwzWkZqxZeuv3@pb*587hdm zyW=H#M$PPRbqwV2=Px;gn*%BM0(|W5feQV=ULm-=W zX=OR!>OHC1u_l@2{l0G4H5~&($Bs+k@UICZ!{h3Bp4F_UkObH3LH^EI6B8>eRb7F7 zIqg7en~0X8I(mbdlUMPx&PMAy5#-U0JEcEZ-ES`PDI$D3uo?8qH4a_7ig4atem!@= z(W-h)Ts_c`9x2NEFf1X=bm&N`1E$oNb$do?&XaP&O@?Y3+Io601$TIr%|W?z@K7OH zQ2b-;lo9wKx?X^zvIU_G&r3TZ((Kf(tq3Ru;HE^SJG|R_0ELx>o%H*@$cVYQqMa7KE&`FZi)YN*}Kg((hzt|E>47SolV(gc4vufSf5TC9@ zv}%k#g&Qsx)^gYxR>flHF)JH&TiBR*aObhu20Upap@Rdu0jTPc4vZQo?cN4G69ejH zs@g>K(&i>^z+iFdmet$2LQ5R3g(jn-Y&9v+ys!ILCAq2x9}-{;K%@xunU6Ry0{buz z2u_95f6HCB^U}T2fd|msJdW;9=5^2C?o9W+3H;~=;Makz6H|?zxHwWWpX1wIJ`04u zfB_2X<+%^6@!2~6EVc(s9Xft7CQ6;m+bS*xEYHZ8bRx34Gs$Hjc~e(g#hg4UE&`3W z^)QmmxpF_??zX-i1n@jun;%G_+E>l?^tt)};-CdQ6LvRv z`Rk{14fk*WO=Ni9x~jZ}wHgiv+kAyGR;m&bXup3ajS1`<9Q4kwHK@By3?`!Bmao+%&w75AP+fek9JOGE~mW&Mqpu zxB77w@J9VebI)Kh{^9Z8IL_P)regpq2V77dA9GwEmd6o;P**d890ndB=$^;*+1&~K z_<4c&WUJ6UzMh&@Ow4~{q!AUDIXTpxRWQ@Q=ZfoKjJ2w?7eG%yfdn=uW^(P;<=2T=SK+WR-%`2Z1P=MiXNC~~5=J+ySGH<~Y*7xG9d)&@@A z!PFl_J-}|v(9lrG0n4gQe`*a1@H`4EOMN0}WV>}m3>7y~;Y>PjoJ=1ACfo}s4L-Nn z2&4ORJhuD6Hh}kE)tA7+2x`3N%R?I#FrO^*McQ&2Sa$qHr{8AT`gS=w15=fU?xUtI5^;IO5Z-zP`Q}pgonV@%YXJ@!9h5 zcpOOJy~`ge{XeX|by$>L+dT{@-60(UDh<*d(jg6k(hUMq(lLN^qm*=~(%nc2NOucJ zcQ<@{^nT)f-}`yKe|~u2F~H0<*X%g=I@em~-nlH3w5C&0LSQY2ULsM`-Du!O{laQj z^x?^V#;{|5A}hu|JbG7pD;7`XQRngc;Nh88O03}xA_BlNJ_ZlsGn=Je`nPJr`5g9{ z)#N7)ZtFCGK{EWKfaD6HF>hIReJPn7T_Abg2D!O1Y$<_3$WJ_yJm4q#p+5&~fY z4ecS%`(NiOMiJzZUB~P2NTf4)qMlJ+<^Nh-{s17rYmP|0asA$;MlS)*Sl-#S9f zeE_k$F;|c>0tE=rnT4}*uP8OWso-`crrk)0DJRF^;^MNwM+WWSLb2M`-|LRJ+5>1> z+g`~|KUifmi2#uswBV(_{Rb6VRsd}sFh}?TP#NigqS!sOWMab<6&Dw=4i67Nc`ISp zh9jC%WxR_kt2iLw*>br$VE|NcoIm@Tw!sIV`6lvMs%tEl(EC`bkY_^d_rudt4sCtq z3^1PwdD6{3vqE*&9+Y8_)ptFYEHQ#dU z`ixz-@9_B0764y@PviT500b8$MvJ!v+GRu)oQzvq#@ZT6P(P4;RZ5N%%~z>|iC#Gz zEb`b}`iZYne(WECKPc!ee1TfX_wTfT(EBY_rx=3Sy}1S(yK zP8MKcZ{h)nyh;6)@lrNNMO;#nl8Z}$WPUSc)QnkFpRsM1{~ToELb_G(O}E`!eBpfi z?K#^^3&l27=j{3i4;gRGjjGPYvY98uuDGF61r-V7R)wa4I?T z!HIb|$ft|WnuFfrOo-jKLG_2&r+@SZaJ(vb*SFw|hf_o#K-71-y~TqXW3k{ zK0!qam}+Dq<~kHlyIYAVZo0TOJC3tCFdcMOTKw@~z}jv3qJ&1hnj2U!JZ^V=P$DtM zb)d2znAZS*Aw%`#UpLYDNI)~A+%}kRgU5fAiIE~=XozB%dA@Y>Iqh+*&_kGC>%vm! zx-X!Lt`Va1vTm~R;(Zy2K(H{1slPJr(+_QJsM~I5^A6?*6w6HK;~(`J{U`eK zY1J{E)A0mZcCO5Dj)QxwXE^317KmrH@0n5PkZGO^3{mhmFO=}KygT&(FuL@5_7nyN zWJt?tfJLBsH$G$gGUsqNznb$5=_G0)8;Du=9H;w2$-29_B(}(gYyZ)s%11b>rf9W^%K&zvN zf|GudxYmUMV&Ev#yzSA35$W)uuEk07w$^&}6KDg^xw+fx$B(0LX$h{+C=g(7O7|a^ zms2cNaH0b05~1X5FM-$drT-Dsb?~=lF|A#XkVhp-eAVxjrYC_L>EO&h5SZPFIdP1> zr|SYPDY1+9z7fIzyd)A$ZqG_9pLb_oQr0H9Q5w=ps?ZgcVYO;oUEn${3hM*|XYaQpxz1uoDco&?-!X_w)Czv%m8 zw-A1N4WJ=n1Z1AS*P8L@kCiNj$LnvsW_&P@S?S=bxn4n6 zX{xUFrm8Mp0nfxhfcP$)PU$!$Q^3bwlr*J(mtX$%{|e1-li6W*!g>56N-$p-F|Z(o zJ+*x|iVFkMEAd?}j=JJPCEbB74}A5f|4GZ%;^k8H&}j@wi9O91PwGs&EULl5DFBM{ z>A`q1Fk}P;RsK^@*t(yo=CcLj-AWO!!(Bc&ZMq8L;}4!1b*cmug(_PgiNE6c7ou%m zsCb;(*Y64U0M{%>@ZgTm6$6Nv5@8`T1>)*%Y}!VJw!V*?3S#K|dQnPd+nH!5KT7GfZsr2Tp^ z-{>esmkSasbD$^q!kq4VRFip;WDf;C3X92f8SXS?QZI*}B@{yF}x{?+NK z-0Md|sE)H!x7F%CdZ+m(CH++S!Xh2mA&k& zVpqB$zIa=W9ykGBFup?qr+C*oEcIcks*xJ7 zr;)h!GR%#I^D`}!>42F*WO?Gjz4HYLln0vJR15wUqgH9Z8NtE@QQO;fbQ}iGy5ZGu zkte!>7(i7^Sv{oPm6Bc|Xg3k*2{cyT)Wj^p1X!TmP1E5vr567+o!+<2uX>ItvQ^NL z!skp95Yw?ZoLQGLaHLu8@xY7sf@Il{nqjUB>gvMgz&*OjmR(g*-`ybbY%B&;>>vEi52*FedPqjm>U>!!^4z zmquo=+g9KlQ$lp#fO|F_B+VOLmdcI-#RMM>#yOV zj^KjusfzfeQIqFko$qWO4ygN0Ok;BQPmJ@JEJ`#l-b=v^jw0s=&bA z^-9;=co^dr&ev}DNYvRtz*Rii z6js=r32lLJ*<2z31{m?QU*P9lE)CE@R7~7~+|)L?NS=Rifi(`?vJ-;I!Z-z8Zkp$% ze_;8KKhn6=r4Oh0cfaUbuS;6MofWGN%S9u8W2ovxRJI1st^w`@^-#X|aKXWJLBR`w z$7Wu>YFAh=QU6KGrax2kI(mW$iWmajNss}a^WS&!3gO&t{-Q+gekN(KaZmuL2LflC zyWmlXMC-r+N|_l;L5d=@SokrgdUAKycD2IE`6jvOtNf^>=GQQ=Z=$9kJUV zKcG)j3Ma4;;<+(W{wD|5Gzs;8 zSJuxL*Gb}+fx9z^4>*1%=sx)l?QwD^Byx4cL_G#>n%4QT>!&mC-!oqQuzB)C!=hM? z^Ea!e?0@D*lUYyABp*V;jq~;t4i%)Y1!DixkhyiA7zWMUrDy*ifJhrvlmAyB@@n;1 z>65eakRRh(&bF$O9$JiuxuA&~`FUKOb5ehCYF2d)n6l8q)%?)8e`NjHw zs1A7ie^MY8f6<0-O)Ly_x9a{>G`w=_>e8bbe36)%5u{wkl_fsD91c#4!+tH~cm%6^ z2PZ^xUe@cBt4I_M>U{|LYPo@cJ=Aco8HSvir_4MINofyC{|sDjk3ZVnAh>$re8g3N zfzkaw?t)?h53s=JOK@QFCwqBY19)@Z{(Z#rr0bHUgWNMsg zXa~b!vw)SJKn5ngs)8C?3jmbwPL|4&4jS4{(9M26K0YI4@KD7*q(HBQ{0)Ci2gPt% z0*{xt|FP?54LKMQds%u=8B-W$F+7I>P+uGHDGs&G;*JU-bpis=K>y%~;8Idj62{vX zjtF?_A#+e8J)o&U29nL=1UyNxp0VQ*&nDGhZ~gi*V5a=#%eG2jMK(Oh_JBE5d9KqQ zXzi&&{U16C>Hx$M`E600f28^;I}bRsLJz?~zP^Ehz|-@Gkn>Bz|LyKjj|SO!Yi}Xl zvzNMIz^e=u5P&lC-1Um>#=dmpk`P#SGL@-WP5ZTiA$P8R#{|n|URg(CqUbTVqb=qjYmv80a2_+RHv$pb`fl zf(S7wFDcyxcHqLo0L;14wBGAob9)__p9{q-U}cEKbYY3C=Z!yq4Rr$?{5Ox~${Ebh zf3J_2vvPb_zo?LWps8afRZ;V3)nnmRm&dXIDkJz-&hdjeqN>ZOrYl2VHW zq8CP{MhnS@PiJO8pklkfLp+NpF`5c0Kq3+@7yt1eO@-^ng}{2Ky(P9x@FOLtl*K8- z|2XO3WD7AvDd<+%FE+@*W+g0#LZLJHvPhrN=>uR$YH?{mEfAv~8~TcvkT^*9e!MP4 zDH8r%RNhiPGkj7{F6@OOhkA@6h^H~1+;`15R#yy^2ij`>YnO3+?WVYFmnLAJh5?M* z-W0MPTo9qqwHUU`x}pj8s_N4*ThQ| z7#$XZVj#PV_!3#c#Y&46oZZ?=C$;Ibe^yJxX*2$2Of$<5G5(OYa4^7!soypiobzs0 z9hXcHA3c5|0*Ra;e_QQj$c0|x33-)|nRd zHVWM@YXN@c;wIFF;k}Mx>RMZ?$YnP&0NI^}Z7bc1fD#n}RvJJo#g$BbaS=3{2#Mbn z=xOgRs%{1JYnLH6PhE332$g`Xup9&NT$cziVO^ju6w7hLZ^TR!+tCqCh8@la2JrZz zln@V^%#lB$8{F?I7ul+R3U8|z_;uQMEfhk(uu%MGHa`Bw2sSs4M*@vAbt?)Z6rf7v z8C-yZUrr7${*M&RjI&%wDV&2@q!eHrk|UaeKsk^>2jr&gI*>NZ!}*yIa*BV{A+_D< z8=Qa}Gn6lkNrpS_VtqE653;1tyW$RX%!lX5#SqT}>gqm^Tn3Pw%aV zM)yW~+lBci8i$Q3A}+2`7n2Y|VW4Xo~lpenW&a*bCV2=R44A05uv z#(KZO_fXFd5-Alqb}v^~&ldo0eq53naZe7mt{4|MjC{|>pfdtnojvbWzk!uiw#)8< zBzQH&&&|`Kn8qY`3#0Y~#_tB3=P6r)9w5MjuC z_*@GM)Y5wK{#GM_!@59nSHLA9_-B-a`9vWJ8c8h}mu=_nUN7G5;a1J&Z&@zhEjEQP zRm#pv%0I!$)Jx+&yyf1uJH9G;Zuz7u+d{6)=H8&{x}e`EUw(5F({5+VrqkeMC1v!BacVnKy_C4%Y5^S`xx5%UnjaF{4RxpVj!nyWCPiNWX>ch)QD}xe+dSZ4CJW8;-fpTie4VpA+0YhpT!<4R321|ffn~CV0l~%p^#Pxc>EOJHzp>t*zR*Vb8G6Nf4Q9 z0^fY9R0yW^$ICDP4Rg-t8XXbOoF?9$8@!#t0yUgQYn7}C9ET}|a?N<4*rNLj(Mk+H z>@R!Q;K1q!-qF$6C@8Ybgg{A$7B?J}J~}wDRVL+!P?F0halL9Fv(~)^R`pU4ip#i{ zvBX8Op@Y1P=3HZgh!F$~K+yJ5X*ZK1FlgNE`(t4R0~GnwKg6sA>}+(a@fb!l9*$j3 z$Oyo1zRh`j12`WfdtQoH5^71`S6Loo*$Qev%aN;~Tu1DuV?vOeOT3z(?nsUhyEqd} zVB1`+Py~*g=%@tW@A-O%7l*1=)Agu8|KZ#-yZpGRS;mG2HG=}&E)9wDnHLr}5Fh%1 zQ5bFNE_7Cw54$)b{7!m{1) zaIkaQDWzcG85`ex2qc0Soi(c0#(HR&q~%m)ZB}VW0#A*!;$SPJ=Bwyidk#=Xrk4RA z<6Z=%It8?>GYv>-jLBT+`Fi&d7L-tYWi_T^JX|Dicn5EZV?EvuH)*xm4NJkX26+4j z?UF8(eRRTL)e9pimwNu3W7LtD6O?u;?*hWdxoKe>6%?WOC)a1#v|bYm{h)9VLVA_F zzgWVbEAK^M%JJHM>9#TVPKZX}0!@_|D(>4;CR6y%i?jVm{*Z$*rpr6M`zpL5?FreVKP4Ks#t9h154y4Me$ogX` zcXzF+DZ&`nEGc;vV7Nu&vWDw*c}C_Z`gns(eXqKw-l6eYTou=1%TW z2G+&k^Pfdu>5ag%-%@yf4FnbNz+m{7h0wFE4E^g(RGPyBcvp)z-@}3mwc1A2eyu-C z&roLpa3QM{s=)268_B}RXHlk z`+~GO-d(GSau+}Eyy3$ zpwv5!mGB>407uK=Z@v9VF05u4N+smFFOv|)N-$xfLdCz^I(CLTTEZBuu+0KhqEPUp ziF|7w4izSWU;e{TJ~H_N?PU}_RUz*k*iRZ zOQXkF&kWd}f9g4MZ_#bmU0ZP9yIer!{W0e-$UCk@Aytw0iT8}=Fk-|^#+_FD{_!q2 z&mt(5Dh-@rL4h!`-rY5)-ORgA3O8GAu;AtH9*6={05+eUgY~|jGP+zYZg{?X_AW(Z zMvU?*aAAS`tQP}cLhiBq2sKA+lYgFZ|HT#@hmYH@SO{92oJ)v=~U%WYtEE^Dv--_4kLSnaH75V*&Q~$G4 z4X09=Yz)CPu**k|RX@{(V9sF#wO^3;qN?n4qLwt-m5xeaD#bpJXKE!lh(c$CoSavp z?1M=Me*hL0IiuhdLQv4}cPwtdy{UC~<^|iYx9vK0Ki5fqLX*{U%Vnyf(jk!}h?lv) z#jtBova1jKJ?RkcEIWEf*HbW{g>JzP>^`r5ku=qG<+dY3-IH`E+PHE;^85az-3iGG z1<}Q9BAn4E#YqW$Ox2{^8jU(qGHIu*xHy=~OZJa0m4lksrGx2qQ>+eT(k zB85=)o83F|KAtHB`DC$F1qzO|83IbL*oZK?$us|LFk0H}vpAx3mrKk8cUi+2B_wQg zFGb|eK8Uvo0-?}#zb^m#5*FyIn zP<>Hcdt$BE=oejD*32mF-I1t(BZFa-5|UjucmVxb&7GK9@sc!&fn_23ip&C>MM07+ z7{;t|RhDjE$tixvtAaG_T*dk2fkgXfF5U&@D~`(>aQMzpiIDKs?ZKSGb0@|*XNsGH zz)~Z`x%aBtM$iGdI7Bept}7@pCB&t{Oucp!nBA7AQof9G_N7I2!4NMqe#nz^=QV`p zDMRsNi-Ur&gkuV;;yIV@t(t7mh36m7o^x@yBOnv8+i5TbJL7@9A?-D81O382o1cZ1 zcE5V{s>0=v2Atb=B2wNgB?OM|a6fGrjD*}!hmj)J%p=6>qBJ zMgkt_6%#M+R*VR=h`TMv-kl+YS}*M4^|8zZ)W-Stfg|LMPTNXFu?Ij6g`j zbC{xl!2`qWhzEx)Z%LZe$5b)gS)B5UiF(Kn%n_|y?u$20`f`N5=~;VJ4nHE5EB#!$ zNo+xLQF&zx?!30Rj902y_1bb0k~`~YSSFJ~!Zvc`Pm>tNk?z~qMGV~#f_B>tTF_vm zq7c#k!}(_*WE_qz&a?|nw!x*FBO8a`^L}1=*RI+SHc{B=Lei2^9g&3NneB*=*i8FfUpZRo`%7>v6qN;L zXSAwy0h-X2P$Ab%*vU<(?UV2j=O>inVHbi=#|T|4wme}&4f4EuCJCbPX9jZscYFl@gi z?$M2)3Lf=LnRwVq4d`U2(w!_?u8#<5vLioLSI8ug?67P`-QLy+iK}i}qJ2ITC_X7T zj2J7Cai6+}w*35AH<7b~U1Ju~FoSVeV?dVA(wMCr9H%Ro%l zcS2Xk#?cdZY)`Mb<_Ef0BsYt~Kii#`@(;@}rxas7gfOLgA0FijI%uwHl<4-=+8IeJ zDu&v#4Nsy|2>HdtsHv!^7^?adw_BF9owr~Hdzx*f?rk_M<}F^s++CtH9=bJt5|{T} z8XbG*8Fje67hG#)u*8ffVQ{h$J&gJ6Dm$Sj5HWgE!4HcyI*K=GIPdEV2UF#n%^o$I zt4DF0kz`@!yk!%((W*W0*wp5Qiz8~Ct&Q;ev4VL`O2TDjczu?l{PO8l zs6l(cbGv1~4Phk?G?z=01Rm_-Cj2<2(7j7)EYSKjQ)ReoDd=FIEY-CRetEUaMDFF6 zNgkRe+%^kcx$;_B$J9;Rh`A=ntQSQGN zD=L1Qt)yHi^V`4$5h`vtz~pL~Od?3PJ(l$Y<>>v(+v1iHrK2~{g(@>Y;}_?a^KC5n zeHeq0B#7(uj~+d0yxzP1^5#^2hoOQ|^NL8@i_;i0@V=g?S@%H*-1LW~RJ{%%G2u1K z8E*?0>F>#onyU;9MfqR98ui8PDl0q_r+JBccd5v;Mp(N=pTLFAW$jxr5Tcb$ZjI@V zn})du!FneDK~eaDyg!9D;l7&ks^v@&1LqM&oXF$Wvt9n_W=}-0aRNe*A5w{wo50c0 zk!GpBP=%@delXY)t|>(H>boVOBmniNfnXDV~$3k%=AdGjWVNIvNHCGLVd zX0BpjC`$mHs(v5-MYpj2T&+tUiHndH!l&H!TTCBem_n4iK~yKVbAq_w5?A(tN*Qbd zCdN5`146<88H1%_LO)!pMxiEzP^`>-Fvp)k2B@~2Z&DMPfI+vhJBnyIY` z+7qL((?G}6xjCx=0SEHJtswhvUIW|c11tCO@%o22LmX4*XZt=ju(MXSbMI>}GHW|_ z6dTctY)u9pK0R{Qan!L%gOgImbK0Rbo~fi8j-Y*5J8yeBZo5@*aNg3c95_iy8=<7m zd9%apvU~5ld)z$muFsc&In<___P7S$koYT)D)qkpbfpDDNM_~zy&p1Lc+Q3^n^`Zm5=o?`N*t$R zE|bY1Vf(BJQYf8lzOGb*bRFO>^6m^gf=TL^l$B)z<((nO*Q_-p#Z9mX=suw=fsw+< z&1DPYWAwSmgMzrp&t8&ZE8fcDuwmHT#**=)%;vR}F@`c}RZFlZVqNFtv<;-RVKa=> zjT|$g>UuruQuKw~uwPUZ?=SA#w)$Qk_7OZ-$c0nlNG_0)QX5!5R#sCBvq((Hl~Gn^ z)h*$cr1RfMd=CzAbxUSHC6x5CB>F&e^D&XBCF7*%@}WS(3Yi=@k+mWZR{tUKO0sy+ z0t4MOhh$orPj)gpot`s)XCF&$-7+=fD8;_HXpn+tZrWN%B(1I&>+1G98oKo#Klg3V zEDj^MO0%;?45=#Jd{bxw-!RUYen_(M?ul+uXXG34gIe8cNo5 zv?1v1Ls;%(z@f@5Q_Fw+p7PU{)9BLJI4oef?2i?iN-86V{u~m@Q*{+VJ)fxESG174PeM?LkB^qg#CBE=TeB2f#Uc?PTn!1U z)L7nq%$s%Ef*q4;-V97f@O#|WGrpO$WPGo=-p&1pq3afBPsK8jK@7#BD#l?mW!oBi zG-l=L9pYH?bKw}nbu_gz?)j62VZR{$@l<+MKV&LsuVer=S(*rYn5V&r4{w#cHhR<`*;Puy1rrrD_20o?u99?4QjuH9er~3kDU+Mxf zpN{sVo}`DFVn0YV9c+!E`{rjp`Q{eceL~wm^BLB`tDlvD3VH^LgtB04d?$3&A;mef zmY46hT4;>LRp4pdwDiL&%b-Q!NP6>fJH6Qh=kW85x(DJjX9mnKxxcYEE7`58h^ zBXszX7Nyr`^ou!h?=PdDQYkFiq+hQPRW7RoVxO3jLo`<>OBM-7>Oex#*_qRLxptjj z==S|+_&dw$wZi4tSY;GqNN>De=Da&g?|Pb1iWBkvyd7e5b~f|iuvTZeV3?L`;D`Qr z?cN=lG;#EYN)2vPRyFOb@dyNs`|_8Or5>KvMD=+IRY{(;+_w$0t2I{B6BgYSCa=L; zxo7XDtQFVjwZ!aSo;}BR%l^<=&iH;F=8M=FA2KCEHNXW#oHmcDv?wwS=M~Mr-ucZH zKtvpjJNXl_>@xU9SZ~;TC6M|?3g)kns%FWlp4z~I*%V|t807rLt8@GGC=$}xe{pE^ z8=gfqHVUi_VltaI+HkS750y^W-|>}LaKZT$$7^$0q-O>Zxcp9}`aR)k0VZ;C!;GEB-iLQmy&Rm$2O2HRj0EOAkuSNVj<~sK}XG zY`&6D&6XV{Ev9>1(M?$7Wk4>UPW<>h)_7qUzv^D0SrvH5tmklZ$mqF@tl;t!7RU0f z52!H;^q z=X*zp)q!Q%nurQ&1R3k8?ik)h#r0cJ-MiBjf$HFsSWRIm@K7FIO8R3xP_#V7p`W-T zh})!bo>9&I#S{eKv77d0{9;!sPe`MMVmE&XmL2ZFj_Kf}aOPJ#rs-}0k+lShZhn;( zBSM6dmb+)GtL_Qx>kkzK4li7!aHtI1s%9GQ;F4#DgJwpFYE)Y7pEm<5sUZx*P-tG zn#C=5S1z5~kKgSY*sM4iC2?b#++IcLOl%BqXw`I4s#$Ty3P{bzH4u3^F%2~ZF3HQw zHy(UL4HQ`0Dk^!_^ofqypGT8pru`sI#2xM1fldpU>^ChE?*NeD3PD6~8k#2SQ=~LUaW7CMvplnUYqe7(x zD!Y(Mt!Jy57e~0{Kj}0$Io7}P!CIR(%VNG*hr8`{HS{ zIJ=vmp^d$0%ew4rGdFuUcN>kMRc(d*nfOqou&}&}!bviCL5@!oAi=n|iU6sPq6r_8 z2{`I0R~qv%#Zq+f$6*OdR^=HyBnC@C5V;wVE;NY(pFTR-or_xW7RT(3vCU1$-sR14 z$y7b(#esq{-j0(yfQZt zuw5T8zooyhLzxzKC3&?hD~u8IJ@@5ev*$G>!M*>_#ruyRKT-$NTrnic^S!$ISXlTo zC<|9nT(1&oC03(#2}T`Wr@Q{wM;DqfWQYUoK+Jccb!W~(8;Vg5uxIgcv?OlkY&P`d zw(Y0u$()x%kj$BM;(PR=0?`9}uk&Z51)u75GIMhS0s;TR>9v5hzXaz znRrxb*xoLnkjfnDP-F-*FfkboXFesXejVrEk3T&-4mJlDSALGqEO5da94z?B1O^Oa zfPsGYCOlH+)tMw>Ge-J=h>74EI$cv$rTpfNtgbFOSY28cBMk^ED=YP1zlvm9V^~(C zqZRSopnNVL)^abnVAp{?;&r7OQ)M|E&$RvAcZlyy6Rt=j%W96W`+Zp!*cR>~F*R}9 z$koLuC1RuT%2c$px=CyrKUCRl^uij4 zJg4*vVg_B*`YqZ?TYS}uizF}EUN+Erb#*l+F0Nd^4esMd(6VDiSN#>5|~cmLs`3#`hmADrj%W8v;u3RS?N5(d+$fo;cPGiizq^r%!n_dl_hzxF4;|Y={ ztI7TbaJdU#j}8YGC!s*&=)9%Hzj!;vsnk4iV$-mv>1hjiuawQ5{c@}({}Kz%vkum& zWL_zcV$a=*+#d6GAIa%PqSLZ&rE?)aZe4fuo6;p3*Jly z4-1(%7nJ>Ys03%)RCIOQW^FW{yB>)@dr3ICYDdET&SbVaMQ0CuEZtr?SELA2nePb_ zoip2XmpfX<5e>1jx1!b7`)<+Q3o&}Va+kKNpJI2z#@;WuUR%G^_GE_MWFWJRvfc&9 zI0@v1;Y6^@$5ewu4f)oVYw5dY4X+;w(T#^)Eg+(7ANib=n=sh&r8KcYU#4SW9(CHw zSIX$RF%#-S^6F=A(17U5HGX3}Mx2j?=TF#5MDo@|0iu;~?&pJo8eZMk*vDNYD`b@K zZu&{=awV%5`0sFj`h&9qV-30@7qAt<11Bdq*Ubxxkzl0M?ESRbb z^$aWKNvj)|iZbedn%bK+24wD(l+YYqH#J9HlUH5!Ka-rg3lzLTLw#hjpO)6XCPu?c z|GUG{SJ5F%js!a)DcFv!8_u@IMXCwM2W@wJiJCEXW4$o%qb8~a|qNw3P>?K&= zoSkG@MU-@P-9YP&V=~v!_5smJ6PKooKce;9(ZJQ^;!n1jzoyYvEK!>$8w8>vfG_A6B584MXnR`6?HZxKFHS zEj^^?s7hD+@tCSM85>zIB~zPGlPovx$l_jT-4fKYnP%l@*ifFoN=+H42I z-KxJ!Yj|hVnA}ls=K~pI*LjWM>A6fVZ_?O}C32-z*00f?Uuos|QnKgkIgc{AEsL(h zdTqS?GR=RzbpP!4&RNn?&&yo$E=kIlTa zfsNs;mBgnri+Qn;!d)QJYT`aizdufka`>gH5=_0~Y?FIZQGk|K0cSRILM^mdZ9RK& z-g4h>T{DmG|K9Mi`(Y;mZ0+6E_Db>-=~NaBfw*DGC8^K96d?Y;8c`!Qw964fd2oi! zF&n(lrI9GF2(`XR2Mp-Goam4;pSslQZ5Hqs>8l1rvTKN>=meeJwsyg^3&U6S6vaba z^xTCo_j5g&{>c8o{nAS#4~Xh7e;dVD&Rg#^~^*sGi4spey{AMAr*S(N6}a6`6fN5Nx)m6Cm* z+scY2CRqaR55C2nT{8fro>%aj`r!CvmJr#i{IusDd-RNGvhn?y2J1%pWLM!j*n-V# zj)cv<2`MdAM>;z)MrBXKf_g5EQiPK`+RSjw8QLaK&=aX2z{thdtx7EBy7$rQc_EKH*|wu6yYJPZk#M1<_eJ10%rATz_QR5rog9AGfWEty z#Jb;1vSI`I;OFO(hvV)_qOk;)e~EB~*~HJEd&UY>v+S9N`Dr^l%_sFD%gvRc#Aues z9vt{+?#l_(LK@&C_sh#mW$C9KOVJ>K%KR~=HFeqiU|wtC>uys@o+)xC1?K)lnb*0b zY#cdy9QKM@21avB#IIm9ehZ?{D_#<}XNyYJ9^|l!O9m-g=jzHgY06E8cs?}-@al`& zGM$qIOV*m?LYV;3LUstYKeTN3IzHYwSJy&hMc@IL41+7P>V7HGWOr=_* z#CUyC!NnC~<*Pe}Cq`h}sCnEzpr6$CEpheSxT`ne88vfqgJ8t6Xy4BFQ0(0y2uO_K z-S~x!FXp`KV|jjax_j**ZwapOt#mRk-zm93)uQMgPQo$MoEZ%wmN&^Dt~d1Nl)OPW zEqJfQU`j?WN@xoRb{vnVt+=`(DF*!sbibR#>jdOlm?s8g(&oQCV0f`nA1oTA`!xf0 z_U1C5rOEFw5~4rn5cy?rs1F)lWg`dR&arl$r|~<#8ylcmmZD>lXgHL#>L&eAZbd|T zw{x2rspPaHa;vd%zUb4tzYxT}nvnl;TgaprFZbnV9Dg-YQgSd1GXwLhjk^ZUowXUV z(hBD7F*mbpLi_4zQs(s}%A#4%*3pDrdE*v(_XbUeYPTUi--Q}yl6jx3GN+dv1k>h4 zgteG3tTGIAqUT%{EbXXGhlipRhU)y*lTOTiq`mEPvmtk?d3W^dsN$Gm#vp zahD*~0{5(z-bL0D^=KD;=OQ>{9)Pqtu}CbPqZ9b~3|#bnNsmVEBX z*1h-;pj{J*S_%AUXAt0KY^nuWbF(RoQ*y2dD+P7Zi%tH)%mNT?n zm%_WB^WnTInz!lT#-kK>QSn|j&#`y)DWJn?Sg&_}-W(Skb>?vcg1D1IzPRy7c0lOb z3OLtH;J>j+^-W<|PJ z2zB(}Jx^CToH4~UT%yBs8Q%4gH%&nkz0T6)Zmfw$SnUdTg`OcF{J!wwdB#Yzs`?4q zaYV#qov+EZr*fqFeNp90o<-sq(Y$ylX39cEX^8a6S^v&rZ@$z`Mknvq8zeI$oJ1@ICNqVLI4y&IU+m&f^n6V93vwm z2|RXGr>Cb@i_L<7`NG03eWoZ0Oz�Hs#z$Twmb&I$~KF7L=1-2#@^33m|Tz|Jq#m znrJKSJ}marhr{bdBV8}v?_B+Vo7mNi^I)f+mfLk9TN;CwM=V3f7FB>57#YStPruY=@jl z(5@YEQITsuLY?u3NoY}~tOykad|X#!e+CW8xo56hv|OTZEq88Ec=~Zx-Vd1LAh&Dy zEEToUQEQ3|Q>JzK1^EViTZFC&J8zQZR1ZffuS4U*OxCX>m?!lu=P;0Xq@Do`JmlTtqari;KXp@9G4zKC#Hccr%`K7)9%ekViZVsai=RDL;%fs znt^iir?qAZ{v$8HwODQok=lXeEyl3?D_NW`KMJ=pILXDsuA2Il(Q(9 zPA*d#lwh`gc4J%!h)vnsMlNKkYIoF6EG(hs^9x)G&;gkqov+ExC14N2wHpmso7`BrPXGwVro0cgfiX zg_mBtd2bAof3uW-RvC3|w(*W*_D-RDPn*KKqz=aHjV-Kde=2!AHY2p9j(Ysw+3fFb z)y&JS&d4=5TB#mV6ufbk55sk2jytcZ+VVUjJ>jR2&)v)2N@4(iK?gC_cuFQsZe5n8 z5@gmO6E>k#0?8b!-Y7kYeed;e&T0}S^;l9E9t2$t@aIPg_#Ord>-T@g-REfvsIhne z0|v640+slC=rob~4$yP}8Rkd+TlJ%sDk7Ve#3&=l)ap;eW|Ph;#4p>$#;+{DC)x^U z4SacWobPTieH@9=y0!GD_S`yM*KFxW%I=o-e0Bzz-Ujd9 zk`@XNcZ%=)d;4K2``xjwRG?E#c07cQ)~}VVdv@1LZq{UP_TKWO~ zDg4;zM+%yt0kWGGQ9uhmWHeC>EF@b$-nHx>Mn!SPPi$d+peFZTn}>wvH|~9H)j6Y2 z*H|@Ln9#GXLs^=`FIY;1OOV^IZTLMsJi42ce(Z@gf$a-UN*VuQqPV zLYT;|JDQ14Mkjb;EL@e8{tyY=T{?2Cxhp;jTtO8-&w^14Jc(2Eqni!eo^-gb;dq>wvAT_b`SHDNni|!ldccXM6#hdj5YyC-| z1zg!e82y-lC~XXI8`(Jgo#Lvpscw%DyCJ%B>!c*_f&NCS@FCqZ*RnIzmz;($i_uo*_67!DgwW&7v zE#HS3oUeB)pZ2TgyXXDb0;V0iDV!sVBAvOi1>4K|*AR`%_W8x^oeCKuH@ciO^1eNj ziULhX^4C(rAxs9w;**owXM8=b>sh>PK26k`fTV$8lrvku#(+!xmM}i7a)@8;7Y&+H z>UK^rS~xkzAbwcw#tLuHd>Ft&x@&u8u1Te%buW39ftoBAFKl_Bx^L7Euy%`cb!KBx zdBuFh8AGH9b4J6?5ZT56SiFN}CP?T@F~;dqVB0<>Eym=B*8G|jRv3Jc66i)*;nzzol&-&jfP&_hcfQ=C=&d8W%SBlYhD6?q_ zISX97j<7>-JJ1M>L60xtKcxUGk?T-eulR2zTu?u2!j6$%`rH4HsJ9HOvWfPG zkw!`y1Vmc8JEcpyB-}J2-5mnLMmnTRKtftNH%LlKgLHRyzr%CRdH;OjQgqMF+-uhQ z#hRIp^8{a#J6xBe^JmEqsTW`VuD(^<2TLJ5px}k#<&vC0YV8f%zr~xUsIZ_X_qoWc z6hAuD8>$9vPFf&!?UU<~GdZ%GUC3UgP6FrXpYhN~of~GEbnRf6IeIEJp7#pzza3ax zEN5j0L);25**9_dN5h$C z$k0QfuYPkrDrun};{qLrklWbDF8$FZuA)UZHqdL4LrXT&fZ@-gwOiJqaf$;t+uMzL zNB~v1+l56(DE21oVHXBOpk-`AiO!5)J_5DqY@L07+ClRlWvP1^xUCLo$~lEY7d+=V z=HU2M-Nde~k;>}>8Z1dkgnx(r(Kq87OHRfa-mH`Oo(|07{_6zn7!|Xt zg>>ZQ(cq|MBB1{SzCeS4AMF@yQ`USDdj5he7N;nb26Ll3cGwUZ+)H$dj>Cn43OuncfJMx=@%@rtasH#KzmVKQ#qj=(+V&st$>vut zi&(WP^`STOuJ@TQg%#f|e;hJ^KZlT2bp1ZkGI4e_u;JvmRXS0559|uaUxOQASABnJ z@M34DD4rehlvDc-3{wxiuth1eiivSmxP6>8XqYx=Fz%mC7nr_yxZjS*nQkO|oA)oy z@yGBS#DC+D;dpH1V9LBS(x(FQPpKjZEV--jXDA;QKjT&nOIQ9de(&YI38_1ERRe%{ zz7=LjHkGkkr9|P`)ACuwYVM)FmOJ9rmJtUgb`7chkD3$6n+pu}lcB>3h&|JH4|(<^ z5l0vLG2Qp$w)X@FzhK5BA8?&F0G?i~ap#<{YX}$b$6_7}t$R=;v(oq@aM5rPOO-#O z&=D2tkkk@F@{z{)uN465F1L#lhT*5|cdl>=KDc|_&iU^wuhI$j(cnvk3TIsCTf4u# z(ZfwyJ)q)w{CAJ`P7DM}PR)vKb@1|<@~!@@9^dU%s&;Iuzd8Noe|U+4)qncdnq>UY zBVc0`WdzRriPZ{r0>}g-q>!!%AFe z+n4dZcwgP~3gKNx^SO3J)&st}#%hMr86gSDax_T*Ggkm0vD>D8gf-5D`1o+6?NlqW z`KWj+sI==DD%KO;fNz5yhr&ginHPTZRpC45Q>*qff#%h~kx?6u^36)z&Nc3lOm0j| z6nSi0{8j4vJaLI?O}^drB|Ij`K6uPrd9Ma}f@>Sibwl{fyg`O6VyzDY34{7Cv}w=& zO7OknhH9KMEU3=WwKr%9WNZDWY`xKU`wEn7pd5(iFG!d!A66=fm6h0kAuFB%w)6=G z+(JnCn|qs=wl!JE{&ogPg*0Y=e!H|tOKkiBKGY4uL}my;siC&*48Khl+ea%_DVyI} z6N|cIN?JaqB?bv9*`iSs3oWWF4Acp+92UKoaQ>4;!s(4et;|H@c(e_w=bnR-^*KzQ z)DwYPg{}P?Z{$}VF*8U{QNa#^rZ-{7W|?uPm5`byDGyRzbrQL&1(NB|IUKvm0^yUO zuw}sE%4gm`+!o9I^-g1wYcKD|6?Q7)h(;B3tvQ_7Q-%bPpTgOT;wU0xlC6cG%2V;r zlYqjqd=lCOd-r2_%-{xB(xOH3p-5l6@~N)H8d3!0eE-Kag6%HC(4XyhD*zB~4f3g3opS`>R`5y8ZH}p-kcea9)4tP?vmH@u1Autp-XaZ!uw}%`YUn=h$xmT~X{J1#)Rkhky;* zHE4cWi4N7M+ZP3*^VdNhi~yjI1egP66Pw`YM>aIhkWm&M%dvq4w6(8<=9Q9~%CB~>oObY8=+*Ng49IOHEX1I>+?oZAT-LXRDNW}3YgOTb1Kwa0gofvF$FJpM6_Y7%!x#k9 zKYL5@CWS|y}$}Hfkc}iD)&dDjn0*X`PudQl*Kc`GXxvOyLx z$P@8a_OZ5uqbyD!gQ9f?+@@FQORL(k!N zDfHKDr>nJ_Tkp7uud5_TIv$31yng!q{(2a=gtA7v+5=68Aqfdb>%lMJu2RueHD#41 zoAIeW+)Ne2Bs%49wzA?xZwhYxU!BKaJgShmAu)86@k?2K8DG|k6}N`X^}OY*>i4KZLb zJnu^Eu84<0Z_-b|jk)5!dmQ7@Vlt0g)FlnhtK_m|bdc34-NYy+>KpXAwEVD+5r4?l z4jwWlm8vc(uV*@4=kie#--=x3-*U(Y0)*%YAeBo+Qm+%sq&GG|-pwYYyFa^j=8H-2 zd@nuCs?FEn0&q+u$izChT;=kVMYrhb(M`I#ud7NM5ZJGRm;?`;f8Z*P?tL%h zd3~8S4JnP|Dx7cjGp8yq-9$r{=Yy=OO?K??Cl;2I0VqAz^baWF8>{f81TtMEJxXYZ zz889`2)ULb-QULekc{ut7pj_INa32SV5OAA1ZUPnbJeDGRtyMch&wjGIwUjiUf0{j z7kH`lUVs{<^+>B!-qlp7+ad~g7yVpO3hxQQ6YL9o*7^MWW_{e!}pY+7= z!-FZhX;{KHHbOLM`s)jbfdjxSmzHA;5I;ho4gNLupJ=oOHboH7bE%gYOaRGw5`w0h zUj=mTIkTV3C?sWw|Dj)PKjGTik`+p)X0Ut~*_3HK)zfjz>-X*;;t?SbJrE=H%p1sj zxgSSfGq$$I8j1ZO-dn_T|7$7Ta$wL@7~DccnV5IN(^Bxgq&73h^H z&8A_%g0@7qx&Hs-=}oXA>M~l_EAxbM;iR}9vc@kzfVm=Go_Js1523Z~#59h!De?%P z&l>LJya%Bbm+(DFN;heXrS;*)da@Lq{2OgBRHXk*qiS7Lsik3TpSQ-o<+#M#ZAA5| z$&TDQ#ScWqSMzNSelIHF2&}hW22|=NCtK2zEeGKCPki@e zD(6(c3LcOwcM?GR<-MlES9aPBjbH{n_?eyuw5WTH+uDKS%j!q}p>ZNm<0O*Swe&gP z{v8x5Ot)DNb8lp$JT*H}+2-eD_8lLj?D>JwlGh)4f3cR^n|58Ud};pbJT2zj%ML8+ zNokBc#&B$0RP^u?JmUEG*ZT3evIh*NL;@TE#9lcvAH80HE79k52&%9W(eUVEsL+4P zCM`zaob(rBi$c{kf;_tjh%e_5eoelM{y2S3_BE0CkT_KO`b4q1>EL($q3D3~aMv#v zU{=zY_ey_^dK=2TYkd zi*wrGa=BPxy#zko%zal_eg9)LWz~YoUk+G2fLvu26)}^O+H~*YtcZiRczzbE)e>=d ztoUx_GqK{Ny2J3tw(VFH3o5tV^CFuW=r*yFAZp}hQ80Ro3_UAq`|R&WBg&Wm(Z&nA za#zA4tFe3zx4r4)bniAaL{*3<((Kxwk97-QU55m=gxrjTPchckyM*Qgl2ZL`=O(w0 z##sRy`pfo?;m41s7(PvRWhJmrb`_xF5vF;VkYJwZ& zKuu$6gqdpOD;f9Udv0rWf^Gy<%1+~7^idYQXcWNof3;J$s4}BdfctSC=TJ9?LVbTb z`C(cahYpz+D8mW9j#R1C;dc#?_+($ZX6mpv;LB*dQ0=6)>P1UeccjDJ@zE+p506ZV zkgo7V_*=&%1u<5-&1lyLhd3u#Xx%}$a>2zhZ&_!oAW}_qYphtzlk?}c8mViU71`=5 z)Xg<)B;WHDuGuwfRQ=d(3Z=T{yP*q?wjFD?BHQO2DHlKLzv6(ffbM~ZSfqW4={pV! z6|zVXE9O!H`!9Q^eD(=5Vp6biSoiL8u#ZXupOnPZU-^lT=(+R!$fA%1%pRan%WOG{ zS}4{;W)#UP;10HbJBJp}@Q=-rQvrUYX8C3hW`f8p>K5q@XPvkI-?=#@z|qkOnW12J z7BYSZB(!M-lH3@Y(2U^sNXQaMaTjpI#K}>XjI4jqk#7+aVNw~vtH-oRxx~`*O*9@* z`2AZzCO?+m#H=+V0g>&Ci18a>7D=jIs+gOli0TzD(Ers7rZ&fw7qG0dZ>dWB%6HUM zm+h@!Rpf&`uaAW|Wh`_=<~3^vN|zByDZrJXEXG%Fr*swjGyMZ{VmZ%3DXFFZ?If=B z34b5EbuRQ--Z@l$=*|(V_P0==xF3tLN!%`|*)fFAR3~GQjZg5sc<>Twa?ojhw{ygV z<>?Emr1uFT_;g^v$dX@x(2RSLi*z9Ek^-6qK@{qCgmek@wV9A`A9?~IC^=#7RKl%J zyCuAwp&r@T>A+ox#N2qf=o-6q0~5F_F$uTx7izC>C;b!#VPBtA%x`&fNT3~vd}yi2 z@N2mhCa>iydCx`b!x!hgW!)P0IJU0+?C_Bw&7z9;{S60u184PHzBsLF)uY(=Jnp7B zBGs;#$@Bzk!@18C6FBAyGhCnZCpw3{rmjRdFhlPbjyyRg7y-dXH82;ov9aGKEbi8aPAvkIqPV$=yWP2Zu$v@T2g&ve<+e}Dr z*)|D@lmXq%=ismOv~}GbA26TV&ZhpVAG0xP?;rLqTsFZhyXpXE8PPkp?@Ju~eY6ir zs#w`Yw_?1Z7!XGQ{4K|31}!0JN{3 z@Kv7qg$KG5$U(n^z5|WB=mezR=kw$~aH>)^;>2=q83jvWLXFL^G`n1{d7iCS5#TrV z91L65)cqi&0}w&I;UYA&Sric4k9R0FvAW29mlk9UoUWx?{{K@o!gBuoA-;76sE>(1 zb$zS|a55ag-6Gw&M|z5CU6cS;Q!@nuF~d&n9Ut2JMJn$dh{VDko^P5}stq_bSU#Ta z`_B{6Z!biC7*ADw9I`o4(L;R0ozyIG{jY!dplLY?7IJ$)9ltc&<2p-$#bAGBeaus4 zytXYpZP1Pa^Sj)@>Ph}g=cIB+3P3H#IiW&gM1GAIhy0A?=bTv33bPlM`*}(Ds!Opu zmgo4b%@A4KX)CrTSY;AU2cE!!onM{o9=;n)ApwES)rqESai+{42T)~M2{Z(<3 zsk9M7-h9AVf2P9lC0r=VPp6?$QLF~^vJ0;=%c9#;tQRjYp0|#L4##>vnHVvqihl2vxVH*0>@eA64 zKz_dyD1KU@JAqk$^{!c;0No&i^Uc=|um_m~!Sy(b^5z-XeR6(N47>`U;y=c zcK8)mR^mvjnK^n>dL@@P1auC>PxGy7tr+?TQNRt0VK1WXWAG<$5KE~K322tK1m-oL z24VMeT9CUa5rSBFrfU0#A9$kn3*zb*!1Z=vZb{h~-5l|qFe1q=B(^o5-;uklv+FpT zichHXH-wW`Y|E$JD__j~?0dL{uP-4ACzDM#aDe%Ly#Te6D8UvgJ)C+H)#0EKm|~^t zjeK+DZ0#o47n_amUSahoZz;v^KSOwN6kJv?zRts4^4j}fKY1;=@I(@m@5aKT#?!}L zeG=T$v9+S4-fK*IqVKF(-qgH3*+!es19IYGcu@RLL$G?jG>M|t#{Lv3UA<027ayr8 zP>N9aK4Cs zf>kb*(da0UZgatnGj? ziS>W|jcSbi(#b$Jc=FBxMKwMQ5`gO;Z8Cgp-~ArECt_5Zcwx#?)xmMGhIXaz9+I&C zp|zJVJPK;=`ir<`;`A{cxo9-2*ECQ#JElRMpp&ez~Vpyi>&Z)#Ss=fJi z2(-6R3f_|MwMJBmS#E_Xg56=j{XkL>%UUqE?0R^4p`5!_oP9PXU!4F8DWdp`My5te z^5LVJ!`?mw9e&DJtk8|8Q{Mf`zj@fL$y32 z?l0voeVFk7^Evp{3zkYDQRc`2OC1BgmqNHQ*?^{KtX-PZ z&>(Qvju7$7OuoW&JrLb!OZ7~vnEiu4x%C~`bAtCcyx`lTqqXQy=AY5@>oVX4?gbVU z6`Hfu`JT!3vY=GAQ?Ev^G&ibNd$w`6u$J}jPuJ)i?cyBxz6CVzkc#*ik%K{z&ASIw zs@QMhPAc`6_ddctMneLZK8p>(Y2L$|KCmniDSFIqNDDmIUw$swcRM^xo?U&HsGAc{ zkDRKoLl=(sJX?Q|elmr?b8a%{y<2UVFu7n&N%= zn{%%6);BUtndI%@qeQqpRGGOB6gzH1cE4b)COBI9qSGyViks7HsCpygN$2*>?DlCk8Qzqw zSFIkVO=>Ook}HCl! zNzp-2qca5lNkXYFK34^K2f;gq;aLs?Q4Du(5`@R0sIDnh{W}3Wh~W9U^OnO|zxc+7 z&6%v}0S+USLbj}?9(7KX;i0A+yQ%v8ai>B5Oa1}CTLaiG@B-25K5s|vu>;Ib?0_@v9V@7bamTr z&zqo8`A~E(3R{>ypm9xUOMGvMx412FM{rtM>J`^$Fu!+*`O>?Td%T3QY*i)I=2B-P zKH1wmd}zYp`Z`j&meo+tvVl7`QT?;D6pfS#mol~cnS?^(V^pa#8;$5LJSVaU7<%Er%3u(#2rgtG-ri>HOiT`xK+9O`p&FuyK<-EyjAhaJTu_w0_pM zf*$e37fC#chtJuMXOdQ;{(Nt>p|i7RwDLX7^||NrFcIVvko`0)1XUmofPE5}#(a0t zWoanqa^mnwG2aNudV6}6&m)JF;xK$yS^aX6TSVv3+u=I(f}R5d`_H`ncash-DeTO% z`-ZgGL-AEU`bYQS!`4(GQdWu#S>*zYS+kcrym6vSBlBHFi9Y@WyIGW_iwN&(Cz1x? zJKkxchx4!)zmM!cs-I^`BOy2_u`chr*|)DmsU6HX9&Hq)xPA3af1%*J8+k_5HBrrv zF{m;b;=m^-1CxU*Ur|x(t=aKJAMj#D(FDY(!Md%Vb9@)I&F(?v`eW>vb}g3U$VzyG z{Mk|W#cRtm_)oRIh|?~@8?HOM4h@2ok)_LPmb;r1PINWsiC7WV`8>C}Gu zXWPkxQ_s1@)3n%28b@-!2$&N`Si@@A?$!P2?A&3w$vqd1dsC~tWAAfqoDwD)M#sOR zJp=-yB(=76HtiiE!j}f71Ritsj-`ZhQZ%^ZHjlyNihg0sCo!Jmr=7r|a-p&6`BdxQ zJJ~?=cXjWl??M>kFZ$V%T$e7xgz`rrw2a5-h|en4rKUKshOIX{OuRPJZB_*tGM5J| z)@98qH8n51KbBXr+zT64-J5~s!A2F0ZLgi34!b)$=J$b?XCF(7%YOTujdTc3J!lfXqvE|!(6&}z`~VVKLLMug zgD0+=eet3|&^s>WPv2l^zSmp1(Qy1=ONIXZ9PWeOD!2heS4xWP#ndvhlZu{{(oUF7 ziYuW|;@wWnr2Mzur4w%6pS+~p;nG;lsx9d`_A=@X+XRBIPVEITGV@lxLgn<|!(N48 zhtg-c2+B8^*84;%x_?n~L~6tP)}Z5v^wP4T+SQ+H&wUjw2N0kPx&NQJ~mO>)L!3hNmS$t?qT8b9S>~vs^)Us;N zvQ#rnY4LoUgLHiB!-WMB(@gD>Q!U#KQdomdtnZ!9yO)OwX30YCtUzOP6bz9w^H{~? zU&klrL~OlAmKfKZBAp6fJ7BD+=6mRqp0;Rt&Rp6H&f6HaDp(e{Haqg7+-7G@R_weg zYg<`xb^iC=VbKY_dv7M$9)?Lm0^)@`9L7ii$+@ACzj{iLdE`e@uCJ}9nfeMD|56OU5Qe}QClrC#Z_@pyK-m75WTCu0Nu4e1K^Bpr)t)Z*z~ zTgk3evA(t(HxM3qZ*y}YrJytS#TF?&iWbY+6SvL5)GCa9W-#C9Uc)An<0ZtvT`-G3 z8dPEBP~1?P>59*NeG16n4qzAj*dr}{bRq*d|9d8-R74fwk$T}|spgv#fhLRc>itnZ z4h{|sa>0bc!a`p19-Q-Yk0Ak+;jIx>Eop~7{6KI7oA0Q+GJU)p4#tYtonwj6L0jV% zRlyigVGJp?E8+@y)7#_H(iH8=ZknLZ)ydgVIic0CKOl9g6wX~y(e~TrTHMmR5DUi^ z=Cv!!;`I>Mx5(>JDZ!!MxT`i_5see7Q2v-Yl-qYDB5v#@`g3x6x;@vxWo2chEQ^B% zSuE{%>>_el2^+GAb^Fn3Ll`EYQ+v&ihK2^zqMw63j#4h%7crE{bqhapzVdghQeK@; zy&&3jiK|#GA3(wIL9=zI{`-2+2`{v$?dcvl5}J)Ddft1@TDDii`S!ALqo-Kv-t%ZO zSFGr-Z#Pe9OnE+rRmkb+pU?^sMj~`5UK~SkGdO>5%dJ$TroNmk)$bv(OkaMQ)s543 z??ZtMiGL|Z_u7H3r02Kw1j(24n=Xi9E@)`V^r(stUuiu|npRFe%D+Q{G|sDm_&e=% zqxSCH%sclE_{D==Y1J(cD!Y?lB7fWZyGdMQoPpY)CtRU#k@ZgV1l37qaIA~$S$1|d zT=|sZaHa@Yl2o{kck9k@L4|AI!35!0CBbVUNzoRzw*>PMfVA~bs6*PRjll@mJCpKZ8~pK9+zbF3sQ;mz05k68Z9+c^lvRa4j0)X>n- zusdWQJjcgzl<>W_8GIuGXF9_<2R6M}ZmELg!%=JNEEoq3jvwguc$=rkftRMgNSXFbzO&e~dktP&ekvH$wv1WAe>+g(th$+W~E%zKVs80?v; z^by#wF2_mdLTS>VFUSouvYCOesZs8MqCWimVU#9CyJPVh&7QFW3MKpO6Na!Y>?9~} zSZ*x)fN7I*N*#Ef1}4E)PBBK7x&KwfyEF%gCv9eX5Ci2Z9ChQo-U{qO9)B3)8PaELds$_dMSLP`C1ZRN7pH<$t#jAuYgK;7ODI9lI<) z3IZ(q(^#=hsvjAv4t_)K2JYgvNda{jG#I8L_61KRIGrq+1HtRuNide|*-pR3>IuzC z_#tWBajpI05J-KJoRgGQ%n^a`14)pF){2!;b9yU!8`8^QG-CZallx_C<$^HO&wcPd zXb5c|XQw{@{w?tw9evgXBNk!AUJxgi+Q6l+axE%VRy_%AuNRhC>O4%Z3!B*19qpT2 zFe}M^&b-|BT>F=8JAn=43?0|G2h<2n;mUcC^r&zjGCkmjM2J|MU$oxga&o3C)5t791m z*1PpnhE(44o6+*#m!c_W&!Ua%o0WT>aC3;&Z`Y}Idp`vCMNGCVj{@!@ItlWil2q0+`+Cb!S7TdFHaq8=P3^ ziyEV6%@;3~GBNz`a>pFkP)(5)J{B6vYN~I&oYPNS_xRj3U5&OH&PF!)dQ`o;?^}I& zrk3?rnaYj*`Vdk~eBW-eIUuQ4~y)L?1iwb~uK|htt)Z3;f>6|HvtX$Q7~T zXOl1j=2e#+aIG-&VaebX7P!ppKVekmo(Ccom&B*v-ROPd7>?9$iTeuU3P0b~6C)~? zHMT5FahJY|n8@ul3_{a>!a1Pi7eK`3fR9LUt@GN;)c-18pst##D z6z9^Vp(+HK8#jN2bNIFU8I=jwuz7!ar457|i%ECgg;_v~)!y`Tm(3ez{xGsKfE{P} zZQnff!2_TjIKFThY;*4>aKd6xAH%oc(XmZTwM)^v2X8_MCyxdC~QI4Ul0W0y#iTvP4pHktRNZ|7V1%NavSSUAu_o)I~lv;k@W z@1~<*0Jv6;ypAsgOxBAu$Cq8#Q&o;TF9gZXg*mqA1<2p&^{!u+wW&whUXwmzXdCe+ ztSS-*^pkHoNC*(mVyV~Ol^+D*q`VUu}TRHgco!_*mD7=%0smwyi9rE z_~TBb#5mkJ|69_sf~=4$574XB-MEYljlvqdqAKleymKm`mdX>?dNv{-lpaNii~IBz zr2?)>vR-6(!Gou&^qd5>?;-H)8TM|Dk5Dbh*NC4cO?VPaZrn|H_Bz>xZ8CX~OG4&* z8VopGgq%ZzJ^n8bDBYxr0IId}a$%RG$-gcomWpq@1NN|_AAQz0nI-Aq# zBWiWein?9#ow=eu)Bn{}akLP!PrY*-bkSfT6E`NP)$Zl1cx*tDN z=>cyCp%b8nbrr}&J3$kf-%H5IAg83HfD9!c^k3M@sal~P9pZMZO{2`5N%SE=YfK7+ALH@yWC-v>G-W7v4 zz6VuZp|Yh$4UmF>&j42Q}s2y=c=~MPmDM{6`#nnE+6eS3LP+{Jz$P1kU&7%#lA# z?$&Z3J0aS>bEJIy|Jj8)VRFY=nn+zwm>K|6MlxVdOZe<)vB z5~$Yy^QxidG08%ib0g@Ex1%#>gT01T?=N({+IVMbk1Ufh&22LLakMM`HPqw0DcU_Z zI`n5iRdL~6sL!NSqFF?nBv3bSz=H#F(GQDRl%`89Td!|9+th9Al$PUUK*m|oR6?T$ z+xpJ-XOjxwu%NwhdR@l1*y-F>lQad9+(z>FksqMPr;_W^XdqD`3&5_(6|3nFXnsAmdDrmk- zE@&UUoJzGOVISRp=t3Iyn!}weBRcCVUu{#a8m%y5(A!^CRW)aydp}R}9^4*u_;wNK zuJ+*hJo*kA#A;jeCIH<^aP3|POaC2aSwZISS(w-X>>hJ>ryJ6Hd2D2x%JNUbO}!W0dK9?jb%ZjIwjS8g$W&BvxM>gP$Y_du?AiCr0_Y3~{O zbD&Co%cmjNiaPYI`V{FK!wY?z*}Ha87VM3LlXI2j#cLMci!v78#C^*rQt64i&3z|n z!gcOWUD~sH?9j(hz)vojXXw1>PEi(0(VfnZv+LBN5?iYV;U%>RFI+QLmbJ=EeQAJ4 zzv8z=SybmoG>zpQlBlU$@eFrv7w_QR6Oa!78c|vI6R)J*@($OMxH5 z*Z~0l%`Qw_8}=FfB9-T@DJ0R8$T-dQJ2ug0gG@wRXTOk794GY0SuvxclF&>2$~3yM({D+k)*qA-EvDSj_bvHyp?KwMaMq32 z9N=$ZVaa&(fr?4|vk3m{xNtsbj1_Wge~ZB@=3Xw>m73W>xu~SkqMo(O{_e&CXcwmA zuikHS`%}|y4wq9Q%ap9AHYj+~`M)OoyCC19ZTa>dU(Xud2KJ*(+M^qSUjzs38L9t_ zLC?#%&CSg!^ZrCak?vQVh@)e(Z(^ZUb9{3Fm2NB>C=}xAabu2VNnoWP6k-6iOOn^Z z*q9P%-ISy%YoSl6DRv%Y@O|`$XkT8f#Z!JJIq79*km>oj+o{s|%IG<^bJ-0dnY{nG zAylSV@~1QYx?&QrT3YGVB{X9s+PFsN_I?CN>-K%=x37a!JmnLq71+E zXHsFfh3_M6$?DridyQ^ajuEY=>O5ILui5b#=|@)S&b4xvT9Habe(7Ptx;p&L{xTdA zB9wYnavC<=iVyNHV-8f#tgtv}#iT$(?g>16S&r)7{Oip=#_iZN9|_^+Uh+EE687{Y z4kr?dlkHS1Dni^G>Jq(oDTwdIzG6D90j!&hr-2B}j=J|_W_^@ceyzvznyxH$n6t&| zsAdStg>KHzSj@yMmQz-$&0rE@)-382YH5QF0pMgj#T@S`GZt^ZoSw>dpEv4w+2ZbE zfU!sAJ~DvLCPL|w5(VgC@?w}B1na8mX!PwyFZzESU7!79gm0TH^ z31mpoE?g8Stz7!GssYbq4M$WzXT)Qq{}xE-42FJ0 z+X%1R1bscY=0LH`93^naN%G7ev!aqQ+;-`k5<{!SBsQvV;{^ql8VJ=Zg6WEyqa%e& zw57J-K#hT9j-cZ5)|*?QL}{vo*sN&pJzY?LAU-48{J7r%H^EC1=uT+|hs&OBavjCW zI@r&Dbv^D%2Im{O)!7ZL;o!`KiVuFWsZs_jb>#^`Qr)hf(7Z_V#Hvi$Yy+Cky*_C; z3#~@s{IS@8INCHFz|}N#_cY)+R$bOA5|h0N%!?l-lx`T5Wy4UlE53ppjyT)RE-Y1$ zA@0*t+3Ctpq@olxc1$y{zPUN}Ly?F;hh~K&%O`O2D^mpu7R}Y9eoXAL3r(0f_|M|! zPNO$-csK3tdu?1^+?KY};@8ps5x3t%E1aDrvm~X~Uc-O8HmX1Fx^xx%Dkt|OR=_7~ z#~^Fm<6`xF3$E)>k=r@Ki$exz<@p^gL<4d{AoSdhin}it6i=_h;+4yv{xX z6vy9pKCV%3-$Jn4$HW2pJ)1v(Di7j33%yp}<)rdEg)kQ$_>pJp&#!o8^?`^Wo#tgc z*Cbd9-;G?3a{Rw%#_6dJEMa;-^lt^eR?{xK+CnJ*y5oUrl+%#CxBYS-;R@4`-PXC- z-dXUi6njO5d`MhMn^M7QFl~oJ?28Pgo?x=ZAD6j08?RS#aWkUU>uD7eW8&f@Hit5R z^szz=m}dP4Poz^YZGQLG1*>ww#b71UH*(p9+R;2;@M0o}7K`f1RpiI@1eK){1uOW< zqae5U?YIteds5+gJI%>fT@_R+B73GS9aAH@?$~e@CmyR}dWBg^a+aB|3%ChK1-9*9j-;;| z^Db%mO0h{55;^@>H52VKhNUITXn9!2wmm50L1r%h&;ss20&xz7!eR1QbKG}H6;!Vb zNX&MhANSNhoY6>EcgOXMhVc*Fx(Po+j9<81$>IjlE&_oIDyb*NUd%`+@D{Kq!pk$hW2jt+Q znAYcF?+SPS{_^w~&KEP&@82VC4p3e_+)m3<%qz%C{|?%%nQ{<0$KN;|Hl=Wm7<0@^ z0Wx6I%Q?~~(Ny5+S@v{myKcij)(KsO1~VQaYWxLT;caxA+kKEZP9 zg&$2{BJRE@)60)&WYJYE2>+ zd;iPX3AOc}cfk{8wF~4!juuIFf*m!im*h za%&K1-4qz-)jZw*2kr@@rV4Eb($;Voy@V$IM&074Ya**DAm{-7Z-k+gJxE$AwM%pr z!=VYV0G1ae^8o}U;~0ctx`0K?O;^~a4TIiK3ll7XqLvKsSyZKO`bhf&l&O<-u6Ta% z%s%~HXd90eZYGCmg);x=rSYQaj0seoe|2CcLG?cBTzhqAy`$tVnt1Df= z`6Kv>KLsxZ>nWI*6t@n0ul{QpAQa@RMSUt+#%43Mo_mFJooWsAg5&@V{}Z2z5RNm^ zxc;<`0^W3NVukJ8<$UOEs&xgOQk&1cD- z4Tvuo9ohDi4wH%as%mv(+x(?e#2D&&dOIL=YqiuqA1`<(v%w;+r%jhedsGED$CHL> z1=pv254aMU+kI!uJ!B#ScRmeVF826buk-#nHZ<-kHID~!_)`H<96vF_Z8#g!26~aF zA;T;q?}Zxa4_Q?)=Uc%8*ql5PQBtv7 zK0vR4L>OlD^p16Pb(-{<4b*>FY{`lX>(;u{?6ntbcB-yN~sFqo2%MfOZd7pvDz*2nwo+ORe z)?`WC_cZ~ImHkft#*!i#(2u9gny!1qaMSs)cLUKS62VY|A7YP;XOF@mZ9lcxKgIw( znH}k8VxTQBiNZcMM#??S?Y{DgPndGg3!K;&&}fBrijoeB68b*pwV}ISAcRsujtlI=FdO%*`2q zGn~i=U&p>v3o$>^mu$aY{FelojvMw|KaywE=5a${xPIkuJ{dGD3JE~aZIA-9z0_QYVYug z=Adkp(S^anK4C!>rGf+EMXOx8QtAKyu&xJlGux=(SNvaCXg$Y2K%a$3fUz!T^1fb0 zgh2QeC3!i5V_i+c;7Fi(%w8>9NOOmV94C$jeRHEPqnnYG%w&pRMX z(bWjNj#X%NdjFT}5!q=GFC#l|azcNoZ5mwiXIzk(TGteLiTp#1>yNuHDS7YB)V&tM zgcv)PkEp1Ij=C;$hhV0cQ`(Y9Myl_x$bgpgY!-0#PRqvxtRRR3YoQ$U32jr2%GBB| z^hMbU@$V!9nQoMMw${c}afDE5Y%Lk5XUVA{^}PPK>zBRh$t#)JeK)SPY05#bx!8+4 z@Rq1=&&JS(vZCkzgIriM?)QPhBLWr0^}oEcY!TWGAMF8=it11LD#R!<&LJ)JFA`KR zKw^yaF}-bSDofigAv14roy$;t`EgMG_i04^4ow$)u31F0E>l1<*clNM=_6%>$!9(D z1??-skCUf^tQAUWoEZ#{_ksJfZmQai@$ZW*cy=9SM^8bGdRJ6Wwf{kjfQE+bjNRTQ zDd?5y5}orBS0Q~kREpEyfTCjmp2El@tY0`Nxq9R~I_)SH_hH{toCmW2y;AYfCj};Y zP4pq-?m1h_qk4F%J;d>Rz4`1x(Exn6O;!h&K<1GsC^#&-5-pS&a%h(dZY>{d4H=Ow zjY5}!r%MhLnpzV%VEaU~wX)Tc&mH<9q@6ure_@Z6yM00mP^o4BoHvG&6}6-Zz-jzB zMqLNAksJrm2&n!!_n(goMKorFX7_A8y?e6FJ(Ar5zp7oGl0DnW)9hrD?-A*(?V8c$yLi z`MFWN{GKl0#Ck7}_N2JY*0Gf(efZtHinW)X9wa*8;0e^qi#j}e7kYKCxye3beG9Os ztHiJgEKjOIUPqHv3{Etm1qA$5%nn8My4|Dha}47gng6NYAwCANhyQ6qVIBnL1P0gf zz_I-UI~o;(z|ZDao6+OhrMIoGK4J&BKiemtgB9~(pEMRaU1yjz{H+?uQ+{aden zDFV9|p@3a-h}@iImsCeIw4RG)9ou5Qdx3ajk< zGt*;}z3-A4%2ZB9KYr9_c_L5YsRRs%dfx8qP8p(9Rt}1Op%)|?gEU`a%~FXRGGMs7 zFd^GD0AHi|1DlA~nFDud@7s6jsH;7aGRSvZpmQr$dn4=s+D{&Bu6>wLIdg_jq z7kGE)1J~#xa1|F#W&8H2$t2eBgWrYutA`%Rv%N9AvFfXfU3<7>0b4r%`gQC%m(9os zn6Yr}|Hsx_09DyVZJ;PNT~ZR#-JJ>|UDDkk-Cfes(hbs}Al;pZ?(ROop}V=8@Bjaq zduQ%D$8j9a!Sn8S?-kGUthFppgESsm53R4^OYWdLa2`C@-cnvBEY_Yk6rQIy<%KpF z*r`q*gT;gChip$LYvi=F1|PTXBF_f8jsHB}uV7e!L_f@U(=*r2b>s}IgdTKnlP89jz*V1x_Ntt2G;?~y|jwj~%c>*@^ZE~mqS=H92UBY2(D)}G2>skA3@Hgi6NycC?-B`H3< zuk|d9FhsXaj(>vzun(gF?)>oz+4WfCn)$8*1yLYLleGR7m)4~;t@ZMU9**Wc?5`S= z`!jRpf6rE8Py|+IqB?d`rnxNZ?GuJl-KGlZhf19$)wc$dAxJe^Ee*BLZ7!}&_S5CZ zQu&HE!3Q!mhOJUPx_jZih=|yB`O~U`Uj_`O`KYVITcW3pU6raGW6xR;EZ<{*iXBeyeMxbU+q0JuFmk$0(1js#iZ2`V;B5P-kgv%l>= z$M5YM(tjwrZg7van5}%7&@ZxD-(96$V=*U`Ka`ft$H2?k(1pwKZ~0AW!-#!>t9t)d zxW>he0MFr#oBimPaYlYsI#Cpux|SE89Z@51!zLAI=_48Eev*vhw%H4-DCL2l$d-Vt>~`sghf!y zo0;Ral=%DmQ_P3+FMoghWDjo2CJu!84i0RK4bILyAw6Do6f&Kj9Z0Isw4m#?&>%jH zPa9$z!9!iucz7)~u1Dl&fH8PsBE0{ZRW(Pe;lWL{y2v(|X>bC`9cUM&wHXb*MVMnt zQipJfw`786fV?(?5n=RD^zzA1NLNo!POY9^!)H`z+h z5opxJ%+q84@a>XJ`4GbiwkA=IL`s-AjA(`axkc5!=~Gn-h%UTxZ~ww zqVw|N*^nYy{=~#eG8dY`f#!fWd&}W`=27>-D9&v4Hq*8%)7-FmwQ2GV0m=UO+0N0J zB$F%4+2NrB-dmpn|3krSeGS76{#8w!D5s~y6D`UQ$9bPC&*OxgGyDE0xaQ){Zs7%EFWX21HCoZgplc!MBF z;EXGY&1lrNmxuIxeQmw=erf({oZwPkM+Nc|LlhYDKG8#up3p<7&V46#T3PeD4QRqi z-)YGS%$Cz`wpMahvogSh$UENLsy=W#ahn2y%uGB-GO)E@}}6!4QJ{Xdh8uKONRIh{zy`*Yo7%lbXJ zb#%S(=mtQ#g-IrnmxIv7c4{b%x@YTP3 zAxWQx8qL@Wi;GFkKp*^bUT8law%~dU$a|a}KpJlkFOS@s8jpKn4WYPdvuceOqvkGr zgb(~Xj^*VQwWq_*b`#8gP8pOuJR#W2UQrX3nA#az+B9el4vnMB%gZD3T5Su261)vQZoJ;XKii!S<<$0y0WrgA zP|!oGKYqy)Y$gmDSIPq_A|dH?GLZeaaxQMYw=|s!6(#!sELcoTO!n5r*uU8(HxNt> zA-u3@I(nY&b#L>yBdeOADb!JJ1UQ=cEq(&cbQjI^FAMUxIT!Z^DG`oTV5nEmkn$mq zX}+RnFkK*mj)gO#GhLzQ!?pt+97rpA2M77Dd`K)iOZWLze65rI8wX;$-d&|mj20!# zY`Wz~)@97XWRmk=IvO4~y{Fq(&Uv+2;4U5^ZqGCHOi4v${qj~^(tMd)grrMF$FnOC z37>1fa{R+#XwLBXGONG?&Txs>ODrtamOJt-`kGH)zlyP9N)yRQ0QL3ojkp*D%eFm7 zJO(b2Dbw=|Zw$5?Wi;>GA1uKa0-$}62yT>Cb;C0M^Cy7jB0WAn?u3|A^P!pQ(9M&e z7CG~DTOEX979l{;Yp*GuaH4Res*i)HF`h5e&sYmlj%zb9_W>N?+k-}%s)MiIm*Dhn zlwb(u7TO67?l$^lNF{Q$b4v5hUasm8=0@B3>9(brsDuCnDw2QvX{L zs%inMz5U^km#+mW5U&>GbI1*&ViwW}aa}j^&f1v}fufT)`+u^mpJH5p3)?`Fh>9P2 zebNu25!5ov6!H`k7W>6VsuEgqYKpWoe~a>OZfmDs)*ao`BB(MAgSRrq%(#*F@P~Qyv3FYuK>JGy5M1Uue1e#UvMLY$P)-@6&5N!(!=uTK zybC*?^h(OOIGeVYBY&~Oa8@0Xm22ue_zxL8-s5xS!FC+P5B>7~^fZH6@OBOf3fr@* z-{Uc*p>ea`244VXUU=!cZ9-9Ar~p`0aET?_HeC{?k7j3D>-e%77u1e@Ya_eU<&j%Q`>*@9wBX zpRZ2hv?gR{XO|I;db4$NnqjNsggX_df}YAZJ35PHw;e{UaOAM>)@q;$TBKd%Cg|5` zH8!kpD`P`h%P(CWh6hL!lVlc0+Q^#j^}auc4<_>b8aWCbzfP$6<~%?=<#CIalI|V* z>5}1NKdRNh@80A)_KC@fsvRz?gaiZkROw-4(Np;cS~{AWJZVS_X(xd*n2&YT_WD=a zK6ofq1>5)necl1-1h4EZA}eefCK7hVlp1pr*XX-G64kgH6Oy`TKz7$Wmb8vO#9IdN z`BkF)-@QeL<8!iD&av(8&Rqy4_W0l!86|>dUDsFZ;gV|NF6EY*F_Z@3xE)UvO@Rdx z<%K}TK<8mfoJs~?bZ4X>ids&swekAld?@=kMa6Y*3+i@W_f8P)inZ?b9oB?W&HW)( z2U8EsgYTXf30kpfU6$z}k`hV1Kf`<2N*dWZE(?eonS4_vaDb1%Y1>{l<#J7nR4Iug zUFcR4vC!NcC>k5+)Rfut6aW_MO22D9!VneuUltEg1&Jp?^F$%W&d$!$qcIjZc{9aW z^&#f?8tNj$^~_owFCC4P$#`InNEG;s{TA>xm8JpJ1tKsZu0%pPj^!1QoLqYmAKGN$RI{6MYlNb-+Okn zXYByw{q=uDE4b@-ySuxp4Nmm%uaO-9H`vL#HQ2%GL^s7gJyPn$zXhhXNTt=gd*2*- zqmYxATHtz|W8Gc09E}JUW9zoR=ot>OZuW_z&HVGus&4c4-X3bVH7;9WakkxbG7JA| z1oaK--3G>L*4bJNpF*O>drja4!(=E;k+c=@csSgn7gE0~A!H9M<;MpP$ z?9gG7Dy4a~#5o3%yd${?e8*pA=vnLXxH8Uj`?>%0Q!RXT@w8rR^hygv+YjG_DLn$F z)<02O8c+!%yd_QjL{`^+PYf8cnw287GF_?-Gg&It37c zEOxK+;+iQY2HCvmim~P6`f7f#nqq4nlqmOPfjP)gz*L~$303hBZ$Tn~Yi!e=qxG2(nkI%{?aXMJ&1?5Eh4w%6&eBcg%2nhz5 z`XD5G9_a={=vMpgMTv-Upt*L^Tw z%|7&NHi1D)Ouc-gb=kk)u@#rfz7e0B^kbk^%5LGTEaUaI@8$#&AzTpG?MJ7J=PZOC zA+@;ng}9C%V1lk`^=+;}4h-CE%Y(Xa_tEv1Ve>nx0{z}`j^zE@QOvDTh$O{&TD>ez zQ}0SyxX2A%+1ZV0PKSylSMb!C3JYGn~I?uVB@#1EA*sjdqVa@xo##_J3X(F*dd1v zBku)`c$A>2OKVWZ{}$(l*LHd6y1CS@@n9> z_?rctCknNA7&=xB7-o~}Ewxncm2aAX^I$oi@3#Gi8`~wy2wWR_on;eo6RiI}a;`Z9ZM?;$48d>;d1VJ06aOg@b@yJ?LUSy@tz=H&yiUstbDtRHRd^`@_4>F!>%f z`oRvpA@jKi2ec)f0@oXhw~_}|hZC7yJOvhN zmYdy|w@%JqNC{4^d&`Wu6B)c1Y|3)2#>QavX5Pfkm>w`Gj`~|2kxJ_wzJFf&kUdT1 z0dvJt`G84)FFowTZ|k1gAU4fhM+=jP=jCV?Ggnn94@2(N0_=t*F+XkiSrJ>l!QNB& z@ngrt{@N`+TkPleSO3uqJ$iO^ngAensblBnGPrTjjG@pgQV(z(6-kT~n9I7)+_>2$ z)dZsBHfn|BgP#%Ip5P}LULlL8t^NMh{>(=2()$@Y4#tC4uysB`B8r_3Y>YBoDcXqM zXExuwgJWgKTJktuq2#``n5iw>yv0CYIfmdr&PQf7@`aq7;D8h670FEG>5w4^ou>`` zdD7WF+EK{6MHRi2Bz2u@08oL80Wv3!?xk4h;0V(Uz^&HDi%C7Ol z#O4TNv2d8!=M{}V_gI~x1loc`rY5_)E0--npr2#T(S1YlNrD1?0R_Ew3vY*ff`C?P z#K;#-MUv4a)oPXZQ88m~hc9#Q#}GkE4=z*eqQ_f7UmXu0$0@a-%9bje(DwwDt97s? z_cE(y*Fyq%c~upiaM;7G27M%~3;Q_LnGlSOM7!H##ZvJPxnWt$#z&w8v64f{+Pl^Z zckAq{cypAUJDPtz>CLo%6er?%QopD#vQM{$G78z2jf>AIide1#Fpbe8RBWudQ-oaM ziDJb;CrSRFElpDX`cj%x?|#ojRYj|6_`bW*6$cpQaNLUFdl1L15ptoPf{8t`9h$JF zTAWM%OWh?`%BeKxgT>chNWKxP1`V(CZa~~?tnxqp6{e+#zOdSQG}b%Zg|ggLuDUd) z8y{ZQXTud(7a)jFf#GN|iHTxBppk3=Cdn{RQ&XQ=bj|uiS&Tq4I@}N0FM>cDo(ZC& z*UgV=?`IP_T+{A8WZQn?~hW$ZIdp?zO|mm$=i!#h~IR>;kfgTZ(35Y`R+u= zqc#i6_nu)05MW}d7;^SkbHzrUj!MQy*>^dfeX!+7Cm=5-lA0@C@^c@rT7Il>US5?~ zx?YDYv&M)Y#+}IsiErx)eJ6QW%hBws`^;BN9&{0{4lz>G!aC3YS7feM*g*B}r|*y0 z5vk|m#Gk%@It_1<|dXfZNlb7 zpApNsiL}{%a9UA$d30?p7au>rM(p*~RjK!*S8}rY0XNw;r(0U1x$m6$+qf3za z!ybos{L4&sPWv*UsQF#}w z3k)48mnFg+76=`i#=dTOm@I@Qv|JKO2nu2j^9Qkzz33`8;(<^;?oeX(Cy@3JeZ7E{ zP{lKff>Nf5Y4d5@JFagnUi+`n(Z^g<7I_zV@N3@HP{!C}AGsJ)dtLGevRtxo>oii1 z{WxbrX1JK)onN+bHRwuT!Mw3z3jl)~1Q% zAM)M1%&6Lk&CJc~CfXj9O@`BrK;^S#{OcDlY%ccH!1zxd-p2IIa`dha6a`}wNE;Z8 zONNH-uMt`+vy|Qm7y3WXhIruz2x&npQo#=dyy-8s<>fzr`-Tp;wY7za!}O`-c<#k*vOIusA-F2pLL3g-tee~ET zJob0J9q?)v7Ws&!J>J(ZO}FKGhQ7w96?3=jt$fZYEk#}*Pb#FIn5y=ck{%p9Y$18W z*|rv$?IN2dX}P>)SaK5<;tmP%iJRBdk|r=U87rhJZXN4Je|&gIOw?{7G)W&O6Rd#? z5b|j#!DmQMYZxBNE%IRKJp7%R`OMqf`$nrKy5!HFkab9;f~xAzrY3$6v34yiq@JAE zfv`#3(vlvemY_ou+K=DAiHlpDpvYt7f*ligx7-?l?Va7J?5`SsX)TBI=A`waa-v!;K=HyK9_kV$hhj-?t z4MJ*4+4M$$i2usS7>*(pEv>AykXP2ylGfDJoR?*wmUmv6o2%B=R(>@-ZEiH_>RuKb z+j@PPJfR)3**_V{iGt~fnGr!oL}bX#y-!Hm-~0LGVyaju?%nmx`S0Ci%|)SMMaOBm z^69&n%0KBh5KkAct8r|LXjP?4i*P-l?G+w8OSOx0Z z>)O>7{o!L_NU!+tekB)XfD>6)M#uboih$=0DiKl1`Q>k51ggxZzbPq+eE&|RtgQS+ zR1{v<*!ZieDvsyvMc2$sVreN;dwcs)ot17=Q4wO2)YR^BhH-p+{KVAMHx-p>(~@FJ zdFc$9j$jPMkhgH{1UbYtKYSOS`{__B$mITVIAfEsl1b$HEN&cLD|3OW|MF>Hju8N|_#%ij7U?nM@= z^*2@t&5tRJ$BJG+e@G$CQ(sD6#1E<7$9V%Su%!PU_!mB#Ff);q)(0YXhAhQq+Xc*;9U z^*Z3Voc3@cA|e1G;i;lyZa@krk&7-XD+`|8yMV08@T8PTP8PGr^oDC|SuhKMVp_ZA z<>IXKJ|Vi$WnHX&@Lm5kB98t{+;Dw;s(aGHo+kL8aO|dkDIclGc2K8qRvsl*3mZ<;)Aw(Q42m_-uE}grT&H|Db>YCZ#`&jW9v&i4H`ZILIjZ@ z!@n5*AF6F6U@u97gZMKvB0H;SExsV1myL~$$z6iXnog~|4~tgqVRyOCeZ*x+{pP*2 z^v&Ryr{^)gh32IJw#Z!lIHe3LcqfNR%aYS~=2O$x>(g0&huW9?4IqJ$P-|!zdEYR%>;mOK+)`FJ+XG;cu56a>MzW?4My@?3_jM1hxO-O>U3&)L4r)JJOcJzh3E! z9FFKqdC%~L5W{b+3p?um0{`Uk?sc|&_AmC5ce}*R#@}q1P97hyXa8yK-jwWgzL4X; zA})``G)zh==J30_T#n-7k9vh-Rn?e8$BCWoe(;$PLwNmSU*AS#WSO9PcL!{@Z84{> z9G6HLJ(398`kNtfogczRuj|0A6ls=4$I&XEoK@EC-JD-KMXInD{_5XUj8#6mwQe!5 ziWJI(``uif4t8geMKPPvl#{YzyvojX-`vl-tXGOG(Xx4Ew(DOxtQLBRyzf368Rh2L zmlhPfadzJCWj>j*tl3`uqB`}84cvSJ2K-Y(kAEelXs=3?p|~E zq#B$L?cG&^4tHPRE2^vXRA=PE!qkVx4y;VE$9kmt4Uc}dec2B&lTVL}@72m~pK&!5 z{aexZvI^J~0nbC$_jE}mr(vLKY~%3cN_?HUrc$@V86S}#q-|n)+Wu(eGdLhNHa6eE z=i@{?Jotc!e4N)akpWOMTwZrvK#HAABs&dBNKKUz5_%5!uw)3MPP3Gh6e2dGHt@N> z5)*Os_4TQ!sYl(stgIM}jg9FqwCmy<8hF7(cguLrrdx(TD1u*{j)=?8VNryx8@sJO zF&N>@%yJ5TYp{|wNKkhHRqdGgj1;IiOOB@(e%PFWzwzIIpWspHOMag-}9Tnn+aCgr>~Okw$G!4YY3m?nJDhjAdS{X-BSWo}5zLb!ZsI$3v zZbZJDTWAvV&nfTKi`Tj)-t3RXWa5SARYbp@e&m) z_$#3aCD@P%LX)o>h{Y^g`c`J&Y##==2L=X@k58>^;m5S`wfpM6Xj>7&2O&+k$>@1~ z+$qFGM8J1DZ&4U~=5mk=WilrXk0le&(GttfYD8a#%XnWntS)IDkCPzAc&UA62g`M( zBW3rSOIG*2eXXFWDz%i$=XSXh-oCwyBtP+wYJq<-360y&NHQMq*g=Imlbg*3Jj^eD zG8}(`9IAMF@boE5o~prwP4D*1ypSv&=Vw;%hq>gMfcC2KR+WPjJv~dlacGUuJ-o# zZJnHqfp%W~cqgm06tw~Qq_PLcBbH&lE#-~SqY`vfNqN_F(;{sbV%(`y!Z z*TrB+54x8ueDX-<5xCA-*XFn1-|J}PlcSLzOd8*^-%R1ibh$Cj`S}L# z@m8mFRM0VRgtfQ4{MX|;k(6pha-Vj!Vq@b3vF$?d<-2`x38}2S`oIct6J`$Pj|}Q* z8jm-adIXE4yMLOS)3ba@lJOL;UbteQqdy;;Jh==h?(X5?ND#>} zyBz6Sl-ak16vP8>Wr-N~2-KGzchF_slh>)~sDyIA$;-(V)LCg~ zkQ6l*rJlt)(9zK!9quo$eiAm<oH_$Y?`=Tbv$<*n^?fqc#Yo| z(*G6_>66+s3W=9pU0rKDv+zHkz5k5rpQ89nrPY`6{kLB#KT?A5xz(_PJ*<%J)5Bj7 zld{JE_h4qs@_5%Vyf>eDfatzDiCv1H*&M}Pd5B8Xc>Pf#7fm8JdIG_ekC59h*P3DS z@6%HO5-uzmveZ_{auE2E>8ZD5lG=|ILnYMw?0)Tyz8w9+uY$}R4`A7aWx?R>MI}3p zlk8i2VHSjGM+Nns(F(X|%)W8vEh7sajuV00v)lu2E*!l2ULF0@k_v~=LvNYg0Jeu) zm>b*>dD{BCw--=SbN8A=b9-#bg~}X;0croBm(SQ5da4_DjMvAsSp<`^tE#`O@j5zp zuM8-{R=YUkzZ1Ia4e66$e?`CEvluimZ&sJQ31Isgf!`abgkM@#kz1A-b>8xC{Em3J z#-4~25>DnJg>&wB-bomyx7TD~z0JQZCVISm;+4kd6b&_@Z}04Uky{r1Ojsz2OdQ5n zUp>5qq;a*K7Fk+a+1l3~@+U-M>2b~T6_Uy@*EE#1#U6&-E*eym9C{&h#{m12omj%| z)Q%(Iatbd)$lKMLStHsE9V!PfLuzWOn5-<&4;-`&4u*q-?NigLp}sx|tI1GjTiY}7 zXw@1k0+3W0ZjWSgdL95EJmLd)dy!fN8$7Z5#ok8&fpnldFkL%6IniJ13{D0a&-7mv zMz{0b0EyhJqN1?U(o*GN1s$E`)gQltgT*6dFT(^{^DtG zufs-)b#ogCf&ToV`5KOw>uP0V!vs845g7TZp`ihyOpUu@TL%ZQVm?ZLmG#WcCFSH$ z;a`1+;Gm-mftcX0H!nUuzPPmX<}rk5`Y*lvALUCf48$zg%^UZO7Rau5$t&@!g3?6S zQ0Vc-0wvseUoDok_s^zm_;94~9xS_N%|}KL_=&?zGI2VM6ABPOK?Hj;qO;I%Q1WN)_TFee|qzImOYy+=`J^K zn+ziOG5fbXQ*0caHz$r2q%zq2YPY!pBR?QJECdRFMQj`F2;Wbssjw+@o3aNUwZ(dQ zFuFziewEPH`30J@D5^bEVBL7S={bAT>B%xU)~k?3L47l`_Ds(p)uSt+uV2De{>eM< zEN~2hEauHFc4xXjf6u1fx1oGFd2NtrBY`YBmCq@pCm?EOZgz|^2SvUitKQ;AZng`L zTMNkT`r{jWM;Zc8MpbA*Jb!mzM&aDTLec2!))Q{K$nUauU@TfZ~sh5abq3WQd9@~ zI58>7bS$vfkY!45OQVKQTStmT6yQnic;V(#5#5`m$?uf+4akb{k zBo!_zWto<+G=S-LEIx8Z{3)j~w8MQr^}cur4&}|;)t|1(>(3AwoH+mq@O?E0Cm$>P`w25y~Jd*lIIEin|u#f>zG(rAapcbP+vCg2Lz~=OxTlP*>+bqLfbr0J+s2=E%A+a}OUFy>lN_A1G|j_I$&v!~ zmM)?-y=SQ4qa(9D_Mk{K*;9sV&EOg8s4@M#6lAe?C*zD33)k>%_kMMWu2GK-Q28Ru zvxaGSm*b>dMP=o3*fztUS@W{jk`I|UaIa}=qg|z?nMF(G$*FrE4hWox`j@=@o(Wfa z3wA=fPd6btCtb^|>2eCiLjl$n76WIqkvm$$vAB^d zyQ>mz5!9T=A01S0R=RnMTLs}VA3_1B*IgwD%cnu}V`-r!>T=EjkOOpSe$ zpej>}V%?Cr%G&iRr; z4I=hQb~;zjVB`0);7Jf_?;;&t918r9dPVy0jw%v|Gy1*(r804IHfb(IG~p7~K8ui( zDyz5En9nxc;uK#f!13=sO5pzXJC79H-roKjqQ6VBpEHJKMbuu#VZ~W`c^!5h+NpHy zE%XO3gw~}ILiUN7n11218ds|4Nz(X5M3|P`-9*q~V`KaJ`SJ4Q3w$6T(Aev@Yl@CW z-`m&7lR|aKk^9+(^_g+3)rZaVjyc?9=(pRAX|2eGdIQEGmX)%}BB}PgK(vGqLxPSa zQBr-((Tykkr$uHHBa?vKaH9rq*@5B#TbnXS`<&3Bow>O=2nOc|_O12xeM3VfLuERQ zS&|R|c*e)aS87Vizvc&eK$_)e<>ylxKq4&HmEH~-1_x86yCY8`F#c?d(->&FlpV>r z_(ru6Mp&}sy?;BoyuN$TmkOQO5iw%H9d0~AghYHG-Sl_|$LqD!_A{#E?LkTN<>}Fd z?p;pxIAIMrJ9pgGjLZ9`yGG?doxLcsW15kU!TA+?SFW#{pEZXy$TpG8cXs;cZ}E4P zkwR)y-PNFO44ax`m62nr;)f4S?B1{cO%hCt+e@;B(S4$@Zt}8CYdrmlMF((xPSYWK zdoK6g4+g@JQOZh`kMOsw=Y@q=ms5wv7x|A?YcZP{x<&>8l8o3JcDPDdaTAlf>4D_5 zT1`8pQWWDG94~+MV;Sa|>}txI-pc+bO^aLw&aZxewiyOde_#-1XWu7j6=c{kj86-ITN2oFnnOr;%ynyxe z^^h>t8hPn?u9;3XX8yLtXFdF%ePv+*5?|?INwm$at*>@=<;pVaAtCRIii&I;9CGUG zlh@Yt2Umn;WRO7*ozXuX07?g$6vyzWa^2s*wBh05{(*t|dYKO&r)Opg*m1_RJqBYX zj2c)w7Z(#XCmi;0cvS0LCXHd9edKq{@oiexxU$c?K< zGW+wF-3UaM1&TDq>yD;G_V)HPeYEpo3K_$Z#ZB}qNnvBrW6H34jF$fT+Ni{SiT+aO zJ=gvrNEmL1tl)ho_~Tzu!3qE*zyW<$!r%HoEdU%XEvn%5X0!NSb;X_fJQ0v45Ch&Pf8G^&5dh`m@aq;n>B3D9JSJ#BIg!oNO67RA0 zs_~9nkLpC>V$ zX|s&6RPq?5SR0y|nW;}irKE*QZ>;lgCKXVrEwk`ML7 zpy``y_NiY=H6dlkcXtjbU;8E{B@LD{?R|xS<<-i>>#N=Il4FFkj){R-W{j^etd@p+ zeC4TlvQCQc`u~axH_v*<2a-7z>{CH-?wU}tL}#35^i6Hl>GdabedJC#4TXN_UxBAo z^7QH{yeZJSJzrwNBHr}qi<3sXH88fyo3?j!ZMpM%E=LphA(A&DvIXV$P_a9|)|`n; zr_GdSQ$+L{`rclN)AQ-cNj-i2tZArdq2e0$wy2q%+24|Vrw0a;JfmoD?u_4FpWUE( zQ&F2H8UC@E{|{y;2rb_`73LBwNwV_;ygh*$hNW^;?AuFlcp_egeM1c`=X>XjGK ziOE9fxsxO`G~h+t!i9rY>ZO~hX@vm`I^n0>AMq3Cv(O$hKp~6IU8Ehux6=R0e%HBp zs8*?vefP;j%6IDcB4SsZ_d4N(t%V>)CUuG@@3Hj(n2HM6^zsgd-MLqDbF;QRgVx#( z-RFB>u2Lv5IR44YqJXIa?*c&L%hIx{%=-F30GNlx&S}lnS4I*$U@^A>j>9HKzyk*V z#B<>mJQrC z9NsZNE4Kbt6+6XNt3<|Ck(V@{vXlrs;#F)7cY>U*QSy54-jdN%__lOAPGoVCPCc2k zuB7BIbYaK-&U*3{fID({9!)8`ie&L+++0|Q8qYsw+^^eH@bi_4y#C%E^+Hja25@yK zt;gArvhwQozPXT{h)0a#SY0DxwFuh|Q^uxvf2}07Y0aevKXTDGA{cVQfO2`I?eA4Y2Qk>ggEk z`v&XAT?xgaoo%w@6g*rO(k=C>7y)}6999>LDK-JMhqu28XRfwRtu-7bcE<$3t?FM^ zFAjKoe=jR2_}U!bJc5WnA|@1r6*@d|d&+9JAcn55e)zjKt)(Bx=j*F3dkD9(zaL&x+t=6k-n5;Ljt+-O_Y?jwCR{;@=i? z`;cqz^yM`-K{^i{0K`Q;T%bA2HsaeO*aO@F<+s94X3sD8Ar7fy~&G!$hV}d{Gr0` zC}{3f@xPlt*Sghau_?27jk%0PD{y~sOx*orrtNl1E)gQRjWH%#5v3)14hMMWNFU)> zsP%r0Np70R!%3G z?+s8n|L{tNps!=#a#^7Iv^{gH%1o-m-OSwtK75NI`S0veC!LFEaEOEgZ$NCQkKApyx6OP9e}8qG-(PhR{1z88q^$5Q^Xsg-Sob8T zj$1^dWCA-gXTQ{7>%@Sf!ey1${0TmqbEN*ZmT)DZvx5`a&J}gzedF=*TUefgba^h5bFXi;7iG=L*85<%@{&7 z&MrT)Lhg3s!8>rA5-ZDfv!_ESxBEJe^-mIMCs4!JG`5@jEXQPc~>t3-kN!!G`-wt(^QE z15?w&s+)==hRdIWfdMGF&D9b7ycg*1Oo2aEJ`Fz5*H_9qN09~FC#-G5cre{V@g*_n z9GFc^z+sgGtrPwvu|}owzV7a(SMCD}MdTHEdB={zu`1;J=4uj*DP8 z-5UoWrhfBo>_CT&#WxvmBZZTc|IQ}gUqLf%I7#yOMFY}w&giZT5)x9X6jv|c2{oGD zCnhEzja0s-rlDGEJ@8BAc6_f=qup4wMsKk{;T`}eGUkh=vQ!?YkeLqAHwW0AIYE>P z8VVgvC<_Qr3>Vru377ZH2iwu(t5;Vn*Q>p;v9TwukN4vC&CQvfZ}G9Z3p>fOP`-@M zjPyiLC+NCwUPdt-Z)Z3EOCJvwm#X!Cet~?UZUmmOl-qZb*(7Z4+Dp`c>wqr|VH?NX ztm+d6dZuk!*wRC`aI zMLZg`F$9f@ypcW`8cyd%0laRc>rWy|DYG+0r+rSQFxrdN_m>IR-fdGu@tjMXD`8DC z6B9Z5hRaa{iTwA0-@13Q0qY>H7o&E1n*2V+!YP03kHlO3_;(RNBH`lVF4zzAgRSCt*t_hB4I1Ka*tr~wD66TB>YBL; zx8&EPsY}W`eq8nO-o4TVP@?o73NIPe-fTZ?)Rl?lk<~XmaswGC@$vM^rEHP}oDp9Y z6mAY@$Js$=r`Ek$CTAVQ0Wjk3 z8CB+5l1c_n?07n#N;a?s@ubcr@oC-&p^%98u(13}6DV#naILuWFns$~PI-zQ%E$;h zr}hk6H)%!_^AkizSFZDV;$0l7KpY<$ZypbqQKY5kh9CDiak$ZAi>(tGT-`~zc}ZIY zJb_TEonCD3Nt{9aMko@fRQfoVy)Czyc*1KkoNJoWsaX*d-VexuPTSn>7j*Zxp|}k~ zu#IKUptWnLymZRfaTT4^v?Ijwr7qiFfBjTp*FF*<#@*j*0Fe_Al zj#NHZbbwC+xg-)nh)rnp^XK2)VDyTNEr==??oY559WIom)Umu{>hq%~ivPTv0!9dg zNEDAVB2QM}S>1=(I{O|8$*9!SUx0h?*jLAsP5pFo_Qh!%^(zJ?iFA6{#=89}6fEa? z_bDX8r$rDGv*5K5Xf5^zMWy76>mGCMt<@95-q%rKGU-PzBLfQHcHMX+(3DjR2vbdi z3{CO{T8JHf6RX^F+uGT)*pLaylD|>~-~H*8i3KwFR9@!@y^cU!9?aGE6yb#7Y;KjA z9jJ)Kd9Rd90`oIo85jtQk}iB6BH)!hTG3^09TV;u8~c^UFS)+dwA+U8=^gS^Y(ENt z5WCffvAAF&pkERrB<#^Q;5GTN3C+-(VFLA|kMP^$qD?g9e40&Jn*+G#-a5gN0-k4r zk^iR!l+NF4Az8RCkDkm>0a>eMlPT9djyya6>U>l$|LaC493;o z7)!H#{e8heS5%f+rZGGp4|t3J(EL9@E(4f^4c1=ZG?rPIb!Y`R(%bKZgo6N%+S%2q zpfTQ7#GM&%7JFv(NQgI9sE~gIq`6?A+(;&fQ&c-oUQyQuK%z;ctXMf$&auR>CCUa>4 zG|H3i_w~A;pjA!g%wKLH%F5E6qH}aTT>35dNlL2!>YtGXA`+5i%gAW<>h@@LKmLD0 zGfcG63ubUKYu+5Is6T0502qskO&}_w)kN8)>0ZQ&Evc4=0j{+CZnDMx^7nCL z)fs(qvZCAM`OO;VOzi=LA*9kVUpCbkyp@K=D!f{o?e?XyY_K7ev7wUC+U(o$j(Y}k zx$FyXwIg(d43BvCpymFyrq@WArm+8C9Tg_S|4T=eTo3d9vg{KQ8rs|2`;m;S^S`B| zHU0jh%6yXA7L4TWjrqt=02KS0S`GAwq;=?V4lwCFRGtJfaWR^=b?v2EoP!zcq;+Dx zW1D+>a`bAx`dd;@7UnKNM{Xc3l#crClgv($hKWV!cq^r++FBG1H+bV9g3YT@nf=db zc9ivUga=I7@HO(M?2B`f8fWW9*>rz;jR`-h!A&r{(fjUMD0aui{w|iAo}SH`KZQU% zLjYy^59=1Ce9X`=|MC1P9nlPdQY(VJg~ER=h2jl&t3j*nFME2vSK)57JVpTRFq_L! zc-6WiCQxLnkh445@rmDM-pZ{2&;QK`J12T$TQ=?wO?Ne$)G zj-kVA)5}PU#VZ8-=?C5IQXWG~^9}&t|ekpr@ZXT6`rOG`1NM z5@KU#*FHZ#4<1Dzh4_CuZHuH#QZ%cVMn*?#p*F+(zvA-Wo_f7rft}k|l{BNXUmE&YJ2!IF%i-Ccr5IO!%t%B)ifQ&|eD$~ue z5fF+uOzpDX7fK1mrq{0Q_PN-8w;UJeyw7TASX$}i;__!WptCRWQQqbC#h+2-4L`&0 z??dkD{W=dyXqY#s3_V*SJ-Vr^oaAY!4;Od)C39oSb*BDeP9 zDAptW?jQj)d_<%B5Cg;j&7{}4ii%#Cpx_3wRXciK?z_tX~6j4$35`_w&qFmN2!Ib2vsm zsfgl4zB5N*f)y>C!R&73U3vCobElmbBVyhp6NT}eh4JW&^UdUE7rSGJl>tn#G?=nu zl&0>+y`5pD6g)3#E)S=wW3ojnt$+ybq!a@j?=B0<4pi0QE=H-H;4aM z>vMf@WahLJtj=EevH13bv8-7cr8_W2Rgz`l&r?Q?;AmPw*8fih$~R{=?CoKh%YioN zMqEFoi62@E@y5`&!Uf^prz{5KLz;qz_f%UGdKMD}4bN9}TQ^XN|FVtg0=yH)_tH;A zOLg^2lh%i&Bbm+1OhEH;4&1k`k$mP{TI^#Bknhu0ycCMGU*r85hj{A)CSuW_E1%}rp&|kbAmweVh96Afxlxcd z@g?=y*x1t>9ZWp=##_V>z!j15>A=_=ZcrST-+1I$k66zS`NhzQCjH?zAD^5c1yxq{ zNgQTJcQ@SL8FOUI!|f^`!2|otqb*e@OUl?2v$L~v#=gdXYT~5#y7kyoB)0vkczvAYmI6@)q;cl-Tha5P#(fe(gP-leN^A7G z2e=d%CAaUFHX;h&-0x8m^}OMo75h=9$7wxmK$I$Z1#+o%V@=H}JH7tir^dGD1#UG^ zD%y-+w(DwgpA~1fE+y(5TOCBT)(?62{XUsu!KEu8_zoYl) zr%jcWjdhu*P38ZoNI?W8`}>bbVS6dQQ6d=8!h#T|>YBA1u07A3^Ez{yn)>l)afg9l zSa@NiO@Zr`FB5oF=gHC*cgHMiAW^(^glD1ENx$SOes@(5h$XvwzJ~)67N~|4mpWB| zj$obiTSVzoolLaU@8#E%_g=hkpz=70^|)P6sinM|ruN3-N0KPD_oTM;`EyLyrtJ3i z;|gn8Ifm*83$KIEqK}WS1?aQhW#{hMz;ePD4X1qd&02*-dkE`u*wb;1lH$U zy@wSxUt^S=kDS?n`UJ3|fbyxTYhby{ueu6L8fJCd>f>MG&W&*6Jz{X1hnTX@1`yFs zZnVi=0^;FutCdIAjqxFsG1{qRfYAL%{IT9sZHK(`!Ok~dPm zMD20H>3y<2cx=D%U0K6};DL!LQWcH(4q0GCngrzHC4($`2=OB2tE{|vuza)n{1PXw%qJF(CB00y8_>_RWX#=nTU81Qgm&h6u^xHj zzQ*-GMU$5|8=xv}J32UwXq;Cx7!{|<+K`YoY~;Qh`}&ZYB&O-j!v5)MYO&7KN2-V! zL>v(XENA0v)o!*LBPD#Ebl;Qmw?C!62l-NJF({!5lD)9K7NPF;vz5Xk6UqASad%!` zaXiIs;H?e@O7aEvK`%!_8?7A67i(hYp~pq<_~`y%WRi38M< z&}OA;8x>w`5{J%&voZnI+QdgMq1GG!XpN9Ci3HY9^$vf&8#({Jbvb|YX=TvNf73}_ zV#JbF`P+%Jh-lm$t?FjkHQ#$>kz?Ycx0>)fhGxHvEF2muAvY@or!pjnmjnXAPa3Ds z8Pg)$_vBY4vS=gd#4$e4FVia;zrmEd61CHb!ckq4c3^oNluvz>>2+1N*&vrrm$u~GoVB-__(7q+dHhh>J5`noT}q` z&y{id2QKZis|PkFjNAI@%*f=tvQ*UvJKDLnIl5Ul1o_Q(r!TE<>b%=y?6F@$-oATV zXAlE~Umm@K!qXd7^|ddg@_hb>-F*g5Q)KpXb8#c4$=?WfxGv0MQVg1O7ivj=4W=~G z3VHLc{`cte)rVT$2Dw4unt;vco7zvmu*|8a9rKHdmLNZV7!*4?yPTSy0Rn4I?z;|k ze|=drZDdnm-MX|G1wGGk7eOy-d&ZRG)r5}EypDsftwGkie zkJgrWI}Srhn_))O=7*6cU7o!B=C-hFC}N&Tc=uLND;6z`Zh=rw z|B;fKKl910D+X(|uH8v?cZUD)RKO_#2B>@PQyf2O&z&ib1RRB3HWqG$cY5_rUA4u< z#fs3TA5Ea>?(7YvU4+)GdvHI^nu(X|K%3 z2iDKyYF@fC?z*q4CphvKr&M~9Iso}%{vl5zBF!NIT-cn!iRAoMaujTdwZV)c+E_dfmsldaq}C>-%(z z0%)~Z#7^?<1f;J6vctC(B3Rr7|98Y9g}3k7f3*Pr_en-p*QSqu7nnmzGJ|ODJYo2H zPdu4E7qQ&QVF!!IJTlJWWAbP|-CM(K;|i(iQB zMUfBhd0Fg`W_l~dGkgZ>>YRwwtR9Yj8MJW> z+Iin=2f!l(ODI39nF6e&AVcQovEkuQ(>#7;tBrO>fd^xCKtP6zNlZFZ}zB)A;dfi#^sVwLs4%H;zdCBlt zkrL7N=l|2A^XsY%D~5Gej{Mst+`moyX0AL-bnTPUF1GQ=p~70FIb!J|vQDN4ugDdj{c; zun?y^!k&1oFDbSy_kLXFC(tyKSdenwy$1@;xG|355kO7^v*DrWT~Hej@pG`T69Z!J z3hQu8GLXn8QRW@`&p<3|E48z>rSff0@70BurIqC%gHA+sZ7m}p1_8M? zC}U5%Ns*VAa!}S6ctM{%^_;`oXK(G?KP;Y5*{=NA%`P_TxZ|U=b~OWU_JzVO;SfbY z{zpdg6KVL|QGN3}{%RB$elD5g24C&LgT2k4v@O4W#pR8J!8T87o2uD{40gJUw&mqJzkDqe>?HLr z4ex;=c`tF3x?{|v`S`y%xh6P=Y;1sIVIj&Vz5(C}DH;`!YG5mS#_w#IrhHjCl{n(K zyLwyvOQj7r&-3m1yHqAN#za<0tn0}n#{K_WW{85; z7dLpMpnc#552WfJ_pWKf3E$`m371j*c*!bgtuCZ~?m;sx(G;u{L80z>?55s!8 zjIR)h?Ca|T&E0oC4G#~?29ju7^5y2r9;Ivkf^^!Ulmfk;{cufnu2)ll&ev<3Gu&i{- zcQp^JsNp$35E<3fHC-kYA@_f(Tn`5@n>NJtIJIx50=ennr_Qh+=De20LM$5tN<6Qv zP>u+Te2GFk!D!_DYWdsytIqodu4=lk+(&%*8^3i$3OfVG63lqWS+P^$dvG-wPFOew zHqOhL%?yDqXS+|$y3e2A)7RHOY}XKNqHursFm%*1o=L(#yC=_I9-K^6P-b-(c4$YS_2<|@l`ki#X`eNMMyM>Us&w~@U>BAxiUPJbW->xWb zLsmt$7kfTGYMMmTieW|94t8Z6!CyV^o`RdqBaxS{;d#mz^~m(idd|!4c{YqHq3VS% z3lFBe&V7){0iPzmr`;L8U9PU!JT;?g;3@(Er`wM&YX^;)E}@lJ2+5c5~`*-<2eP?m?dZ z!T@J}S;8X5PA_&j5vcU5K>ozIERd|QR&yDUW?7Sprg7dB^C ziHLkz^o%cp{Z~yqyymV$Zaa}_ZY$ZlUb(y#QfH!uEpF7QPB%pNXhfLrTd^A7Ral6j zg~*aX6TQET#O4o_j}s91Jb7tuvq&*6wA~RHDr79aVDOBMh~Tt(?N3pTM1hGXbqpSF zsLN^&auLoQZPAs2y!k{m`Ni)>OvkO`OVe%611XS zLK^Bc*Lrkm9yDo-tPrlMJH*7qI%GhNtN@z87OQ74!*DSp55I6|2OJBgV`7fX`O{K~ zX}h!HGA`H8Fv=WL4UB1qfKkQZH&QUPrY|WN=ZB5i!}C4EhJy!WlfGdNNA~9WmI-88 z_*pdAkzuI`s}-W=Dp+lE*DkU>V;TgGrV>wPV=96QcN=dt7^B+7Tk# zA$`ByvLkSES1HSe!8a3Im0y!CPZ(#hMpH5SHIen`$?3W*ss)YCyVh1HIEB}l^;9om zYmVUP+XHj;Ji@EJUNqXY)<~hA!*#x5^&qbvYGh$(k(fQRCbcUX>X&DOeqntRy3fq! z!f`_vAO7f`e7HG{?Z#|K5tWS!cf3`fwEI_jRzp&Bf!w!-3BTxqgCaBf)ab)+i5yWV zH63WGw0O#0Y}{v*HgTuhU{@7TdZX1*dWd6tQg5-^<}fv85JKp5|FT)j{bR!KV~>tP z56|HkvUhqrw;-&Bi|DWlu6~4uJCiUI0d<@)zM4CxyKfZTe->_BO-rk{)@}^mn{P%f zbbES#neT@wheZbIkB9XQSZvL&+}8Ni$2w%BTS$AYv~bImKHDGF85*@vq{Za0fR#@7 zKl#ZAro35u)~|88Q{mLJk`*z;2Olmfg1+yYo;+QYRt#pxsxNW#&>E_|nIONA)s#4c za--31vS2cRcbTg)TYZWnPKFBtQEJ0gaYsjsRaL23idJ}#99c*Qp9Jn<@+4kmeVtEB zOwJt_X_>k{5c23Czo1pqos4d0qgdOBSA{v-tQ`}ZQ>^MTTk`fb}?S+B?9BqXE>$$1#-f#I)}1>=TS z6@H{{C$Qmk;f5>doT}d=PlKwP<3UVw^?8L9siWc*F3Yb!jY0D#^Zv+$&O6CH`p!@KArV_8J4qA z=1AI7eYFJ90_$OtFWwQZjCqsj1al zMmmp(w(W=WR9*?birk#G$RRtjkLkV1;2Q(xz*>*{OYN7K`?O9SbkcO^g2Nq+`)aZ) z3Rq;m8F9ucB-%5^kxUqPioIx%rKKfC4i05^d}gG=SNA0gg`-DE%ly((757_Ft|$xC zd1&us->VRCN_%}dEMxgKrlR;2t>_DQ*G~8qX6lX4+6g33X4Z@0-q){toj$ymw%IGi z)?{(_yLag?p=65T=2VS~;ie1m?bj?O-Z!63;twW1RQ|b`35fT4IUluoeF94_js)H2 zd@gs6=NFexOgEN&Hl|EJil-!N%210Aw6s04^y~e(uC(6p#<^IG|Ji9mzM}$s+`gG` zxuS(}IFHET=y|$j4?+{(D?gTR{1Un(I=^+V98~HtWqOYd3;f9~BNbd{8OQ5GHG{&K zL+#M92cC08k?|R_)?Jti;$wW^Dt!C#?IX-oGcIBZTFM9< z4roC4BKC7yq?u8pGt-u*#E{q`{;19xm8!6?cClvI!>0t>i}B{Uu2E|$o(p{?|m*C7YhCH_RI6*u^i#}z%LPHm?YJ1v`S z^v9Bh#=dl(`rGHDB&NMNV)PX6-aN9rWf{r$$iOlcp`q}@2a;qCEedE@ zTw?8W^6?Bob3!VJ!^FMx7cbP{Xun-oIx8=#O5W4hsb0P)khXmhD&WRGK&lmboz{-f zR3H=Lv61%NnPG=vC2IfM9|x~KYLjEWRs8%%r;Fcr+&Lvp-%M|Bg~5TAk^#G6#;woA zQ-4Q(p>gyg`aa|jAff18XINt0e&6M=*naCFb5Fie;%C+I{+9;up_Rz)g8IR{<_GA7 z#0$fPVgQMC{`FxAADA3?{(u(^2M5Wb!>ckek&x#{aV~WNiDHHkj+(#eCXz2_qX#aT zt7@eB;G)39U)(Hn#Kh~3JvAJ8d)akqA?xk%!o+MO5nLBHtaW?zZ5EZr>vufG;uhY}`DlIEB5rllGg6a)1%h&uY8tTVXK!lshG7KJGEahm z>67LK?2GSYyyUHSob~Rrq_@ik&FHZPH3rjP{^YG*>rymqw!OO|BgP;jbw%Rt*^F_6 z#~8(2Y=^9^B?BG%w?2^+58#mI&j-jHV&WMZ<6!7Mg{F8NFY%SH5lFTEi}um#G`UCAl@s%}3P4{@3Adio&nsqjmR z-=x2*Uwonbb0Msr?JmI6_)H+z|8TvKhGR4rF?n-9Gv~d|&E>nw0{qperP4xdys5GU zi4!}?k)o9q+Y=)relS)OYzIR&b}*+y%N=(^RRJR<;1XlsGX9ggYS8tnOc!V;jff=9 zi78d4KBfbi6dMc+Vg`m~AtEE?G&SSaIL9CC_wy?FpWCffw&5nvi*n+2&m{dqXeeqt zz;xA9#{Hr_aRpKK^sG-(`Mp0WW>xXF!a)Z*3X}e);$1X|%i26q|}~ z=TBVWt*PsMU1>8i@+}RRI3g`0T%c2ciiU<&5L@>6_s&z$z;7U(35KK7 zPQ5jt>@h}Y-phOUE?NgVUStvshzfu}sS5}ptNBR}2K51blmtNP=$iL(Nj3uh&@Jy)&GL+Zx7PFk0zXME3!>D{#27D{o!i`rA^e| zqR5eYcQBt8vrrzrZ^@_jFA#Ts8Zm%mBMy_qp+0_5;Ae<{|IWz#+lIV*x`lEBlTz0I z#?N;5{Uw9T<-OHUznrq)X(MzwuD1wOi1Si*OiFw z0U2iEziAeD7W1N5+LqK??<&IUeZ>dxcadGn{FZp6ND?;jB+0xJo z5v#zig1K?PXP0-Q3jc)u0Fm^ypNZRq3x=iB=PbTIy5pc&)1u=}hV<`MERE!3E{(z+ z8&~XQWJdbu)6zg>U!UoV%&JCH$mIy*t(5AF%z5MeEhNyJaqkaHVJ}w(V8UQ#v+ksa z5<8G=&CxH2bB$&xB-=^XYI(Oo5+?JAt2HVMJNKoqfN!3=i zS-6CiW--50sUcDVw~iTFn;)(~B9Z=5^rJ+3>auWRCN?GlhaEqpB}j>6Y&~`rbjwNK zz7YTInUj+<;xXiiZ`D)&cqGq1^YiOFmb0%|E{z)GZ@L(O36%o(ThKKVMPXt&g0>`c zO}q~#YWo~$q>GCKUmQl3#=m z6G;fNCojzFI=>FV@In>=y_pVwltVg6Iq! z#y$fx?O?8~DWLu8s2xJyq*yRs6c-1Qf4O_pNhgeZz8D`3oYMyjE*F(ZhR{q3isHM? zRlw~LFNS8NH>#5n=#3+rgox)PgZa?YK-S>puX zSN4eeb?Nijn3`~{5XEY!%M3V$3eF@#LSC`Jv+G4l1#RU0cf{9tKSCBOTHqW}Y8z~6 zvQ6jD(@S!&9LCt!fD%to^pQIkvMSN6hK0W;=s8+{Xwy$e->aj^wKcOE&_tCXB-eP|x7tu?g>GVn#y1_UPJxM)ScI z;V~+-0o@L*CcO1P4~RsvoY)rpO1yV3Ki3tv$YV3#@0dE~e#IT=-RJA=X9Tt1bl0?` zaReodKya^G0^6qk&4>B;nIAtQ$?qFUbBdhINqzCHYH$ zU5W~a#2mbJ5F5h0sBf9%Q*};RC=(pVhRzw3b6r5}R6|_+KP|UTuXdAabt8ybg0o`? z70);BmzLz>mfovf?l-3o__(H`Gbp*vI%LSSQK+u$__ENa#x(xjCJZ90lj1MML#$(J z1#{K;IesH}gMg#TRpxJ@TY`K{7x?9K)whi{zk^Z&4r=x4R{A_Af4sf#aCKTy%Bb?= z#`r<&E*y?*NY-{=KSJkDI(rHj0O9gYbtIKLMEq41@b7Rx5N$(dCWHK-1Xte16o$9< z&LA>A%6E>!`V%8EQ?Gf|Nh3Q!TvaR=jL_<8VzIH%8=N%MyTFi5k zcm=u6=;L@iff>mdVsuD7Vnw0Hx(TT%Vbd#7{!%;O8T7Zkj@dE8fg4BVxjhq)P;THm z78^mhew|AOUla}MDZ2X~aCZ=tToc~!r!?1?(0x&98lR1Z+2>3sXAz_4F3F56U7c(# zwXcw!m>ZlLdlI{~WS%bV$)ij94X(_BOZ}}RPrm8I1Z0$*AAh0SYDrpd72YM(VSo88_{p#&P@=$%?&9xG>aCxMw&) zrA~9K7Xy_fo`2{U_icKD^Zhk0f9fmN9+x#%UNJz<2==&ae=1$l!>)NN;2J&KfarmZw9>HMg#^jck+-awx#sCPb+ z>Va`PT{6(N#n;+Xjz8cHbGbg_^S>|xl_7I5uoVpt(B z=6U9Q?MdA*zZ?SR3ke7hREdn_7~qD3GcjDfcPJh+XY1E7*>!LN1d8dt7w3!T<)D(o z+wTnH)085wno(4aB=nRRs2K{D4v-$$TU0Dzic$mN3L@+9z^Yy978u(eOsXZBI3E@~ zxESR6!wQT6D3Cd!>zEhSk$nbpt+wdx1&A2qCyLqK7!cRxpn0VnsIc@fiIp0ZRmMZU zz|XB=(@1RW?N1VQA)mPDFimz8=hoHLp+QJUNijJhor`hK;sZkWdu7mp@3b7E8;wMU z`_KC!S?(y-iP@uPyOdo$F;msl?CFjqy;wFJRd2fXR*~?OHEJSJQ6qsya9TWrfsiEt zJ_Voe$W%W4yV6q5(nZpv&}c6b08Oc=Mrw6^R;;Nm+oAQC=CNeKa7*6mZF3 z8-{0odAW)tRV1@pR^x1#?R=vaDQ{fNLL@x+nomQ)oeY;uH5j17e8bX4?j%UWlIu!g zvt7g*6vMS;~?si*vwPPYIqDjoE#e)8eI zpN5$D)S-3UuR+AeBu9ZYaIz1qsy&|-CmDN4A}}{|LrqVj;^0ircIR5BT75*p@fdJh z6>BA|LrT-o<5A_0XABVC$JBsED4-9JEXt&@W$?ST9nEWI{HkEh7Cw%b#LDzDcTgH! zx-|ua!@E6^8|?m$y3hVX6Cf4Ri2XFMCyE>#3nq#-4gMmHIquLEPT&m7ac(k37Q(qK zzmj!W5JOo6(yb?T2NW9A{;LJBXYhqM{1B)((;^E89}nd20E7o7k2glP_cwJ%CfLMl zd>XHItFqix@@-mV8Fk^jWt$n!ZB*}kj2lMaHdR&8`SolSI6`BRP4mUTi za=Sgm+eml+C6~P2_VZB}6~4!*{9gOdVrYcb0BFk`MFQ$m6_WEed9sR%pylF&5dUmj z#@DIx8B(%@Z)B1gQB8aV5#M3|LtdB3{#u~mpGaOQg{qbz625{ zuYv8MfB6stnjwfZ5QR4XdiqC+6eN=>4^qLy!sW;SM5pHaq@9b@nhJ|d5$D{6d9KY* z+j=1I@xxnJ1rc!IX)#?9ZVsq)^}E@z@GK0cNL|%1II0 zR2}BHuI40w$bbFasG}nY6atFxfqVDd2ByYL`OmmL%ij{D(Vw`z5*D4C|05a0D5Aj{ zCFbCuU7{6nIUqP<>PvjydV-$B3e%rT@Z15QCzvg2_l0B)jC-m*+B6@?#QbB;3F4&t zlKj{e$Ztu<7oD*l%=ZCo;jFqhU*<|^U=f8Ka=s<-Tqy)mKt!tsWR-HC{`rqLx3Zvu zJ@bK2HD;>aH+@jW3TD|yx{dD0j`O1t=?nax<+SKHGS8)wyztsrE|1Ku#@MOAFxb+! zYliS~E#2SP%tM)X4yCz37{&INSprVegSIEg@ev9tgORKBc z4!+FDYv9){wR{LZW=j0Bq$DRV0O9J_BFPcOKN~?~HRe0FQ30d@wI-yttdvR`|yB)*Q@M` ztp6S!o<7x7iJ!S6Q{1Zm_;aMV4Isomo@@(_5O8;#sT6(qAOOZT{*6~;Q9`k?+B@wG zsln(dqq@rVF`M;oS$ha>!4*Y-K`Q7~m;k_+-wvwIJp{l0*DYA7iX$h#OL`CVJAgl( zv|Wh~F?fg{*Xmo27K@^1C&2IV@F;=)yT2C!OI4t<_tYHh=RPe-w5y7iU%dBGWgA*` zHFAtt2LX&5^L-u!pa9+-tE4%>2=7nJ0Er@{{i(O~*GF*T);7{sH?-0>7Zg;y!QGxq zRdAdH$cEph=Bd!`_2hBw1f8qT&ygxOQ)>0GHIFzys1Lw@v3iJ(%N+0Z4*6L@0;f~e zwq_rNpMTGhnQs5l3IH9603nIxj;o(fI5fXX5V6&nT6!$oNVUn}`hmuvpktkqxp~1F zCzF;*736EJU0@Hjv6RlRBqMBPDRFj!#0YcAP_@HXr}D}wK_qSAwm!NHTDq|;Xkb@-Y}#2H`YVdX7IwxH{ZoaJ|+N54XTQ#v0M$sE1%&0G0QYtNwY&WC~I zh3cP8X?3U%6r{yykazbecAZ|GYD}zFaT>7p5_F#xetOOI`hqVJT|#&Vxzr61;znVMH|o8oLbpn;PV87(xZK+ zE4QAeXr>00XjA>QP-OSc_vZsr%k->MTc>Y*i47k4H@A0uKwetbdKo#7*#!hDR-3Q3 z380L&Pu$@amc+rH$8rgz**dWC3QCob5C_&1-*e?;V@m&`9y!rUBqMVr4QOtPmn=_a zFTgy2o>N-0bgb+1FWildwj$Xs7=+X9gJUsnN&{0jioR#ZH@|8eZis3$PnU^LZgzVn z_vH5Q0Cc%!KA)#wceG9UK+cK^OT=M1l;5(as_AOsm85n>JNH zr9%}XKMnbUmxlzp`w&h`YFq@=JDgyFH!7^KTD^W#k3S}>;KL2l=`Fi=LDr|>z8R4K)V+S*K4K_l*UwyjA@LP}$R)TY!w`XXM8q*seo4fa>qBglZ>_ z8|Rxa)0n01($d;bGIEc4^qnXdXrx*h`=2dS35M6i5|&j|*q6Q2k`4k17FPuw!hWAr zC&3E1E(w%TNy=W= z>skle@y9i0#x9sKrkC>Wbj&a{9vk}a54j1}(!pMV1a7?;ielpb2j3R47g>SV^N5i3RrI|1BN+8J#k33t&SbOr@-s+6m z*mL#-l7>VuDzG?)Kgz5`)H0HBIOlCUhDe4xMI zt2RE{s)H1T%rWSQB}tz`s6_Cm?JLnVOV-4Gdw9zGZ`|b!xRLnf*>(Dv*Nl*k6Gl#~ z$d@Ka^q}ENMyQ2$0YPBQ)Xj$v34FVsIw%K&<*R35xlQq{X-P7HEes6-mg?KK?FZy3 zm$r{>kgUb*iqYqU0 z99i>lgZ(O#lWxxW7M#z*@A&RnqH{%&TlqvK=InQsd}S-@ChFe!I?N}qE!J&lk(fF@ zu_Vpk69*-rHILVjdsl)3R1Ol|I)v>v)LlHEf{(P!H+P(>tCp<5tr-rl5uJ&9GxMDz z9*omY9cR7?rrtRFS0@V39l~4&g-3Rq@u-AU+y$7Uob6N}Yd<3w9$ZG_uCjeH$@TP@ z{vzn~wBq*}TN*^6JLr>xQ%-BI0vaA$B--7yG1(gI zYa6>SqBCUrQz58*y`jC{5*8p~LZWq1CZD1tswgud5C zt1~VDLxH`iA^8ls=nwJ4z>h`2=Xg9e+z;=5IU35*|rz~IDk7#Q1FpGC5fH!NVEJ%FE_6O(Fyjha8&Dh?IR^3DQ$*i` zv%*2}1pXV-ePGwRiZhC2#3d3TAJm@_;m{HdND5pkRFwrlAlGBWdY>vZ=;fqLEGoeT zu7@(|*5dKZV`C;}a{;Iq$c*vwR&b`$i|x=+Y!S0wu@oi1yGGaTQH&MizwES>Syh0C z=aQlO2}zaN&*VRLCZmaK*wfR~RgOQKueAOs39h%_gw?v2feIY%P6cq0^x~lA)VG1L zUw}&^`vTlCP+rh>3~Mka{^yF8`j+5(#oC_K{kr z0z4Y!8$hmrM$LQwJ|4hY(5Rz9QhuU;A4SP=`@9e+j?0Mt+VlPt$))CG7QxuXR|RHC zfUFMYjmPy5w|+T`wz$*Ml3A}z`ZVHlP~SKI1}K62E!$9(Z{8QvwQ( z_+}E2aV*Ykkb_)55Oh2m|$w{$zS^m zz(~cBZ|DwRNYZ)>@s^48f!LMJlG5c~zc_CKtZ^f{oLF36EPDB%O z)3ERuI7>tvY2Eb93>ax1)Y&ONs&k%aE}J0@RZ$77f3E+K{1F6#gEK#D=(QtG@d&|L zj<4?i>Fu=kPqIddXGYJzN1@K}Qpz`z za+gwus7na090jwovO+bvOW|xSvmm6!>h3|A3%vJNi!_(}ixeZLFd$(%Y?#N|@&t%x zDMMqnSE(b9&CNYrpTA%evB!g|AN+oo1i+c~mKe}-D8U*)(eFzSMg0#gwpBSAnzS9wxT~wKe#+lb`Ca1*6WQQq@sNOe_m0G1M)hmWXMvfSi2VVc_LKz#T zM!7Kk7Z(ie8QmYN)f=0e)xZt4Mc{H!M6ca~j}L=23YRJw_A>*y9iSSjm4C+96cHZGykuMP&Y=gpgP<5$Nom8{IQcSIX-d z^?-gDm=T7Poe69=*?R6OwlpXL6SnLT6ao8p)V%Zq=2-7f2oA~I)iL#&(h~1B9BpL} zMQ`;HNkXnhm0ME3MExpBI*MQjKR#RK(~@}#&@H5Y8lcg&4v+<{^;(N-&4l{;7%5Q* zPIoH+3_m~t!+ z$Gzy+B7q!plttOzI*{0uZZF2lckwZMM)KKhKj~NmR)dfa)vq&`wNT2tsJau`{TE=- zAp$kmpE3T){8~qXwhBXLm}d9d+FHl&&(Whle%ytrb)b}0{`;i)g&5OcU-NNjkU;M; z775!2itwZQimN~>m@kuHXL@jRtOAP=eCRZz790-S|5)52l|cU zd6i*`xlOU1e%$g)*}h8wA>!OSWGFG5Aysv*_FXyyh#*k#S=--Z+*SDMDYlpR@J5&5e9)ax$N$M6k@zccrc&#y zW|KT?mFsb1Y(U7)WGZj5dBzlZCC4K1nKjcT!9(6Jb(%gOaB*-t``;|zoQ``Eza+M^Y!GSXk z7xyp#vWW$b;)JF@X3 zB8-g}jZaY-_dX!_B|Nue{{j zaCSFkpZ$4GzhzemSopQlf6%LY45$ZweGKle^1bMtGHg(C&#uxjHH{}`Q3~~N^8vz^ zQbFqu$%_~1JZWoU%9UlR?&!=&1WGAmeOle}{)s!;0uX|N*o4}I#?O6WfD$d0?g1b_ z^S?WcV=HEGn9Cot`Wm0pOd9RJxvN)f_N_=2X-P+`5#qY}9+e&U{CuepaBkP}oIL04 zWYJeO&Ph|E2PX7I&*x5Am~q3G8qp9n?E5}w#5%k{hlQ_9_%@5#$y!WN`k#wGUmI8$ zOGp-=+x5S2s`VBV%oj~@_RaK|moB9=xebyP)RF^=kvyk#5&$45T)D354$8h5yl-1S z$ppC@oLEghTq+B=aAaYE-SFp%;%y*CfeQcU1}aIc#>PUlfN%a-cWnCSx#MUeXGalK zB0tw3R^@TOmhFL=v2XR7N-OikxI4K4E-s_rAGAYd`k;C*L7)l3c&LwJOi|xWYUPdN zcfh4a;ZoQjIGe$~2Bcm7M9psL@qc1hf-dn<%)eLysd~$w&VP;7c$IUSe~O7R5E4vX z`}+xrXedqPVsEtO*!^#!^RiI>;4pG#W;Bp*qb@f%<~x3onv#-VQNeS&41th;6fT7w zRZf2-U=GS+Fn|k3_Z~9$-vW^_fZ?6`?R<#{dHNsm5Ps-G1uEd{xLB1JdXkK9R^aV_6k ze9DJU^7}cGM3O1lyZ{g}as)(%F2%4%<^aq)^_+ACxNYWL+?dBTkazmv{~${X?@{2Z z4@eeE1C3gQex<;2suAPs6v$r%8r*znc|c}mnLP5ZE&!B89ZE&9y7k_q%pq;dkB6LXW;YX;<(+v$ExFyDcbV3IGFK zHvkv_R05QV=#c4F?kVGT!f4TUf}bO|N?2e*JRR!bqGY#92%7F_KnYF*Xmh(JWSTIB z8qu(?X18}`pFSW571u4cEgAZFYzF^RrXkoS7ibg~9Y;!yQvF?vRas&mQkvY!2}dCO zJKlMj>04;ee;}=_JmUa?cwy2H61Wi3L+>*0obibn&~wYVKKU|#AOi=(l)yD~X4_>V z>bkpxf==4}uuxUgjsqIRknH{twYZ+MJW}DGv*6@pH5T)xFflsdoHIGORUy4c2&xna zmup@`F53>fy--P;=-=L-L}znma*O?94k%?kUvAS^8Bb*R&K zu!_OYKjPF5<`+ur?%c!cNu5I|x(ka~_Akxw)C7>W6jdV(fbmyT2 zBm@MMQba&fLP9{|fPjd^q3a+GhwgaS(ffYKc*ghh`|*6=9t;=?`|P#XnrqH$&Fflo zS?J7)l{oOhz^s^vP)&XLR%6;S8yoT<3hS$iKNHht^Lm%q7TG=W zVz?UQsx=Zx;`+T*hEvl8>TmHz{zL#{FB~D!rY>{(^u1;qU%T4(g@FeaMExLtNCQ#@ zlfZh=Qk$;xcG<)*pBhhJi^WVz^LSit^n~~@q9$i3%!)bxz#)yk6 zWCM7Sg?%E`{rp#g$FhGkN2*oQCHrv{F1bRJ{hK$LI4c{$3;%3R#scnXq*bGWVdR8hX-*3w9eVb%5BbdhxW{nhLpnXRGkZ{eHW z^)Ay2`w=RoWsw2nMz1pYV?bV1pyHdc*ZWQ(&L>eG$kwn~K1n>VT(r1c7azXA8O)$&rX7q>k}gWBS*M%MQ%z zs;IV>919EU??UL0$|2ZeGF4+M@Zv=`wnsC|%|YHPN`A_m$k62lHRZvAkxX*ad1%n?*b7IU0L5_EUsZOoh6OsG2SxU;qfb$qW3GC zA?k3sCz9*zxxvSiAQ&5~zntLMVB#d87KZ0#)n?=1G!>{ImUWTZzZg(A4*t+buNVoSk9Y_Fl$lL&*E!EUNKiE!*5 zgubAeO&L3nc>n&;0|p7QAEv$?ExQVUYV15c8&U?2rVvuYrJrl=E-`>%SN-VKLM(nI zaM&w3Vbi>l*rdvFK$I1!mngF)qWqM^DUm!KwPEV-?mH~DkcYt8F+ zP|Bw?)6VtRpa(kFEa37$-e$>CukF-Jn`+>?9eaDZsV<}vF`YMEe zUq?lLhwsVB;a4L)y=ai&zm9$ArTVWoj@S~cy6>J*S?qi|Vdb=Chdf9HdHy7gBd5iY zmI(5P*#3(OC21V;*VFS#DwCR_8+X@|W${otNgz$w-~?m|2VpZc#3Jiqaxf+k-d6`4 zIj%L$G3^n#oVaq06nB7J*m>|tT)(`M_9j4%%--|$K3$Cd4&A7ve0ut!3QwWpzd5A} zQE}$xV+bWjso4Odz>Jo?0~?>+R93+c+`l|SDG&r-a2&#zP@yiiaOSk*qm|ZE4hZw7 zm^ zge&BZe8oj>SW9Yt*nU6%z8Wdy%a<;Nj0(BA`jr=eS7m&$IsrxV??7+wr%?r&L;r5mhKyWD(hr9TnrqB7J`hz-YVm!p423@+l@FG~zoy%R09Sko0cByX~hS5W0FX#-pSDltb7NzB2Z|u}OLWC-l3Nl$K@4cKwcopMsunf^0O~nAy$tBK0vLf<{h>3drdR%;h3GO)?nu7t;;@y53(y~Sk(TQoxWSuL#%k& zpI}*BtYG#m(J%+kD7ERD_i>fc@|CqQ51y{F!b?4Q^%Fyw0#HgvUvAX`@dUwn1eQPy zp;R&0kh%6tQie7to5&O?IkZuXTUog|vjHE#_*rWjO_E%>`S2aAOi_%$?<(L#Vlqne z=DPT8G0m?p6>>}_x6q{LkV9kP?3T>m+I!5LB`|Ytnt5;GCaG)Nblq$ypKe+)%hl2f zjw9VTdM0#(e8mJG>M{3|S-Jd>U!9~bL)S|w;3g}-7^*d0T>}49LtpwP#rf>EAq9<~ zK9)XyCJP(h`|Gw4G_Ru7!h`Dj;cOv3J!p^&0~g%id&%XRaDE()du(yfVfVC&Pr79`)aSwrjL?i3p;;wS-nh3;F=zcw z4vuI7o?3=Qb+48U4W%-)&@1?EeTjZa&C4hytVbt0Me(fi>miQ1DSxgspQ)u{mi=kx z)X?)nqCY;-Ak24y_W52gCGn_L83tVc;Ph@w#_$hrZSGh;ak|KRBt>s}69Ro{nYHU= zN@Y=u>vuITx43fPB+v}ocf$7SoM>yCYi)D&G3BC#hCj(8(tsCihMan1G_iEfS5XyP zejky^fPXCDq|}2B7JhepP3ojW>ZDC-z1_5DU0XU!C1grs$@;Y=4Xn{)AQc0+p;j9w}EYPoPk%gk}{^N7L}Tj@Qk6ibg&cbRuqsuj5%e?X)fQ z^bB1dounyp^W3zxN+l;3e!IcB&JOeu%aLLyas4X)$rFGQ%FzI(=Lvq}wMQj-PIY=t zPTQK(#(}3tjxtWIZY>Xpv{OdXbY3aqzIwn6vrCo$HHWwOaP7yVoJIX*n1~tG?CkC3 zq;L=3om^Z^(ilBt&M$b7QNUrRQ(8lyoYF#Wp!|rPuFop5QAO~5(WH#!BvtF1P1~n4 ziJeug?|W)mCxem@-@mj(n~N2a9|1*$zxGME2V`Mt8Qckk}*rjREFH=d{N=@EwcG1%7BTJ20F!h&%`%D*D2 z^qC{NU8^ghbxtlYxxf7uL|6k_+N)(de32d7kX^w_eYjM~8rbcnoKa9VCt0{=TB=N; z%;mYCM#j)kQlUzgebkY}6Wj5i<`cQfz!x1trfDBb2_p3osHMN4`0X@aIznjV+X6#Y z{lL9x;Z{xI?I+som|isUZ5!Q|c~pU|ws`1q6DF_9*x;4IW$VhL##wD&7*_ZP2hF7i z%#BqpZk$l4`N;4>0FUyv6&=L2tDz5E)VexqK!Z! z>l#oq~ru{*!MzILjJN2%c91`TETP%swns^W_4n41m(-MbzKkK{| zTuV+!%I2+xk;hT=OdB%$2%U@nxX7(RnqqYwXLXQ7`y)l%q+wef{$vL5)_8=AEiK1|fqYf6`28y`?pq7JuH9U{M{Lx;UeWzx_N=G$W}f@tZbPwr zesbQp*;GPIcPpB3bX29PRrF3tk3-Sx%+%muxh(uc1LY<>{2)q0%U+5+PlJ zzW!ou-PlBp%uDg5UgC4@TqLZ0&X(&3F|(M8%CAb13!!^TlMgbKFt2DMLI`JHHNy}d zkd)Z)^Q!PED7DWhw7Rt(?Vi{;E7^Sz>CRuTT5VNTP|w`U8|;1pJ;V3bA2833u}39e zM(yl)u8HnbxhYt_BaS@JC{S^beUu>1_UaZyNn0u8SIlKTvCNNsoqkiXq#qk3qIzQ% zW4_aSeKnuO7kl4{hv+5~BxS_b3^wSoNbmPN>Ov#fGL2Zo2rLYAa4j>biQ}5ag`kHJ zqP0OQ?r6{n)E$vJmzH++@Jj7XOy3(WVvj8wqRnXfD(3AxU>^>&fZwEXI5+ z69)?~Wnhs$JGVOI>Q_1W9pr^fO)dsPBT#bNb2grIJC6VEYQC@L=%VS24qzxQ+BBFC>+%{lB zDcu&}uLeGUaVu5d@+%*yalNhf->-8Z5HKxLBOrJO=)L`Fa|3hDcWb*YaJ6vkvwSwN ze-^j{uZCpg8LP9TE+*8b7_Q@rRlIm@`2fl1cRqNh1?Ms6_si(#n!tLjB2nzbR608z z^=Jcy;&}!uW3siqDaBYF;e;9aLvI%hQG>Ap7Dwit9`enEPpm@~5y?p)FjSWP5}}bb zwM2`Zx&!kjO*D$76)hAbU(AZuV#QVCU@NFXgL$JtBDk1g9}AcbREBEMUsBI7bYn?y z4IF)lmnef9*zWU^YG>Tz?^E=T^Np)g9aS3piM^%TwMck-tOTE~$^|FP*Bmdj&|>X2 zk@qro%Z2W#Fz}5~bAtk|r`YqP!*ksT4<$l;!A^zIFQjqC&u^hT0V(hY^CXH~R_y}Y zqc1L-;%CC3g%ObR6Yhr|W<-zT(Y4r-ZZkeJ>R`_&^9ujy8-Y+YCOXurJRQ{2&*z#) z*$ejZT-{6}j5LY%mPl5~;{gu!cfVZb^x?}zv1Sdb1{nEuPWmVK+=ZZgR<*9B|9kezURiB=`9jVc*JyS<|pEs(=Og$;P zzCqfqa1YDD$Bp`UM?Z@@Xq7|uYj>NPB*_YYIxi|pilS04C9|YhX!31I`Q|EO%RAn0 zvmDs$vSNFjl~OsOf+LS>2=P-&;uz{Im8|E6G7wwdQZ+nx;$?(%jiNGCO>~W(+z!)K zj;iP-Q9=WaqQEMt!89UcQDX)F$?TVm8_~Y$X>U02g)|vvX)FpXzTan2oX;5+?I7iD zimYy=QyHkSH0D$9hrU*oRPPT@QB~rZ4!4x}G&$3(!`}B8>dh~Byot9TDa)@tuD6zr z9=haVs&m#LuVlnB$qaI5wQv|OOs&hbQj0upo7+ppn!^ejUFyz58mZCcDDFNjJ>2-M+hj*d1W$U*G*ZHqa*viOFx4nn`q9Cr7OprgTT)j0X#1BgQdMal zqKq+?l5tsE@8g5(5#K#>m1d5tY7HP?EDecx?imqySxy}@61K9-Q!KzK)*K5{R&}&> zNmRQmuw=)ct>};s%4vkR2ipCT2h+|t;_R^xf)NEo%HNln$#cj}v>frm!fhqvXkNK4 zVS2%xRquHe(mv)%enYuIXC=TuSG>*03;nk2_`Hyo zC>FuUArBMrqy}jtzsaL>{f)qWp9OZl=7rmKCKFHS0Zgo6XspQLo)eBaF^g`cJlcrB~Ld}S(KFC#<8ktU6MsyuY5)}?dcZG2+!&Yav9N2J*zI^ zqv)=G*v$%4LyA{&3n_IZe2!S?)A7bv+ar+ohDUx=4_m?S{id&g!KW! z{gS4UW+UyBUH*dEbIC+CC5=$HQmgzf8HlWBw#$usN|w*z zeO~)YQby?zh#1kRh>}j_usE3HV%{3|{d;Cq4;-f_AEew-jWa4LWG{ZQg{2+FjePWu zmQ$^7i5j3gk=HhN$feZ4v`}!I?o-Ij$o%B@)xGA?d-oG4a|{$*CX=39vYQ8{d+n53tRuaolE0y z{QvLWq#xq`zQTX+E)=frK($>|beYG2@xWAV+lGNQiV*XH?ap>^Z@%p>@`4%{J(7<9V z1R^3;mctXeKOAb~*EQLYN60pA!$gEkOBmryY^USD;U7_CbEK--Yso%)JpD$_jk5Ti zEMw=z^3p_SAOd{O%jd$W>z3Y+{$Z|YRqFC?wowZ{5g)TbM{|*I}zII>Aj0avE zQ3>R$yY6k)jr(xC^`jlvozl3zI=Xp>4axQUF=?pdfcQ)-l$QlDtG@f} zjCmk%a^-8>BE!l$^=GR8XZQEB7kljpe#?%U!7QJ>A@|LrOv}!u%AE3s;AeNR)B6k$ z`&uYl&752~v%vC);Ck)A4Gn@7-Tle-3{)i!csri@`Qi-jJCh*tT9mS)yh}OLJSL~I zm6~b5iihLJ_<`dJ{{@_O`pwY}U8D2VUGcs2TEUw#Jn5r-0v^*-QoC^}Sljt23J`0BUywV~Q`l8T1W zbhS(KFADz1dgR2qvwW z$M%1G8}Bw=t{x}_>|!s++$>2@vDwOS2*M{F_?z**`m_F?BPY}id%&y&Tih7=q>#&_ zPOX<#EqF4`9mf&nxM3y)T~+ygAd~*jsUhdC*Jy-OP%+ zdW8~fFT&JdjL@&hwqUI}U-p?WDc5U>9)j)o0+~y3>wVRMoGU(v#Yn^ENnl*=W@T3V zW~H=S-gNMvGfu(nmvN`A2uUS2O9+FtTn;oPzBBaervByi_-55*zb5gibYT()-B8&x z{DaJ3y$Oj*VH&PU!_opEM_KYPGiFvBBumjP0leTMl{>8of5uvIJ`QSAGWIeAL`a z1xA;f@%qAly)aqZpqU)equC=!o~+Wy5*5u9`YPb^OYKL?>lUNa#kK3k(;0GstFcv* z1m!k3GBUR0zU0=fcH_k{-EK#@uLX6r3}c@7nN9hpFR1whX%97j`&P18^L%*y)6u*0 zlQ#F$UO_*nomImxHDvaAzPKSFdq$tA$7i-@{QE^tPe9GNwzJM))NeCQgV`$1)+KO0 zX!)INi!F3cA{XU`zC~m8H&}dspXM>9Y;T1)uUi`~TT*s`E_dES(_RHE3w*DHtY&hjAFemZqK_=51q`4xR))Lt7Wlfs{Iyxf3sT}V5z zrZk~cS%c+<1H$7+)S};IL0dsUpq^wKdSUtN#MQCS;b;BVCkInw{b-QjbHa&LS)1$M z_5TtJPZp0(GV>oBiJ1iVQP2;!k-$v?h{nr}-yQof25gAX-{13FYa&+eHr=h=bnDI- zoVOg@xIH-66F6etk*(Li2Wyjk+0hAQYsv}LZB3gIudX8n?RySK5g#?*KyO-QscNYzyR{J1`EKp_MU`lkDNMG$=WS#YKn*@$9?4DhcC<{8B9JGd> z*7>GgeDgm=xK+Z&#Acw@$x@^c_pXwn-Q-ZYWMG2}OZO^aIJNWLr)=j7rT9bb7s!@x za4Rltl@wqsz!OBaf{i@bkU87iC($6N{zEU4(K|913l(kBQN(idvA%M~(A-=T^|N7Z z;Cq_B)F1rJ;kW#eobZwsFyF0B{*$dtAQO6?To~adC{X#3L4qic-}Dx&zS?SX z^)z5i$S@<0U)D4>MTL=2p2Yp?_3C0B)w`a-hWSWTi}Flw=+aNtT49^Eh>&+O){;FM zS-IzDS#dCHpCatXPaF2%HSRY(+dXcnbibGlg&i8?b&6!ZtAz!Ch?fqA*2%32!)@pS zFF{{-=`?Mqt1k0x!g{4_=D1Hr=n+kY_>C~ia4?#=!7HMiXcS#G6P-Ta1$xD1G2W?K zCp5Wz1;xx+*Uws#Vc{2Y(CCwWI*DRqUGL%jD9VZg+4rc2$o`2NQ+M9)bnWNJd?dv&k z3_I)Zm>UZ3YPBjH?nP^WU4C$>`U^GRK0)=&#Wxk^M`U&_SB|xL4PL{r#>G+xaYXKc zVBnrH$}8IM8AHN0n;a5ZW=J00=1skkH5Fz8Vpt6)DV&;0CvrB%80K4$uoUZ_#o9ag zyWJlX+}CzV#jTdLPrddyPo4WXsb8N0Mu^Fu(mpp3?K%j~EABBPfQo?=huh~@-+V;3 zR_n|wyL{>3i(fx7ItWR9^>o70=eeLe+FXY~sM%0jfR?^`QG=iam1}?ECyb%gJLlU+ z;(rG67R+z_1wZcrZ0XZ6BW)7Y%`f2k-KJ%4|0esY1*#>(Cvl=gk(s{qng+kxu{Iol ztESf{Grr~1$+b_!Yt)?mcCVYOTT6wux3`AA_59)0>{+m`TAx0e<5|_a=RP?mwuJCU zA`b503$SXlx19G(yKigwE<_q3EiOrecVp%+(anRJp0`!LO}sFI2UEW|#8huxdnCQL zd!=C&Qu2+6U+x5xFIR|W^{p&g%pnsvN-hw0Hn4u4PMf^0*jc>Nm3P4WxGnF3kQyB_ z0Y(qQH33Y$SNCNgU|mpHzkBq<7L=@q6Fj|zzO*OaHS3C9#>EY3ujlEF|GixO0bfo5 z?keMcty%d52wReKDt6qY6p5wKQI5N>X7Cex!JW$xBw{hJc`hJN5gXj+YV#Hw`p4|BeLa;EJscQ4tx}kQ5|9G)>~Q-@dXus2-x>{ z5y%|6w;V5x`0a%zFD87z7Yx|)Gx4A06~A7-U$FVD)io_PHap;|`)29o3;yRdJdDYT zsqZ=Zb-jV^(AP=7@`3606O-Uhx8b^H#E|x5m)9jT#?`QnN9I z>|bC6CU(dKo z?7U*H%1gPOVNk@xX;2@T^cgbLVrQUXxY`+g)hbv}S#Yn3Fm=4**?~{3Iu$ zFqNNY0m)ap~PX(oP=(CD8ij%8#QC+u61u%n#WX}XvhH_pzGPD2r zL&XXJN9|nTi{zZWdL@Z8s{c?!a0Jplgil{xW6@QCp~VR$ZO+I-ahb=4d??b?Lb z-*!$=ZAgK_9XI~Fm8f|74Spd7P+-=5YsrmG#~dDF5U5tZQY{VRTu1yd4~CvM8B4p* zo0gQ%ygcAPqk%ji!F)+AL&*=02oyhVB>&ZF&lI(>S?$=J383o)$22)6no1HwvfRx9 zE{|b7jltCtCC{ZFU%i1#^k$P$Tb{;9JIdD|+*}d&F!{1lD0gqsjA)yf#IMff#x%&D zl4V|BJ`^=58R@M**zghvJ-faz(x;g5Iho)4U7L((OO^?9SG+|B|J-qVYTd;??Zn&@mo9ICNZe^`1Z4N44aRBYozFt7=`>%?Zm18;Vn2)5T@ zVL}pm0&l#Q92$2F+B#jtOrujU;ysW^x1LJZo6x}E)+-kkZC&Yu4d+`CVN7`eJP!$Z zIzG3xP+VaXp)j`QMJtePUoZw>AFK|3FKG1yi|2=fKOK>RMm=nrZP*|@Uk)n@oR!GC z)6*r(9z1mHNoT$#ZgptrwK<3XM<$@?{g-)s7;oi#(2I^4J#p>0$4ivg^{UbASIS4kVL8Q+I zatyz2-OrT{5${P}i_E+}q29TkE)qYO!9OuP(F_fw$O{suQ#s`C3KOEKoeRc*pk`s4 z+&TSR&Nbdc!i@;j;&SAp`t;_};YkAnr19V#j)2F0A1V-154YXQd?G^wT7+mxs601A zBH_N*G}$|y6N>$U`RYA^Zmp>STMfQfgeu8-QIA7C$$AJjVs3v#ADYAM~AxoS!}5r$3KOxwyGQ;{9OK%j_-R3P11k5a(MTBF2ynk(R4-d~ruZ zF%tn>AtELbN?2YH2Sgy$!CK++!bIu*;;;%67R1D}@6KmYW)NC78bhvj(befE0S)K5 z!ZLhP7Z7}Vh3^Xc#LQc_I7T7ed*$cbghVUR1LY1M!hVmR9oE*4TdDN+d_u(3 z_id7TE7c>GI;d6?(}$5K+Xql984bq4I&PGe()rNB5c_NLqhv`4bjXr) z-nKAcF6&HGhRm1MiBU>9$>a{113|ZSN!^`WmRd+Yu6slZWD@g{YY*t*-Qyc1-{u+# z&D|!TEUoxLGVH8BgdziOTdcpV+}qfy6eu@00DNbRrjC#dPhojgE#J$d9AB%+sYjBMnF^%2O!fx>SQ2~2gf7EI=BfxpiERrAY9FAxtfnZ+JdSU?e0#Fb@P1>NjVM#Oe zC_zC@7bdhObGs2}1+?ZZ@>%E=gS#FCD-smMh653UxD_)IQ1qu$o@<%Ww>Mgv{%Q+k z#EUR4j?qjXbDqYAa6MW{D0F{+3=GLKpm^3f}DfiGv!2L-a=yWrDIw0i?%zdmz? z;mWPWU(4(dx3WX3F@)_kMli#C^k7c^COd;zsKaN3WdbcvNE;KVVO>yw79AbUOM45| zAADHyghe=+R)-5uA#>h~Wsa;R6^WK8eG4In&!}?sSD}}GSTF=fp2)x}%WkdVd6|Z5 z9$c?y1F1`U<)__sKxGoh>>1kUeo}YgWK3prGz5$;pwCJyS;+3A_+1*GB|-e*tT@$y zhwOI6@5;)A@FHmOBDl}J`{m`Iu;9J$rdZwN;@wpwJ8O76nypYk0^!db{Gz@xlFe=T zJWqjOyLQvtNdcSN_n{WK5csO9m4&|bJ~C|Ud!X2*ZYpK^`pz<@F#pd|GoIs3?x5`4 zSXf-1H`0Uh_PWJ=Ub>VCvn6*{Y`Qqx#gb_E(XgCGsuUn`8Pe9K9@W;*w^Xe(p}-cQ z__NM+@38Zu_kiXL5n*A-b0%2Fd*@I5LZJmo5oZY>>yl!4<0(P6m3;oJ?CtH{EDJ$Z z+`Hh6LvOl`1%{vs4r3Ff6CrGlmtD+C)ap`F>{1BsZO_=IWYwly%o5MwA|@(1%%0Rq+6+)%P#X)EO8f z^_JP9fZIgruW+F2?KRt7=S1q$YV3^TjAAYXYj~HkG!jh_VSGg;swzoxvGk@7Ei2xs zKKerRtcArfnV|Cl7exfSS}!=w=+J%2RQ9bj-*)15W8A4zesRYG)5vfzJc^?Vio(#h zA}mqXl-~uVL@wmiUf%(vbt#9ujtywIUP^v%oOt;ovn{z~B@JOE-mHv- ztaWB9LjiRRDK;Vk&^*c5H$+y@R#==}D(mA{>?qDT3Zj?Bj6OGnB6^u0v$Cs2uqM1x zB8}%!Ai-LTCy+mUmouerWtV(HU+K;s$7(bAjPF+SIL z^n40CpN&hsB|`J;4sH#PUq1ydE9Uc3f?Mdq%22LbW;zeaS{r#ElaLD^|Vvr7;8YQdWbQC6UE5pF$Czk9Xk(yzE0hImVv>E z(lNv~hgnX#&x-R%osC_z25dIPHhWNKB#JM^(j4z7KGL?uZCVjQ1eIc%zFr(*Ukjnh z26DVM4mQ}w_R9WS=eL(nTl~s<8IZnX*`OW~X1f%29o3|lv^vfK)e1kzjvbeyW~F1* z;l>#L!RnBTvNBfTFjvqwj67%H*+?1G3z?bM3`h?{+cryRGsH$y?6zFr>Dgv<;$$NSAW`*0;4gI2>9F!btfce=lWAy+`=!Ln1&RXCHvu; zo2gizuGHQcHtz+0aAbsgSWaJ<<3QA>U2AyZah)?NPhau5>DNNqI4~kIwR+>eXS3q( zma%>bLsz3Ha3WKZ532|m8rJ167^UDx8wN5My@+DJ+G0AY$=DKW!)6~|NWNyCEDdAa z$D);yxewoMfac9X4(>CfJCNAp-1Rii%$?-E%R~~@V;9bM#&B=M+(+seajR-BdQPHF zj~1)f+uy5Nmh=X`c!d2L=SgMX(QenIqbPj-d< z)<^0bBIRRRJ;$Z8;zvMy5i;iFc_vb6SauhWVshHPlt)3m%LQl?bXq5mzbUb3X?B_C z&F#h4Y%8;q$%EboN~lV+Rw0?(^rQf3XMlb$dM=|uYU_74tdnv+@`FL8*+i8kMh5RQ z=x~ur!*c-?&U{mR7w2YK*l~+{KP%4YF~nk$&p8mT&dHYYs|(@LHDFpBP_$C<<|)L& zct_U$N|f?GaV~XM9NZvB@IuFcxFydvhkx>XVG$=@0KCmQAsE)F^Zi zq?leM$sfqIr%Ospzk>(obK#vIZDy`7LM&uk#cJ4DVXK1`$~ztYz*{}V5(P6-qEFK$ zzmJofHSwDmkC^v7P?tO%LR%2=$)+(+o1UP8AI1AM1uTfK4vNY>&`}}Dchaz~d-pud zZ<^$3-bxBPnsFf-d#pklHB#pe^YA|KiDea)ni{<3-{>ng3&DVH?=bR6Glql?m;(a) z-1xw6VrhP-H-&9)yJ6mHtWMqL|hK%(^;f|o}*eL~RMpy(E{z4NU zlz`?ppBwuSf`zm$I?_Yh`L$GNBE19|IJgIODrM@O$Hvxa|5-HfJ~c9WbAI?kTZIAZ zD-Ar*&1v9c+?vI+8XMet*IB&0p_mjl6}tfOi!ShK49++RzX@(aN{tZ#P_t7tH3}5R zwgAofTy6A^b}vMJ0J|Yxto{VcbkuglfRmlK>nz*14;)w}v-B&?62T1*Kt;=U(lpt{ zfad)7vFbRReq6Kut8abld!U}4p06@9V+iR)^9u{nfjFzYyj(<7<6nD6ol%wI2j1K# zP3ZnrYp?l|Nsck%SBTrdAm{=j=<9n1R>OtLkT6_wj(lLhG>pUHdw!})IGr8RjP=Hm zSP)Q}u)u+qo0}U|`c_z2IKQQZzHow@DIFB-L0waos0j5l7s$BCLApkD5bBD|(j?b* zs&=6RUJ6!Y+Yg9VP~s_}BNE3xz;Lh!)yo<6;*5BEk>Fr-jT}+SH$ZD(U>~%F zTp3^3{u{bjh+ih=vlc%=ltYA|1~*Cee69RmuV5ZWVFswBN>0gV5(~CO_SU>aBuQ1w znz8OZ@RMdQ%y?dgSd@3Dg0v?wNP*J`rFB7=g@Aenf?jJ{UdTC_dv?Sqr-{Ne2BxNo zB2MEL+f#M!rXZoS*vppO1-3;B$$UX3o(YqF==7l1)z!7=j=v3N89Ic5ipu)(bPsGY z{IR=c423DOK2qUGioyBZfezX8qc;z|5~1Wbduhmn4S8it1GbrRadAC|19j@(tMItb zgMM^;-0I?Z`&LXR1Y`~c{B|ussRFX53VXjOUKna%K$))5(b0TR4Yu9|1L9>01Jw@% z-51(@f{~xRc2NVA+6P|HrHcJcaa^4f+-fjeWa3-!MMsFlzsGE_qlZJzkGB(86rs%g z{QM)G4&J~}OC&&z5dn*K$RTo`sk9W>DGpY0jM6B*OR%(a8unxB6je-+Syu!HHxqO| z@6Y7;*Jh#z8Mc^_@}MqIK~dL| Mexg*NU=i_u0MF-JaR2}S literal 0 HcmV?d00001 diff --git a/server/architecture.svg b/server/architecture.svg index 5318860812..0b9a9ef08e 100644 --- a/server/architecture.svg +++ b/server/architecture.svg @@ -4,235 +4,277 @@ - - + + module_architecture - + cluster_A - -Module A + +Module A cluster_A_beh - -Behaviours + +Behaviours cluster_B - -Module B + +Module B cluster_B_beh - -Behaviours + +Behaviours cluster_C - -Module C + +Module C cluster_C_beh - -Behaviours - - -cluster_D - -Module D - - -cluster_D_beh - -Behaviours + +Behaviours ext - -external events -(packets · messages) + +external events +(packets · messages) bA1 - -BehaviourA1 -──────────────────── -observe(receiver) -  subscribes to flows -  calls dispatch() -──────────────────── -reduce(state, action) -  pure: State → State + +BehaviourA1 +──────────────────── +observe(receiver) +  subscribes to flows +  calls dispatch() +──────────────────── +reduce(state, action) +  pure: State -> State - + ext->bA1 - - -subscribe + + bA2 - -BehaviourA2 -──────────────────── -observe(receiver) -  ... -──────────────────── -reduce(state, action) -  ... + +BehaviourA2 +──────────────────── +observe(receiver) +  ... +──────────────────── +reduce(state, action) +  ... - + ext->bA2 - - + + bAmore - -··· -──────────────────── -observe(receiver) -  ... -──────────────────── -reduce(state, action) -  ... + +··· +──────────────────── +observe(receiver) +  ... +──────────────────── +reduce(state, action) +  ... - + ext->bAmore - - + + + + + +bBmore + +··· +──────────────────── +observe(receiver) +  ... +──────────────────── +reduce(state, action) +  ... + + + +ext->bBmore + + + + + +bCmore + +··· +──────────────────── +observe(receiver) +  ... +──────────────────── +reduce(state, action) +  ... + + + +ext->bCmore + + foldA - -reduce fold -────────────────────────────────── -state = BehaviourA1.reduce(state, a) -state = BehaviourA2.reduce(state, a) -state = ··· + +reduce fold +────────────────────────────────── +state = BehaviourA1.reduce(state, a) +state = BehaviourA2.reduce(state, a) +state = ··· - + bA1->foldA - - -dispatch(action) + + +dispatch(action) + + + +sfA + + +StateFlow<StateA> + + + +bA1->sfA + + +collect() - + bA2->foldA - - + + +dispatch(action) - + bAmore->foldA - - - - - -sfA - - -StateFlow<StateA> + + +dispatch(action) - + foldA->sfA - - -new state - - - -sfA->bA2 - - -collect() -> dispatch() + + +new state + + + +bBmore->sfA + + +collect() + + + +foldB + +reduce fold - - -bB - -observe() -  subscribes to flows -  and external events -reduce(state, action) - - - -sfA->bB - - -collect() + + +bBmore->foldB + + +dispatch(action) - + -bC - -observe() -  subscribes to flows -  and external events -reduce(state, action) - - - -sfA->bC - - -collect() - - +sfB + + +StateFlow<StateB> + + + +bBmore->sfB + + +collect() + + + +foldB->sfB + + + + + +bCmore->sfA + + +collect() + + + +bCmore->sfB + + +collect() + + -bD - -observe() -  subscribes to flows -  and external events -reduce(state, action) - - - -sfA->bD - - -collect() +foldC + +reduce fold - - -sfB - - -StateFlow<StateB> + + +bCmore->foldC + + +dispatch(action) - - -sfC - - -StateFlow<StateC> - - - -sfD - - -StateFlow<StateD> +sfC + + +StateFlow<StateC> + + + +bCmore->sfC + + +collect() + + + +foldC->sfC + + - From 18c5d0dad668799d527a6269053f5c4675edb772 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 13 Apr 2026 21:56:35 +0200 Subject: [PATCH 058/350] Better readme --- server/README.md | 66 ++++++++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/server/README.md b/server/README.md index dcc3593946..2978ebd48e 100644 --- a/server/README.md +++ b/server/README.md @@ -1,4 +1,4 @@ -# SlimeVR Server — Architecture +# SlimeVR Server Architecture This document explains how the server is structured, why it's structured that way, and how to extend it correctly. @@ -6,7 +6,7 @@ This document explains how the server is structured, why it's structured that wa ## Mental Model -Every significant part of the server — a tracker, a UDP connection, a SolarXR session, a config file — is a **module**. Every module is a list of behaviours sharing one context: +Every significant part of the server a tracker, a UDP connection, a SolarXR session, a config file is a **module**. Every module is a list of behaviours sharing one context: ![Architecture diagram](architecture.svg) @@ -21,7 +21,7 @@ The server handles many concurrent connections, each with independent state that The reducer pattern solves all three: - **Thread safety**: `StateFlow.update` is atomic. Concurrent dispatches serialize without locks. -- **Observability**: `context.state` is a hot `StateFlow` — it always holds the current value and any code can `collect` it and react to changes without being coupled to the producer. +- **Observability**: `context.state` is a hot `StateFlow` it always holds the current value and any code can `collect` it and react to changes without being coupled to the producer. - **Extensibility**: adding a feature means adding a `Behaviour` to a list. Nothing else changes. The cost is that you cannot mutate state directly. Everything goes through `dispatch`. That constraint is the point. @@ -44,7 +44,7 @@ class Context( `Context.create` takes an initial state and a list of behaviours. On each `dispatch`, it folds every behaviour's `reduce` over the current state in order and publishes the result. Behaviours that don't care about an action return the state unchanged. -`dispatchAll(actions)` applies a list of actions in one atomic `StateFlow.update`. Use it when you need multiple state changes to be visible as one — intermediate states are never published, so no observer fires between them. +`dispatchAll(actions)` applies a list of actions in one atomic `StateFlow.update`. Use it when you need multiple state changes to be visible as one intermediate states are never published, so no observer fires between them. --- @@ -59,27 +59,27 @@ interface Behaviour { } ``` -`reduce` is a pure function — it cannot launch coroutines, call external services, or read from a clock. It just maps `(state, action) → state`. +`reduce` is a pure function it cannot launch coroutines, call external services, or read from a clock. It just maps `(state, action) → state`. -`observe` is called once at construction, after the context exists. This is where you launch coroutines, subscribe to flows, register event listeners. All coroutines launched here should use `receiver.context.scope` so they die when the module dies — no manual cleanup, no leaks. +`observe` is called once at construction, after the context exists. This is where you launch coroutines, subscribe to flows, register event listeners. All coroutines launched here should use `receiver.context.scope` so they die when the module dies no manual cleanup, no leaks. **Why behaviours instead of methods on the module class?** -The natural alternative is one class with methods for each feature — ping handling, handshake logic, sensor registration, timeout detection, all in the same file. That works until the class has 20 features and every change has global blast radius. Behaviours make the feature list explicit: you read `create()`, you see exactly what the module does. Adding or removing a feature is one line in that list, touching nothing else. +The natural alternative is one class with methods for each feature ping handling, handshake logic, sensor registration, timeout detection, all in the same file. That works until the class has 20 features and every change has global blast radius. Behaviours make the feature list explicit: you read `create()`, you see exactly what the module does. Adding or removing a feature is one line in that list, touching nothing else. **Why `observe` is separate from `reduce`:** -`reduce` must stay pure so state transitions are testable and predictable. Side effects in `observe` are isolated — a behaviour that logs packets doesn't affect state, a behaviour that saves config doesn't affect packet handling. +`reduce` must stay pure so state transitions are testable and predictable. Side effects in `observe` are isolated a behaviour that logs packets doesn't affect state, a behaviour that saves config doesn't affect packet handling. ### Scoping Behaviours to Module Lifetime Every behaviour's coroutines run inside `receiver.context.scope`. When a UDP connection drops, its scope is cancelled, which cancels every coroutine every behaviour launched for that connection. No teardown code needed anywhere. -This is why `observe` receives the module (or context) rather than taking a raw `CoroutineScope` parameter — it enforces that behaviours cannot outlive their module. +This is why `observe` receives the module (or context) rather than taking a raw `CoroutineScope` parameter it enforces that behaviours cannot outlive their module. ### Stateless vs. Stateful Behaviours -Behaviours with no constructor dependencies are `object`s — singletons, zero allocation: +Behaviours with no constructor dependencies are `object`s singletons, zero allocation: ```kotlin object PacketBehaviour : UDPConnectionBehaviour { ... } @@ -123,16 +123,16 @@ Behaviours live in a `behaviours.kt` file in the same package as the module, sep tracker/ ├── module.kt ← Tracker class, TrackerState, TrackerActions, typealiases ├── behaviours.kt ← TrackerBasicBehaviour, TrackerTPSBehaviour, ... -└── config.kt ← TrackerConfigBehaviour (its own file — distinct concern) +└── config.kt ← TrackerConfigBehaviour (its own file distinct concern) ``` -`module.kt` is the standard name for the file containing the module class, state, actions, and typealiases. `behaviours.kt` holds the implementations. For modules with many distinct feature areas, split behaviours by feature instead of one file — `solarxr/` uses this: `datafeed.kt`, `firmware.kt`, `provisioning.kt`, etc. each live alongside `module.kt`. +`module.kt` is the standard name for the file containing the module class, state, actions, and typealiases. `behaviours.kt` holds the implementations. For modules with many distinct feature areas, split behaviours by feature instead of one file `solarxr/` uses this: `datafeed.kt`, `firmware.kt`, `provisioning.kt`, etc. each live alongside `module.kt`. --- ## Actions -Actions are `sealed interface`s. This matters because the compiler enforces exhaustive `when` in reducers — you cannot forget to handle a new action type. Named actions also mean you can grep for all places a specific transition can occur. +Actions are `sealed interface`s. This matters because the compiler enforces exhaustive `when` in reducers you cannot forget to handle a new action type. Named actions also mean you can grep for all places a specific transition can occur. ```kotlin sealed interface UDPConnectionActions { @@ -144,14 +144,14 @@ sealed interface UDPConnectionActions { The choice between a flexible `Update` action and a named action is about who else needs to react: -- **`Update`** — when only the caller cares about the change. It carries a lambda that transforms state directly. No other behaviour pattern-matches it, so there's no coupling. -- **Named action** — when other behaviours need to react to this specific event. `Handshake` in `UDPConnectionActions` signals to multiple behaviours that a connection is now established; each one matches it independently. +- **`Update`** when only the caller cares about the change. It carries a lambda that transforms state directly. No other behaviour pattern-matches it, so there's no coupling. +- **Named action** when other behaviours need to react to this specific event. `Handshake` in `UDPConnectionActions` signals to multiple behaviours that a connection is now established; each one matches it independently. ```kotlin -// Nothing else needs to react — use Update +// Nothing else needs to react use Update tracker.context.dispatch(TrackerActions.Update { copy(tps = newTps) }) -// Multiple behaviours react to this — use a named action +// Multiple behaviours react to this use a named action connection.context.dispatch(UDPConnectionActions.Handshake(deviceId = id)) ``` @@ -170,11 +170,11 @@ receiver.packetEvents.onAny { packet -> ... } // every packet **Why not a central `when` block?** -A central switch means every new packet type requires editing the dispatch hub. With `EventDispatcher`, each behaviour owns its subscription. Adding a new packet type means adding a class to `packets.kt` and a listener in a behaviour — the routing hub never changes. +A central switch means every new packet type requires editing the dispatch hub. With `EventDispatcher`, each behaviour owns its subscription. Adding a new packet type means adding a class to `packets.kt` and a listener in a behaviour the routing hub never changes. It also means behaviours are self-contained: `SensorInfoBehaviour` is the only place that knows what to do with `SensorInfo`. No shared routing code. -`EventDispatcher` dispatches by the runtime type of the event by default. When events are wrapped (e.g. `PacketEvent` where the actual packet is the inner value), pass a `keyOf` lambda to tell the dispatcher which type to route by — otherwise all events would be bucketed under `PacketEvent` regardless of the inner packet type. +`EventDispatcher` dispatches by the runtime type of the event by default. When events are wrapped (e.g. `PacketEvent` where the actual packet is the inner value), pass a `keyOf` lambda to tell the dispatcher which type to route by otherwise all events would be bucketed under `PacketEvent` regardless of the inner packet type. --- @@ -184,9 +184,9 @@ Every module receives a `CoroutineScope` at creation. That scope defines when th - Cancelling the scope cancels all coroutines all behaviours launched inside it - No `close()`, `stop()`, or `dispose()` methods needed anywhere -- A disconnected client, a dropped UDP connection, a closed serial port — all cleaned up by scope cancellation +- A disconnected client, a dropped UDP connection, a closed serial port all cleaned up by scope cancellation -Blocking I/O goes on `Dispatchers.IO`. State updates and logic stay on the default dispatcher. Never use `runBlocking` inside an observer — it blocks the coroutine thread pool. +Blocking I/O goes on `Dispatchers.IO`. State updates and logic stay on the default dispatcher. Never use `runBlocking` inside an observer it blocks the coroutine thread pool. --- @@ -194,7 +194,7 @@ Blocking I/O goes on `Dispatchers.IO`. State updates and logic stay on the defau Not everything needs to be in a `StateFlow`. The rule: **put data in state only if other code needs to react to it changing**. -- `VRServer.handleCounter` is an `AtomicInt` — nothing reacts to it, so a dispatch round-trip would be waste. +- `VRServer.handleCounter` is an `AtomicInt` nothing reacts to it, so a dispatch round-trip would be waste. - `UDPTrackerServer` has no `Context` at all. Its connection map is a `MutableMap` internal to the server loop. Nothing outside reads it. - Tracker config *is* in state because the SolarXR layer and the config autosave both react to body part assignments changing. @@ -221,14 +221,14 @@ receiver.context.state **Dispatch into VRServer from a connection:** ```kotlin -// Inside HandshakeBehaviour.observe() — a UDP connection registers a new device +// Inside HandshakeBehaviour.observe() a UDP connection registers a new device receiver.packetEvents.on { event -> val device = Device.create(...) receiver.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId, device)) } ``` -The key rule: behaviours own the subscription lifetime via `launchIn(receiver.context.scope)`. When the module dies, the subscription stops — no dangling listeners in foreign modules. +The key rule: behaviours own the subscription lifetime via `launchIn(receiver.context.scope)`. When the module dies, the subscription stops no dangling listeners in foreign modules. --- @@ -261,21 +261,21 @@ All three named sockets use the same framing: a **LE u32 length** prefix (includ The UDP receive loop runs on `Dispatchers.IO`. It reads a datagram, parses the packet type and payload via `readPacket`, wraps the result in a `PacketEvent`, and pushes it into a `Channel>` per connection. -A dedicated coroutine per connection drains the channel and calls `EventDispatcher.emit`. Pre-handshake packets are filtered at the channel drain step — a single guard in one place, not spread across every behaviour. +A dedicated coroutine per connection drains the channel and calls `EventDispatcher.emit`. Pre-handshake packets are filtered at the channel drain step a single guard in one place, not spread across every behaviour. --- ## Config -Config is not just file I/O — it is a live state machine. The server supports runtime profile switching, so config data lives in a `StateFlow` like everything else. Code that needs to react to config changes (e.g. tracker body part assignment saving to disk) subscribes to the settings state flow. +Config is not just file I/O it is a live state machine. The server supports runtime profile switching, so config data lives in a `StateFlow` like everything else. Code that needs to react to config changes (e.g. tracker body part assignment saving to disk) subscribes to the settings state flow. The config system has three layers: -- **`AppConfig`** — global state: which user profile and settings profile are active -- **`UserConfig`** — per-user data (body proportions, etc.) -- **`Settings`** — per-profile settings (tracker assignments, port, VRC warnings); persists to disk +- **`AppConfig`** global state: which user profile and settings profile are active +- **`UserConfig`** per-user data (body proportions, etc.) +- **`Settings`** per-profile settings (tracker assignments, port, VRC warnings); persists to disk -Each is a full module with its own `Context`, behaviours, and JSON autosave. Migrations live in the module's `parseAndMigrate` function — add a `version < N` branch there. +Each is a full module with its own `Context`, behaviours, and JSON autosave. Migrations live in the module's `parseAndMigrate` function add a `version < N` branch there. --- @@ -285,7 +285,7 @@ Each is a full module with its own `Context`, behaviours, and JSON autosave. Mig |---|---| | `server/core` | Protocol-agnostic business logic | | `server/desktop` | Platform entry point, IPC wiring, HID, serial flash | -| `context/context.kt` | `Context` and `Behaviour` primitives — no domain logic here | +| `context/context.kt` | `Context` and `Behaviour` primitives no domain logic here | | `udp/` | UDP wire protocol: connection state, packets, behaviours, server loop | | `hid/` | HID tracker receiver: registration, rotation, battery, status | | `solarxr/` | `SolarXRBridge` + per-feature behaviour files (one per RPC area) | @@ -352,7 +352,7 @@ receiver.packetEvents.on { event -> - **Prefer plain functions over classes.** A single-method interface should almost always be a function type (`() -> Unit`, `suspend (ByteArray) -> Unit`). A class with no mutable state should be a plain function or `object`. When in doubt, write a function. - **Prefer plain functions over extension functions.** Only use extensions when the receiver type is genuinely the primary subject. -- **State data classes are all `val`.** Mutable fields in a state class are a design mistake — use `var` in a plain class if you need local mutability, never in state. -- **Use `sealed interface` for actions**, not `sealed class` — no constructor overhead. +- **State data classes are all `val`.** Mutable fields in a state class are a design mistake use `var` in a plain class if you need local mutability, never in state. +- **Use `sealed interface` for actions**, not `sealed class` no constructor overhead. - **Module creation lives in `companion object { fun create(...) }`.** - **Never expose `MutableStateFlow` directly.** Expose `StateFlow` via `context.state`. From db231f368d2759a680e64f4e5c7561ed8c8f19e8 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 13 Apr 2026 10:54:44 -0500 Subject: [PATCH 059/350] Send signal strength in datafeed --- server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index ac7c282396..8214fe7584 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -69,6 +69,7 @@ private fun createDevice( batteryVoltage = device.batteryVoltage, batteryPctEstimate = (device.batteryLevel * 100).toUInt().toUByte(), ping = device.ping?.toUShort(), + rssi = device.signalStrength?.toShort(), ), hardwareInfo = HardwareInfo( mcuId = device.mcuType, From acde3a0e34f43f786ae65a5dde010c7f4a81f42b Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 13 Apr 2026 10:56:03 -0500 Subject: [PATCH 060/350] Get rid of all the redundant `== true`s --- .../main/java/dev/slimevr/solarxr/datafeed.kt | 16 ++++++++-------- .../src/main/java/dev/slimevr/solarxr/serial.kt | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 8214fe7584..76178f5da9 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -34,10 +34,10 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas trackerNum = tracker.id.toUByte(), deviceId = DeviceId(device.id.toUByte()), ), - status = if (trackerMask.status == true) tracker.status else null, - rotation = if (trackerMask.rotation == true) tracker.rawRotation.let { Quat(it.x, it.y, it.z, it.w) } else null, - position = if (trackerMask.position == true && tracker.position != null) tracker.position.let { Vec3f(it.x, it.y, it.z) } else null, - info = if (trackerMask.info == true) { + status = if (trackerMask.status) tracker.status else null, + rotation = if (trackerMask.rotation) tracker.rawRotation.let { Quat(it.x, it.y, it.z, it.w) } else null, + position = if (trackerMask.position && tracker.position != null) tracker.position.let { Vec3f(it.x, it.y, it.z) } else null, + info = if (trackerMask.info) { TrackerInfo( imuType = tracker.sensorType, bodyPart = tracker.bodyPart, @@ -49,10 +49,10 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas } else { null }, - tps = if (trackerMask.tps == true) tracker.tps else null, - temp = if (trackerMask.temp == true && tracker.imuTemp != null) Temperature(temp = tracker.imuTemp) else null, - rawAcceleration = if (trackerMask.rawAcceleration == true) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null, - linearAcceleration = if (trackerMask.linearAcceleration == true) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null // FIXME: temp value + tps = if (trackerMask.tps) tracker.tps else null, + temp = if (trackerMask.temp && tracker.imuTemp != null) Temperature(temp = tracker.imuTemp) else null, + rawAcceleration = if (trackerMask.rawAcceleration) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null, + linearAcceleration = if (trackerMask.linearAcceleration) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null // FIXME: temp value ) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt index 13fe9bc32b..68812a19c9 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt @@ -55,7 +55,7 @@ class SerialBehaviour(private val serialServer: SerialServer) : SolarXRBridgeBeh } receiver.rpcDispatcher.on { req -> - val portLocation = if (req.auto == true) { + val portLocation = if (req.auto) { serialServer.context.state.value.availablePorts.keys.firstOrNull() } else { req.port From 4113a091c133830de36058e234d8d752db393f8e Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 13 Apr 2026 15:10:11 -0500 Subject: [PATCH 061/350] Implement receiving and datafeed sending of tracker mag status --- .../main/java/dev/slimevr/solarxr/datafeed.kt | 1 + .../main/java/dev/slimevr/tracker/module.kt | 5 +++- .../main/java/dev/slimevr/udp/behaviours.kt | 23 +++++++++++---- .../src/main/java/dev/slimevr/udp/packets.kt | 28 +++++++++++++++++-- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 76178f5da9..e973936533 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -45,6 +45,7 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas customName = tracker.customName, mountingOrientation = tracker.mountingOrientation?.let { Quat(it.x, it.y, it.z, it.w) }, isImu = tracker.sensorType != null, + magnetometer = tracker.magStatus, ) } else { null diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index c4b3c5824d..8b32b050e0 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -10,6 +10,7 @@ import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.ImuType @@ -34,6 +35,7 @@ data class TrackerState( val position: Vector3?, val status: TrackerStatus, val completedRestCalibration: Boolean, + val magStatus: MagnetometerStatus, ) sealed interface TrackerActions { @@ -76,7 +78,8 @@ class Tracker( tps = 0u, imuTemp = null, status = TrackerStatus.DISCONNECTED, - completedRestCalibration = false + completedRestCalibration = false, + magStatus = MagnetometerStatus.NOT_SUPPORTED, ) val trackerState = if (savedConfig != null) restoreFromConfig(baseState, savedConfig) diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index bf8e84d7ef..5ac3dc4121 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -12,6 +12,7 @@ import dev.slimevr.tracker.TrackerIdNum import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.datatypes.TrackerStatus import kotlin.random.Random @@ -193,10 +194,22 @@ class SensorInfoBehaviour(private val settings: Settings) : UDPConnectionBehavio val tracker = receiver.getTracker(event.data.sensorId) val action = TrackerActions.Update { + val completedRestCalibration = event.data.hasCompletedRestCalibration + ?: tracker?.context?.state?.value?.completedRestCalibration + ?: false + + val magStatus = event.data.sensorConfig?.let { + if (it.magSupported) { + if (it.magEnabled) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED + } else { + MagnetometerStatus.NOT_SUPPORTED + } + } ?: tracker?.context?.state?.value?.magStatus ?: MagnetometerStatus.NOT_SUPPORTED copy( sensorType = event.data.imuType, status = event.data.status, - completedRestCalibration = event.data.hasCompletedRestCalibration ?: tracker?.context?.state?.value?.completedRestCalibration ?: false + completedRestCalibration = completedRestCalibration, + magStatus = magStatus, ) } @@ -205,10 +218,10 @@ class SensorInfoBehaviour(private val settings: Settings) : UDPConnectionBehavio } else { val deviceState = device.context.state.value val mac = deviceState.macAddress ?: run { - AppLogger.udp.warn("[${deviceState.address}] No MAC address available, falling back to IP for hardware ID") - deviceState.address - } - val hardwareId = "$mac:${event.data.sensorId}" + AppLogger.udp.warn("[${deviceState.address}] No MAC address available, falling back to IP for hardware ID") + deviceState.address + } + val hardwareId = "$mac:${event.data.sensorId}" val trackerId = receiver.serverContext.nextHandle() val newTracker = Tracker.create( id = trackerId, diff --git a/server/core/src/main/java/dev/slimevr/udp/packets.kt b/server/core/src/main/java/dev/slimevr/udp/packets.kt index efe0ae3811..6febe6e857 100644 --- a/server/core/src/main/java/dev/slimevr/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/udp/packets.kt @@ -303,11 +303,35 @@ data class SensorInfo( override val sensorId: Int = 0, val status: TrackerStatus = TrackerStatus.DISCONNECTED, val imuType: ImuType = ImuType.Other, - val sensorConfig: UShort? = null, + val sensorConfig: SensorConfig? = null, val hasCompletedRestCalibration: Boolean? = null, val trackerPosition: Int? = null, val trackerDataType: Int? = null, ) : SensorSpecificPacket { + data class SensorConfig( + val magEnabled: Boolean, + val magSupported: Boolean, + val calibrationEnabled: Boolean, + val calibrationSupported: Boolean, + val tempGradientCalibrationEnabled: Boolean, + val tempGradientCalibrationSupported: Boolean + ) { + companion object { + fun fromUDP(raw: UShort) = with(raw) { + val raw = toInt() + + SensorConfig( + magEnabled = raw and 1 != 0, + magSupported = (raw shr 1) and 1 != 0, + calibrationEnabled = (raw shr 2) and 1 != 0, + calibrationSupported = (raw shr 3) and 1 != 0, + tempGradientCalibrationEnabled = (raw shr 4) and 1 != 0, + tempGradientCalibrationSupported = (raw shr 5) and 1 != 0, + ) + } + } + } + companion object { private fun statusFromUDP(raw: UByte): TrackerStatus = when (raw.toInt()) { 0 -> TrackerStatus.DISCONNECTED @@ -320,7 +344,7 @@ data class SensorInfo( val id = readU8() val stat = statusFromUDP(readUByte()) val imu = if (remaining > 0) ImuType.fromValue(readUByte().toUShort()) ?: ImuType.Other else ImuType.Other - val conf = if (remaining >= 2) readShort().toUShort() else null + val conf = if (remaining >= 2) SensorConfig.fromUDP(readShort().toUShort()) else null val calib = if (remaining > 0) readU8() != 0 else null val pos = if (remaining > 0) readU8() else null val dt = if (remaining > 0) readU8() else null From 1775d54bf10d0af818a64965d0abeeac3169b6d0 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 15 Apr 2026 03:28:57 +0200 Subject: [PATCH 062/350] better bootstraping --- .../src/main/java/dev/slimevr/app-context.kt | 53 ++++++++++++++++ .../src/main/java/dev/slimevr/config/user.kt | 5 +- .../main/java/dev/slimevr/context/context.kt | 12 +++- .../java/dev/slimevr/driver/behaviours.kt | 9 ++- .../main/java/dev/slimevr/driver/module.kt | 22 +++---- .../java/dev/slimevr/feeder/behaviours.kt | 12 ++-- .../main/java/dev/slimevr/feeder/module.kt | 23 ++++--- .../main/java/dev/slimevr/firmware/module.kt | 9 +-- .../dev/slimevr/heightcalibration/module.kt | 8 ++- .../main/java/dev/slimevr/hid/behaviours.kt | 27 ++++---- .../src/main/java/dev/slimevr/hid/module.kt | 24 ++++---- .../java/dev/slimevr/provisioning/module.kt | 14 ++--- .../main/java/dev/slimevr/skeleton/module.kt | 13 ++-- .../src/main/java/dev/slimevr/solarxr/ipc.kt | 10 ++- .../main/java/dev/slimevr/solarxr/module.kt | 34 +++++++---- .../dev/slimevr/solarxr/trackingchecklist.kt | 19 +----- .../slimevr/trackingchecklist/behaviours.kt | 21 ++++--- .../dev/slimevr/trackingchecklist/module.kt | 61 ++++++------------- .../main/java/dev/slimevr/udp/behaviours.kt | 25 ++++---- .../main/java/dev/slimevr/udp/connection.kt | 25 ++++---- .../src/main/java/dev/slimevr/udp/server.kt | 11 ++-- .../main/java/dev/slimevr/vrchat/module.kt | 6 +- .../src/test/java/dev/slimevr/TestServer.kt | 21 ++++++- .../provisioning/ProvisioningManagerTest.kt | 34 +++++++---- .../java/dev/slimevr/solarxr/DataFeedTest.kt | 36 ++++++++--- .../src/main/java/dev/slimevr/desktop/Main.kt | 60 +++++++----------- .../main/java/dev/slimevr/desktop/hid/hid.kt | 10 ++- .../main/java/dev/slimevr/desktop/ipc/ipc.kt | 18 +++--- .../java/dev/slimevr/desktop/ipc/linux.kt | 20 +++--- .../java/dev/slimevr/desktop/ipc/protocol.kt | 12 ++-- .../java/dev/slimevr/desktop/ipc/windows.kt | 20 +++--- .../java/dev/slimevr/desktop/ipc/ws-server.kt | 10 ++- .../dev/slimevr/desktop/vrchat/vrc-config.kt | 10 +-- 33 files changed, 367 insertions(+), 327 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/app-context.kt diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt new file mode 100644 index 0000000000..e634048d2e --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -0,0 +1,53 @@ +package dev.slimevr + +import dev.slimevr.config.AppConfig +import dev.slimevr.firmware.FirmwareManager +import dev.slimevr.heightcalibration.HeightCalibrationManager +import dev.slimevr.provisioning.ProvisioningManager +import dev.slimevr.serial.SerialServer +import dev.slimevr.skeleton.Skeleton +import dev.slimevr.trackingchecklist.TrackingChecklist +import dev.slimevr.vrchat.VRCConfigManager + +interface Phase1ContextProvider { + val server: VRServer + val config: AppConfig + val serialServer: SerialServer +} + +data class Phase1Context( + override val server: VRServer, + override val config: AppConfig, + override val serialServer: SerialServer, +) : Phase1ContextProvider + +interface AppContextProvider : Phase1ContextProvider { + val skeleton: Skeleton + val firmwareManager: FirmwareManager + val vrcConfigManager: VRCConfigManager? + val provisioningManager: ProvisioningManager + val heightCalibrationManager: HeightCalibrationManager + val trackingChecklist: TrackingChecklist + fun startObserving() +} + +class AppContext( + override val server: VRServer, + override val config: AppConfig, + override val serialServer: SerialServer, + override val skeleton: Skeleton, + override val firmwareManager: FirmwareManager, + override val vrcConfigManager: VRCConfigManager?, + override val provisioningManager: ProvisioningManager, + override val heightCalibrationManager: HeightCalibrationManager, + override val trackingChecklist: TrackingChecklist, +) : AppContextProvider { + override fun startObserving() { + skeleton.startObserving() + firmwareManager.startObserving() + provisioningManager.startObserving() + heightCalibrationManager.startObserving() + vrcConfigManager?.startObserving() + trackingChecklist.startObserving(this) + } +} diff --git a/server/core/src/main/java/dev/slimevr/config/user.kt b/server/core/src/main/java/dev/slimevr/config/user.kt index 0796a6ff0e..54161b3836 100644 --- a/server/core/src/main/java/dev/slimevr/config/user.kt +++ b/server/core/src/main/java/dev/slimevr/config/user.kt @@ -83,10 +83,9 @@ class UserConfig( } val initialState = UserConfigState(name = name, data = initialData) - val behaviours = listOf(DefaultUserBehaviour) - val context = Context.create(initialState = initialState, scope = scope, behaviours = behaviours) + val context = Context.create(initialState = initialState, scope = scope, behaviours = listOf(DefaultUserBehaviour)) val userConfig = UserConfig(context, scope = scope, userConfigDir = userConfigDir) - behaviours.forEach { it.observe(userConfig) } + context.observeAll(userConfig) return userConfig } } diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index 6def9340b9..8d9a35fcc0 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -5,16 +5,18 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import java.util.concurrent.CopyOnWriteArrayList interface Behaviour { fun reduce(state: S, action: A): S = state fun observe(receiver: C) {} } -class Context( +class Context( private val mutableStateFlow: MutableStateFlow, private val applyAction: (S, A) -> S, val scope: CoroutineScope, + val behaviours: CopyOnWriteArrayList>, ) { val state: StateFlow = mutableStateFlow.asStateFlow() @@ -24,6 +26,12 @@ class Context( } } + + fun observeAll(receiver: C) = behaviours.forEach { behaviour -> + @Suppress("UNCHECKED_CAST") + (behaviour as Behaviour).observe(receiver) + } + fun dispatchAll(actions: List) { mutableStateFlow.update { currentState -> actions.fold(currentState) { s, action -> applyAction(s, action) } @@ -40,7 +48,7 @@ class Context( val applyAction: (S, A) -> S = { currentState, action -> behaviours.fold(currentState) { s, b -> b.reduce(s, action) } } - return Context(mutableStateFlow, applyAction, scope) + return Context(mutableStateFlow, applyAction, scope, CopyOnWriteArrayList(behaviours)) } } } diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 747cc644c7..8cd10c166c 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -1,12 +1,11 @@ package dev.slimevr.driver -import dev.slimevr.VRServer import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.TrackerActions import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -class DriverBaseBehaviour(private val server: VRServer) : DriverBridgeBehaviour { +object DriverBaseBehaviour : DriverBridgeBehaviour { override fun reduce(state: DriverBridgeState, action: DriverBridgeActions): DriverBridgeState = when (action) { is DriverBridgeActions.UpdateProtocolVersion -> state.copy(protocolVersion = action.version) } @@ -17,7 +16,7 @@ class DriverBaseBehaviour(private val server: VRServer) : DriverBridgeBehaviour } receiver.inbound.on { event -> - server.getTracker(event.trackerId)?.context?.dispatch( + receiver.appContext.server.getTracker(event.trackerId)?.context?.dispatch( TrackerActions.Update { copy(rawRotation = event.rotation) }, ) } @@ -25,7 +24,7 @@ class DriverBaseBehaviour(private val server: VRServer) : DriverBridgeBehaviour // Should be safe: StateFlow never delivers two emissions concurrently to the same collector. val subscribedTrackers = mutableSetOf() - server.context.state + receiver.appContext.server.context.state .onEach { state -> state.trackers.values.forEach { tracker -> val ts = tracker.context.state.value @@ -54,4 +53,4 @@ class DriverBaseBehaviour(private val server: VRServer) : DriverBridgeBehaviour } .launchIn(receiver.context.scope) } -} +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt index af58efa607..67e1c687cd 100644 --- a/server/core/src/main/java/dev/slimevr/driver/module.kt +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -1,7 +1,7 @@ package dev.slimevr.driver +import dev.slimevr.AppContextProvider import dev.slimevr.EventDispatcher -import dev.slimevr.VRServer import dev.slimevr.VRServerActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context @@ -33,17 +33,19 @@ typealias DriverBridgeBehaviour = Behaviour = EventDispatcher(), val outbound: EventDispatcher = EventDispatcher(), ) { fun disconnect() { - serverContext.context.dispatch(VRServerActions.DriverDisconnected(id)) + appContext.server.context.dispatch(VRServerActions.DriverDisconnected(id)) } + fun startObserving() = context.observeAll(this) + companion object { - fun create(id: Int, serverContext: VRServer, scope: CoroutineScope): DriverBridge { - val behaviours = listOf(DriverBaseBehaviour(serverContext)) + fun create(id: Int, appContext: AppContextProvider, scope: CoroutineScope): DriverBridge { + val behaviours = listOf(DriverBaseBehaviour) val context = Context.create( initialState = DriverBridgeState(protocolVersion = 0), @@ -51,13 +53,11 @@ class DriverBridge( behaviours = behaviours, ) - val bridge = DriverBridge(id = id, context = context, serverContext = serverContext) - - behaviours.forEach { it.observe(bridge) } - - serverContext.context.dispatch(VRServerActions.DriverConnected(bridge)) + val bridge = DriverBridge(id = id, context = context, appContext = appContext) + bridge.startObserving() + appContext.server.context.dispatch(VRServerActions.DriverConnected(bridge)) return bridge } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt b/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt index 24a13500ca..8bb95cfe2e 100644 --- a/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt @@ -1,8 +1,6 @@ package dev.slimevr.feeder -import dev.slimevr.VRServer import dev.slimevr.VRServerActions -import dev.slimevr.config.Settings import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin @@ -10,7 +8,7 @@ import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import solarxr_protocol.datatypes.hardware_info.ImuType -class FeederBaseBehaviour(private val server: VRServer, private val settings: Settings) : FeederBridgeBehaviour { +object FeederBaseBehaviour : FeederBridgeBehaviour { override fun reduce(state: FeederBridgeState, action: FeederBridgeActions): FeederBridgeState = when (action) { is FeederBridgeActions.UpdateProtocolVersion -> state.copy(protocolVersion = action.version, firmware = action.firmware) } @@ -21,17 +19,19 @@ class FeederBaseBehaviour(private val server: VRServer, private val settings: Se } receiver.inbound.on { event -> - handleTrackerAdded(event.serial) + handleTrackerAdded(receiver, event.serial) } receiver.inbound.on { event -> - server.getTracker(event.trackerId)?.context?.dispatch( + receiver.appContext.server.getTracker(event.trackerId)?.context?.dispatch( TrackerActions.Update { copy(rawRotation = event.rotation) }, ) } } - private fun handleTrackerAdded(serial: String) { + private fun handleTrackerAdded(receiver: FeederBridge, serial: String) { + val server = receiver.appContext.server + val settings = receiver.appContext.config.settings val scope = server.context.scope val existingTracker = server.context.state.value.trackers.values .find { tracker -> tracker.context.state.value.hardwareId == serial } diff --git a/server/core/src/main/java/dev/slimevr/feeder/module.kt b/server/core/src/main/java/dev/slimevr/feeder/module.kt index 8e19bdbe12..50f380d575 100644 --- a/server/core/src/main/java/dev/slimevr/feeder/module.kt +++ b/server/core/src/main/java/dev/slimevr/feeder/module.kt @@ -1,9 +1,8 @@ package dev.slimevr.feeder +import dev.slimevr.AppContextProvider import dev.slimevr.EventDispatcher -import dev.slimevr.VRServer import dev.slimevr.VRServerActions -import dev.slimevr.config.Settings import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import io.github.axisangles.ktmath.Quaternion @@ -31,16 +30,18 @@ typealias FeederBridgeBehaviour = Behaviour = EventDispatcher(), ) { fun disconnect() { - serverContext.context.dispatch(VRServerActions.FeederDisconnected(id)) + appContext.server.context.dispatch(VRServerActions.FeederDisconnected(id)) } + fun startObserving() = context.observeAll(this) + companion object { - fun create(id: Int, serverContext: VRServer, settings: Settings, scope: CoroutineScope): FeederBridge { - val behaviours = listOf(FeederBaseBehaviour(serverContext, settings)) + fun create(id: Int, appContext: AppContextProvider, scope: CoroutineScope): FeederBridge { + val behaviours = listOf(FeederBaseBehaviour) val context = Context.create( initialState = FeederBridgeState(protocolVersion = 0, firmware = null), @@ -48,13 +49,11 @@ class FeederBridge( behaviours = behaviours, ) - val bridge = FeederBridge(id = id, context = context, serverContext = serverContext) - - behaviours.forEach { it.observe(bridge) } - - serverContext.context.dispatch(VRServerActions.FeederConnected(bridge)) + val bridge = FeederBridge(id = id, context = context, appContext = appContext) + bridge.startObserving() + appContext.server.context.dispatch(VRServerActions.FeederConnected(bridge)) return bridge } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/firmware/module.kt b/server/core/src/main/java/dev/slimevr/firmware/module.kt index 72af72956b..3965ec5416 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/module.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/module.kt @@ -1,5 +1,6 @@ package dev.slimevr.firmware +import dev.slimevr.Phase1ContextProvider import dev.slimevr.VRServer import dev.slimevr.context.Behaviour import dev.slimevr.context.Context @@ -43,6 +44,8 @@ class FirmwareManager( private val serialServer: SerialServer, private val scope: CoroutineScope, ) { + fun startObserving() = context.observeAll(this) + private val runningJobs = mutableMapOf() suspend fun flash( @@ -118,16 +121,14 @@ class FirmwareManager( } companion object { - fun create(serialServer: SerialServer, scope: CoroutineScope): FirmwareManager { + fun create(ctx: Phase1ContextProvider, scope: CoroutineScope): FirmwareManager { val behaviours = listOf(FirmwareManagerBaseBehaviour) val context = Context.create( initialState = FirmwareManagerState(jobs = mapOf()), scope = scope, behaviours = behaviours, ) - val manager = FirmwareManager(context = context, serialServer = serialServer, scope = scope) - behaviours.forEach { it.observe(manager) } - return manager + return FirmwareManager(context = context, serialServer = ctx.serialServer, scope = scope) } } } diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt index fa80e92564..51f88a472d 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt @@ -2,6 +2,7 @@ package dev.slimevr.heightcalibration +import dev.slimevr.Phase1ContextProvider import dev.slimevr.VRServer import dev.slimevr.config.UserConfig import dev.slimevr.context.Behaviour @@ -43,6 +44,8 @@ class HeightCalibrationManager( val serverContext: VRServer, private val userConfig: UserConfig, ) { + fun startObserving() = context.observeAll(this) + private var sessionJob: Job? = null // These Flows do nothing until the calibration use collect on it @@ -89,8 +92,7 @@ class HeightCalibrationManager( companion object { fun create( - serverContext: VRServer, - userConfig: UserConfig, + ctx: Phase1ContextProvider, scope: CoroutineScope, ): HeightCalibrationManager { val behaviours = listOf(CalibrationBehaviour) @@ -99,7 +101,7 @@ class HeightCalibrationManager( scope = scope, behaviours = behaviours, ) - return HeightCalibrationManager(context = context, serverContext = serverContext, userConfig = userConfig) + return HeightCalibrationManager(context = context, serverContext = ctx.server, userConfig = ctx.config.userConfig) } } } diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 0fa4bb32f9..e35363e78c 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -2,7 +2,6 @@ package dev.slimevr.hid import dev.slimevr.AppLogger import dev.slimevr.VRServerActions -import dev.slimevr.config.Settings import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin @@ -10,7 +9,7 @@ import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import solarxr_protocol.datatypes.TrackerStatus -class HIDRegistrationBehaviour(private val settings: Settings) : HIDReceiverBehaviour { +object HIDRegistrationBehaviour : HIDReceiverBehaviour { override fun reduce(state: HIDReceiverState, action: HIDReceiverActions) = when (action) { is HIDReceiverActions.DeviceRegistered -> state.copy( trackers = state.trackers + @@ -33,7 +32,7 @@ class HIDRegistrationBehaviour(private val settings: Settings) : HIDReceiverBeha val existing = state.trackers[packet.hidId] if (existing != null) return@onPacket - val existingDevice = receiver.serverContext.context.state.value.devices.values + val existingDevice = receiver.appContext.server.context.state.value.devices.values .find { it.context.state.value.macAddress == packet.address && it.context.state.value.origin == DeviceOrigin.HID } if (existingDevice != null) { @@ -42,23 +41,23 @@ class HIDRegistrationBehaviour(private val settings: Settings) : HIDReceiverBeha return@onPacket } - val deviceId = receiver.serverContext.nextHandle() + val deviceId = receiver.appContext.server.nextHandle() val device = Device.create( - scope = receiver.serverContext.context.scope, + scope = receiver.appContext.server.context.scope, id = deviceId, address = packet.address, macAddress = packet.address, origin = DeviceOrigin.HID, protocolVersion = 0, ) - receiver.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId, device)) + receiver.appContext.server.context.dispatch(VRServerActions.NewDevice(deviceId, device)) receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, deviceId)) AppLogger.hid.info("Registered HID device ${packet.address} (hidId=${packet.hidId})") } } } -class HIDDeviceInfoBehaviour(private val settings: Settings) : HIDReceiverBehaviour { +object HIDDeviceInfoBehaviour : HIDReceiverBehaviour { override fun reduce(state: HIDReceiverState, action: HIDReceiverActions): HIDReceiverState = when (action) { is HIDReceiverActions.TrackerRegistered -> { val existing = state.trackers[action.hidId] ?: return state @@ -89,7 +88,7 @@ class HIDDeviceInfoBehaviour(private val settings: Settings) : HIDReceiverBehavi if (tracker == null) { val deviceState = device.context.state.value - val existingTracker = receiver.serverContext.context.state.value.trackers.values + val existingTracker = receiver.appContext.server.context.state.value.trackers.values .find { it.context.state.value.hardwareId == deviceState.address && it.context.state.value.origin == DeviceOrigin.HID } if (existingTracker != null) { @@ -97,18 +96,18 @@ class HIDDeviceInfoBehaviour(private val settings: Settings) : HIDReceiverBehavi // HID does not have a rest calibration signal existingTracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType, completedRestCalibration = true) }) } else { - val trackerId = receiver.serverContext.nextHandle() + val trackerId = receiver.appContext.server.nextHandle() val newTracker = Tracker.create( - scope = receiver.serverContext.context.scope, + scope = receiver.appContext.server.context.scope, id = trackerId, deviceId = deviceState.id, sensorType = packet.imuType, hardwareId = deviceState.address, origin = DeviceOrigin.HID, - server = receiver.serverContext, - settings = settings, + server = receiver.appContext.server, + settings = receiver.appContext.config.settings, ) - receiver.serverContext.context.dispatch(VRServerActions.NewTracker(trackerId, newTracker)) + receiver.appContext.server.context.dispatch(VRServerActions.NewTracker(trackerId, newTracker)) receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, trackerId)) AppLogger.hid.info("Registered HID tracker for device ${deviceState.address} (hidId=${packet.hidId})") } @@ -173,4 +172,4 @@ object HIDStatusBehaviour : HIDReceiverBehaviour { ) } } -} +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/hid/module.kt b/server/core/src/main/java/dev/slimevr/hid/module.kt index 65c2d37e73..b1de2c14b6 100644 --- a/server/core/src/main/java/dev/slimevr/hid/module.kt +++ b/server/core/src/main/java/dev/slimevr/hid/module.kt @@ -1,8 +1,7 @@ package dev.slimevr.hid +import dev.slimevr.AppContextProvider import dev.slimevr.EventDispatcher -import dev.slimevr.VRServer -import dev.slimevr.config.Settings import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.device.Device @@ -37,30 +36,31 @@ inline fun HIDPacketDispatcher.onPacket(crossinline call class HIDReceiver( val context: HIDReceiverContext, - val serverContext: VRServer, + val appContext: AppContextProvider, val packetEvents: HIDPacketDispatcher, ) { + fun startObserving() = context.observeAll(this) + fun getDevice(hidId: Int): Device? { val record = context.state.value.trackers[hidId] ?: return null - return serverContext.getDevice(record.deviceId) + return appContext.server.getDevice(record.deviceId) } fun getTracker(hidId: Int): Tracker? { val record = context.state.value.trackers[hidId] ?: return null val trackerId = record.trackerId ?: return null - return serverContext.getTracker(trackerId) + return appContext.server.getTracker(trackerId) } companion object { fun create( serialNumber: String, - serverContext: VRServer, - settings: Settings, + appContext: AppContextProvider, scope: CoroutineScope, ): HIDReceiver { val behaviours = listOf( - HIDRegistrationBehaviour(settings), - HIDDeviceInfoBehaviour(settings), + HIDRegistrationBehaviour, + HIDDeviceInfoBehaviour, HIDRotationBehaviour, HIDBatteryBehaviour, HIDStatusBehaviour, @@ -76,12 +76,10 @@ class HIDReceiver( val receiver = HIDReceiver( context = context, - serverContext = serverContext, + appContext = appContext, packetEvents = dispatcher, ) - - behaviours.forEach { it.observe(receiver) } - + receiver.startObserving() return receiver } } diff --git a/server/core/src/main/java/dev/slimevr/provisioning/module.kt b/server/core/src/main/java/dev/slimevr/provisioning/module.kt index ada8d617ba..5bbfc4c28a 100644 --- a/server/core/src/main/java/dev/slimevr/provisioning/module.kt +++ b/server/core/src/main/java/dev/slimevr/provisioning/module.kt @@ -1,5 +1,6 @@ package dev.slimevr.provisioning +import dev.slimevr.Phase1ContextProvider import dev.slimevr.VRServer import dev.slimevr.context.Behaviour import dev.slimevr.context.Context @@ -35,6 +36,8 @@ data class ProvisioningManager( private val serialServer: SerialServer, private val scope: CoroutineScope, ) { + fun startObserving() = context.observeAll(this) + // Jobs cannot be held into a state / mutable flow // as we cannot guarantee immutability private var provisioningJob: Job? = null @@ -85,21 +88,18 @@ data class ProvisioningManager( macAddress = null, ) - fun create(serialServer: SerialServer, scope: CoroutineScope): ProvisioningManager { + fun create(ctx: Phase1ContextProvider, scope: CoroutineScope): ProvisioningManager { val behaviours = listOf(ProvisioningManagerBaseBehaviour) val context = Context.create( initialState = INITIAL_STATE, scope = scope, behaviours = behaviours, ) - val manager = ProvisioningManager( + return ProvisioningManager( context = context, - serialServer = serialServer, - scope = scope + serialServer = ctx.serialServer, + scope = scope, ) - behaviours.forEach { it.observe(manager) } - - return manager } } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 59e84925a0..b9028ebe2f 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -1,7 +1,7 @@ package dev.slimevr.skeleton import com.jme3.math.FastMath -import dev.slimevr.config.UserConfig +import dev.slimevr.Phase1ContextProvider import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import io.github.axisangles.ktmath.Quaternion @@ -68,11 +68,13 @@ class Skeleton( val context: SkeletonContext, val computed: MutableStateFlow>, ) { + fun startObserving() = context.observeAll(this) + companion object { - fun create(scope: CoroutineScope, userConfig: UserConfig): Skeleton { + fun create(scope: CoroutineScope, ctx: Phase1ContextProvider): Skeleton { val behaviours = listOf( ProportionsBehaviour(), - ScaledProportionsBehaviour(userConfig), + ScaledProportionsBehaviour(ctx.config.userConfig), HeightLogBehaviour(), YouSpinMeRightRoundBehaviour(inputHz = 10f), ComputedSkeletonBehaviour(processors = listOf( @@ -87,10 +89,7 @@ class Skeleton( behaviours = behaviours, ) - val skeleton = Skeleton(context, MutableStateFlow(buildBones(context.state.value))) - behaviours.forEach { it.observe(skeleton) } - - return skeleton + return Skeleton(context, MutableStateFlow(buildBones(context.state.value))) } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/ipc.kt b/server/core/src/main/java/dev/slimevr/solarxr/ipc.kt index a05ebcd948..c15fd1d29f 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/ipc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/ipc.kt @@ -1,7 +1,7 @@ package dev.slimevr.solarxr import com.google.flatbuffers.FlatBufferBuilder -import dev.slimevr.VRServer +import dev.slimevr.AppContextProvider import io.ktor.util.moveToByteArray import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow @@ -9,16 +9,14 @@ import solarxr_protocol.MessageBundle import java.nio.ByteBuffer suspend fun handleSolarXRBridge( - server: VRServer, + appContext: AppContextProvider, messages: Flow, send: suspend (ByteArray) -> Unit, - behaviours: List, ) = coroutineScope { val bridge = SolarXRBridge.create( - id = server.nextHandle(), - serverContext = server, + id = appContext.server.nextHandle(), + appContext = appContext, scope = this, - behaviours = behaviours, ) bridge.outbound.on { bundle -> diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 24b8ced701..922a8440e5 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -1,7 +1,7 @@ package dev.slimevr.solarxr +import dev.slimevr.AppContextProvider import dev.slimevr.EventDispatcher -import dev.slimevr.VRServer import dev.slimevr.VRServerActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context @@ -44,7 +44,7 @@ suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRBridge) { class SolarXRBridge( val id: Int, val context: SolarXRBridgeContext, - val serverContext: VRServer, + val appContext: AppContextProvider, val dataFeedDispatcher: EventDispatcher, val rpcDispatcher: EventDispatcher, val outbound: EventDispatcher = EventDispatcher(), @@ -54,33 +54,45 @@ class SolarXRBridge( suspend fun sendDataFeed(frame: DataFeedMessageHeader) = outbound.emit(MessageBundle(dataFeedMsgs = listOf(frame))) fun disconnect() { - serverContext.context.dispatch(VRServerActions.SolarXRDisconnected(id)) + appContext.server.context.dispatch(VRServerActions.SolarXRDisconnected(id)) } + fun startObserving() = context.observeAll(this) + companion object { + fun buildBehaviours(appContext: AppContextProvider): List = buildList { + add(DataFeedInitBehaviour(appContext.server, appContext.skeleton)) + add(SerialBehaviour(appContext.serialServer)) + add(FirmwareBehaviour(appContext.server, appContext.firmwareManager)) + appContext.vrcConfigManager?.let { vrc -> + add(VrcBehaviour(vrc, appContext.server, userHeight = { appContext.skeleton.context.state.value.userHeight })) + } + add(HeightCalibrationBehaviour(appContext.heightCalibrationManager)) + add(ProvisioningBehaviour(appContext.server, appContext.provisioningManager)) + add(SkeletonBehaviour(appContext.config.userConfig, appContext.skeleton)) + add(TrackingChecklistBehaviour(appContext.trackingChecklist, appContext.config.settings)) + add(AssignTrackerBehaviour(appContext.server)) + } + fun create( id: Int, - serverContext: VRServer, + appContext: AppContextProvider, scope: CoroutineScope, - behaviours: List, ): SolarXRBridge { val context = Context.create( initialState = SolarXRBridgeState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), scope = scope, - behaviours = behaviours, + behaviours = buildBehaviours(appContext), ) val bridge = SolarXRBridge( id = id, context = context, - serverContext = serverContext, + appContext = appContext, dataFeedDispatcher = EventDispatcher(), rpcDispatcher = EventDispatcher(), ) - - behaviours.forEach { it.observe(bridge) } - - + bridge.startObserving() return bridge } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt b/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt index 51f99cf2f5..3fddce7d56 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt @@ -2,7 +2,6 @@ package dev.slimevr.solarxr import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions -import dev.slimevr.trackingchecklist.ChecklistStep import dev.slimevr.trackingchecklist.TrackingChecklist import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn @@ -11,7 +10,6 @@ import kotlinx.coroutines.flow.onEach import solarxr_protocol.rpc.IgnoreTrackingChecklistStepRequest import solarxr_protocol.rpc.TrackingChecklistRequest import solarxr_protocol.rpc.TrackingChecklistResponse -import solarxr_protocol.rpc.TrackingChecklistStep import solarxr_protocol.rpc.TrackingChecklistStepId class TrackingChecklistBehaviour( @@ -27,22 +25,11 @@ class TrackingChecklistBehaviour( private fun buildResponse(): TrackingChecklistResponse { val state = checklist.context.state.value return TrackingChecklistResponse( - steps = state.steps.map { (id, step) -> toProtocolStep(id, step) }, - ignoredSteps = (state.ignoredSteps + parseMutedSteps()).toList(), + steps = state.steps.values.toList(), + ignoredSteps = parseMutedSteps().toList(), ) } - private fun toProtocolStep(id: TrackingChecklistStepId, step: ChecklistStep): TrackingChecklistStep = - TrackingChecklistStep( - id = id, - valid = step.valid, - enabled = step.enabled, - optional = step.optional, - ignorable = step.ignorable, - visibility = step.visibility, - extraData = step.extraData, - ) - override fun observe(receiver: SolarXRBridge) { combine( checklist.context.state, @@ -63,4 +50,4 @@ class TrackingChecklistBehaviour( }) } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index 95ae6db0c8..15840665df 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -27,6 +27,7 @@ import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.TrackerId import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.TrackingChecklistNeedCalibration +import solarxr_protocol.rpc.TrackingChecklistStep import solarxr_protocol.rpc.TrackingChecklistStepId import solarxr_protocol.rpc.TrackingChecklistSteamVRDisconnected import solarxr_protocol.rpc.TrackingChecklistTrackerError @@ -58,7 +59,7 @@ class SteamVRCheckBehaviour(private val server: VRServer) : TrackingChecklistBeh server.context.state .map { state -> val connected = state.drivers.isNotEmpty() - ChecklistStep( + TrackingChecklistStep( valid = connected, enabled = true, ignorable = true, @@ -72,12 +73,12 @@ class SteamVRCheckBehaviour(private val server: VRServer) : TrackingChecklistBeh } class HMDCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { - private fun computeStep(trackers: List): ChecklistStep { + private fun computeStep(trackers: List): TrackingChecklistStep { // FIXME: Most likely incomplete val hasSteamVR = trackers.any { tracker -> tracker.origin == DeviceOrigin.DRIVER } val hmdTracker = trackers.firstOrNull { tracker -> tracker.origin == DeviceOrigin.DRIVER && tracker.position != null } val isAssigned = hmdTracker?.bodyPart == BodyPart.HEAD - return ChecklistStep( + return TrackingChecklistStep( valid = isAssigned, enabled = hasSteamVR, ignorable = true, @@ -97,12 +98,12 @@ class HMDCheckBehaviour(private val server: VRServer) : TrackingChecklistBehavio } class TrackerRestCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { - private fun computeStep(trackers: List): ChecklistStep { + private fun computeStep(trackers: List): TrackingChecklistStep { val uncalibratedTrackers = trackers.filter { tracker -> (tracker.origin == DeviceOrigin.UDP || tracker.origin == DeviceOrigin.HID) && tracker.status == TrackerStatus.OK && !tracker.completedRestCalibration } - return ChecklistStep( + return TrackingChecklistStep( valid = uncalibratedTrackers.isEmpty(), enabled = trackers.isNotEmpty(), extraData = if (!uncalibratedTrackers.isEmpty()) TrackingChecklistNeedCalibration( @@ -121,11 +122,11 @@ class TrackerRestCheckBehaviour(private val server: VRServer) : TrackingChecklis } class TrackerErrorCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { - private fun computeStep(trackers: List): ChecklistStep { + private fun computeStep(trackers: List): TrackingChecklistStep { val errorTrackers = trackers .filter { tracker -> tracker.status == TrackerStatus.ERROR && tracker.bodyPart != null } .toSet() - return ChecklistStep( + return TrackingChecklistStep( valid = errorTrackers.isEmpty(), enabled = trackers.isNotEmpty(), extraData = if (errorTrackers.isNotEmpty()) TrackingChecklistTrackerError( @@ -148,12 +149,12 @@ class VRChatSettingsCheckBehaviour( private val skeleton: Skeleton, private val vrcConfigManager: VRCConfigManager, ) : TrackingChecklistBehaviourType { - private fun computeStep(vrc: VRCConfigState, userHeight: Double): ChecklistStep { + private fun computeStep(vrc: VRCConfigState, userHeight: Double): TrackingChecklistStep { val values = vrc.currentValues - if (!vrc.isSupported || values == null) return ChecklistStep(valid = true, enabled = false) + if (!vrc.isSupported || values == null) return TrackingChecklistStep(valid = true, enabled = false) val recommended = computeRecommendedValues(server, userHeight) val validity = computeValidity(values, recommended) - return ChecklistStep(valid = isVRCConfigValid(validity, vrc.mutedWarnings), enabled = true) + return TrackingChecklistStep(valid = isVRCConfigValid(validity, vrc.mutedWarnings), enabled = true) } override fun observe(receiver: TrackingChecklist) { diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt index 3104b28552..b3b1111d8b 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt @@ -1,32 +1,18 @@ package dev.slimevr.trackingchecklist -import dev.slimevr.VRServer +import dev.slimevr.AppContextProvider import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.skeleton.Skeleton -import dev.slimevr.vrchat.VRCConfigManager import kotlinx.coroutines.CoroutineScope -import solarxr_protocol.rpc.TrackingChecklistExtraData +import solarxr_protocol.rpc.TrackingChecklistStep import solarxr_protocol.rpc.TrackingChecklistStepId -import solarxr_protocol.rpc.TrackingChecklistStepVisibility - -data class ChecklistStep( - val valid: Boolean, - val enabled: Boolean, - val optional: Boolean = false, - val ignorable: Boolean = false, - val visibility: TrackingChecklistStepVisibility = TrackingChecklistStepVisibility.WHEN_INVALID, - val extraData: TrackingChecklistExtraData? = null, -) data class TrackingChecklistState( - val steps: Map = emptyMap(), - val ignoredSteps: Set = emptySet(), + val steps: Map = emptyMap(), ) sealed interface TrackingChecklistActions { - data class UpdateStep(val id: TrackingChecklistStepId, val step: ChecklistStep) : TrackingChecklistActions - data class SetIgnored(val id: TrackingChecklistStepId, val ignored: Boolean) : TrackingChecklistActions + data class UpdateStep(val id: TrackingChecklistStepId, val step: TrackingChecklistStep) : TrackingChecklistActions } typealias TrackingChecklistContext = Context @@ -35,45 +21,34 @@ typealias TrackingChecklistBehaviourType = Behaviour state.copy(steps = state.steps + (action.id to action.step)) - is TrackingChecklistActions.SetIgnored -> state.copy( - ignoredSteps = if (action.ignored) state.ignoredSteps + action.id else state.ignoredSteps - action.id, - ) } } class TrackingChecklist( val context: TrackingChecklistContext, ) { - fun setIgnored(stepId: TrackingChecklistStepId, ignored: Boolean) { - context.dispatch(TrackingChecklistActions.SetIgnored(stepId, ignored)) + fun startObserving(appContext: AppContextProvider) { + val stepBehaviours: List = buildList { + add(SteamVRCheckBehaviour(appContext.server)) + add(HMDCheckBehaviour(appContext.server)) + add(TrackerRestCheckBehaviour(appContext.server)) + add(TrackerErrorCheckBehaviour(appContext.server)) + appContext.vrcConfigManager?.let { add(VRChatSettingsCheckBehaviour(appContext.server, appContext.skeleton, it)) } + } + context.behaviours.addAll(stepBehaviours) + context.observeAll(this) } companion object { - fun create( - scope: CoroutineScope, - server: VRServer, - skeleton: Skeleton, - vrcConfigManager: VRCConfigManager?, - ): TrackingChecklist { - val stepBehaviours: List = buildList { - add(SteamVRCheckBehaviour(server)) - add(HMDCheckBehaviour(server)) - add(TrackerRestCheckBehaviour(server)) - add(TrackerErrorCheckBehaviour(server)) - vrcConfigManager?.let { add(VRChatSettingsCheckBehaviour(server, skeleton, it)) } - } - - val behaviours = listOf(ChecklistBaseBehaviour) + stepBehaviours - + fun create(scope: CoroutineScope): TrackingChecklist { + val initialBehaviours = listOf(ChecklistBaseBehaviour) val context = Context.create( initialState = TrackingChecklistState(), scope = scope, - behaviours = behaviours, + behaviours = initialBehaviours, ) - val checklist = TrackingChecklist(context) - behaviours.forEach { it.observe(checklist) } return checklist } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 5ac3dc4121..a915859454 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -2,7 +2,6 @@ package dev.slimevr.udp import dev.slimevr.AppLogger import dev.slimevr.VRServerActions -import dev.slimevr.config.Settings import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin @@ -79,7 +78,7 @@ object PingBehaviour : UDPConnectionBehaviour { } val ping = (System.currentTimeMillis() - state.lastPing.startTime) / 2 - val device = receiver.serverContext.getDevice(deviceId) ?: return@onPacket + val device = receiver.appContext.server.getDevice(deviceId) ?: return@onPacket device.context.dispatch(DeviceActions.Update { copy(ping = ping) }) } } @@ -97,16 +96,16 @@ object HandshakeBehaviour : UDPConnectionBehaviour { val state = receiver.context.state.value val device = if (state.deviceId == null) { - val deviceId = receiver.serverContext.nextHandle() + val deviceId = receiver.appContext.server.nextHandle() val newDevice = Device.create( id = deviceId, - scope = receiver.serverContext.context.scope, + scope = receiver.appContext.server.context.scope, address = state.address, macAddress = packet.data.macString, origin = DeviceOrigin.UDP, protocolVersion = packet.data.protocolVersion, ) - receiver.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId = deviceId, context = newDevice)) + receiver.appContext.server.context.dispatch(VRServerActions.NewDevice(deviceId = deviceId, context = newDevice)) receiver.context.dispatch(UDPConnectionActions.Handshake(deviceId)) newDevice } else { @@ -152,7 +151,7 @@ object TimeoutBehaviour : UDPConnectionBehaviour { receiver.getDevice()?.context?.dispatch( DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, ) - state.trackerIds.mapNotNull { receiver.serverContext.getTracker(it.id) }.forEach { tracker -> + state.trackerIds.mapNotNull { receiver.appContext.server.getTracker(it.id) }.forEach { tracker -> tracker.context.dispatch(TrackerActions.Update { copy(status = TrackerStatus.DISCONNECTED) }) } } else { @@ -181,7 +180,7 @@ object DeviceStatsBehaviour : UDPConnectionBehaviour { } } -class SensorInfoBehaviour(private val settings: Settings) : UDPConnectionBehaviour { +object SensorInfoBehaviour : UDPConnectionBehaviour { override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { is UDPConnectionActions.AssignTracker -> state.copy(trackerIds = state.trackerIds + action.trackerId) else -> state @@ -222,19 +221,19 @@ class SensorInfoBehaviour(private val settings: Settings) : UDPConnectionBehavio deviceState.address } val hardwareId = "$mac:${event.data.sensorId}" - val trackerId = receiver.serverContext.nextHandle() + val trackerId = receiver.appContext.server.nextHandle() val newTracker = Tracker.create( id = trackerId, hardwareId = hardwareId, sensorType = event.data.imuType, deviceId = deviceState.id, origin = DeviceOrigin.UDP, - scope = receiver.serverContext.context.scope, - server = receiver.serverContext, - settings = settings, + scope = receiver.appContext.server.context.scope, + server = receiver.appContext.server, + settings = receiver.appContext.config.settings, ) - receiver.serverContext.context.dispatch( + receiver.appContext.server.context.dispatch( VRServerActions.NewTracker(trackerId = trackerId, context = newTracker), ) receiver.context.dispatch( @@ -297,7 +296,7 @@ object BundledPacketBehaviour : UDPConnectionBehaviour { object FlagsBehaviour : UDPConnectionBehaviour { override fun reduce( state: UDPConnectionState, - action: UDPConnectionActions + action: UDPConnectionActions, ): UDPConnectionState { return when (action) { is UDPConnectionActions.FirmwareFeatures -> state.copy(features = action.features) diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 558f26e3bb..91bd50eb25 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -1,8 +1,7 @@ package dev.slimevr.udp +import dev.slimevr.AppContextProvider import dev.slimevr.EventDispatcher -import dev.slimevr.VRServer -import dev.slimevr.config.Settings import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.device.Device @@ -50,7 +49,7 @@ typealias UDPConnectionBehaviour = Behaviour>, private val socket: DatagramSocket, @@ -67,14 +66,16 @@ class UDPConnection( } } + fun startObserving() = context.observeAll(this) + fun getDevice(): Device? { val deviceId = context.state.value.deviceId - return if (deviceId != null) serverContext.getDevice(deviceId) else null + return if (deviceId != null) appContext.server.getDevice(deviceId) else null } fun getTracker(id: Int): Tracker? { val trackerId = context.state.value.trackerIds.find { it.trackerNum == id } - return if (trackerId != null) serverContext.getTracker(trackerId.id) else null + return if (trackerId != null) appContext.server.getTracker(trackerId.id) else null } companion object { @@ -83,8 +84,7 @@ class UDPConnection( socket: DatagramSocket, remoteIp: String, remotePort: Int, - serverContext: VRServer, - settings: Settings, + appContext: AppContextProvider, scope: CoroutineScope, ): UDPConnection { val behaviours = listOf( @@ -93,11 +93,11 @@ class UDPConnection( TimeoutBehaviour, PingBehaviour, DeviceStatsBehaviour, - SensorInfoBehaviour(settings), + SensorInfoBehaviour, SensorRotationBehaviour, BundledPacketBehaviour, FlagsBehaviour, - TemperatureBehaviour + TemperatureBehaviour, ) val context = Context.create( @@ -111,7 +111,7 @@ class UDPConnection( port = remotePort, deviceId = null, trackerIds = listOf(), - features = null + features = null, ), scope = scope, behaviours = behaviours, @@ -123,7 +123,7 @@ class UDPConnection( val conn = UDPConnection( context = context, - serverContext = serverContext, + appContext = appContext, packetEvents = dispatcher, packetChannel = packetChannel, socket = socket, @@ -131,8 +131,7 @@ class UDPConnection( remotePort = remotePort, scope = scope, ) - - behaviours.forEach { it.observe(conn) } + conn.startObserving() // Dedicated coroutine per connection so the receive loop is never blocked by packet processing scope.launch { diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index a1a95cfe91..5ef0d49592 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -1,8 +1,7 @@ package dev.slimevr.udp +import dev.slimevr.AppContextProvider import dev.slimevr.AppLogger -import dev.slimevr.VRServer -import dev.slimevr.config.AppConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -19,11 +18,10 @@ class UDPTrackerServerState( ) suspend fun createUDPTrackerServer( - serverContext: VRServer, - configContext: AppConfig, + appContext: AppContextProvider, ): UDPTrackerServerState { val state = UDPTrackerServerState( - port = configContext.settings.context.state.value.data.trackerPort, + port = appContext.config.settings.context.state.value.data.trackerPort, connections = mutableMapOf(), ) @@ -64,8 +62,7 @@ suspend fun createUDPTrackerServer( remoteIp = ip, remotePort = port, socket = socket, - serverContext = serverContext, - settings = configContext.settings, + appContext = appContext, scope = udpScope, ) diff --git a/server/core/src/main/java/dev/slimevr/vrchat/module.kt b/server/core/src/main/java/dev/slimevr/vrchat/module.kt index a25a70d33c..5314a72d1d 100644 --- a/server/core/src/main/java/dev/slimevr/vrchat/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrchat/module.kt @@ -46,6 +46,8 @@ class VRCConfigManager( val context: VRCConfigContext, val config: AppConfig, ) { + fun startObserving() = context.observeAll(this) + companion object { fun create( config: AppConfig, @@ -69,9 +71,7 @@ class VRCConfigManager( values.collect { context.dispatch(VRCConfigActions.UpdateValues(it)) } } - val manager = VRCConfigManager(context = context, config = config) - behaviours.forEach { it.observe(manager) } - return manager + return VRCConfigManager(context = context, config = config) } } } diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index d8f5911622..2e0804f3e2 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -8,7 +8,12 @@ import dev.slimevr.config.UserConfigState import dev.slimevr.context.Context import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialServer +import dev.slimevr.skeleton.Skeleton +import dev.slimevr.skeleton.DEFAULT_SKELETON_STATE +import dev.slimevr.skeleton.buildBones +import dev.slimevr.skeleton.ProportionsBehaviour import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow import java.nio.file.Files fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create( @@ -35,13 +40,23 @@ fun buildTestVrServer(scope: CoroutineScope): VRServer = VRServer.create(scope) fun buildTestUserConfig(scope: CoroutineScope): UserConfig { val tempDir = Files.createTempDirectory("slimevr-test").toFile() tempDir.deleteOnExit() - val behaviours = listOf(DefaultUserBehaviour) val context = Context.create( initialState = UserConfigState(data = UserConfigData(), name = "test"), scope = scope, - behaviours = behaviours, + behaviours = listOf(DefaultUserBehaviour), ) val userConfig = UserConfig(context, scope = scope, userConfigDir = tempDir) - behaviours.forEach { it.observe(userConfig) } + context.observeAll(userConfig) return userConfig } + +fun buildTestSkeleton(scope: CoroutineScope): Skeleton { + val context = Context.create( + initialState = DEFAULT_SKELETON_STATE, + scope = scope, + behaviours = listOf(ProportionsBehaviour()), + ) + val skeleton = Skeleton(context, MutableStateFlow(buildBones(context.state.value))) + skeleton.startObserving() + return skeleton +} diff --git a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt index 2b87ef3064..7fa4a4c803 100644 --- a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt +++ b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt @@ -4,6 +4,7 @@ import dev.slimevr.VRServer import dev.slimevr.VRServerActions import dev.slimevr.buildTestSerialServer import dev.slimevr.buildTestVrServer +import dev.slimevr.context.Context import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin @@ -22,6 +23,15 @@ import kotlin.test.assertNull private fun fakePort(loc: String = "COM1") = SerialPortInfo(loc, "Fake $loc", 0x1A86, 0x7523) +private fun buildManager(serialServer: dev.slimevr.serial.SerialServer, scope: CoroutineScope): ProvisioningManager { + val context = Context.create( + initialState = ProvisioningManager.INITIAL_STATE, + scope = scope, + behaviours = listOf(ProvisioningManagerBaseBehaviour), + ) + return ProvisioningManager(context = context, serialServer = serialServer, scope = scope).also { it.startObserving() } +} + // Injects a fully connected device into the VRServer, simulating a tracker appearing on the network. private fun connectDevice(vrServer: VRServer, mac: String, scope: CoroutineScope) { val device = Device.create( @@ -42,7 +52,7 @@ class ProvisioningManagerTest { @Test fun `NO_SERIAL_DEVICE_FOUND when no port appears within 15 seconds`() = runTest { val serialServer = buildTestSerialServer(backgroundScope) - val manager = ProvisioningManager.create(serialServer, backgroundScope) + val manager = buildManager(serialServer, backgroundScope) manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) @@ -54,7 +64,7 @@ class ProvisioningManagerTest { @Test fun `OBTAINING_MAC_ADDRESS after port is detected and 2 second reboot delay elapses`() = runTest { val serialServer = buildTestSerialServer(backgroundScope) - val manager = ProvisioningManager.create(serialServer, backgroundScope) + val manager = buildManager(serialServer, backgroundScope) manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) @@ -67,7 +77,7 @@ class ProvisioningManagerTest { @Test fun `NO_SERIAL_LOGS_ERROR when tracker produces no serial output after 5 second timeout`() = runTest { val serialServer = buildTestSerialServer(backgroundScope) - val manager = ProvisioningManager.create(serialServer, backgroundScope) + val manager = buildManager(serialServer, backgroundScope) manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) @@ -81,7 +91,7 @@ class ProvisioningManagerTest { @Test fun `recovers from NO_SERIAL_LOGS_ERROR and sets macAddress when logs appear containing the MAC`() = runTest { val serialServer = buildTestSerialServer(backgroundScope) - val manager = ProvisioningManager.create(serialServer, backgroundScope) + val manager = buildManager(serialServer, backgroundScope) manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) @@ -102,7 +112,7 @@ class ProvisioningManagerTest { @Test fun `CONNECTION_ERROR when logs are present but no MAC received within 5 second timeout`() = runTest { val serialServer = buildTestSerialServer(backgroundScope) - val manager = ProvisioningManager.create(serialServer, backgroundScope) + val manager = buildManager(serialServer, backgroundScope) manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) @@ -121,7 +131,7 @@ class ProvisioningManagerTest { @Test fun `CONNECTION_ERROR when WiFi credentials not acknowledged within 5 second timeout`() = runTest { val serialServer = buildTestSerialServer(backgroundScope) - val manager = ProvisioningManager.create(serialServer, backgroundScope) + val manager = buildManager(serialServer, backgroundScope) manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) @@ -141,7 +151,7 @@ class ProvisioningManagerTest { @Test fun `CONNECTION_ERROR when WiFi does not connect within 15 second timeout`() = runTest { val serialServer = buildTestSerialServer(backgroundScope) - val manager = ProvisioningManager.create(serialServer, backgroundScope) + val manager = buildManager(serialServer, backgroundScope) manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) @@ -163,7 +173,7 @@ class ProvisioningManagerTest { @Test fun `CONNECTION_ERROR after exhausting all retries on can't connect`() = runTest { val serialServer = buildTestSerialServer(backgroundScope) - val manager = ProvisioningManager.create(serialServer, backgroundScope) + val manager = buildManager(serialServer, backgroundScope) manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) @@ -190,7 +200,7 @@ class ProvisioningManagerTest { @Test fun `COULD_NOT_FIND_SERVER when tracker does not appear on the network within 30 second timeout`() = runTest { val serialServer = buildTestSerialServer(backgroundScope) - val manager = ProvisioningManager.create(serialServer, backgroundScope) + val manager = buildManager(serialServer, backgroundScope) manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) @@ -215,7 +225,7 @@ class ProvisioningManagerTest { fun `DONE when full provisioning succeeds`() = runTest { val serialServer = buildTestSerialServer(backgroundScope) val vrServer = buildTestVrServer(backgroundScope) - val manager = ProvisioningManager.create(serialServer, backgroundScope) + val manager = buildManager(serialServer, backgroundScope) manager.startProvisioning(vrServer, "wifi", "pass", null) serialServer.onPortDetected(fakePort()) @@ -241,7 +251,7 @@ class ProvisioningManagerTest { fun `resets to NONE and clears port after USB disconnect following DONE`() = runTest { val serialServer = buildTestSerialServer(backgroundScope) val vrServer = buildTestVrServer(backgroundScope) - val manager = ProvisioningManager.create(serialServer, backgroundScope) + val manager = buildManager(serialServer, backgroundScope) manager.startProvisioning(vrServer, "wifi", "pass", null) serialServer.onPortDetected(fakePort()) @@ -271,7 +281,7 @@ class ProvisioningManagerTest { @Test fun `does not retry the same port after failure - waits for USB disconnect`() = runTest { val serialServer = buildTestSerialServer(backgroundScope) - val manager = ProvisioningManager.create(serialServer, backgroundScope) + val manager = buildManager(serialServer, backgroundScope) manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt index c68b79f640..4d416ceff2 100644 --- a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -1,8 +1,10 @@ package dev.slimevr.solarxr -import dev.slimevr.buildTestUserConfig +import dev.slimevr.AppContextProvider +import dev.slimevr.EventDispatcher +import dev.slimevr.buildTestSkeleton import dev.slimevr.buildTestVrServer -import dev.slimevr.skeleton.Skeleton +import dev.slimevr.context.Context import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest @@ -15,14 +17,32 @@ import kotlin.test.assertEquals private fun testConn(backgroundScope: kotlinx.coroutines.CoroutineScope, onSend: suspend (ByteArray) -> Unit): SolarXRBridge { val server = buildTestVrServer(backgroundScope) - val userConfig = buildTestUserConfig(backgroundScope) - val skeleton = Skeleton.create(backgroundScope, userConfig) - val bridge = SolarXRBridge.create( - id = 1, - serverContext = server, + val skeleton = buildTestSkeleton(backgroundScope) + val appContext = object : AppContextProvider { + override val server = server + override val skeleton = skeleton + override val config get() = error("not used") + override val serialServer get() = error("not used") + override val firmwareManager get() = error("not used") + override val vrcConfigManager = null + override val provisioningManager get() = error("not used") + override val heightCalibrationManager get() = error("not used") + override val trackingChecklist get() = error("not used") + override fun startObserving() {} + } + val context = Context.create( + initialState = SolarXRBridgeState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), scope = backgroundScope, behaviours = listOf(DataFeedInitBehaviour(server, skeleton)), ) + val bridge = SolarXRBridge( + id = 1, + context = context, + appContext = appContext, + dataFeedDispatcher = EventDispatcher(), + rpcDispatcher = EventDispatcher(), + ) + bridge.startObserving() bridge.outbound.on { _ -> onSend(ByteArray(0)) } return bridge } @@ -101,4 +121,4 @@ class DataFeedTest { } // TODO: need more tests for the content of a datafeed + check if the masks work -} \ No newline at end of file +} 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 f8a9c0b110..126954e013 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -2,10 +2,13 @@ package dev.slimevr.desktop +import dev.slimevr.AppContext +import dev.slimevr.Phase1Context import dev.slimevr.VRServer import dev.slimevr.config.AppConfig import dev.slimevr.desktop.hid.createDesktopHIDManager import dev.slimevr.desktop.ipc.createIpcServers +import dev.slimevr.desktop.ipc.createSolarXRWebsocketServer import dev.slimevr.desktop.serial.createDesktopSerialServer import dev.slimevr.desktop.vrchat.createDesktopVRCConfigManager import dev.slimevr.firmware.FirmwareManager @@ -13,16 +16,6 @@ import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resolveConfigDirectory import dev.slimevr.skeleton.Skeleton -import dev.slimevr.solarxr.AssignTrackerBehaviour -import dev.slimevr.solarxr.DataFeedInitBehaviour -import dev.slimevr.solarxr.FirmwareBehaviour -import dev.slimevr.solarxr.HeightCalibrationBehaviour -import dev.slimevr.solarxr.ProvisioningBehaviour -import dev.slimevr.solarxr.SerialBehaviour -import dev.slimevr.solarxr.SkeletonBehaviour -import dev.slimevr.solarxr.TrackingChecklistBehaviour -import dev.slimevr.solarxr.VrcBehaviour -import dev.slimevr.desktop.ipc.createSolarXRWebsocketServer import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.createUDPTrackerServer import kotlinx.coroutines.launch @@ -32,42 +25,35 @@ fun main(args: Array) = runBlocking { val configFolder = resolveConfigDirectory() ?: error("Unable to resolve config folder") val config = AppConfig.create(this, configFolder = configFolder.toFile()) val server = VRServer.create(this) - val serialServer = createDesktopSerialServer(this) - val firmwareManager = FirmwareManager.create(serialServer = serialServer, scope = this) - val vrcConfigManager = createDesktopVRCConfigManager(config = config, scope = this) - val heightCalibrationManager = HeightCalibrationManager.create(serverContext = server, userConfig = config.userConfig, scope = this) - val provisioningManager = ProvisioningManager.create(serialServer, this) + val phase1 = Phase1Context(server = server, config = config, serialServer = serialServer) - val skeleton = Skeleton.create(this, config.userConfig) - val trackingChecklist = TrackingChecklist.create( - scope = this, + val firmwareManager = FirmwareManager.create(ctx = phase1, scope = this) + val vrcConfigManager = createDesktopVRCConfigManager(ctx = phase1, scope = this) + val skeleton = Skeleton.create(scope = this, ctx = phase1) + val provisioningManager = ProvisioningManager.create(ctx = phase1, scope = this) + val heightCalibrationManager = HeightCalibrationManager.create(ctx = phase1, scope = this) + val trackingChecklist = TrackingChecklist.create(scope = this) + + val appContext = AppContext( server = server, + config = config, + serialServer = serialServer, skeleton = skeleton, + firmwareManager = firmwareManager, vrcConfigManager = vrcConfigManager, + provisioningManager = provisioningManager, + heightCalibrationManager = heightCalibrationManager, + trackingChecklist = trackingChecklist, ) - launch { - createUDPTrackerServer(server, config) - } - launch { - createDesktopHIDManager(server, config.settings, this) - } + appContext.startObserving() - val solarXRBehaviours = listOf( - DataFeedInitBehaviour(server, skeleton), - SerialBehaviour(serialServer), - FirmwareBehaviour(server, firmwareManager), - VrcBehaviour(vrcConfigManager, server, userHeight = { skeleton.context.state.value.userHeight }), - HeightCalibrationBehaviour(heightCalibrationManager), - ProvisioningBehaviour(server, provisioningManager), - SkeletonBehaviour(config.userConfig, skeleton), - TrackingChecklistBehaviour(trackingChecklist, config.settings), - AssignTrackerBehaviour(server), - ) - launch { createSolarXRWebsocketServer(server, solarXRBehaviours) } - launch { createIpcServers(server, config.settings, solarXRBehaviours) } + launch { createUDPTrackerServer(appContext) } + launch { createDesktopHIDManager(appContext, this) } + launch { createSolarXRWebsocketServer(appContext) } + launch { createIpcServers(appContext) } Unit } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index d7a4c915b5..1fb923fe8d 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -1,8 +1,7 @@ package dev.slimevr.desktop.hid +import dev.slimevr.AppContextProvider import dev.slimevr.AppLogger -import dev.slimevr.VRServer -import dev.slimevr.config.Settings import dev.slimevr.device.DeviceActions import dev.slimevr.hid.HIDReceiver import kotlinx.coroutines.CoroutineScope @@ -52,7 +51,7 @@ private fun enumerateCompatibleDevices(): Map { private data class ActiveReceiver(val job: Job, val receiver: HIDReceiver) -fun createDesktopHIDManager(serverContext: VRServer, settings: Settings, scope: CoroutineScope) { +fun createDesktopHIDManager(appContext: AppContextProvider, scope: CoroutineScope) { val active = mutableMapOf() scope.launch { @@ -92,8 +91,7 @@ fun createDesktopHIDManager(serverContext: VRServer, settings: Settings, scope: val receiver = HIDReceiver.create( serialNumber = serial, - serverContext = serverContext, - settings = settings, + appContext = appContext, scope = deviceScope, ) @@ -120,7 +118,7 @@ fun createDesktopHIDManager(serverContext: VRServer, settings: Settings, scope: withContext(NonCancellable + Dispatchers.IO) { hidDevice.close() } withContext(NonCancellable) { for (record in receiver.context.state.value.trackers.values) { - serverContext.getDevice(record.deviceId)?.context?.dispatch( + appContext.server.getDevice(record.deviceId)?.context?.dispatch( DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, ) } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt index f4f282d5c6..4bd23d7eec 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt @@ -1,10 +1,8 @@ package dev.slimevr.desktop.ipc +import dev.slimevr.AppContextProvider import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform -import dev.slimevr.VRServer -import dev.slimevr.config.Settings -import dev.slimevr.solarxr.SolarXRBridgeBehaviour import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch @@ -16,18 +14,18 @@ const val DRIVER_PIPE = "\\\\.\\pipe\\SlimeVRDriver" const val FEEDER_PIPE = "\\\\.\\pipe\\SlimeVRInput" const val SOLARXR_PIPE = "\\\\.\\pipe\\SlimeVRRpc" -suspend fun createIpcServers(server: VRServer, settings: Settings, behaviours: List) = coroutineScope { +suspend fun createIpcServers(appContext: AppContextProvider) = coroutineScope { when (CURRENT_PLATFORM) { Platform.LINUX, Platform.OSX -> { - launch { createUnixDriverSocket(server) } - launch { createUnixFeederSocket(server, settings) } - launch { createUnixSolarXRSocket(server, behaviours) } + launch { createUnixDriverSocket(appContext) } + launch { createUnixFeederSocket(appContext) } + launch { createUnixSolarXRSocket(appContext) } } Platform.WINDOWS -> { - launch { createWindowsDriverPipe(server) } - launch { createWindowsFeederPipe(server, settings) } - launch { createWindowsSolarXRPipe(server, behaviours) } + launch { createWindowsDriverPipe(appContext) } + launch { createWindowsFeederPipe(appContext) } + launch { createWindowsSolarXRPipe(appContext) } } else -> Unit diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index 8640ff4878..6794412d6d 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -1,9 +1,7 @@ package dev.slimevr.desktop.ipc -import dev.slimevr.VRServer -import dev.slimevr.config.Settings +import dev.slimevr.AppContextProvider import dev.slimevr.getSocketDirectory -import dev.slimevr.solarxr.SolarXRBridgeBehaviour import dev.slimevr.solarxr.handleSolarXRBridge import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow @@ -19,29 +17,27 @@ import java.nio.channels.ServerSocketChannel import java.nio.channels.SocketChannel import kotlin.io.path.Path -suspend fun createUnixDriverSocket(server: VRServer) = acceptUnixClients(DRIVER_SOCKET_NAME) { channel -> +suspend fun createUnixDriverSocket(appContext: AppContextProvider) = acceptUnixClients(DRIVER_SOCKET_NAME) { channel -> handleDriverConnection( - server = server, + appContext = appContext, messages = readFramedMessages(channel), send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, ) } -suspend fun createUnixFeederSocket(server: VRServer, settings: Settings) = acceptUnixClients(FEEDER_SOCKET_NAME) { channel -> +suspend fun createUnixFeederSocket(appContext: AppContextProvider) = acceptUnixClients(FEEDER_SOCKET_NAME) { channel -> handleFeederConnection( - server = server, - settings = settings, + appContext = appContext, messages = readFramedMessages(channel), send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, ) } -suspend fun createUnixSolarXRSocket(server: VRServer, behaviours: List) = acceptUnixClients(SOLARXR_SOCKET_NAME) { channel -> +suspend fun createUnixSolarXRSocket(appContext: AppContextProvider) = acceptUnixClients(SOLARXR_SOCKET_NAME) { channel -> handleSolarXRBridge( - server = server, + appContext = appContext, messages = readFramedMessages(channel), - send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, - behaviours = behaviours, + send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } } ) } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 6672ef25af..7e2c9d5c41 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -1,7 +1,6 @@ package dev.slimevr.desktop.ipc -import dev.slimevr.VRServer -import dev.slimevr.config.Settings +import dev.slimevr.AppContextProvider import dev.slimevr.driver.DriverBridge import dev.slimevr.driver.DriverBridgeInbound import dev.slimevr.driver.DriverBridgeOutbound @@ -20,7 +19,7 @@ import kotlinx.coroutines.sync.withLock const val PROTOCOL_VERSION = 5 suspend fun handleDriverConnection( - server: VRServer, + appContext: AppContextProvider, messages: Flow, send: suspend (ByteArray) -> Unit, ) = coroutineScope { @@ -30,7 +29,7 @@ suspend fun handleDriverConnection( send(ProtobufMessage.ADAPTER.encode(msg)) } - val bridge = DriverBridge.create(id = server.nextHandle(), serverContext = server, scope = this) + val bridge = DriverBridge.create(id = appContext.server.nextHandle(), appContext = appContext, scope = this) bridge.outbound.on { event -> sendMsg(ProtobufMessage(tracker_added = TrackerAdded(tracker_id = event.trackerId, tracker_serial = event.serial, tracker_name = event.name))) @@ -57,12 +56,11 @@ suspend fun handleDriverConnection( } suspend fun handleFeederConnection( - server: VRServer, - settings: Settings, + appContext: AppContextProvider, messages: Flow, send: suspend (ByteArray) -> Unit, ) = coroutineScope { - val bridge = FeederBridge.create(id = server.nextHandle(), serverContext = server, settings = settings, scope = this) + val bridge = FeederBridge.create(id = appContext.server.nextHandle(), appContext = appContext, scope = this) send(ProtobufMessage.ADAPTER.encode(ProtobufMessage(version = Version(protocol_version = PROTOCOL_VERSION)))) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt index 5cc2ae45c3..77d932c833 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt @@ -6,9 +6,7 @@ import com.sun.jna.platform.win32.WinBase import com.sun.jna.platform.win32.WinError import com.sun.jna.platform.win32.WinNT import com.sun.jna.ptr.IntByReference -import dev.slimevr.VRServer -import dev.slimevr.config.Settings -import dev.slimevr.solarxr.SolarXRBridgeBehaviour +import dev.slimevr.AppContextProvider import dev.slimevr.solarxr.handleSolarXRBridge import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow @@ -22,29 +20,27 @@ import java.nio.ByteOrder private val k32 = Kernel32.INSTANCE private val adv32 = Advapi32.INSTANCE -suspend fun createWindowsDriverPipe(server: VRServer) = acceptWindowsClients(DRIVER_PIPE) { handle -> +suspend fun createWindowsDriverPipe(appContext: AppContextProvider) = acceptWindowsClients(DRIVER_PIPE) { handle -> handleDriverConnection( - server = server, + appContext = appContext, messages = readFramedMessages(handle), send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, ) } -suspend fun createWindowsFeederPipe(server: VRServer, settings: Settings) = acceptWindowsClients(FEEDER_PIPE) { handle -> +suspend fun createWindowsFeederPipe(appContext: AppContextProvider) = acceptWindowsClients(FEEDER_PIPE) { handle -> handleFeederConnection( - server = server, - settings = settings, + appContext = appContext, messages = readFramedMessages(handle), send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, ) } -suspend fun createWindowsSolarXRPipe(server: VRServer, behaviours: List) = acceptWindowsClients(SOLARXR_PIPE) { handle -> +suspend fun createWindowsSolarXRPipe(appContext: AppContextProvider) = acceptWindowsClients(SOLARXR_PIPE) { handle -> handleSolarXRBridge( - server = server, + appContext = appContext, messages = readFramedMessages(handle), - send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, - behaviours = behaviours, + send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } } ) } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ws-server.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ws-server.kt index 272d8fbec4..f022389356 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ws-server.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ws-server.kt @@ -1,8 +1,7 @@ package dev.slimevr.desktop.ipc +import dev.slimevr.AppContextProvider import dev.slimevr.AppLogger -import dev.slimevr.VRServer -import dev.slimevr.solarxr.SolarXRBridgeBehaviour import dev.slimevr.solarxr.handleSolarXRBridge import io.ktor.server.application.install import io.ktor.server.engine.embeddedServer @@ -16,7 +15,7 @@ import kotlinx.coroutines.flow.flow const val SOLARXR_PORT = 21110 -suspend fun createSolarXRWebsocketServer(server: VRServer, behaviours: List) { +suspend fun createSolarXRWebsocketServer(appContext: AppContextProvider) { val engine = embeddedServer(Netty, port = SOLARXR_PORT) { install(WebSockets) @@ -24,7 +23,7 @@ suspend fun createSolarXRWebsocketServer(server: VRServer, behaviours: List send(Frame.Binary(fin = true, data = bytes)) }, - behaviours = behaviours, ) } } @@ -46,4 +44,4 @@ suspend fun createSolarXRWebsocketServer(server: VRServer, behaviours: List VRCConfigManager.create( - config = config, + config = ctx.config, scope = scope, isSupported = true, values = windowsVRCConfigFlow(), ) Platform.LINUX -> VRCConfigManager.create( - config = config, + config = ctx.config, scope = scope, isSupported = true, values = linuxVRCConfigFlow(), ) else -> VRCConfigManager.create( - config = config, + config = ctx.config, scope = scope, isSupported = false, values = emptyFlow(), From 94e34ca3af32fca8ad489bdc7836fbe2f2fa1c3f Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 15 Apr 2026 03:43:34 +0200 Subject: [PATCH 063/350] Fix steps id --- .../src/main/java/dev/slimevr/solarxr/trackingchecklist.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt b/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt index 3fddce7d56..e8ae71f8df 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt @@ -23,9 +23,9 @@ class TrackingChecklistBehaviour( .toSet() private fun buildResponse(): TrackingChecklistResponse { - val state = checklist.context.state.value + val steps = checklist.context.state.value.steps.mapValues { it.value.copy(id = it.key) }.values.toList() return TrackingChecklistResponse( - steps = state.steps.values.toList(), + steps = steps, ignoredSteps = parseMutedSteps().toList(), ) } From 984c98fd5bcc0f3db0b5bb81d859df33d50364b7 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 15 Apr 2026 09:35:43 +0200 Subject: [PATCH 064/350] Cleanup of tests -> update readme in consequence --- server/README.md | 251 +++++++++++++++--- .../src/test/java/dev/slimevr/TestServer.kt | 21 +- .../java/dev/slimevr/solarxr/DataFeedTest.kt | 12 +- 3 files changed, 232 insertions(+), 52 deletions(-) diff --git a/server/README.md b/server/README.md index 2978ebd48e..d145bbad01 100644 --- a/server/README.md +++ b/server/README.md @@ -30,21 +30,27 @@ The cost is that you cannot mutate state directly. Everything goes through `disp ## The Context -`Context` (`context/context.kt`) holds two things: the current state as a `StateFlow`, and the coroutine scope that defines this module's lifetime. +`Context` (`context/context.kt`) holds the current state as a `StateFlow`, the coroutine scope that defines this module's lifetime, and the behaviour list. ```kotlin -class Context( +class Context( val state: StateFlow, val scope: CoroutineScope, + val behaviours: CopyOnWriteArrayList>, ) { fun dispatch(action: A) fun dispatchAll(actions: List) + fun observeAll(receiver: C) } ``` `Context.create` takes an initial state and a list of behaviours. On each `dispatch`, it folds every behaviour's `reduce` over the current state in order and publishes the result. Behaviours that don't care about an action return the state unchanged. -`dispatchAll(actions)` applies a list of actions in one atomic `StateFlow.update`. Use it when you need multiple state changes to be visible as one intermediate states are never published, so no observer fires between them. +`dispatchAll(actions)` applies a list of actions in one atomic `StateFlow.update`. Use it when you need multiple state changes to be visible as one - intermediate states are never published, so no observer fires between them. + +`observeAll(receiver)` calls `observe(receiver)` on every behaviour in the list. This is how observation is wired after construction - see [Module Lifecycle](#module-lifecycle). + +The `behaviours` list is a `CopyOnWriteArrayList`. This allows adding behaviours after construction, which is needed for modules whose behaviour list depends on runtime context (e.g. `TrackingChecklist` adds step behaviours in `startObserving`). --- @@ -59,27 +65,27 @@ interface Behaviour { } ``` -`reduce` is a pure function it cannot launch coroutines, call external services, or read from a clock. It just maps `(state, action) → state`. +`reduce` is a pure function - it cannot launch coroutines, call external services, or read from a clock. It just maps `(state, action) → state`. -`observe` is called once at construction, after the context exists. This is where you launch coroutines, subscribe to flows, register event listeners. All coroutines launched here should use `receiver.context.scope` so they die when the module dies no manual cleanup, no leaks. +`observe` is called once after construction, after the context exists. This is where you launch coroutines, subscribe to flows, register event listeners. All coroutines launched here should use `receiver.context.scope` so they die when the module dies - no manual cleanup, no leaks. **Why behaviours instead of methods on the module class?** -The natural alternative is one class with methods for each feature ping handling, handshake logic, sensor registration, timeout detection, all in the same file. That works until the class has 20 features and every change has global blast radius. Behaviours make the feature list explicit: you read `create()`, you see exactly what the module does. Adding or removing a feature is one line in that list, touching nothing else. +The natural alternative is one class with methods for each feature - ping handling, handshake logic, sensor registration, timeout detection, all in the same file. That works until the class has 20 features and every change has global blast radius. Behaviours make the feature list explicit: you read `create()`, you see exactly what the module does. Adding or removing a feature is one line in that list, touching nothing else. **Why `observe` is separate from `reduce`:** -`reduce` must stay pure so state transitions are testable and predictable. Side effects in `observe` are isolated a behaviour that logs packets doesn't affect state, a behaviour that saves config doesn't affect packet handling. +`reduce` must stay pure so state transitions are testable and predictable. Side effects in `observe` are isolated - a behaviour that logs packets doesn't affect state, a behaviour that saves config doesn't affect packet handling. ### Scoping Behaviours to Module Lifetime Every behaviour's coroutines run inside `receiver.context.scope`. When a UDP connection drops, its scope is cancelled, which cancels every coroutine every behaviour launched for that connection. No teardown code needed anywhere. -This is why `observe` receives the module (or context) rather than taking a raw `CoroutineScope` parameter it enforces that behaviours cannot outlive their module. +This is why `observe` receives the module (or context) rather than taking a raw `CoroutineScope` parameter - it enforces that behaviours cannot outlive their module. ### Stateless vs. Stateful Behaviours -Behaviours with no constructor dependencies are `object`s singletons, zero allocation: +Behaviours with no constructor dependencies are `object`s - singletons, zero allocation: ```kotlin object PacketBehaviour : UDPConnectionBehaviour { ... } @@ -103,6 +109,20 @@ val behaviours: List = buildList { } ``` +For modules like `SolarXRBridge` where the behaviour list is large and reusable, extract it into a named function on the companion object: + +```kotlin +companion object { + fun buildBehaviours(appContext: AppContextProvider): List = buildList { + add(DataFeedInitBehaviour(appContext.server, appContext.skeleton)) + add(FirmwareBehaviour(appContext.server, appContext.firmwareManager)) + // ... + } +} +``` + +This lets tests call `buildBehaviours` with a stub context, or ignore it entirely and construct the module directly with only the behaviours they need. + ### The Receiver Type The type parameter `C` controls what `observe` receives. For simple modules where behaviours only need to dispatch or subscribe to state, `C` is the context itself. For modules where behaviours also need to call methods (send bytes, emit events), `C` is the module class: @@ -123,16 +143,97 @@ Behaviours live in a `behaviours.kt` file in the same package as the module, sep tracker/ ├── module.kt ← Tracker class, TrackerState, TrackerActions, typealiases ├── behaviours.kt ← TrackerBasicBehaviour, TrackerTPSBehaviour, ... -└── config.kt ← TrackerConfigBehaviour (its own file distinct concern) +└── config.kt ← TrackerConfigBehaviour (its own file - distinct concern) +``` + +`module.kt` is the standard name for the file containing the module class, state, actions, and typealiases. `behaviours.kt` holds the implementations. For modules with many distinct feature areas, split behaviours by feature instead of one file - `solarxr/` uses this: `datafeed.kt`, `firmware.kt`, `provisioning.kt`, etc. each live alongside `module.kt`. + +--- + +## Module Lifecycle + +Every module has a two-step lifecycle: **construction** then **observation wiring**. + +```kotlin +// Step 1: create the module (pure construction, no side effects) +val manager = FirmwareManager.create(ctx = phase1, scope = scope) + +// Step 2: wire observation (launches coroutines, subscribes to flows) +manager.startObserving() +``` + +`create()` builds the context with its behaviour list and constructs the module. No coroutines are launched, no flows are subscribed. The module is inert. + +`startObserving()` calls `context.observeAll(this)`, which calls `observe(receiver)` on every behaviour. This is where all side effects begin. + +**Why separate construction from observation?** + +Some modules need to exist before all their dependencies are assembled. For example, `TrackingChecklist` is constructed before `AppContext` exists, but its step behaviours depend on `Skeleton` and `VRCConfigManager` which are part of `AppContext`. By deferring observation, the module can be constructed in any order and wired later: + +```kotlin +// Constructed early - no AppContext needed yet +val trackingChecklist = TrackingChecklist.create(scope = this) + +// AppContext assembled from all modules +val appContext = AppContext(server, config, serialServer, skeleton, ..., trackingChecklist) + +// Observation wired now - TrackingChecklist adds its step behaviours using AppContext +appContext.startObserving() ``` -`module.kt` is the standard name for the file containing the module class, state, actions, and typealiases. `behaviours.kt` holds the implementations. For modules with many distinct feature areas, split behaviours by feature instead of one file `solarxr/` uses this: `datafeed.kt`, `firmware.kt`, `provisioning.kt`, etc. each live alongside `module.kt`. +`AppContext.startObserving()` calls `startObserving()` on every bootstrap module in sequence, and `trackingChecklist.startObserving(appContext)` adds its behaviours to `context.behaviours` before calling `context.observeAll(this)`. + +**Runtime modules** (UDP connections, SolarXR bridges, HID receivers) are created and immediately observed - they have all their dependencies at creation time: + +```kotlin +val bridge = SolarXRBridge.create(id = id, appContext = appContext, scope = scope) +// startObserving() is called inside create() +``` + +--- + +## Dependency Injection + +The server uses a typed context hierarchy for dependency injection. Contexts are interfaces - modules declare the minimum phase they need in `create()`, which gives compile-time guarantees that their dependencies exist. + +``` +Phase1ContextProvider +└── AppContextProvider +``` + +**`Phase1ContextProvider`** - the bootstrap phase. Available before `AppContext` is assembled: + +```kotlin +interface Phase1ContextProvider { + val server: VRServer + val config: AppConfig + val serialServer: SerialServer +} +``` + +**`AppContextProvider`** - the full application context. Available after all bootstrap modules are constructed: + +```kotlin +interface AppContextProvider : Phase1ContextProvider { + val skeleton: Skeleton + val firmwareManager: FirmwareManager + val vrcConfigManager: VRCConfigManager? + val provisioningManager: ProvisioningManager + val heightCalibrationManager: HeightCalibrationManager + val trackingChecklist: TrackingChecklist + fun startObserving() +} +``` + +**The rule:** always use `AppContextProvider` unless the module genuinely cannot be constructed after `AppContext` is assembled. In practice, only bootstrap modules (`FirmwareManager`, `Skeleton`, `ProvisioningManager`, `HeightCalibrationManager`) use `Phase1ContextProvider` in `create()`. Runtime modules like `SolarXRBridge` always receive `AppContextProvider`. + +The concrete classes `Phase1Context` and `AppContext` implement these interfaces. Tests implement the interfaces directly with only the fields they need - see [Testing](#testing). --- ## Actions -Actions are `sealed interface`s. This matters because the compiler enforces exhaustive `when` in reducers you cannot forget to handle a new action type. Named actions also mean you can grep for all places a specific transition can occur. +Actions are `sealed interface`s. This matters because the compiler enforces exhaustive `when` in reducers - you cannot forget to handle a new action type. Named actions also mean you can grep for all places a specific transition can occur. ```kotlin sealed interface UDPConnectionActions { @@ -148,10 +249,10 @@ The choice between a flexible `Update` action and a named action is about who el - **Named action** when other behaviours need to react to this specific event. `Handshake` in `UDPConnectionActions` signals to multiple behaviours that a connection is now established; each one matches it independently. ```kotlin -// Nothing else needs to react use Update +// Nothing else needs to react - use Update tracker.context.dispatch(TrackerActions.Update { copy(tps = newTps) }) -// Multiple behaviours react to this use a named action +// Multiple behaviours react to this - use a named action connection.context.dispatch(UDPConnectionActions.Handshake(deviceId = id)) ``` @@ -170,11 +271,11 @@ receiver.packetEvents.onAny { packet -> ... } // every packet **Why not a central `when` block?** -A central switch means every new packet type requires editing the dispatch hub. With `EventDispatcher`, each behaviour owns its subscription. Adding a new packet type means adding a class to `packets.kt` and a listener in a behaviour the routing hub never changes. +A central switch means every new packet type requires editing the dispatch hub. With `EventDispatcher`, each behaviour owns its subscription. Adding a new packet type means adding a class to `packets.kt` and a listener in a behaviour - the routing hub never changes. It also means behaviours are self-contained: `SensorInfoBehaviour` is the only place that knows what to do with `SensorInfo`. No shared routing code. -`EventDispatcher` dispatches by the runtime type of the event by default. When events are wrapped (e.g. `PacketEvent` where the actual packet is the inner value), pass a `keyOf` lambda to tell the dispatcher which type to route by otherwise all events would be bucketed under `PacketEvent` regardless of the inner packet type. +`EventDispatcher` dispatches by the runtime type of the event by default. When events are wrapped (e.g. `PacketEvent` where the actual packet is the inner value), pass a `keyOf` lambda to tell the dispatcher which type to route by - otherwise all events would be bucketed under `PacketEvent` regardless of the inner packet type. --- @@ -182,11 +283,11 @@ It also means behaviours are self-contained: `SensorInfoBehaviour` is the only p Every module receives a `CoroutineScope` at creation. That scope defines when the module lives and dies: -- Cancelling the scope cancels all coroutines all behaviours launched inside it +- Cancelling the scope cancels all coroutines - all behaviours launched inside it - No `close()`, `stop()`, or `dispose()` methods needed anywhere -- A disconnected client, a dropped UDP connection, a closed serial port all cleaned up by scope cancellation +- A disconnected client, a dropped UDP connection, a closed serial port - all cleaned up by scope cancellation -Blocking I/O goes on `Dispatchers.IO`. State updates and logic stay on the default dispatcher. Never use `runBlocking` inside an observer it blocks the coroutine thread pool. +Blocking I/O goes on `Dispatchers.IO`. State updates and logic stay on the default dispatcher. Never use `runBlocking` inside an observer - it blocks the coroutine thread pool. --- @@ -194,7 +295,7 @@ Blocking I/O goes on `Dispatchers.IO`. State updates and logic stay on the defau Not everything needs to be in a `StateFlow`. The rule: **put data in state only if other code needs to react to it changing**. -- `VRServer.handleCounter` is an `AtomicInt` nothing reacts to it, so a dispatch round-trip would be waste. +- `VRServer.handleCounter` is an `AtomicInt` - nothing reacts to it, so a dispatch round-trip would be waste. - `UDPTrackerServer` has no `Context` at all. Its connection map is a `MutableMap` internal to the server loop. Nothing outside reads it. - Tracker config *is* in state because the SolarXR layer and the config autosave both react to body part assignments changing. @@ -221,14 +322,14 @@ receiver.context.state **Dispatch into VRServer from a connection:** ```kotlin -// Inside HandshakeBehaviour.observe() a UDP connection registers a new device +// Inside HandshakeBehaviour.observe() - a UDP connection registers a new device receiver.packetEvents.on { event -> val device = Device.create(...) receiver.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId, device)) } ``` -The key rule: behaviours own the subscription lifetime via `launchIn(receiver.context.scope)`. When the module dies, the subscription stops no dangling listeners in foreign modules. +The key rule: behaviours own the subscription lifetime via `launchIn(receiver.context.scope)`. When the module dies, the subscription stops - no dangling listeners in foreign modules. --- @@ -261,21 +362,85 @@ All three named sockets use the same framing: a **LE u32 length** prefix (includ The UDP receive loop runs on `Dispatchers.IO`. It reads a datagram, parses the packet type and payload via `readPacket`, wraps the result in a `PacketEvent`, and pushes it into a `Channel>` per connection. -A dedicated coroutine per connection drains the channel and calls `EventDispatcher.emit`. Pre-handshake packets are filtered at the channel drain step a single guard in one place, not spread across every behaviour. +A dedicated coroutine per connection drains the channel and calls `EventDispatcher.emit`. Pre-handshake packets are filtered at the channel drain step - a single guard in one place, not spread across every behaviour. --- ## Config -Config is not just file I/O it is a live state machine. The server supports runtime profile switching, so config data lives in a `StateFlow` like everything else. Code that needs to react to config changes (e.g. tracker body part assignment saving to disk) subscribes to the settings state flow. +Config is not just file I/O - it is a live state machine. The server supports runtime profile switching, so config data lives in a `StateFlow` like everything else. Code that needs to react to config changes (e.g. tracker body part assignment saving to disk) subscribes to the settings state flow. The config system has three layers: -- **`AppConfig`** global state: which user profile and settings profile are active -- **`UserConfig`** per-user data (body proportions, etc.) -- **`Settings`** per-profile settings (tracker assignments, port, VRC warnings); persists to disk +- **`AppConfig`** - global state: which user profile and settings profile are active +- **`UserConfig`** - per-user data (body proportions, etc.) +- **`Settings`** - per-profile settings (tracker assignments, port, VRC warnings); persists to disk -Each is a full module with its own `Context`, behaviours, and JSON autosave. Migrations live in the module's `parseAndMigrate` function add a `version < N` branch there. +Each is a full module with its own `Context`, behaviours, and JSON autosave. Migrations live in the module's `parseAndMigrate` function - add a `version < N` branch there. + +--- + +## Testing + +The module pattern is designed to be testable at each layer independently. + +### Testing reducers + +Create a context directly with the behaviours you want to test. No module class, no scope tricks: + +```kotlin +val context = Context.create( + initialState = FirmwareManagerState(jobs = mapOf()), + scope = this, + behaviours = listOf(FirmwareManagerBaseBehaviour), +) +context.dispatch(FirmwareManagerActions.UpdateJob(...)) +assertEquals(FirmwareUpdateStatus.UPLOADING, context.state.value.jobs["COM1"]?.status) +``` + +### Testing observe behaviour + +Construct the module directly - bypassing `create()` - with only the behaviours relevant to the test. This avoids constructing unrelated dependencies: + +```kotlin +val serialServer = buildTestSerialServer(backgroundScope) +val context = Context.create( + initialState = ProvisioningManager.INITIAL_STATE, + scope = backgroundScope, + behaviours = listOf(ProvisioningManagerBaseBehaviour), +) +val manager = ProvisioningManager(context = context, serialServer = serialServer, scope = backgroundScope) +manager.startObserving() + +manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) +``` + +For modules that need an `AppContextProvider` (e.g. `SolarXRBridge`), extend `TestAppContext` and override only what the test uses. Every other field defaults to `error("not used in test")`: + +```kotlin +val server = buildTestVrServer(backgroundScope) +val skeleton = buildTestSkeleton(backgroundScope) +val appContext = object : TestAppContext() { + override val server = server + override val skeleton = skeleton +} +val context = Context.create( + initialState = SolarXRBridgeState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), + scope = backgroundScope, + behaviours = listOf(DataFeedInitBehaviour(server, skeleton)), +) +val bridge = SolarXRBridge(id = 1, context = context, appContext = appContext, + dataFeedDispatcher = EventDispatcher(), rpcDispatcher = EventDispatcher()) +bridge.startObserving() +``` + +`TestAppContext` lives in `TestServer.kt`. When a new module is added to `AppContextProvider`, add a default stub there - all existing tests automatically get the default without changes. + +**The pattern:** only initialize what the test exercises. If a behaviour accesses a field that isn't overridden, it throws - that's intentional. It keeps tests focused and stubs minimal. + +### Test helpers (`TestServer.kt`) + +`buildTestVrServer`, `buildTestSerialServer`, `buildTestUserConfig`, and `buildTestSkeleton` are available for constructing real lightweight instances of common dependencies. --- @@ -285,7 +450,7 @@ Each is a full module with its own `Context`, behaviours, and JSON autosave. Mig |---|---| | `server/core` | Protocol-agnostic business logic | | `server/desktop` | Platform entry point, IPC wiring, HID, serial flash | -| `context/context.kt` | `Context` and `Behaviour` primitives no domain logic here | +| `context/context.kt` | `Context` and `Behaviour` primitives - no domain logic here | | `udp/` | UDP wire protocol: connection state, packets, behaviours, server loop | | `hid/` | HID tracker receiver: registration, rotation, battery, status | | `solarxr/` | `SolarXRBridge` + per-feature behaviour files (one per RPC area) | @@ -314,26 +479,31 @@ typealias MyContext = Context typealias MyBehaviour = Behaviour ``` -2. **Define the module class** (holds context + anything behaviours need to call): +2. **Define the module class** with a `create()` factory and a `startObserving()` method: ```kotlin class MyModule(val context: MyContext, val server: VRServer) { + fun startObserving() = context.observeAll(this) + companion object { - fun create(scope: CoroutineScope, server: VRServer): MyModule { - val behaviours = listOf(MyCoreBehaviour, ...) - val context = Context.create(initialState = MyState(false), scope = scope, behaviours = behaviours) - val module = MyModule(context, server) - behaviours.forEach { it.observe(module) } - return module + fun create(ctx: Phase1ContextProvider, scope: CoroutineScope): MyModule { + val context = Context.create( + initialState = MyState(false), + scope = scope, + behaviours = listOf(MyCoreBehaviour), + ) + return MyModule(context, ctx.server) } } } ``` -3. **Write behaviours** in `behaviours.kt` (or per-feature files for large modules). +3. **Register in `AppContext`** and call `startObserving()` inside `AppContext.startObserving()`. + +4. **Write behaviours** in `behaviours.kt` (or per-feature files for large modules). ### Adding a Behaviour to an Existing Module -Add it to the `behaviours` list in `create()`. The behaviour's `reduce` and `observe` are automatically wired in. Nothing else changes. +Add it to the `behaviours` list in `create()`. The behaviour's `reduce` and `observe` are automatically wired when `startObserving()` is called. Nothing else changes. ### Adding a New UDP Packet Type @@ -352,7 +522,8 @@ receiver.packetEvents.on { event -> - **Prefer plain functions over classes.** A single-method interface should almost always be a function type (`() -> Unit`, `suspend (ByteArray) -> Unit`). A class with no mutable state should be a plain function or `object`. When in doubt, write a function. - **Prefer plain functions over extension functions.** Only use extensions when the receiver type is genuinely the primary subject. -- **State data classes are all `val`.** Mutable fields in a state class are a design mistake use `var` in a plain class if you need local mutability, never in state. -- **Use `sealed interface` for actions**, not `sealed class` no constructor overhead. +- **State data classes are all `val`.** Mutable fields in a state class are a design mistake - use `var` in a plain class if you need local mutability, never in state. +- **Use `sealed interface` for actions**, not `sealed class` - no constructor overhead. - **Module creation lives in `companion object { fun create(...) }`.** +- **Module observation wiring lives in `startObserving()`**, called via `context.observeAll(this)`. - **Never expose `MutableStateFlow` directly.** Expose `StateFlow` via `context.state`. diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 2e0804f3e2..6585ff60f0 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -1,17 +1,23 @@ package dev.slimevr import dev.llelievr.espflashkotlin.FlasherSerialInterface +import dev.slimevr.config.AppConfig import dev.slimevr.config.DefaultUserBehaviour import dev.slimevr.config.UserConfig import dev.slimevr.config.UserConfigData import dev.slimevr.config.UserConfigState import dev.slimevr.context.Context +import dev.slimevr.firmware.FirmwareManager +import dev.slimevr.heightcalibration.HeightCalibrationManager +import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialServer -import dev.slimevr.skeleton.Skeleton import dev.slimevr.skeleton.DEFAULT_SKELETON_STATE -import dev.slimevr.skeleton.buildBones import dev.slimevr.skeleton.ProportionsBehaviour +import dev.slimevr.skeleton.Skeleton +import dev.slimevr.skeleton.buildBones +import dev.slimevr.trackingchecklist.TrackingChecklist +import dev.slimevr.vrchat.VRCConfigManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import java.nio.file.Files @@ -60,3 +66,14 @@ fun buildTestSkeleton(scope: CoroutineScope): Skeleton { skeleton.startObserving() return skeleton } + +abstract class TestAppContext : AppContextProvider { + override val config: AppConfig get() = error("not used in test") + override val serialServer: SerialServer get() = error("not used in test") + override val firmwareManager: FirmwareManager get() = error("not used in test") + override val vrcConfigManager: VRCConfigManager? = null + override val provisioningManager: ProvisioningManager get() = error("not used in test") + override val heightCalibrationManager: HeightCalibrationManager get() = error("not used in test") + override val trackingChecklist: TrackingChecklist get() = error("not used in test") + override fun startObserving() {} +} diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt index 4d416ceff2..65d0019a5b 100644 --- a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -1,7 +1,7 @@ package dev.slimevr.solarxr -import dev.slimevr.AppContextProvider import dev.slimevr.EventDispatcher +import dev.slimevr.TestAppContext import dev.slimevr.buildTestSkeleton import dev.slimevr.buildTestVrServer import dev.slimevr.context.Context @@ -18,17 +18,9 @@ import kotlin.test.assertEquals private fun testConn(backgroundScope: kotlinx.coroutines.CoroutineScope, onSend: suspend (ByteArray) -> Unit): SolarXRBridge { val server = buildTestVrServer(backgroundScope) val skeleton = buildTestSkeleton(backgroundScope) - val appContext = object : AppContextProvider { + val appContext = object : TestAppContext() { override val server = server override val skeleton = skeleton - override val config get() = error("not used") - override val serialServer get() = error("not used") - override val firmwareManager get() = error("not used") - override val vrcConfigManager = null - override val provisioningManager get() = error("not used") - override val heightCalibrationManager get() = error("not used") - override val trackingChecklist get() = error("not used") - override fun startObserving() {} } val context = Context.create( initialState = SolarXRBridgeState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), From 1185825b3e2a871588376035e2c78ed0e5559e15 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 15 Apr 2026 11:44:11 +0200 Subject: [PATCH 065/350] Atempt at mag code (untested, incomplete) --- .../src/main/java/dev/slimevr/app-context.kt | 4 + .../main/java/dev/slimevr/config/settings.kt | 1 + .../src/main/java/dev/slimevr/solarxr/mag.kt | 70 +++++++ .../main/java/dev/slimevr/solarxr/module.kt | 1 + .../main/java/dev/slimevr/tracker/config.kt | 11 + .../main/java/dev/slimevr/udp/behaviours.kt | 191 +++++++++++++----- .../main/java/dev/slimevr/udp/connection.kt | 12 +- .../src/main/java/dev/slimevr/udp/packets.kt | 16 +- .../src/main/java/dev/slimevr/udp/server.kt | 129 +++++++----- .../src/test/java/dev/slimevr/TestServer.kt | 2 + .../src/main/java/dev/slimevr/desktop/Main.kt | 5 +- 11 files changed, 337 insertions(+), 105 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/mag.kt diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index e634048d2e..03564300cd 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -7,6 +7,7 @@ import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.serial.SerialServer import dev.slimevr.skeleton.Skeleton import dev.slimevr.trackingchecklist.TrackingChecklist +import dev.slimevr.udp.UdpServer import dev.slimevr.vrchat.VRCConfigManager interface Phase1ContextProvider { @@ -28,6 +29,7 @@ interface AppContextProvider : Phase1ContextProvider { val provisioningManager: ProvisioningManager val heightCalibrationManager: HeightCalibrationManager val trackingChecklist: TrackingChecklist + val udpServer: UdpServer fun startObserving() } @@ -41,6 +43,7 @@ class AppContext( override val provisioningManager: ProvisioningManager, override val heightCalibrationManager: HeightCalibrationManager, override val trackingChecklist: TrackingChecklist, + override val udpServer: UdpServer, ) : AppContextProvider { override fun startObserving() { skeleton.startObserving() @@ -49,5 +52,6 @@ class AppContext( heightCalibrationManager.startObserving() vrcConfigManager?.startObserving() trackingChecklist.startObserving(this) + udpServer.startReceiving(this, server.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 16e2b8f06a..777860d4d2 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -37,6 +37,7 @@ data class TrackerConfig( val customName: String? = null, @Serializable(with = QuaternionSerializer::class) val mountingOrientation: Quaternion? = null, + val magEnabled: Boolean? = null, ) @Serializable diff --git a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt new file mode 100644 index 0000000000..58b89361f2 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt @@ -0,0 +1,70 @@ +package dev.slimevr.solarxr + +import dev.slimevr.AppContextProvider +import dev.slimevr.device.DeviceOrigin +import dev.slimevr.udp.SensorConfigFlags +import dev.slimevr.udp.UDPConnectionActions +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import solarxr_protocol.datatypes.DeviceId +import solarxr_protocol.datatypes.MagnetometerStatus +import solarxr_protocol.datatypes.TrackerId +import solarxr_protocol.rpc.ChangeMagToggleRequest +import solarxr_protocol.rpc.MagToggleResponse + +class MagBehaviour( + private val appContext: AppContextProvider, +) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { + val subscribedTrackers = mutableSetOf() + appContext.server.context.state + .distinctUntilChangedBy { it.trackers.keys } + .onEach { serverState -> + serverState.trackers.values.forEach { tracker -> + val trackerState = tracker.context.state.value + if (subscribedTrackers.add(trackerState.id)) { + val device = appContext.server.getDevice(trackerState.deviceId) ?: return@forEach + val trackerId = TrackerId( + trackerNum = trackerState.id.toUByte(), + deviceId = DeviceId(device.context.state.value.id.toUByte()), + ) + tracker.context.state + .distinctUntilChangedBy { it.magStatus } + .onEach { state -> + receiver.sendRpc( + MagToggleResponse(trackerId = trackerId, enable = state.magStatus == MagnetometerStatus.ENABLED), + ) + } + .launchIn(receiver.context.scope) + } + } + } + .launchIn(receiver.context.scope) + + receiver.rpcDispatcher.on { req -> + val trackerId = req.trackerId ?: return@on + val tracker = appContext.server.getTracker(trackerId.trackerNum.toInt()) ?: return@on + val trackerState = tracker.context.state.value + + if (trackerState.magStatus == MagnetometerStatus.NOT_SUPPORTED) return@on + + when (trackerState.origin) { + DeviceOrigin.UDP -> { + val connection = appContext.udpServer.findConnectionForDevice(trackerState.deviceId) ?: return@on + val sensorId = connection.context.state.value.trackerIds + .find { it.id == trackerState.id } + ?.trackerNum + ?: return@on + connection.context.dispatch( + UDPConnectionActions.SetSensorConfig(sensorId = sensorId, flags = SensorConfigFlags(magStatus = if (req.enable) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED)), + ) + } + DeviceOrigin.HID -> { + // TODO: implement HID mag toggle + } + else -> return@on + } + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 922a8440e5..2f45cbf821 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -72,6 +72,7 @@ class SolarXRBridge( add(SkeletonBehaviour(appContext.config.userConfig, appContext.skeleton)) add(TrackingChecklistBehaviour(appContext.trackingChecklist, appContext.config.settings)) add(AssignTrackerBehaviour(appContext.server)) + add(MagBehaviour(appContext)) } fun create( diff --git a/server/core/src/main/java/dev/slimevr/tracker/config.kt b/server/core/src/main/java/dev/slimevr/tracker/config.kt index 18f6f745d9..b886b76708 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/config.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/config.kt @@ -8,17 +8,28 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.MagnetometerStatus fun restoreFromConfig(state: TrackerState, config: TrackerConfig): TrackerState = state.copy( bodyPart = config.bodyPart?.takeIf { it != BodyPart.NONE } ?: state.bodyPart, customName = config.customName ?: state.customName, mountingOrientation = config.mountingOrientation ?: state.mountingOrientation, + magStatus = when (config.magEnabled) { + true -> MagnetometerStatus.ENABLED + false -> MagnetometerStatus.DISABLED + null -> MagnetometerStatus.NOT_SUPPORTED + } ) private fun stateToConfig(state: TrackerState) = TrackerConfig( bodyPart = state.bodyPart, customName = state.customName, mountingOrientation = state.mountingOrientation, + magEnabled = when (state.magStatus) { + MagnetometerStatus.DISABLED -> false + MagnetometerStatus.ENABLED -> true + MagnetometerStatus.NOT_SUPPORTED -> null + }, ) class TrackerConfigBehaviour( diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index a915859454..fe40c12978 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -8,7 +8,11 @@ import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import dev.slimevr.tracker.TrackerIdNum +import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import solarxr_protocol.datatypes.MagnetometerStatus @@ -16,6 +20,7 @@ import solarxr_protocol.datatypes.TrackerStatus import kotlin.random.Random internal const val CONNECTION_TIMEOUT_MS = 5000L +internal const val CONNECTION_REMOVAL_MS = 30_000L object PacketBehaviour : UDPConnectionBehaviour { override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { @@ -87,27 +92,42 @@ object PingBehaviour : UDPConnectionBehaviour { object HandshakeBehaviour : UDPConnectionBehaviour { override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { is UDPConnectionActions.Handshake -> state.copy(didHandshake = true, deviceId = action.deviceId) - is UDPConnectionActions.Disconnected -> state.copy(didHandshake = false) + is UDPConnectionActions.TimedOut -> state.copy(didHandshake = false) else -> state } + private fun findOrCreateDevice(receiver: UDPConnection, state: UDPConnectionState, data: Handshake): Device { + val devices = receiver.appContext.server.context.state.value.devices.values + val existing = data.macString?.let { mac -> + devices.find { device -> + val ds = device.context.state.value + ds.macAddress == mac && ds.origin == DeviceOrigin.UDP + } + } + if (existing != null) { + receiver.context.dispatch(UDPConnectionActions.Handshake(existing.context.state.value.id)) + return existing + } + val deviceId = receiver.appContext.server.nextHandle() + val newDevice = Device.create( + id = deviceId, + scope = receiver.appContext.server.context.scope, + address = state.address, + macAddress = data.macString, + origin = DeviceOrigin.UDP, + protocolVersion = data.protocolVersion, + ) + receiver.appContext.server.context.dispatch(VRServerActions.NewDevice(deviceId = deviceId, context = newDevice)) + receiver.context.dispatch(UDPConnectionActions.Handshake(deviceId)) + return newDevice + } + override fun observe(receiver: UDPConnection) { receiver.packetEvents.onPacket { packet -> val state = receiver.context.state.value val device = if (state.deviceId == null) { - val deviceId = receiver.appContext.server.nextHandle() - val newDevice = Device.create( - id = deviceId, - scope = receiver.appContext.server.context.scope, - address = state.address, - macAddress = packet.data.macString, - origin = DeviceOrigin.UDP, - protocolVersion = packet.data.protocolVersion, - ) - receiver.appContext.server.context.dispatch(VRServerActions.NewDevice(deviceId = deviceId, context = newDevice)) - receiver.context.dispatch(UDPConnectionActions.Handshake(deviceId)) - newDevice + findOrCreateDevice(receiver, state, packet.data) } else { receiver.context.dispatch(UDPConnectionActions.Handshake(state.deviceId)) receiver.getDevice() ?: run { @@ -147,12 +167,12 @@ object TimeoutBehaviour : UDPConnectionBehaviour { val timeUntilTimeout = CONNECTION_TIMEOUT_MS - (System.currentTimeMillis() - state.lastPacket) if (timeUntilTimeout <= 0) { AppLogger.udp.info("[${state.address}] Connection timed out for ${state.id}") - receiver.context.dispatch(UDPConnectionActions.Disconnected) + receiver.context.dispatch(UDPConnectionActions.TimedOut) receiver.getDevice()?.context?.dispatch( - DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, + DeviceActions.Update { copy(status = TrackerStatus.TIMED_OUT) }, ) state.trackerIds.mapNotNull { receiver.appContext.server.getTracker(it.id) }.forEach { tracker -> - tracker.context.dispatch(TrackerActions.Update { copy(status = TrackerStatus.DISCONNECTED) }) + tracker.context.dispatch(TrackerActions.Update { copy(status = TrackerStatus.TIMED_OUT) }) } } else { delay(timeUntilTimeout + 1) @@ -162,6 +182,34 @@ object TimeoutBehaviour : UDPConnectionBehaviour { } } +object DisconnectBehaviour : UDPConnectionBehaviour { + override fun observe(receiver: UDPConnection) { + var removalJob: Job? = null + receiver.context.state + .distinctUntilChangedBy { it.didHandshake } + .onEach { state -> + if (!state.didHandshake) { + removalJob = receiver.context.scope.launch { + delay(CONNECTION_REMOVAL_MS) + val currentState = receiver.context.state.value + AppLogger.udp.info("[${currentState.address}] Connection removed after extended timeout") + receiver.appContext.udpServer.context.dispatch(UdpServerActions.ConnectionRemoved(currentState.address)) + receiver.getDevice()?.context?.dispatch( + DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, + ) + currentState.trackerIds.mapNotNull { receiver.appContext.server.getTracker(it.id) }.forEach { tracker -> + tracker.context.dispatch(TrackerActions.Update { copy(status = TrackerStatus.DISCONNECTED) }) + } + } + } else { + removalJob?.cancel() + removalJob = null + } + } + .launchIn(receiver.context.scope) + } +} + object DeviceStatsBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { receiver.packetEvents.onPacket { event -> @@ -192,18 +240,22 @@ object SensorInfoBehaviour : UDPConnectionBehaviour { ?: error("invalid state - a device should exist at this point") val tracker = receiver.getTracker(event.data.sensorId) + val trackerState = tracker?.context?.state?.value + val sensorConfig = event.data.sensorConfig + + val completedRestCalibration = event.data.hasCompletedRestCalibration + ?: trackerState?.completedRestCalibration + ?: false + + val magStatus = sensorConfig?.let { + if (it.magSupported) { + if (it.magEnabled) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED + } else { + MagnetometerStatus.NOT_SUPPORTED + } + } ?: trackerState?.magStatus ?: MagnetometerStatus.NOT_SUPPORTED + val action = TrackerActions.Update { - val completedRestCalibration = event.data.hasCompletedRestCalibration - ?: tracker?.context?.state?.value?.completedRestCalibration - ?: false - - val magStatus = event.data.sensorConfig?.let { - if (it.magSupported) { - if (it.magEnabled) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED - } else { - MagnetometerStatus.NOT_SUPPORTED - } - } ?: tracker?.context?.state?.value?.magStatus ?: MagnetometerStatus.NOT_SUPPORTED copy( sensorType = event.data.imuType, status = event.data.status, @@ -221,27 +273,52 @@ object SensorInfoBehaviour : UDPConnectionBehaviour { deviceState.address } val hardwareId = "$mac:${event.data.sensorId}" - val trackerId = receiver.appContext.server.nextHandle() - val newTracker = Tracker.create( - id = trackerId, - hardwareId = hardwareId, - sensorType = event.data.imuType, - deviceId = deviceState.id, - origin = DeviceOrigin.UDP, - scope = receiver.appContext.server.context.scope, - server = receiver.appContext.server, - settings = receiver.appContext.config.settings, - ) - receiver.appContext.server.context.dispatch( - VRServerActions.NewTracker(trackerId = trackerId, context = newTracker), - ) - receiver.context.dispatch( - UDPConnectionActions.AssignTracker( - trackerId = TrackerIdNum(id = trackerId, trackerNum = event.data.sensorId), - ), - ) - newTracker.context.dispatch(action) + val existingTracker = receiver.appContext.server.context.state.value.trackers.values + .find { t -> t.context.state.value.deviceId == deviceState.id && t.context.state.value.hardwareId == hardwareId } + + val assignedTracker = if (existingTracker != null) { + receiver.context.dispatch( + UDPConnectionActions.AssignTracker( + trackerId = TrackerIdNum(id = existingTracker.context.state.value.id, trackerNum = event.data.sensorId), + ), + ) + existingTracker.context.dispatch(action) + existingTracker + } else { + val trackerId = receiver.appContext.server.nextHandle() + val newTracker = Tracker.create( + id = trackerId, + hardwareId = hardwareId, + sensorType = event.data.imuType, + deviceId = deviceState.id, + origin = DeviceOrigin.UDP, + scope = receiver.appContext.server.context.scope, + server = receiver.appContext.server, + settings = receiver.appContext.config.settings, + ) + + receiver.appContext.server.context.dispatch( + VRServerActions.NewTracker(trackerId = trackerId, context = newTracker), + ) + receiver.context.dispatch( + UDPConnectionActions.AssignTracker( + trackerId = TrackerIdNum(id = trackerId, trackerNum = event.data.sensorId), + ), + ) + newTracker.context.dispatch(action) + newTracker + } + + val magStatus = assignedTracker.context.state.value.magStatus + if (magStatus != MagnetometerStatus.NOT_SUPPORTED) { + receiver.context.dispatch( + UDPConnectionActions.SetSensorConfig( + sensorId = event.data.sensorId, + flags = SensorConfigFlags(magStatus = magStatus), + ), + ) + } } } } @@ -321,3 +398,23 @@ object TemperatureBehaviour : UDPConnectionBehaviour { } } } + +object SensorConfigBehaviour : UDPConnectionBehaviour { + override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { + is UDPConnectionActions.SetSensorConfig -> state.copy( + sensorConfigFlags = state.sensorConfigFlags + (action.sensorId to action.flags), + ) + else -> state + } + + override fun observe(receiver: UDPConnection) { + receiver.context.state + .distinctUntilChangedBy { it.sensorConfigFlags } + .onEach { state -> + for ((sensorId, flags) in state.sensorConfigFlags) { + receiver.send(SetConfigFlag(sensorId = sensorId, configType = SensorConfigType.MAGNETOMETER, state = flags.magStatus == MagnetometerStatus.ENABLED)) + } + } + .launchIn(receiver.context.scope) + } +} diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 91bd50eb25..8035f3b525 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import kotlinx.io.Buffer import kotlinx.io.readByteArray +import solarxr_protocol.datatypes.MagnetometerStatus import java.net.DatagramPacket import java.net.DatagramSocket import java.net.InetAddress @@ -22,6 +23,10 @@ data class LastPing( val startTime: Long, ) +data class SensorConfigFlags( + val magStatus: MagnetometerStatus, +) + data class UDPConnectionState( val id: String, val lastPacket: Long, @@ -33,6 +38,7 @@ data class UDPConnectionState( val deviceId: Int?, val trackerIds: List, val features: FirmwareFeatures?, + val sensorConfigFlags: Map, ) sealed interface UDPConnectionActions { @@ -41,7 +47,8 @@ sealed interface UDPConnectionActions { data class LastPacket(val packetNum: Long? = null, val time: Long) : UDPConnectionActions data class AssignTracker(val trackerId: TrackerIdNum) : UDPConnectionActions data class FirmwareFeatures(val features: dev.slimevr.udp.FirmwareFeatures) : UDPConnectionActions - data object Disconnected : UDPConnectionActions + data class SetSensorConfig(val sensorId: Int, val flags: SensorConfigFlags) : UDPConnectionActions + data object TimedOut : UDPConnectionActions } typealias UDPConnectionContext = Context @@ -91,6 +98,7 @@ class UDPConnection( PacketBehaviour, HandshakeBehaviour, TimeoutBehaviour, + DisconnectBehaviour, PingBehaviour, DeviceStatsBehaviour, SensorInfoBehaviour, @@ -98,6 +106,7 @@ class UDPConnection( BundledPacketBehaviour, FlagsBehaviour, TemperatureBehaviour, + SensorConfigBehaviour, ) val context = Context.create( @@ -112,6 +121,7 @@ class UDPConnection( deviceId = null, trackerIds = listOf(), features = null, + sensorConfigFlags = emptyMap(), ), scope = scope, behaviours = behaviours, diff --git a/server/core/src/main/java/dev/slimevr/udp/packets.kt b/server/core/src/main/java/dev/slimevr/udp/packets.kt index 6febe6e857..b7abdfb1f0 100644 --- a/server/core/src/main/java/dev/slimevr/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/udp/packets.kt @@ -449,10 +449,20 @@ data class AckConfigChange(override val sensorId: Int = 0, val configType: UShor } } -data class SetConfigFlag(override val sensorId: Int = 255, val configType: UShort = 0u, val state: Boolean = false) : SensorSpecificPacket { +enum class SensorConfigType(val id: UShort) { + MAGNETOMETER(1u), + ; + + companion object { + private val byId = entries.associateBy { it.id } + fun fromId(id: UShort) = byId[id] + } +} + +data class SetConfigFlag(override val sensorId: Int = 255, val configType: SensorConfigType, val state: Boolean = false) : SensorSpecificPacket { override fun write(dst: Sink) { dst.writeUByte(sensorId.toUByte()) - dst.writeShort(configType.toShort()) + dst.writeShort(configType.id.toShort()) dst.writeUByte(if (state) 1u else 0u) } } @@ -499,12 +509,12 @@ fun readPacket(type: PacketType, src: Source): UDPPacket = when (type) { PacketType.FEATURE_FLAGS -> FeatureFlags.read(src) PacketType.ROTATION_AND_ACCEL -> RotationAndAccel.read(src) PacketType.ACK_CONFIG_CHANGE -> AckConfigChange.read(src) - PacketType.SET_CONFIG_FLAG -> SetConfigFlag() PacketType.FLEX_DATA -> FlexData.read(src) PacketType.POSITION -> PositionPacket.read(src) PacketType.PACKET_BUNDLE -> PacketBundle.read(src) PacketType.PACKET_BUNDLE_COMPACT -> PacketBundleCompact.read(src) PacketType.PROTOCOL_CHANGE -> ProtocolChange.read(src) + else -> error("Inbound support not implemented for ${type::class.simpleName}") } fun writePacket(dst: Sink, packet: UDPPacket) { diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index 5ef0d49592..511b19f390 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -2,80 +2,105 @@ package dev.slimevr.udp import dev.slimevr.AppContextProvider import dev.slimevr.AppLogger +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext import kotlinx.io.Buffer import java.net.DatagramPacket import java.net.DatagramSocket import kotlin.time.measureTime -class UDPTrackerServerState( - val port: Int, - val connections: MutableMap, +data class UdpServerState( + val connections: Map, ) -suspend fun createUDPTrackerServer( - appContext: AppContextProvider, -): UDPTrackerServerState { - val state = UDPTrackerServerState( - port = appContext.config.settings.context.state.value.data.trackerPort, - connections = mutableMapOf(), - ) +sealed interface UdpServerActions { + data class ConnectionAdded(val ip: String, val conn: UDPConnection) : UdpServerActions + data class ConnectionRemoved(val ip: String) : UdpServerActions +} + +typealias UdpServerContext = Context +typealias UdpServerBehaviour = Behaviour - val socket = withContext(Dispatchers.IO) { - DatagramSocket(state.port) +object UdpServerBaseBehaviour : UdpServerBehaviour { + override fun reduce(state: UdpServerState, action: UdpServerActions) = when (action) { + is UdpServerActions.ConnectionAdded -> state.copy(connections = state.connections + (action.ip to action.conn)) + is UdpServerActions.ConnectionRemoved -> state.copy(connections = state.connections - action.ip) } - val recvBuffer = ByteArray(2048) - val recvPacket = DatagramPacket(recvBuffer, recvBuffer.size) +} + +class UdpServer(val context: UdpServerContext) { + fun startObserving() = context.observeAll(this) + + fun findConnectionForDevice(deviceId: Int): UDPConnection? = + context.state.value.connections.values.find { conn -> + conn.context.state.value.deviceId == deviceId + } - supervisorScope { - val udpScope = this - launch(Dispatchers.IO) { - while (isActive) { - socket.receive(recvPacket) - val took = measureTime { - val src = Buffer() - src.write(recvBuffer, 0, recvPacket.length) + fun startReceiving(appContext: AppContextProvider, scope: CoroutineScope) { + scope.launch { + val port = appContext.config.settings.context.state.value.data.trackerPort + val socket = withContext(Dispatchers.IO) { DatagramSocket(port) } + val recvBuffer = ByteArray(2048) + val recvPacket = DatagramPacket(recvBuffer, recvBuffer.size) - val packetId = src.readInt() - val packetNumber = src.readLong() - val type = PacketType.fromId(packetId) ?: continue - val packetData = readPacket(type, src) + launch(Dispatchers.IO) { + while (isActive) { + socket.receive(recvPacket) + val took = measureTime { + val src = Buffer() + src.write(recvBuffer, 0, recvPacket.length) - val ip = recvPacket.address.hostAddress - val port = recvPacket.port - val connContext = state.connections[ip] + val packetId = src.readInt() + val packetNumber = src.readLong() + val type = PacketType.fromId(packetId) ?: return@measureTime + val packetData = readPacket(type, src) - val event = PacketEvent( - data = packetData, - packetNumber = packetNumber, - ) + val ip = recvPacket.address.hostAddress + val port = recvPacket.port + val conn = context.state.value.connections[ip] - if (connContext !== null) { - connContext.packetChannel.trySend(event) - } else { - val newContext = UDPConnection.create( - id = ip, - remoteIp = ip, - remotePort = port, - socket = socket, - appContext = appContext, - scope = udpScope, - ) + val event = PacketEvent(data = packetData, packetNumber = packetNumber) - state.connections[ip] = newContext - newContext.packetChannel.trySend(event) + if (conn != null) { + conn.packetChannel.trySend(event) + } else { + val newConn = UDPConnection.create( + id = ip, + remoteIp = ip, + remotePort = port, + socket = socket, + appContext = appContext, + scope = scope, + ) + context.dispatch(UdpServerActions.ConnectionAdded(ip, newConn)) + newConn.packetChannel.trySend(event) + } + } + if (took.inWholeMilliseconds > 2) { + AppLogger.udp.warn("Packet processing took too long ${took.inWholeMilliseconds}") } - } - if (took.inWholeMilliseconds > 2) { - AppLogger.udp.warn("Packet processing took too long ${took.inWholeMilliseconds}") } } } } - return state -} + companion object { + val INITIAL_STATE = UdpServerState(connections = emptyMap()) + + fun create(scope: CoroutineScope): UdpServer { + val context = Context.create( + initialState = INITIAL_STATE, + scope = scope, + behaviours = listOf(UdpServerBaseBehaviour), + ) + val server = UdpServer(context) + server.startObserving() + return server + } + } +} \ No newline at end of file diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 6585ff60f0..abbd03e79b 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -17,6 +17,7 @@ import dev.slimevr.skeleton.ProportionsBehaviour import dev.slimevr.skeleton.Skeleton import dev.slimevr.skeleton.buildBones import dev.slimevr.trackingchecklist.TrackingChecklist +import dev.slimevr.udp.UdpServer import dev.slimevr.vrchat.VRCConfigManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -75,5 +76,6 @@ abstract class TestAppContext : AppContextProvider { override val provisioningManager: ProvisioningManager get() = error("not used in test") override val heightCalibrationManager: HeightCalibrationManager get() = error("not used in test") override val trackingChecklist: TrackingChecklist get() = error("not used in test") + override val udpServer: UdpServer get() = error("not used in test") override fun startObserving() {} } 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 126954e013..7fb4ba79dd 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -17,7 +17,7 @@ import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resolveConfigDirectory import dev.slimevr.skeleton.Skeleton import dev.slimevr.trackingchecklist.TrackingChecklist -import dev.slimevr.udp.createUDPTrackerServer +import dev.slimevr.udp.UdpServer import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -35,6 +35,7 @@ fun main(args: Array) = runBlocking { val provisioningManager = ProvisioningManager.create(ctx = phase1, scope = this) val heightCalibrationManager = HeightCalibrationManager.create(ctx = phase1, scope = this) val trackingChecklist = TrackingChecklist.create(scope = this) + val udpServer = UdpServer.create(scope = this) val appContext = AppContext( server = server, @@ -46,11 +47,11 @@ fun main(args: Array) = runBlocking { provisioningManager = provisioningManager, heightCalibrationManager = heightCalibrationManager, trackingChecklist = trackingChecklist, + udpServer = udpServer, ) appContext.startObserving() - launch { createUDPTrackerServer(appContext) } launch { createDesktopHIDManager(appContext, this) } launch { createSolarXRWebsocketServer(appContext) } launch { createIpcServers(appContext) } From b7cd179cc6f62df21db218abc335606033e7a5fe Mon Sep 17 00:00:00 2001 From: loucass003 Date: Fri, 17 Apr 2026 19:06:05 +0200 Subject: [PATCH 066/350] Progress on mag crap --- .../src/main/java/dev/slimevr/app-context.kt | 66 ++++++------- .../main/java/dev/slimevr/config/settings.kt | 1 + .../main/java/dev/slimevr/context/context.kt | 1 - .../src/main/java/dev/slimevr/solarxr/mag.kt | 97 ++++++++++++++----- .../main/java/dev/slimevr/tracker/config.kt | 15 +-- .../main/java/dev/slimevr/tracker/module.kt | 2 +- .../main/java/dev/slimevr/udp/behaviours.kt | 38 ++++++-- .../main/java/dev/slimevr/udp/connection.kt | 4 +- 8 files changed, 144 insertions(+), 80 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index 03564300cd..80e0bc9c05 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -11,47 +11,47 @@ import dev.slimevr.udp.UdpServer import dev.slimevr.vrchat.VRCConfigManager interface Phase1ContextProvider { - val server: VRServer - val config: AppConfig - val serialServer: SerialServer + val server: VRServer + val config: AppConfig + val serialServer: SerialServer } data class Phase1Context( - override val server: VRServer, - override val config: AppConfig, - override val serialServer: SerialServer, + override val server: VRServer, + override val config: AppConfig, + override val serialServer: SerialServer, ) : Phase1ContextProvider interface AppContextProvider : Phase1ContextProvider { - val skeleton: Skeleton - val firmwareManager: FirmwareManager - val vrcConfigManager: VRCConfigManager? - val provisioningManager: ProvisioningManager - val heightCalibrationManager: HeightCalibrationManager - val trackingChecklist: TrackingChecklist - val udpServer: UdpServer - fun startObserving() + val skeleton: Skeleton + val firmwareManager: FirmwareManager + val vrcConfigManager: VRCConfigManager? + val provisioningManager: ProvisioningManager + val heightCalibrationManager: HeightCalibrationManager + val trackingChecklist: TrackingChecklist + val udpServer: UdpServer + fun startObserving() } class AppContext( - override val server: VRServer, - override val config: AppConfig, - override val serialServer: SerialServer, - override val skeleton: Skeleton, - override val firmwareManager: FirmwareManager, - override val vrcConfigManager: VRCConfigManager?, - override val provisioningManager: ProvisioningManager, - override val heightCalibrationManager: HeightCalibrationManager, - override val trackingChecklist: TrackingChecklist, - override val udpServer: UdpServer, + override val server: VRServer, + override val config: AppConfig, + override val serialServer: SerialServer, + override val skeleton: Skeleton, + override val firmwareManager: FirmwareManager, + override val vrcConfigManager: VRCConfigManager?, + override val provisioningManager: ProvisioningManager, + override val heightCalibrationManager: HeightCalibrationManager, + override val trackingChecklist: TrackingChecklist, + override val udpServer: UdpServer, ) : AppContextProvider { - override fun startObserving() { - skeleton.startObserving() - firmwareManager.startObserving() - provisioningManager.startObserving() - heightCalibrationManager.startObserving() - vrcConfigManager?.startObserving() - trackingChecklist.startObserving(this) - udpServer.startReceiving(this, server.context.scope) - } + override fun startObserving() { + skeleton.startObserving() + firmwareManager.startObserving() + provisioningManager.startObserving() + heightCalibrationManager.startObserving() + vrcConfigManager?.startObserving() + trackingChecklist.startObserving(this) + udpServer.startReceiving(this, server.context.scope) + } } diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 777860d4d2..d6b563bfee 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -46,6 +46,7 @@ data class SettingsConfigState( val mutedVRCWarnings: List = listOf(), val mutedChecklistSteps: Set = emptySet(), val trackers: Map = emptyMap(), + val globalMagEnabled: Boolean = true, val version: Int = SETTINGS_CONFIG_VERSION, ) diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index 8d9a35fcc0..3c575509c0 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -26,7 +26,6 @@ class Context( } } - fun observeAll(receiver: C) = behaviours.forEach { behaviour -> @Suppress("UNCHECKED_CAST") (behaviour as Behaviour).observe(receiver) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt index 58b89361f2..704972175e 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt @@ -1,49 +1,48 @@ package dev.slimevr.solarxr import dev.slimevr.AppContextProvider +import dev.slimevr.config.SettingsActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.udp.SensorConfigFlags import dev.slimevr.udp.UDPConnectionActions +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.datatypes.TrackerId import solarxr_protocol.rpc.ChangeMagToggleRequest +import solarxr_protocol.rpc.ChangeSettingsRequest +import solarxr_protocol.rpc.MagToggleRequest import solarxr_protocol.rpc.MagToggleResponse class MagBehaviour( private val appContext: AppContextProvider, ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { - val subscribedTrackers = mutableSetOf() - appContext.server.context.state - .distinctUntilChangedBy { it.trackers.keys } - .onEach { serverState -> - serverState.trackers.values.forEach { tracker -> - val trackerState = tracker.context.state.value - if (subscribedTrackers.add(trackerState.id)) { - val device = appContext.server.getDevice(trackerState.deviceId) ?: return@forEach - val trackerId = TrackerId( - trackerNum = trackerState.id.toUByte(), - deviceId = DeviceId(device.context.state.value.id.toUByte()), - ) - tracker.context.state - .distinctUntilChangedBy { it.magStatus } - .onEach { state -> - receiver.sendRpc( - MagToggleResponse(trackerId = trackerId, enable = state.magStatus == MagnetometerStatus.ENABLED), - ) - } - .launchIn(receiver.context.scope) + + receiver.rpcDispatcher.on { req -> + val trackerId = req.trackerId + + if (trackerId == null) { + receiver.appContext.config.settings.context.dispatch( + SettingsActions.Update { + copy(globalMagEnabled = req.enable) } - } + ) + receiver.sendRpc(MagToggleResponse( + trackerId = null, + enable = req.enable + )) + return@on } - .launchIn(receiver.context.scope) - receiver.rpcDispatcher.on { req -> - val trackerId = req.trackerId ?: return@on val tracker = appContext.server.getTracker(trackerId.trackerNum.toInt()) ?: return@on val trackerState = tracker.context.state.value @@ -57,8 +56,32 @@ class MagBehaviour( ?.trackerNum ?: return@on connection.context.dispatch( - UDPConnectionActions.SetSensorConfig(sensorId = sensorId, flags = SensorConfigFlags(magStatus = if (req.enable) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED)), + UDPConnectionActions.SetSensorConfig( + sensorId = sensorId, + flags = SensorConfigFlags(magEnabled = req.enable) + ), ) + + tracker.context.scope.launch { + withTimeout(10_000) { + tracker.context.state + .distinctUntilChangedBy { it.magStatus } + .first { trackerState -> + val status = when (trackerState.magStatus) { + MagnetometerStatus.ENABLED -> true + MagnetometerStatus.DISABLED -> false + MagnetometerStatus.NOT_SUPPORTED -> false + } + return@first status == req.enable + } + receiver.sendRpc( + MagToggleResponse( + trackerId = trackerId, + enable = req.enable + ) + ) + } + } } DeviceOrigin.HID -> { // TODO: implement HID mag toggle @@ -66,5 +89,29 @@ class MagBehaviour( else -> return@on } } + + receiver.rpcDispatcher.on { req -> + val trackerId = req.trackerId + + if (trackerId == null) { + val globalMagEnabled = receiver.appContext.config.settings.context.state.value.data.globalMagEnabled + receiver.sendRpc(MagToggleResponse( + trackerId = null, + enable = globalMagEnabled + )) + return@on + } + + val tracker = appContext.server.getTracker(trackerId.trackerNum.toInt()) ?: return@on + val trackerState = tracker.context.state.value + receiver.sendRpc(MagToggleResponse( + trackerId = trackerId, + enable = when (trackerState.magStatus) { + MagnetometerStatus.ENABLED -> true + MagnetometerStatus.DISABLED -> false + MagnetometerStatus.NOT_SUPPORTED -> false + } + )) + } } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/config.kt b/server/core/src/main/java/dev/slimevr/tracker/config.kt index b886b76708..45b6817640 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/config.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/config.kt @@ -17,19 +17,14 @@ fun restoreFromConfig(state: TrackerState, config: TrackerConfig): TrackerState magStatus = when (config.magEnabled) { true -> MagnetometerStatus.ENABLED false -> MagnetometerStatus.DISABLED - null -> MagnetometerStatus.NOT_SUPPORTED + null -> state.magStatus } ) -private fun stateToConfig(state: TrackerState) = TrackerConfig( +private fun applyStateToConfig(config: TrackerConfig, state: TrackerState) = config.copy( bodyPart = state.bodyPart, customName = state.customName, mountingOrientation = state.mountingOrientation, - magEnabled = when (state.magStatus) { - MagnetometerStatus.DISABLED -> false - MagnetometerStatus.ENABLED -> true - MagnetometerStatus.NOT_SUPPORTED -> null - }, ) class TrackerConfigBehaviour( @@ -38,11 +33,11 @@ class TrackerConfigBehaviour( ) : TrackerBehaviour { override fun observe(receiver: Tracker) { receiver.context.state - .distinctUntilChangedBy { stateToConfig(it) } + .distinctUntilChangedBy { it.bodyPart to it.customName to it.mountingOrientation } .drop(1) .onEach { state -> - settings.context.dispatch(SettingsActions.UpdateTracker(hardwareId) { stateToConfig(state) }) + settings.context.dispatch(SettingsActions.UpdateTracker(hardwareId) { applyStateToConfig(this, state) }) } .launchIn(receiver.context.scope) } -} +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 8b32b050e0..8101ad883c 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -34,7 +34,7 @@ data class TrackerState( val imuTemp: Float?, val position: Vector3?, val status: TrackerStatus, - val completedRestCalibration: Boolean, + val completedRestCalibration: Boolean?, val magStatus: MagnetometerStatus, ) diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index fe40c12978..4328cb1eda 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -241,13 +241,10 @@ object SensorInfoBehaviour : UDPConnectionBehaviour { val tracker = receiver.getTracker(event.data.sensorId) val trackerState = tracker?.context?.state?.value - val sensorConfig = event.data.sensorConfig val completedRestCalibration = event.data.hasCompletedRestCalibration - ?: trackerState?.completedRestCalibration - ?: false - val magStatus = sensorConfig?.let { + val magStatus = event.data.sensorConfig?.let { if (it.magSupported) { if (it.magEnabled) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED } else { @@ -280,7 +277,10 @@ object SensorInfoBehaviour : UDPConnectionBehaviour { val assignedTracker = if (existingTracker != null) { receiver.context.dispatch( UDPConnectionActions.AssignTracker( - trackerId = TrackerIdNum(id = existingTracker.context.state.value.id, trackerNum = event.data.sensorId), + trackerId = TrackerIdNum( + id = existingTracker.context.state.value.id, + trackerNum = event.data.sensorId + ), ), ) existingTracker.context.dispatch(action) @@ -311,14 +311,17 @@ object SensorInfoBehaviour : UDPConnectionBehaviour { } val magStatus = assignedTracker.context.state.value.magStatus - if (magStatus != MagnetometerStatus.NOT_SUPPORTED) { + val globalMagEnabled = receiver.appContext.config.settings.context.state.value.data.globalMagEnabled + if (magStatus != MagnetometerStatus.NOT_SUPPORTED && globalMagEnabled) { receiver.context.dispatch( UDPConnectionActions.SetSensorConfig( sensorId = event.data.sensorId, - flags = SensorConfigFlags(magStatus = magStatus), + flags = SensorConfigFlags(magEnabled = magStatus == MagnetometerStatus.ENABLED), ), ) } + + } } } @@ -412,9 +415,28 @@ object SensorConfigBehaviour : UDPConnectionBehaviour { .distinctUntilChangedBy { it.sensorConfigFlags } .onEach { state -> for ((sensorId, flags) in state.sensorConfigFlags) { - receiver.send(SetConfigFlag(sensorId = sensorId, configType = SensorConfigType.MAGNETOMETER, state = flags.magStatus == MagnetometerStatus.ENABLED)) + receiver.send( + SetConfigFlag( + sensorId = sensorId, + configType = SensorConfigType.MAGNETOMETER, + state = flags.magEnabled + ) + ) } } .launchIn(receiver.context.scope) } } + +object AckConfigBehaviour : UDPConnectionBehaviour { + override fun observe(receiver: UDPConnection) { + receiver.packetEvents.onPacket { event -> + val configType = SensorConfigType.fromId(event.data.configType) ?: return@onPacket + if (configType != SensorConfigType.MAGNETOMETER) return@onPacket + val flags = receiver.context.state.value.sensorConfigFlags[event.data.sensorId] ?: return@onPacket + val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket + val magStatus = if (flags.magEnabled) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED + tracker.context.dispatch(TrackerActions.Update { copy(magStatus = magStatus) }) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 8035f3b525..9cfcdc51b6 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import kotlinx.io.Buffer import kotlinx.io.readByteArray -import solarxr_protocol.datatypes.MagnetometerStatus import java.net.DatagramPacket import java.net.DatagramSocket import java.net.InetAddress @@ -24,7 +23,7 @@ data class LastPing( ) data class SensorConfigFlags( - val magStatus: MagnetometerStatus, + val magEnabled: Boolean, ) data class UDPConnectionState( @@ -107,6 +106,7 @@ class UDPConnection( FlagsBehaviour, TemperatureBehaviour, SensorConfigBehaviour, + AckConfigBehaviour, ) val context = Context.create( From aa505d041b2aee13ee4467e65401a313541fd382 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 21 Apr 2026 01:31:24 +0200 Subject: [PATCH 067/350] Working mag. That was awfull --- .../src/main/java/dev/slimevr/solarxr/mag.kt | 81 ++++----- .../java/dev/slimevr/tracker/behaviours.kt | 10 +- .../main/java/dev/slimevr/tracker/config.kt | 9 +- .../slimevr/trackingchecklist/behaviours.kt | 2 +- .../main/java/dev/slimevr/udp/behaviours.kt | 155 +++++++++--------- .../main/java/dev/slimevr/udp/connection.kt | 3 +- 6 files changed, 121 insertions(+), 139 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt index 704972175e..40648e1437 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt @@ -5,81 +5,62 @@ import dev.slimevr.config.SettingsActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.udp.SensorConfigFlags import dev.slimevr.udp.UDPConnectionActions -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout -import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.MagnetometerStatus -import solarxr_protocol.datatypes.TrackerId import solarxr_protocol.rpc.ChangeMagToggleRequest -import solarxr_protocol.rpc.ChangeSettingsRequest import solarxr_protocol.rpc.MagToggleRequest import solarxr_protocol.rpc.MagToggleResponse class MagBehaviour( private val appContext: AppContextProvider, ) : SolarXRBridgeBehaviour { - override fun observe(receiver: SolarXRBridge) { + private fun setUDPTrackerMag(trackerId: Int, deviceId: Int, enable: Boolean): Boolean { + val connection = appContext.udpServer.findConnectionForDevice(deviceId) ?: return false + val sensorId = connection.context.state.value.trackerIds.find { it.id == trackerId }?.trackerNum ?: return false + connection.context.dispatch( + UDPConnectionActions.SetSensorConfig( + sensorId = sensorId, + flags = SensorConfigFlags(magStatus = if (enable) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED), + ) + ) + return true + } + override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { req -> val trackerId = req.trackerId if (trackerId == null) { - receiver.appContext.config.settings.context.dispatch( - SettingsActions.Update { - copy(globalMagEnabled = req.enable) + receiver.appContext.config.settings.context.dispatch(SettingsActions.Update { copy(globalMagEnabled = req.enable) }) + appContext.server.context.state.value.trackers.values.forEach { tracker -> + val state = tracker.context.state.value + if (state.magStatus == MagnetometerStatus.NOT_SUPPORTED) return@forEach + when (state.origin) { + DeviceOrigin.UDP -> setUDPTrackerMag(state.id, state.deviceId, req.enable) + DeviceOrigin.HID -> { /* TODO: implement HID mag toggle */ } + else -> Unit } - ) - receiver.sendRpc(MagToggleResponse( - trackerId = null, - enable = req.enable - )) + } + receiver.sendRpc(MagToggleResponse(trackerId = null, enable = req.enable)) return@on } val tracker = appContext.server.getTracker(trackerId.trackerNum.toInt()) ?: return@on val trackerState = tracker.context.state.value - if (trackerState.magStatus == MagnetometerStatus.NOT_SUPPORTED) return@on when (trackerState.origin) { DeviceOrigin.UDP -> { - val connection = appContext.udpServer.findConnectionForDevice(trackerState.deviceId) ?: return@on - val sensorId = connection.context.state.value.trackerIds - .find { it.id == trackerState.id } - ?.trackerNum - ?: return@on - connection.context.dispatch( - UDPConnectionActions.SetSensorConfig( - sensorId = sensorId, - flags = SensorConfigFlags(magEnabled = req.enable) - ), - ) - + if (!setUDPTrackerMag(trackerState.id, trackerState.deviceId, req.enable)) return@on tracker.context.scope.launch { withTimeout(10_000) { tracker.context.state .distinctUntilChangedBy { it.magStatus } - .first { trackerState -> - val status = when (trackerState.magStatus) { - MagnetometerStatus.ENABLED -> true - MagnetometerStatus.DISABLED -> false - MagnetometerStatus.NOT_SUPPORTED -> false - } - return@first status == req.enable - } - receiver.sendRpc( - MagToggleResponse( - trackerId = trackerId, - enable = req.enable - ) - ) + .first { it.magStatus == if (req.enable) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED } + receiver.sendRpc(MagToggleResponse(trackerId = trackerId, enable = req.enable)) } } } @@ -94,23 +75,17 @@ class MagBehaviour( val trackerId = req.trackerId if (trackerId == null) { - val globalMagEnabled = receiver.appContext.config.settings.context.state.value.data.globalMagEnabled receiver.sendRpc(MagToggleResponse( trackerId = null, - enable = globalMagEnabled + enable = receiver.appContext.config.settings.context.state.value.data.globalMagEnabled, )) return@on } - val tracker = appContext.server.getTracker(trackerId.trackerNum.toInt()) ?: return@on - val trackerState = tracker.context.state.value + val trackerState = appContext.server.getTracker(trackerId.trackerNum.toInt())?.context?.state?.value ?: return@on receiver.sendRpc(MagToggleResponse( trackerId = trackerId, - enable = when (trackerState.magStatus) { - MagnetometerStatus.ENABLED -> true - MagnetometerStatus.DISABLED -> false - MagnetometerStatus.NOT_SUPPORTED -> false - } + enable = trackerState.magStatus == MagnetometerStatus.ENABLED, )) } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 5f05f45b19..dca2247b1f 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -81,7 +81,15 @@ object TrackerTapDetectionBehaviour : TrackerBehaviour { } object TrackerBasicBehaviour : TrackerBehaviour { - override fun reduce(state: TrackerState, action: TrackerActions) = if (action is TrackerActions.Update) action.transform(state) else state + override fun reduce(state: TrackerState, action: TrackerActions) : TrackerState { + val newState = if (action is TrackerActions.Update) + action.transform(state) + else + state + if (action is TrackerActions.Update) + println(newState) + return newState + } } object TrackerTPSBehaviour : TrackerBehaviour { diff --git a/server/core/src/main/java/dev/slimevr/tracker/config.kt b/server/core/src/main/java/dev/slimevr/tracker/config.kt index 45b6817640..a51a4a1848 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/config.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/config.kt @@ -25,6 +25,11 @@ private fun applyStateToConfig(config: TrackerConfig, state: TrackerState) = con bodyPart = state.bodyPart, customName = state.customName, mountingOrientation = state.mountingOrientation, + magEnabled = when (state.magStatus) { + MagnetometerStatus.ENABLED -> true + MagnetometerStatus.DISABLED -> false + MagnetometerStatus.NOT_SUPPORTED -> config.magEnabled + }, ) class TrackerConfigBehaviour( @@ -33,11 +38,11 @@ class TrackerConfigBehaviour( ) : TrackerBehaviour { override fun observe(receiver: Tracker) { receiver.context.state - .distinctUntilChangedBy { it.bodyPart to it.customName to it.mountingOrientation } + .distinctUntilChangedBy { it.bodyPart to it.customName to it.mountingOrientation to it.magStatus } .drop(1) .onEach { state -> settings.context.dispatch(SettingsActions.UpdateTracker(hardwareId) { applyStateToConfig(this, state) }) } .launchIn(receiver.context.scope) } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index 15840665df..50ba339f7f 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -101,7 +101,7 @@ class TrackerRestCheckBehaviour(private val server: VRServer) : TrackingChecklis private fun computeStep(trackers: List): TrackingChecklistStep { val uncalibratedTrackers = trackers.filter { tracker -> (tracker.origin == DeviceOrigin.UDP || tracker.origin == DeviceOrigin.HID) - && tracker.status == TrackerStatus.OK && !tracker.completedRestCalibration + && tracker.status == TrackerStatus.OK && (tracker.completedRestCalibration != null && !tracker.completedRestCalibration) } return TrackingChecklistStep( valid = uncalibratedTrackers.isEmpty(), diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 4328cb1eda..2a059fe863 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -229,6 +229,44 @@ object DeviceStatsBehaviour : UDPConnectionBehaviour { } object SensorInfoBehaviour : UDPConnectionBehaviour { + private suspend fun assignTracker(receiver: UDPConnection, device: Device, event: PacketEvent): Pair { + val deviceState = device.context.state.value + val mac = deviceState.macAddress ?: run { + AppLogger.udp.warn("[${deviceState.address}] No MAC address available, falling back to IP for hardware ID") + deviceState.address + } + val hardwareId = "$mac:${event.data.sensorId}" + + val existingTracker = receiver.appContext.server.context.state.value.trackers.values + .find { t -> t.context.state.value.deviceId == deviceState.id && t.context.state.value.hardwareId == hardwareId } + + if (existingTracker != null) { + receiver.context.dispatch( + UDPConnectionActions.AssignTracker( + trackerId = TrackerIdNum(id = existingTracker.context.state.value.id, trackerNum = event.data.sensorId), + ), + ) + return existingTracker to false + } + + val trackerId = receiver.appContext.server.nextHandle() + val newTracker = Tracker.create( + id = trackerId, + hardwareId = hardwareId, + sensorType = event.data.imuType, + deviceId = deviceState.id, + origin = DeviceOrigin.UDP, + scope = receiver.appContext.server.context.scope, + server = receiver.appContext.server, + settings = receiver.appContext.config.settings, + ) + receiver.appContext.server.context.dispatch(VRServerActions.NewTracker(trackerId = trackerId, context = newTracker)) + receiver.context.dispatch( + UDPConnectionActions.AssignTracker(trackerId = TrackerIdNum(id = trackerId, trackerNum = event.data.sensorId)), + ) + return newTracker to true + } + override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { is UDPConnectionActions.AssignTracker -> state.copy(trackerIds = state.trackerIds + action.trackerId) else -> state @@ -236,92 +274,44 @@ object SensorInfoBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { receiver.packetEvents.onPacket { event -> - val device = receiver.getDevice() - ?: error("invalid state - a device should exist at this point") - - val tracker = receiver.getTracker(event.data.sensorId) - val trackerState = tracker?.context?.state?.value + val device = receiver.getDevice() ?: error("invalid state - a device should exist at this point") - val completedRestCalibration = event.data.hasCompletedRestCalibration - - val magStatus = event.data.sensorConfig?.let { - if (it.magSupported) { - if (it.magEnabled) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED - } else { - MagnetometerStatus.NOT_SUPPORTED - } - } ?: trackerState?.magStatus ?: MagnetometerStatus.NOT_SUPPORTED + val existingTracker = receiver.getTracker(event.data.sensorId) + if (existingTracker != null) { + existingTracker.context.dispatch(TrackerActions.Update { + copy(sensorType = event.data.imuType, status = event.data.status, completedRestCalibration = event.data.hasCompletedRestCalibration) + }) + return@onPacket + } - val action = TrackerActions.Update { + val (tracker, isNew) = assignTracker(receiver, device, event) + tracker.context.dispatch(TrackerActions.Update { copy( sensorType = event.data.imuType, status = event.data.status, - completedRestCalibration = completedRestCalibration, - magStatus = magStatus, + completedRestCalibration = event.data.hasCompletedRestCalibration, + magStatus = if (isNew && magStatus == MagnetometerStatus.NOT_SUPPORTED) { + if (event.data.sensorConfig?.magSupported == true) MagnetometerStatus.DISABLED else MagnetometerStatus.NOT_SUPPORTED + } else magStatus, ) - } + }) - if (tracker != null) { - tracker.context.dispatch(action) - } else { - val deviceState = device.context.state.value - val mac = deviceState.macAddress ?: run { - AppLogger.udp.warn("[${deviceState.address}] No MAC address available, falling back to IP for hardware ID") - deviceState.address - } - val hardwareId = "$mac:${event.data.sensorId}" - - val existingTracker = receiver.appContext.server.context.state.value.trackers.values - .find { t -> t.context.state.value.deviceId == deviceState.id && t.context.state.value.hardwareId == hardwareId } - - val assignedTracker = if (existingTracker != null) { - receiver.context.dispatch( - UDPConnectionActions.AssignTracker( - trackerId = TrackerIdNum( - id = existingTracker.context.state.value.id, - trackerNum = event.data.sensorId - ), - ), - ) - existingTracker.context.dispatch(action) - existingTracker + val remoteMagStatus = event.data.sensorConfig?.let { + if (it.magSupported) { + if (it.magEnabled) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED } else { - val trackerId = receiver.appContext.server.nextHandle() - val newTracker = Tracker.create( - id = trackerId, - hardwareId = hardwareId, - sensorType = event.data.imuType, - deviceId = deviceState.id, - origin = DeviceOrigin.UDP, - scope = receiver.appContext.server.context.scope, - server = receiver.appContext.server, - settings = receiver.appContext.config.settings, - ) - - receiver.appContext.server.context.dispatch( - VRServerActions.NewTracker(trackerId = trackerId, context = newTracker), - ) - receiver.context.dispatch( - UDPConnectionActions.AssignTracker( - trackerId = TrackerIdNum(id = trackerId, trackerNum = event.data.sensorId), - ), - ) - newTracker.context.dispatch(action) - newTracker - } - - val magStatus = assignedTracker.context.state.value.magStatus - val globalMagEnabled = receiver.appContext.config.settings.context.state.value.data.globalMagEnabled - if (magStatus != MagnetometerStatus.NOT_SUPPORTED && globalMagEnabled) { - receiver.context.dispatch( - UDPConnectionActions.SetSensorConfig( - sensorId = event.data.sensorId, - flags = SensorConfigFlags(magEnabled = magStatus == MagnetometerStatus.ENABLED), - ), - ) + MagnetometerStatus.NOT_SUPPORTED } - - + } ?: MagnetometerStatus.NOT_SUPPORTED + + var desiredMagStatus = tracker.context.state.value.magStatus + val globalMagEnabled = receiver.appContext.config.settings.context.state.value.data.globalMagEnabled + if (remoteMagStatus != desiredMagStatus) { + if (globalMagEnabled && remoteMagStatus != MagnetometerStatus.ENABLED && desiredMagStatus != MagnetometerStatus.NOT_SUPPORTED) + desiredMagStatus = MagnetometerStatus.ENABLED + receiver.context.dispatch( + UDPConnectionActions.SetSensorConfig(sensorId = event.data.sensorId, flags = SensorConfigFlags(magStatus = desiredMagStatus)), + ) } } } @@ -419,9 +409,10 @@ object SensorConfigBehaviour : UDPConnectionBehaviour { SetConfigFlag( sensorId = sensorId, configType = SensorConfigType.MAGNETOMETER, - state = flags.magEnabled + state = flags.magStatus == MagnetometerStatus.ENABLED ) ) + println("SEND CONFIG = ${flags.magStatus}") } } .launchIn(receiver.context.scope) @@ -432,11 +423,13 @@ object AckConfigBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { receiver.packetEvents.onPacket { event -> val configType = SensorConfigType.fromId(event.data.configType) ?: return@onPacket - if (configType != SensorConfigType.MAGNETOMETER) return@onPacket val flags = receiver.context.state.value.sensorConfigFlags[event.data.sensorId] ?: return@onPacket + val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket - val magStatus = if (flags.magEnabled) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED - tracker.context.dispatch(TrackerActions.Update { copy(magStatus = magStatus) }) + if (configType == SensorConfigType.MAGNETOMETER) { + tracker.context.dispatch(TrackerActions.Update { copy(magStatus = flags.magStatus) }) + println("UPDATE? = ${flags.magStatus}") + } } } } diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 9cfcdc51b6..79237586f7 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import kotlinx.io.Buffer import kotlinx.io.readByteArray +import solarxr_protocol.datatypes.MagnetometerStatus import java.net.DatagramPacket import java.net.DatagramSocket import java.net.InetAddress @@ -23,7 +24,7 @@ data class LastPing( ) data class SensorConfigFlags( - val magEnabled: Boolean, + val magStatus: MagnetometerStatus, ) data class UDPConnectionState( From fa3ebf4eb160bc3bc6e86d70c92fef532a3bad29 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 21 Apr 2026 05:07:05 +0200 Subject: [PATCH 068/350] Cleaner actions for common dispatch --- .../java/dev/slimevr/driver/behaviours.kt | 2 +- .../java/dev/slimevr/feeder/behaviours.kt | 2 +- .../main/java/dev/slimevr/hid/behaviours.kt | 8 ++-- .../java/dev/slimevr/tracker/behaviours.kt | 16 +++---- .../main/java/dev/slimevr/tracker/module.kt | 3 ++ .../main/java/dev/slimevr/udp/behaviours.kt | 42 +++++++++---------- 6 files changed, 37 insertions(+), 36 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 8cd10c166c..ab97b876f4 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -17,7 +17,7 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { receiver.inbound.on { event -> receiver.appContext.server.getTracker(event.trackerId)?.context?.dispatch( - TrackerActions.Update { copy(rawRotation = event.rotation) }, + TrackerActions.SetRotation(rotation = event.rotation), ) } diff --git a/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt b/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt index 8bb95cfe2e..52284de613 100644 --- a/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt @@ -24,7 +24,7 @@ object FeederBaseBehaviour : FeederBridgeBehaviour { receiver.inbound.on { event -> receiver.appContext.server.getTracker(event.trackerId)?.context?.dispatch( - TrackerActions.Update { copy(rawRotation = event.rotation) }, + TrackerActions.SetRotation(rotation = event.rotation), ) } } diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index e35363e78c..33f24cad08 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -125,22 +125,22 @@ object HIDRotationBehaviour : HIDReceiverBehaviour { override fun observe(receiver: HIDReceiver) { receiver.packetEvents.onPacket { packet -> val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + tracker.context.dispatch(TrackerActions.SetRotation(rotation = packet.rotation, acceleration = packet.acceleration)) } receiver.packetEvents.onPacket { packet -> val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + tracker.context.dispatch(TrackerActions.SetRotation(rotation = packet.rotation, acceleration = packet.acceleration)) } receiver.packetEvents.onPacket { packet -> val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + tracker.context.dispatch(TrackerActions.SetRotation(rotation = packet.rotation)) } receiver.packetEvents.onPacket { packet -> val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = packet.rotation) }) + tracker.context.dispatch(TrackerActions.SetRotation(rotation = packet.rotation, acceleration = packet.acceleration)) } } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index dca2247b1f..56eff2bc8c 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -81,14 +81,14 @@ object TrackerTapDetectionBehaviour : TrackerBehaviour { } object TrackerBasicBehaviour : TrackerBehaviour { - override fun reduce(state: TrackerState, action: TrackerActions) : TrackerState { - val newState = if (action is TrackerActions.Update) - action.transform(state) - else - state - if (action is TrackerActions.Update) - println(newState) - return newState + override fun reduce(state: TrackerState, action: TrackerActions) = when (action) { + is TrackerActions.Update -> action.transform(state) + is TrackerActions.SetMagStatus -> state.copy(magStatus = action.status) + is TrackerActions.SetStatus -> state.copy(status = action.status) + is TrackerActions.SetRotation -> state.copy( + rawRotation = action.rotation ?: state.rawRotation, + acceleration = action.acceleration ?: state.acceleration, + ) } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 8101ad883c..add3ad94e8 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -40,6 +40,9 @@ data class TrackerState( sealed interface TrackerActions { data class Update(val transform: TrackerState.() -> TrackerState) : TrackerActions + data class SetMagStatus(val status: MagnetometerStatus) : TrackerActions + data class SetStatus(val status: TrackerStatus) : TrackerActions + data class SetRotation(val rotation: Quaternion? = null, val acceleration: Vector3? = null) : TrackerActions } typealias TrackerContext = Context diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 2a059fe863..52dc58b5b9 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -172,7 +172,7 @@ object TimeoutBehaviour : UDPConnectionBehaviour { DeviceActions.Update { copy(status = TrackerStatus.TIMED_OUT) }, ) state.trackerIds.mapNotNull { receiver.appContext.server.getTracker(it.id) }.forEach { tracker -> - tracker.context.dispatch(TrackerActions.Update { copy(status = TrackerStatus.TIMED_OUT) }) + tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.TIMED_OUT)) } } else { delay(timeUntilTimeout + 1) @@ -198,7 +198,7 @@ object DisconnectBehaviour : UDPConnectionBehaviour { DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, ) currentState.trackerIds.mapNotNull { receiver.appContext.server.getTracker(it.id) }.forEach { tracker -> - tracker.context.dispatch(TrackerActions.Update { copy(status = TrackerStatus.DISCONNECTED) }) + tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.DISCONNECTED)) } } } else { @@ -278,23 +278,23 @@ object SensorInfoBehaviour : UDPConnectionBehaviour { val existingTracker = receiver.getTracker(event.data.sensorId) if (existingTracker != null) { - existingTracker.context.dispatch(TrackerActions.Update { - copy(sensorType = event.data.imuType, status = event.data.status, completedRestCalibration = event.data.hasCompletedRestCalibration) - }) + existingTracker.context.dispatchAll(listOf( + TrackerActions.Update { copy(sensorType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration) }, + TrackerActions.SetStatus(event.data.status), + )) return@onPacket } val (tracker, isNew) = assignTracker(receiver, device, event) - tracker.context.dispatch(TrackerActions.Update { - copy( - sensorType = event.data.imuType, - status = event.data.status, - completedRestCalibration = event.data.hasCompletedRestCalibration, - magStatus = if (isNew && magStatus == MagnetometerStatus.NOT_SUPPORTED) { - if (event.data.sensorConfig?.magSupported == true) MagnetometerStatus.DISABLED else MagnetometerStatus.NOT_SUPPORTED - } else magStatus, - ) - }) + tracker.context.dispatchAll(listOf( + TrackerActions.Update { copy(sensorType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration) }, + TrackerActions.SetStatus(event.data.status), + )) + if (isNew && tracker.context.state.value.magStatus == MagnetometerStatus.NOT_SUPPORTED) { + tracker.context.dispatch(TrackerActions.SetMagStatus( + if (event.data.sensorConfig?.magSupported == true) MagnetometerStatus.DISABLED else MagnetometerStatus.NOT_SUPPORTED + )) + } val remoteMagStatus = event.data.sensorConfig?.let { if (it.magSupported) { @@ -321,22 +321,22 @@ object SensorRotationBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { receiver.packetEvents.onPacket { event -> val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = event.data.rotation) }) + tracker.context.dispatch(TrackerActions.SetRotation(rotation = event.data.rotation)) } receiver.packetEvents.onPacket { event -> val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = event.data.rotation, acceleration = event.data.acceleration) }) + tracker.context.dispatch(TrackerActions.SetRotation(rotation = event.data.rotation, acceleration = event.data.acceleration)) } receiver.packetEvents.onPacket { event -> val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(acceleration = event.data.acceleration) }) + tracker.context.dispatch(TrackerActions.SetRotation(acceleration = event.data.acceleration)) } receiver.packetEvents.onPacket { event -> val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = event.data.rotation) }) + tracker.context.dispatch(TrackerActions.SetRotation(rotation = event.data.rotation)) } } } @@ -412,7 +412,6 @@ object SensorConfigBehaviour : UDPConnectionBehaviour { state = flags.magStatus == MagnetometerStatus.ENABLED ) ) - println("SEND CONFIG = ${flags.magStatus}") } } .launchIn(receiver.context.scope) @@ -427,8 +426,7 @@ object AckConfigBehaviour : UDPConnectionBehaviour { val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket if (configType == SensorConfigType.MAGNETOMETER) { - tracker.context.dispatch(TrackerActions.Update { copy(magStatus = flags.magStatus) }) - println("UPDATE? = ${flags.magStatus}") + tracker.context.dispatch(TrackerActions.SetMagStatus(flags.magStatus)) } } } From 943d51dbdc345de1c84ee81d634b118ca865fd85 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 21 Apr 2026 06:46:16 +0200 Subject: [PATCH 069/350] Debug tools + Spotless --- server/README.md | 130 ++++++------ .../src/main/java/dev/slimevr/app-context.kt | 66 +++--- .../java/dev/slimevr/config/behaviours.kt | 2 + .../java/dev/slimevr/config/serializers.kt | 10 +- .../main/java/dev/slimevr/config/settings.kt | 4 +- .../main/java/dev/slimevr/context/context.kt | 45 +++- .../slimevr/context/debug/DebugMiddleware.kt | 6 + .../context/debug/LoggingMiddleware.kt | 124 +++++++++++ .../java/dev/slimevr/driver/behaviours.kt | 2 +- .../java/dev/slimevr/feeder/behaviours.kt | 2 +- .../java/dev/slimevr/firmware/behaviours.kt | 2 + .../main/java/dev/slimevr/firmware/module.kt | 6 +- .../main/java/dev/slimevr/firmware/serial.kt | 1 - .../slimevr/heightcalibration/behaviours.kt | 2 +- .../main/java/dev/slimevr/hid/behaviours.kt | 2 +- .../src/main/java/dev/slimevr/hid/packets.kt | 1 - .../dev/slimevr/provisioning/behaviours.kt | 5 +- .../java/dev/slimevr/provisioning/phases.kt | 4 +- .../java/dev/slimevr/serial/behaviours.kt | 2 + .../java/dev/slimevr/serial/connection.kt | 1 - .../java/dev/slimevr/skeleton/behaviours.kt | 8 +- .../main/java/dev/slimevr/skeleton/module.kt | 11 +- .../java/dev/slimevr/skeleton/processors.kt | 2 +- .../java/dev/slimevr/skeleton/proportions.kt | 23 +-- .../main/java/dev/slimevr/solarxr/datafeed.kt | 54 +++-- .../src/main/java/dev/slimevr/solarxr/mag.kt | 24 ++- .../java/dev/slimevr/solarxr/provisioning.kt | 3 +- .../dev/slimevr/solarxr/skeletonconfig.kt | 4 +- .../dev/slimevr/solarxr/trackerassignment.kt | 16 +- .../dev/slimevr/solarxr/trackingchecklist.kt | 63 +++--- .../java/dev/slimevr/tracker/behaviours.kt | 9 +- .../main/java/dev/slimevr/tracker/config.kt | 2 +- .../main/java/dev/slimevr/tracker/module.kt | 18 +- .../slimevr/trackingchecklist/behaviours.kt | 192 +++++++++--------- .../dev/slimevr/trackingchecklist/module.kt | 54 ++--- .../main/java/dev/slimevr/udp/behaviours.kt | 44 ++-- .../src/main/java/dev/slimevr/udp/packets.kt | 19 +- .../src/main/java/dev/slimevr/udp/server.kt | 9 +- .../main/java/dev/slimevr/vrchat/module.kt | 19 +- .../src/main/java/dev/slimevr/vrserver.kt | 6 +- .../github/axisangles/ktmath/EulerAngles.kt | 16 ++ .../io/github/axisangles/ktmath/Matrix3.kt | 23 +++ .../io/github/axisangles/ktmath/Quaternion.kt | 16 ++ .../io/github/axisangles/ktmath/Vector3.kt | 15 ++ .../reducers/FirmwareManagerReducerTest.kt | 2 +- .../main/java/dev/slimevr/desktop/hid/hid.kt | 2 +- .../java/dev/slimevr/desktop/hid/packets.kt | 2 +- .../java/dev/slimevr/desktop/ipc/linux.kt | 2 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 8 +- .../java/dev/slimevr/desktop/ipc/windows.kt | 2 +- 50 files changed, 673 insertions(+), 412 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/context/debug/DebugMiddleware.kt create mode 100644 server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt diff --git a/server/README.md b/server/README.md index d145bbad01..a962344b06 100644 --- a/server/README.md +++ b/server/README.md @@ -34,13 +34,13 @@ The cost is that you cannot mutate state directly. Everything goes through `disp ```kotlin class Context( - val state: StateFlow, - val scope: CoroutineScope, - val behaviours: CopyOnWriteArrayList>, + val state: StateFlow, + val scope: CoroutineScope, + val behaviours: CopyOnWriteArrayList>, ) { - fun dispatch(action: A) - fun dispatchAll(actions: List) - fun observeAll(receiver: C) + fun dispatch(action: A) + fun dispatchAll(actions: List) + fun observeAll(receiver: C) } ``` @@ -60,8 +60,8 @@ A behaviour is one feature of a module. It has two methods, both optional: ```kotlin interface Behaviour { - fun reduce(state: S, action: A): S = state // pure, no side effects - fun observe(receiver: C) {} // side effects go here + fun reduce(state: S, action: A): S = state // pure, no side effects + fun observe(receiver: C) {} // side effects go here } ``` @@ -95,8 +95,8 @@ Behaviours that need external services are `class`es, constructed at the call si ```kotlin class TrackerConfigBehaviour( - private val settings: Settings, - private val hardwareId: String, + private val settings: Settings, + private val hardwareId: String, ) : TrackerBehaviour { ... } ``` @@ -104,8 +104,8 @@ When a behaviour is conditional (depends on a nullable service), use `buildList` ```kotlin val behaviours: List = buildList { - add(AlwaysPresentBehaviour) - optionalService?.let { add(OptionalServiceBehaviour(it)) } + add(AlwaysPresentBehaviour) + optionalService?.let { add(OptionalServiceBehaviour(it)) } } ``` @@ -113,11 +113,11 @@ For modules like `SolarXRBridge` where the behaviour list is large and reusable, ```kotlin companion object { - fun buildBehaviours(appContext: AppContextProvider): List = buildList { - add(DataFeedInitBehaviour(appContext.server, appContext.skeleton)) - add(FirmwareBehaviour(appContext.server, appContext.firmwareManager)) - // ... - } + fun buildBehaviours(appContext: AppContextProvider): List = buildList { + add(DataFeedInitBehaviour(appContext.server, appContext.skeleton)) + add(FirmwareBehaviour(appContext.server, appContext.firmwareManager)) + // ... + } } ``` @@ -205,9 +205,9 @@ Phase1ContextProvider ```kotlin interface Phase1ContextProvider { - val server: VRServer - val config: AppConfig - val serialServer: SerialServer + val server: VRServer + val config: AppConfig + val serialServer: SerialServer } ``` @@ -215,13 +215,13 @@ interface Phase1ContextProvider { ```kotlin interface AppContextProvider : Phase1ContextProvider { - val skeleton: Skeleton - val firmwareManager: FirmwareManager - val vrcConfigManager: VRCConfigManager? - val provisioningManager: ProvisioningManager - val heightCalibrationManager: HeightCalibrationManager - val trackingChecklist: TrackingChecklist - fun startObserving() + val skeleton: Skeleton + val firmwareManager: FirmwareManager + val vrcConfigManager: VRCConfigManager? + val provisioningManager: ProvisioningManager + val heightCalibrationManager: HeightCalibrationManager + val trackingChecklist: TrackingChecklist + fun startObserving() } ``` @@ -237,9 +237,9 @@ Actions are `sealed interface`s. This matters because the compiler enforces exha ```kotlin sealed interface UDPConnectionActions { - data class Handshake(val deviceId: Int) : UDPConnectionActions - data class LastPacket(val packetNum: Long?, val time: Long) : UDPConnectionActions - data object Disconnected : UDPConnectionActions + data class Handshake(val deviceId: Int) : UDPConnectionActions + data class LastPacket(val packetNum: Long?, val time: Long) : UDPConnectionActions + data object Disconnected : UDPConnectionActions } ``` @@ -312,20 +312,20 @@ Two common patterns: // Inside TrackerConfigBehaviour.observe() // When tracker state changes, persist the relevant fields to Settings receiver.context.state - .distinctUntilChangedBy { stateToConfig(it) } - .drop(1) - .onEach { state -> - settings.context.dispatch(SettingsActions.UpdateTracker(hardwareId) { stateToConfig(state) }) - } - .launchIn(receiver.context.scope) + .distinctUntilChangedBy { stateToConfig(it) } + .drop(1) + .onEach { state -> + settings.context.dispatch(SettingsActions.UpdateTracker(hardwareId) { stateToConfig(state) }) + } + .launchIn(receiver.context.scope) ``` **Dispatch into VRServer from a connection:** ```kotlin // Inside HandshakeBehaviour.observe() - a UDP connection registers a new device receiver.packetEvents.on { event -> - val device = Device.create(...) - receiver.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId, device)) + val device = Device.create(...) + receiver.serverContext.context.dispatch(VRServerActions.NewDevice(deviceId, device)) } ``` @@ -390,9 +390,9 @@ Create a context directly with the behaviours you want to test. No module class, ```kotlin val context = Context.create( - initialState = FirmwareManagerState(jobs = mapOf()), - scope = this, - behaviours = listOf(FirmwareManagerBaseBehaviour), + initialState = FirmwareManagerState(jobs = mapOf()), + scope = this, + behaviours = listOf(FirmwareManagerBaseBehaviour), ) context.dispatch(FirmwareManagerActions.UpdateJob(...)) assertEquals(FirmwareUpdateStatus.UPLOADING, context.state.value.jobs["COM1"]?.status) @@ -405,9 +405,9 @@ Construct the module directly - bypassing `create()` - with only the behaviours ```kotlin val serialServer = buildTestSerialServer(backgroundScope) val context = Context.create( - initialState = ProvisioningManager.INITIAL_STATE, - scope = backgroundScope, - behaviours = listOf(ProvisioningManagerBaseBehaviour), + initialState = ProvisioningManager.INITIAL_STATE, + scope = backgroundScope, + behaviours = listOf(ProvisioningManagerBaseBehaviour), ) val manager = ProvisioningManager(context = context, serialServer = serialServer, scope = backgroundScope) manager.startObserving() @@ -421,16 +421,16 @@ For modules that need an `AppContextProvider` (e.g. `SolarXRBridge`), extend `Te val server = buildTestVrServer(backgroundScope) val skeleton = buildTestSkeleton(backgroundScope) val appContext = object : TestAppContext() { - override val server = server - override val skeleton = skeleton + override val server = server + override val skeleton = skeleton } val context = Context.create( - initialState = SolarXRBridgeState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), - scope = backgroundScope, - behaviours = listOf(DataFeedInitBehaviour(server, skeleton)), + initialState = SolarXRBridgeState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), + scope = backgroundScope, + behaviours = listOf(DataFeedInitBehaviour(server, skeleton)), ) val bridge = SolarXRBridge(id = 1, context = context, appContext = appContext, - dataFeedDispatcher = EventDispatcher(), rpcDispatcher = EventDispatcher()) + dataFeedDispatcher = EventDispatcher(), rpcDispatcher = EventDispatcher()) bridge.startObserving() ``` @@ -471,8 +471,8 @@ bridge.startObserving() data class MyState(val connected: Boolean) sealed interface MyActions { - data object Connected : MyActions - data object Disconnected : MyActions + data object Connected : MyActions + data object Disconnected : MyActions } typealias MyContext = Context @@ -482,18 +482,18 @@ typealias MyBehaviour = Behaviour 2. **Define the module class** with a `create()` factory and a `startObserving()` method: ```kotlin class MyModule(val context: MyContext, val server: VRServer) { - fun startObserving() = context.observeAll(this) - - companion object { - fun create(ctx: Phase1ContextProvider, scope: CoroutineScope): MyModule { - val context = Context.create( - initialState = MyState(false), - scope = scope, - behaviours = listOf(MyCoreBehaviour), - ) - return MyModule(context, ctx.server) - } - } + fun startObserving() = context.observeAll(this) + + companion object { + fun create(ctx: Phase1ContextProvider, scope: CoroutineScope): MyModule { + val context = Context.create( + initialState = MyState(false), + scope = scope, + behaviours = listOf(MyCoreBehaviour), + ) + return MyModule(context, ctx.server) + } + } } ``` @@ -512,7 +512,7 @@ Add it to the `behaviours` list in `create()`. The behaviour's `reduce` and `obs 3. Register a listener in a behaviour's `observe`: ```kotlin receiver.packetEvents.on { event -> - receiver.context.dispatch(...) + receiver.context.dispatch(...) } ``` diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index 80e0bc9c05..03564300cd 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -11,47 +11,47 @@ import dev.slimevr.udp.UdpServer import dev.slimevr.vrchat.VRCConfigManager interface Phase1ContextProvider { - val server: VRServer - val config: AppConfig - val serialServer: SerialServer + val server: VRServer + val config: AppConfig + val serialServer: SerialServer } data class Phase1Context( - override val server: VRServer, - override val config: AppConfig, - override val serialServer: SerialServer, + override val server: VRServer, + override val config: AppConfig, + override val serialServer: SerialServer, ) : Phase1ContextProvider interface AppContextProvider : Phase1ContextProvider { - val skeleton: Skeleton - val firmwareManager: FirmwareManager - val vrcConfigManager: VRCConfigManager? - val provisioningManager: ProvisioningManager - val heightCalibrationManager: HeightCalibrationManager - val trackingChecklist: TrackingChecklist - val udpServer: UdpServer - fun startObserving() + val skeleton: Skeleton + val firmwareManager: FirmwareManager + val vrcConfigManager: VRCConfigManager? + val provisioningManager: ProvisioningManager + val heightCalibrationManager: HeightCalibrationManager + val trackingChecklist: TrackingChecklist + val udpServer: UdpServer + fun startObserving() } class AppContext( - override val server: VRServer, - override val config: AppConfig, - override val serialServer: SerialServer, - override val skeleton: Skeleton, - override val firmwareManager: FirmwareManager, - override val vrcConfigManager: VRCConfigManager?, - override val provisioningManager: ProvisioningManager, - override val heightCalibrationManager: HeightCalibrationManager, - override val trackingChecklist: TrackingChecklist, - override val udpServer: UdpServer, + override val server: VRServer, + override val config: AppConfig, + override val serialServer: SerialServer, + override val skeleton: Skeleton, + override val firmwareManager: FirmwareManager, + override val vrcConfigManager: VRCConfigManager?, + override val provisioningManager: ProvisioningManager, + override val heightCalibrationManager: HeightCalibrationManager, + override val trackingChecklist: TrackingChecklist, + override val udpServer: UdpServer, ) : AppContextProvider { - override fun startObserving() { - skeleton.startObserving() - firmwareManager.startObserving() - provisioningManager.startObserving() - heightCalibrationManager.startObserving() - vrcConfigManager?.startObserving() - trackingChecklist.startObserving(this) - udpServer.startReceiving(this, server.context.scope) - } + override fun startObserving() { + skeleton.startObserving() + firmwareManager.startObserving() + provisioningManager.startObserving() + heightCalibrationManager.startObserving() + vrcConfigManager?.startObserving() + trackingChecklist.startObserving(this) + udpServer.startReceiving(this, server.context.scope) + } } diff --git a/server/core/src/main/java/dev/slimevr/config/behaviours.kt b/server/core/src/main/java/dev/slimevr/config/behaviours.kt index ffd4d8a264..bdc7289862 100644 --- a/server/core/src/main/java/dev/slimevr/config/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/config/behaviours.kt @@ -10,7 +10,9 @@ object DefaultGlobalConfigBehaviour : GlobalConfigBehaviour { object DefaultSettingsBehaviour : SettingsBehaviour { override fun reduce(state: SettingsState, action: SettingsActions) = when (action) { is SettingsActions.Update -> state.copy(data = action.transform(state.data)) + is SettingsActions.LoadProfile -> action.newState + is SettingsActions.UpdateTracker -> { val existing = state.data.trackers[action.hardwareId] ?: TrackerConfig() state.copy(data = state.data.copy(trackers = state.data.trackers + (action.hardwareId to action.transform(existing)))) diff --git a/server/core/src/main/java/dev/slimevr/config/serializers.kt b/server/core/src/main/java/dev/slimevr/config/serializers.kt index 2091dc254b..9161888a86 100644 --- a/server/core/src/main/java/dev/slimevr/config/serializers.kt +++ b/server/core/src/main/java/dev/slimevr/config/serializers.kt @@ -22,8 +22,11 @@ object BodyPartSerializer : KSerializer { override fun serialize(encoder: Encoder, value: BodyPart?) { val jsonEncoder = encoder as JsonEncoder - if (value == null) jsonEncoder.encodeJsonElement(JsonNull) - else jsonEncoder.encodeJsonElement(JsonPrimitive(value.name)) + if (value == null) { + jsonEncoder.encodeJsonElement(JsonNull) + } else { + jsonEncoder.encodeJsonElement(JsonPrimitive(value.name)) + } } override fun deserialize(decoder: Decoder): BodyPart? { @@ -38,8 +41,7 @@ private data class QuaternionSurrogate(val w: Float, val x: Float, val y: Float, object QuaternionSerializer : KSerializer { override val descriptor = QuaternionSurrogate.serializer().descriptor - override fun serialize(encoder: Encoder, value: Quaternion) = - encoder.encodeSerializableValue(QuaternionSurrogate.serializer(), QuaternionSurrogate(value.w, value.x, value.y, value.z)) + override fun serialize(encoder: Encoder, value: Quaternion) = encoder.encodeSerializableValue(QuaternionSurrogate.serializer(), QuaternionSurrogate(value.w, value.x, value.y, value.z)) override fun deserialize(decoder: Decoder): Quaternion { val s = decoder.decodeSerializableValue(QuaternionSurrogate.serializer()) return Quaternion(s.w, s.x, s.y, s.z) diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index d6b563bfee..8553c2c132 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -2,10 +2,10 @@ package dev.slimevr.config import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin -import io.github.axisangles.ktmath.Quaternion import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable @@ -28,8 +28,6 @@ import java.io.File private const val SETTINGS_CONFIG_VERSION = 1 - - @Serializable data class TrackerConfig( @Serializable(with = BodyPartSerializer::class) diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index 3c575509c0..292cd8d418 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -1,5 +1,6 @@ package dev.slimevr.context +import dev.slimevr.context.debug.DebugMiddleware import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -14,16 +15,37 @@ interface Behaviour { class Context( private val mutableStateFlow: MutableStateFlow, - private val applyAction: (S, A) -> S, + val reducer: (S, A) -> S, val scope: CoroutineScope, val behaviours: CopyOnWriteArrayList>, + private val debugMiddleware: DebugMiddleware? = null, ) { val state: StateFlow = mutableStateFlow.asStateFlow() fun dispatch(action: A) { - mutableStateFlow.update { - applyAction(it, action) + if (debugMiddleware == null) { + mutableStateFlow.update { currentState -> reducer(currentState, action) } + return } + val caller = captureCallerBehaviour() + val before = mutableStateFlow.value + mutableStateFlow.update { currentState -> reducer(currentState, action) } + debugMiddleware.onDispatch(caller, before, action, mutableStateFlow.value) + } + + fun dispatchAll(actions: List) { + if (debugMiddleware == null) { + mutableStateFlow.update { currentState -> + actions.fold(currentState) { s, action -> reducer(s, action) } + } + return + } + val caller = captureCallerBehaviour() + val before = mutableStateFlow.value + mutableStateFlow.update { currentState -> + actions.fold(currentState) { s, action -> reducer(s, action) } + } + debugMiddleware.onDispatchBatch(caller, before, actions, mutableStateFlow.value) } fun observeAll(receiver: C) = behaviours.forEach { behaviour -> @@ -31,23 +53,28 @@ class Context( (behaviour as Behaviour).observe(receiver) } - fun dispatchAll(actions: List) { - mutableStateFlow.update { currentState -> - actions.fold(currentState) { s, action -> applyAction(s, action) } - } + private fun captureCallerBehaviour(): String? { + val knownBehaviourClasses = behaviours.mapTo(HashSet()) { b -> b::class.java.name } + return Thread.currentThread().stackTrace + .firstOrNull { frame -> frame.className in knownBehaviourClasses } + ?.className?.substringAfterLast('.') } companion object { + val debugEnabled: Boolean = System.getProperty("slimevr.debug.context") == "true" || + System.getenv("SLIMEVR_DEBUG_CONTEXT") == "true" + fun create( initialState: S, scope: CoroutineScope, behaviours: List>, + debugMiddleware: DebugMiddleware? = null, ): Context { val mutableStateFlow = MutableStateFlow(initialState) - val applyAction: (S, A) -> S = { currentState, action -> + val reducer: (S, A) -> S = { currentState, action -> behaviours.fold(currentState) { s, b -> b.reduce(s, action) } } - return Context(mutableStateFlow, applyAction, scope, CopyOnWriteArrayList(behaviours)) + return Context(mutableStateFlow, reducer, scope, CopyOnWriteArrayList(behaviours), if (debugEnabled) debugMiddleware else null) } } } diff --git a/server/core/src/main/java/dev/slimevr/context/debug/DebugMiddleware.kt b/server/core/src/main/java/dev/slimevr/context/debug/DebugMiddleware.kt new file mode 100644 index 0000000000..918778d229 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/context/debug/DebugMiddleware.kt @@ -0,0 +1,6 @@ +package dev.slimevr.context.debug + +interface DebugMiddleware { + fun onDispatch(caller: String?, before: S, action: A, after: S) + fun onDispatchBatch(caller: String?, before: S, actions: List, after: S) +} diff --git a/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt b/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt new file mode 100644 index 0000000000..10542e14ed --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt @@ -0,0 +1,124 @@ +package dev.slimevr.context.debug + +import kotlin.reflect.KClass + +private object Ansi { + const val RESET = "\u001B[0m" + const val DIM = "\u001B[2m" + const val CYAN = "\u001B[36m" + const val YELLOW = "\u001B[33m" + const val GREEN = "\u001B[32m" + const val RED = "\u001B[31m" + const val MAGENTA = "\u001B[35m" + const val ARROW = "\u001B[2m→\u001B[0m" +} + +enum class DiffStyle { INLINE, MULTILINE } + +class LoggingMiddleware( + moduleName: String, + private val logNoOps: Boolean = false, + private val diffStyle: DiffStyle = DiffStyle.MULTILINE, + private val allow: Set>? = null, + private val block: Set> = emptySet(), +) : DebugMiddleware { + private val tag = "${Ansi.CYAN}[$moduleName]${Ansi.RESET}" + + private fun isAllowed(action: A): Boolean { + val klass = action!!::class + if (klass in block) return false + if (allow != null && klass !in allow) return false + return true + } + + override fun onDispatch(caller: String?, before: S, action: A, after: S) { + if (!isAllowed(action)) return + if (!logNoOps && before == after) return + val actionName = "${Ansi.YELLOW}${action!!::class.simpleName ?: "UnknownAction"}${Ansi.RESET}" + val callerSuffix = if (caller != null) " ${Ansi.DIM}(from $caller)${Ansi.RESET}" else "" + println(formatLine("$tag $actionName$callerSuffix", before, after)) + } + + override fun onDispatchBatch(caller: String?, before: S, actions: List, after: S) { + val visible = actions.filter { action -> isAllowed(action) } + if (visible.isEmpty()) return + if (!logNoOps && before == after) return + val names = visible.joinToString(", ") { action -> action!!::class.simpleName ?: "?" } + val actionName = "${Ansi.MAGENTA}batch${Ansi.RESET} ${Ansi.YELLOW}[$names]${Ansi.RESET}" + val callerSuffix = if (caller != null) " ${Ansi.DIM}(from $caller)${Ansi.RESET}" else "" + println(formatLine("$tag $actionName$callerSuffix", before, after)) + } + + private fun formatLine(header: String, before: S, after: S): String { + if (before == after) return "$header ${Ansi.DIM}(no-op)${Ansi.RESET}" + val changes = fieldChanges(before.toString(), after.toString()) + if (changes.isEmpty()) return "$header ${Ansi.DIM}(no-op)${Ansi.RESET}" + if (diffStyle == DiffStyle.INLINE) { + val inline = changes.joinToString(", ") { c -> + "${Ansi.DIM}${c.name}:${Ansi.RESET} ${Ansi.RED}${c.before}${Ansi.RESET} ${Ansi.ARROW} ${Ansi.GREEN}${c.after}${Ansi.RESET}" + } + return "$header $inline" + } + if (changes.size == 1 && !isNestedObject(changes[0].before)) { + val c = changes[0] + return "$header ${Ansi.DIM}${c.name}:${Ansi.RESET} ${Ansi.RED}${c.before}${Ansi.RESET} ${Ansi.ARROW} ${Ansi.GREEN}${c.after}${Ansi.RESET}" + } + val nameWidth = changes.maxOf { c -> c.name.length } + val rows = changes.joinToString("\n") { c -> formatChange(c, nameWidth) } + return "$header\n$rows" + } +} + +private fun isNestedObject(value: String): Boolean = value.contains('(') && value.endsWith(')') + +private fun formatChange(c: FieldChange, nameWidth: Int): String { + if (isNestedObject(c.before) && isNestedObject(c.after)) { + val subChanges = fieldChanges(c.before, c.after) + if (subChanges.isNotEmpty()) { + val subNameWidth = subChanges.maxOf { s -> s.name.length } + val subOldWidth = subChanges.maxOf { s -> s.before.length } + val rows = subChanges.joinToString("\n") { s -> + val name = s.name.padEnd(subNameWidth) + val old = s.before.padEnd(subOldWidth) + " ${Ansi.DIM}$name${Ansi.RESET} ${Ansi.RED}$old${Ansi.RESET} ${Ansi.ARROW} ${Ansi.GREEN}${s.after}${Ansi.RESET}" + } + val typeName = c.before.substringBefore('(') + return " ${Ansi.DIM}${c.name.padEnd(nameWidth)}${Ansi.RESET} ${Ansi.DIM}$typeName${Ansi.RESET}\n$rows" + } + } + return " ${Ansi.DIM}${c.name.padEnd(nameWidth)}${Ansi.RESET} ${Ansi.RED}${c.before}${Ansi.RESET} ${Ansi.ARROW} ${Ansi.GREEN}${c.after}${Ansi.RESET}" +} + +private data class FieldChange(val name: String, val before: String, val after: String) + +private fun parseFields(s: String): Map { + val inner = s.substringAfter('(').dropLast(1) + val fields = mutableListOf() + var depth = 0 + var start = 0 + for (i in inner.indices) { + when (inner[i]) { + '(' -> depth++ + + ')' -> depth-- + + ',' -> if (depth == 0) { + fields.add(inner.substring(start, i).trim()) + start = i + 1 + } + } + } + if (start < inner.length) fields.add(inner.substring(start).trim()) + return fields.associate { field -> + val eq = field.indexOf('=') + field.substring(0, eq) to field.substring(eq + 1) + } +} + +private fun fieldChanges(before: String, after: String): List { + val beforeMap = parseFields(before) + val afterMap = parseFields(after) + return beforeMap.keys.intersect(afterMap.keys) + .filter { key -> beforeMap[key] != afterMap[key] } + .map { key -> FieldChange(key, beforeMap[key]!!, afterMap[key]!!) } +} diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index ab97b876f4..e0c2dab276 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -53,4 +53,4 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { } .launchIn(receiver.context.scope) } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt b/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt index 52284de613..76b679caa0 100644 --- a/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt @@ -69,4 +69,4 @@ object FeederBaseBehaviour : FeederBridgeBehaviour { device.context.dispatch(DeviceActions.Update { copy(protocolVersion = 0) }) } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt b/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt index 528199c9b4..a052831261 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt @@ -5,7 +5,9 @@ object FirmwareManagerBaseBehaviour : FirmwareManagerBehaviour { is FirmwareManagerActions.UpdateJob -> state.copy( jobs = state.jobs + (action.jobStatus.portLocation to action.jobStatus), ) + is FirmwareManagerActions.RemoveJob -> state.copy(jobs = state.jobs - action.portLocation) + is FirmwareManagerActions.ClearJobs -> state.copy(jobs = mapOf()) } } diff --git a/server/core/src/main/java/dev/slimevr/firmware/module.kt b/server/core/src/main/java/dev/slimevr/firmware/module.kt index 3965ec5416..47b813ddd6 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/module.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/module.kt @@ -28,7 +28,7 @@ data class FirmwareManagerState( sealed interface FirmwareManagerActions { data class UpdateJob( - val jobStatus: FirmwareJobStatus + val jobStatus: FirmwareJobStatus, ) : FirmwareManagerActions data class RemoveJob(val portLocation: String) : FirmwareManagerActions @@ -74,7 +74,7 @@ class FirmwareManager( firmwareDeviceId = SerialDevicePort(port = portLocation), status = status, progress = progress, - ) + ), ), ) }, @@ -105,7 +105,7 @@ class FirmwareManager( firmwareDeviceId = firmwareDeviceId, status = status, progress = progress, - ) + ), ), ) }, diff --git a/server/core/src/main/java/dev/slimevr/firmware/serial.kt b/server/core/src/main/java/dev/slimevr/firmware/serial.kt index 4beae6fd93..2fd9421ae2 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/serial.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/serial.kt @@ -22,7 +22,6 @@ import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.FirmwarePart import solarxr_protocol.rpc.FirmwareUpdateStatus - @OptIn(ExperimentalCoroutinesApi::class) suspend fun doSerialFlash( portLocation: String, diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt index 1fb1a9178f..2ee1d403b6 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt @@ -186,7 +186,7 @@ internal suspend fun runCalibrationSession( userConfig.context.dispatch( UserConfigActions.Update { copy(userHeight = relativeY, proportions = computeDefaultProportionsByBone(relativeY)) - } + }, ) } } diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 33f24cad08..81790a386f 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -172,4 +172,4 @@ object HIDStatusBehaviour : HIDReceiverBehaviour { ) } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/hid/packets.kt b/server/core/src/main/java/dev/slimevr/hid/packets.kt index 2ad4b34360..08b888680f 100644 --- a/server/core/src/main/java/dev/slimevr/hid/packets.kt +++ b/server/core/src/main/java/dev/slimevr/hid/packets.kt @@ -65,4 +65,3 @@ data class HIDRotationButton( val acceleration: Vector3, val rssi: Int, ) : HIDPacket - diff --git a/server/core/src/main/java/dev/slimevr/provisioning/behaviours.kt b/server/core/src/main/java/dev/slimevr/provisioning/behaviours.kt index e799f1bed3..fcf2fee737 100644 --- a/server/core/src/main/java/dev/slimevr/provisioning/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/provisioning/behaviours.kt @@ -8,12 +8,15 @@ object ProvisioningManagerBaseBehaviour : ProvisioningManagerBehaviour { portLocation = action.portLocation, macAddress = null, ) + is ProvisioningActions.StatusChanged -> state.copy( status = action.status, ) + is ProvisioningActions.MacAddressObtained -> state.copy( macAddress = action.mac, ) + is ProvisioningActions.Clear -> INITIAL_STATE } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/provisioning/phases.kt b/server/core/src/main/java/dev/slimevr/provisioning/phases.kt index dd1d22e716..6344ee5260 100644 --- a/server/core/src/main/java/dev/slimevr/provisioning/phases.kt +++ b/server/core/src/main/java/dev/slimevr/provisioning/phases.kt @@ -40,7 +40,7 @@ internal suspend fun selectAndOpenPort( listOf( ProvisioningActions.PortSelected(portEntry.key), ProvisioningActions.StatusChanged(WifiProvisioningStatus.SERIAL_INIT), - ) + ), ) serialServer.openConnection(portEntry.key) return true @@ -133,7 +133,6 @@ internal suspend fun waitForWifiConnect( context: ProvisioningManagerContext, serialConn: SerialConnection.Console, ): Boolean { - var connectRetries = 0 while (currentCoroutineContext().isActive) { @@ -150,6 +149,7 @@ internal suspend fun waitForWifiConnect( } -> true lines.any { "can't connect from any credentials" in it.lowercase() } -> false + else -> null } } diff --git a/server/core/src/main/java/dev/slimevr/serial/behaviours.kt b/server/core/src/main/java/dev/slimevr/serial/behaviours.kt index 353b83ba43..15c6082739 100644 --- a/server/core/src/main/java/dev/slimevr/serial/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/serial/behaviours.kt @@ -24,7 +24,9 @@ object SerialLogBehaviour : SerialConnectionBehaviour { val lines = if (state.logLines.size >= MAX_LOG_LINES) state.logLines.drop(1) else state.logLines state.copy(logLines = lines + action.line) } + is SerialConnectionActions.ClearLogs -> state.copy(logLines = listOf()) + is SerialConnectionActions.Disconnected -> state.copy(connected = false) } } diff --git a/server/core/src/main/java/dev/slimevr/serial/connection.kt b/server/core/src/main/java/dev/slimevr/serial/connection.kt index c7365d95da..225e706ca7 100644 --- a/server/core/src/main/java/dev/slimevr/serial/connection.kt +++ b/server/core/src/main/java/dev/slimevr/serial/connection.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.CoroutineScope val MAC_REGEX = Regex("mac: (([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2})", RegexOption.IGNORE_CASE) - data class SerialPortHandle( val portLocation: String, val descriptivePortName: String, diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index d6e5ce4c8f..6a166e60b5 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -22,6 +22,7 @@ class ProportionsBehaviour : SkeletonBehaviour { } state.copy(bones = newBones, userHeight = computeUserHeight(newBones.mapValues { (_, bone) -> bone.length })) } + else -> state } } @@ -61,6 +62,7 @@ class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour bones[action.bodyPart] = bone.copy(rotation = action.rotation) state.copy(bones = bones) } + else -> state } } @@ -76,13 +78,13 @@ class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour SkeletonActions.SetBoneRotation( BodyPart.CHEST, Quaternion.fromRotationVector(Vector3(cos(elapsed), sin(elapsed), 0f)), - ) + ), ) receiver.context.dispatch( SkeletonActions.SetBoneRotation( BodyPart.LEFT_LOWER_LEG, Quaternion.fromRotationVector(Vector3(cos(elapsed + 1000), sin(elapsed + 1000), 0f)), - ) + ), ) } } @@ -91,7 +93,7 @@ class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour class ComputedSkeletonBehaviour( val hz: Float = 100f, - val processors: List = emptyList() + val processors: List = emptyList(), ) : SkeletonBehaviour { override fun observe(receiver: Skeleton) { val intervalMs = (1000f / hz).toLong() diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index b9028ebe2f..6ffd744490 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -26,7 +26,6 @@ data class BoneState( get() = tailPosition - headPosition } - data class SkeletonState(val bones: Map, val userHeight: Double) val DEFAULT_SKELETON_STATE: SkeletonState = run { @@ -77,10 +76,12 @@ class Skeleton( ScaledProportionsBehaviour(ctx.config.userConfig), HeightLogBehaviour(), YouSpinMeRightRoundBehaviour(inputHz = 10f), - ComputedSkeletonBehaviour(processors = listOf( -// PredictionProcessor(predictionAmount = 0.3f), - SmoothingProcessor(smoothing = 0.3f) - )), + ComputedSkeletonBehaviour( + processors = listOf( +// PredictionProcessor(predictionAmount = 0.3f), + SmoothingProcessor(smoothing = 0.3f), + ), + ), ) val context = Context.create( diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt index 51edb41d21..4ba99df0f1 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt @@ -28,7 +28,7 @@ class SmoothingProcessor(var smoothing: Float) : SkeletonProcessor { rotation = smoothedRotations[bodyPart] ?: bone.rotation, length = smoothedLengths[bodyPart] ?: bone.length, ) - } + }, ) } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt index 3eb029f323..5625bf732f 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt @@ -46,17 +46,15 @@ private val HEIGHT_CONTRIBUTING_BONES: Set = // Sums the HEIGHT_CONTRIBUTING_BONES lengths to derive standing height. // For symmetric bones (legs) the larger side is used. -fun computeUserHeight(lengths: Map): Double = - HEIGHT_CONTRIBUTING_BONES.sumOf { bone -> - val bodyParts = SKELETON_BONE_TO_BODY_PARTS[bone] ?: return@sumOf 0.0 - bodyParts.maxOfOrNull { lengths[it] ?: 0f }?.toDouble() ?: 0.0 - } +fun computeUserHeight(lengths: Map): Double = HEIGHT_CONTRIBUTING_BONES.sumOf { bone -> + val bodyParts = SKELETON_BONE_TO_BODY_PARTS[bone] ?: return@sumOf 0.0 + bodyParts.maxOfOrNull { lengths[it] ?: 0f }?.toDouble() ?: 0.0 +} // Returns proportions keyed by SkeletonBone.name for config storage. // Only height-scaled bones are included. -fun computeDefaultProportionsByBone(height: Float): Map = - HEIGHT_SCALED_BONE_RATIOS.mapKeys { (bone, _) -> bone.name } - .mapValues { (_, ratio) -> height * ratio } +fun computeDefaultProportionsByBone(height: Float): Map = HEIGHT_SCALED_BONE_RATIOS.mapKeys { (bone, _) -> bone.name } + .mapValues { (_, ratio) -> height * ratio } // Returns proportions for all tracked bones: height-scaled + default lengths for the rest. fun computeAllDefaultProportionsByBone(height: Float): Map { @@ -72,8 +70,7 @@ fun computeAllDefaultProportionsByBone(height: Float): Map { return heightScaled + nonScaled } -fun expandProportions(proportions: Map): Map = - proportions.entries.flatMap { (boneName, length) -> - val bone = SkeletonBone.entries.firstOrNull { it.name == boneName } ?: return@flatMap emptyList() - (SKELETON_BONE_TO_BODY_PARTS[bone] ?: return@flatMap emptyList()).map { bodyPart -> bodyPart to length } - }.toMap() +fun expandProportions(proportions: Map): Map = proportions.entries.flatMap { (boneName, length) -> + val bone = SkeletonBone.entries.firstOrNull { it.name == boneName } ?: return@flatMap emptyList() + (SKELETON_BONE_TO_BODY_PARTS[bone] ?: return@flatMap emptyList()).map { bodyPart -> bodyPart to length } +}.toMap() diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index e973936533..e28fb90dd8 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -28,34 +28,32 @@ import solarxr_protocol.datatypes.math.Quat import solarxr_protocol.datatypes.math.Vec3f import java.nio.ByteBuffer -private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMask: TrackerDataMask, datafeedConfig: DataFeedConfig): TrackerData { - return TrackerData( - trackerId = TrackerId( - trackerNum = tracker.id.toUByte(), - deviceId = DeviceId(device.id.toUByte()), - ), - status = if (trackerMask.status) tracker.status else null, - rotation = if (trackerMask.rotation) tracker.rawRotation.let { Quat(it.x, it.y, it.z, it.w) } else null, - position = if (trackerMask.position && tracker.position != null) tracker.position.let { Vec3f(it.x, it.y, it.z) } else null, - info = if (trackerMask.info) { - TrackerInfo( - imuType = tracker.sensorType, - bodyPart = tracker.bodyPart, - displayName = tracker.name, - customName = tracker.customName, - mountingOrientation = tracker.mountingOrientation?.let { Quat(it.x, it.y, it.z, it.w) }, - isImu = tracker.sensorType != null, - magnetometer = tracker.magStatus, - ) - } else { - null - }, - tps = if (trackerMask.tps) tracker.tps else null, - temp = if (trackerMask.temp && tracker.imuTemp != null) Temperature(temp = tracker.imuTemp) else null, - rawAcceleration = if (trackerMask.rawAcceleration) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null, - linearAcceleration = if (trackerMask.linearAcceleration) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null // FIXME: temp value - ) -} +private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMask: TrackerDataMask, datafeedConfig: DataFeedConfig): TrackerData = TrackerData( + trackerId = TrackerId( + trackerNum = tracker.id.toUByte(), + deviceId = DeviceId(device.id.toUByte()), + ), + status = if (trackerMask.status) tracker.status else null, + rotation = if (trackerMask.rotation) tracker.rawRotation.let { Quat(it.x, it.y, it.z, it.w) } else null, + position = if (trackerMask.position && tracker.position != null) tracker.position.let { Vec3f(it.x, it.y, it.z) } else null, + info = if (trackerMask.info) { + TrackerInfo( + imuType = tracker.sensorType, + bodyPart = tracker.bodyPart, + displayName = tracker.name, + customName = tracker.customName, + mountingOrientation = tracker.mountingOrientation?.let { Quat(it.x, it.y, it.z, it.w) }, + isImu = tracker.sensorType != null, + magnetometer = tracker.magStatus, + ) + } else { + null + }, + tps = if (trackerMask.tps) tracker.tps else null, + temp = if (trackerMask.temp && tracker.imuTemp != null) Temperature(temp = tracker.imuTemp) else null, + rawAcceleration = if (trackerMask.rawAcceleration) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null, + linearAcceleration = if (trackerMask.linearAcceleration) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null, // FIXME: temp value +) private fun createDevice( device: DeviceState, diff --git a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt index 40648e1437..e5dae7200c 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt @@ -24,7 +24,7 @@ class MagBehaviour( UDPConnectionActions.SetSensorConfig( sensorId = sensorId, flags = SensorConfigFlags(magStatus = if (enable) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED), - ) + ), ) return true } @@ -64,9 +64,11 @@ class MagBehaviour( } } } + DeviceOrigin.HID -> { // TODO: implement HID mag toggle } + else -> return@on } } @@ -75,18 +77,22 @@ class MagBehaviour( val trackerId = req.trackerId if (trackerId == null) { - receiver.sendRpc(MagToggleResponse( - trackerId = null, - enable = receiver.appContext.config.settings.context.state.value.data.globalMagEnabled, - )) + receiver.sendRpc( + MagToggleResponse( + trackerId = null, + enable = receiver.appContext.config.settings.context.state.value.data.globalMagEnabled, + ), + ) return@on } val trackerState = appContext.server.getTracker(trackerId.trackerNum.toInt())?.context?.state?.value ?: return@on - receiver.sendRpc(MagToggleResponse( - trackerId = trackerId, - enable = trackerState.magStatus == MagnetometerStatus.ENABLED, - )) + receiver.sendRpc( + MagToggleResponse( + trackerId = trackerId, + enable = trackerState.magStatus == MagnetometerStatus.ENABLED, + ), + ) } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/provisioning.kt b/server/core/src/main/java/dev/slimevr/solarxr/provisioning.kt index ccf13ff4ca..5d60ecc79b 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/provisioning.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/provisioning.kt @@ -2,11 +2,10 @@ package dev.slimevr.solarxr import dev.slimevr.VRServer import dev.slimevr.provisioning.ProvisioningManager -import solarxr_protocol.rpc.StartWifiProvisioningRequest - import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import solarxr_protocol.rpc.StartWifiProvisioningRequest import solarxr_protocol.rpc.StopWifiProvisioningRequest import solarxr_protocol.rpc.WifiProvisioningStatusResponse diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt index e46be4bc1a..6a9eaa8a20 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt @@ -54,7 +54,7 @@ class SkeletonBehaviour( userConfig.context.dispatch( UserConfigActions.Update { copy(userHeight = height, proportions = computeDefaultProportionsByBone(height)) - } + }, ) } } @@ -72,7 +72,7 @@ class SkeletonBehaviour( receiver.rpcDispatcher.on { req -> val bone = req.bone ?: return@on userConfig.context.dispatch( - UserConfigActions.Update { copy(proportions = proportions + (bone.name to req.value)) } + UserConfigActions.Update { copy(proportions = proportions + (bone.name to req.value)) }, ) receiver.sendRpc(buildConfigResponse()) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt b/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt index 3b94b917dc..52f525ffa2 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt @@ -17,13 +17,15 @@ class AssignTrackerBehaviour( val bodyPart = req.bodyPosition?.takeIf { it != BodyPart.NONE } val mountingOrientation = req.mountingOrientation?.let { Quaternion(it.w, it.x, it.y, it.z) } - tracker.context.dispatch(TrackerActions.Update { - copy( - bodyPart = bodyPart, - customName = req.displayName, - mountingOrientation = mountingOrientation, - ) - }) + tracker.context.dispatch( + TrackerActions.Update { + copy( + bodyPart = bodyPart, + customName = req.displayName, + mountingOrientation = mountingOrientation, + ) + }, + ) } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt b/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt index e8ae71f8df..83f697849b 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt @@ -13,41 +13,42 @@ import solarxr_protocol.rpc.TrackingChecklistResponse import solarxr_protocol.rpc.TrackingChecklistStepId class TrackingChecklistBehaviour( - private val checklist: TrackingChecklist, - private val settings: Settings, + private val checklist: TrackingChecklist, + private val settings: Settings, ) : SolarXRBridgeBehaviour { - private fun parseMutedSteps(): Set = - settings.context.state.value.data.mutedChecklistSteps - .mapNotNull { name -> TrackingChecklistStepId.entries.firstOrNull { stepId -> stepId.name == name } } - .toSet() + private fun parseMutedSteps(): Set = settings.context.state.value.data.mutedChecklistSteps + .mapNotNull { name -> TrackingChecklistStepId.entries.firstOrNull { stepId -> stepId.name == name } } + .toSet() - private fun buildResponse(): TrackingChecklistResponse { - val steps = checklist.context.state.value.steps.mapValues { it.value.copy(id = it.key) }.values.toList() - return TrackingChecklistResponse( - steps = steps, - ignoredSteps = parseMutedSteps().toList(), - ) - } + private fun buildResponse(): TrackingChecklistResponse { + val steps = checklist.context.state.value.steps.mapValues { it.value.copy(id = it.key) }.values.toList() + return TrackingChecklistResponse( + steps = steps, + ignoredSteps = parseMutedSteps().toList(), + ) + } - override fun observe(receiver: SolarXRBridge) { - combine( - checklist.context.state, - settings.context.state.map { state -> state.data.mutedChecklistSteps }, - ) { _, _ -> buildResponse() } - .onEach { response -> receiver.sendRpc(response) } - .launchIn(receiver.context.scope) + override fun observe(receiver: SolarXRBridge) { + combine( + checklist.context.state, + settings.context.state.map { state -> state.data.mutedChecklistSteps }, + ) { _, _ -> buildResponse() } + .onEach { response -> receiver.sendRpc(response) } + .launchIn(receiver.context.scope) - receiver.rpcDispatcher.on { - receiver.sendRpc(buildResponse()) - } + receiver.rpcDispatcher.on { + receiver.sendRpc(buildResponse()) + } - receiver.rpcDispatcher.on { req -> - val stepId = req.stepId ?: return@on - val name = stepId.name - settings.context.dispatch(SettingsActions.Update { - copy(mutedChecklistSteps = if (req.ignore) mutedChecklistSteps + name else mutedChecklistSteps - name) - }) - } - } + receiver.rpcDispatcher.on { req -> + val stepId = req.stepId ?: return@on + val name = stepId.name + settings.context.dispatch( + SettingsActions.Update { + copy(mutedChecklistSteps = if (req.ignore) mutedChecklistSteps + name else mutedChecklistSteps - name) + }, + ) + } + } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 56eff2bc8c..ef251dd950 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -1,19 +1,15 @@ package dev.slimevr.tracker -import kotlin.time.TimeSource import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch - import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch +import kotlin.time.TimeSource private const val NS_CONVERTER = 1.0e9f private const val CLUMP_TIME_NS = 0.06f * NS_CONVERTER @@ -83,8 +79,11 @@ object TrackerTapDetectionBehaviour : TrackerBehaviour { object TrackerBasicBehaviour : TrackerBehaviour { override fun reduce(state: TrackerState, action: TrackerActions) = when (action) { is TrackerActions.Update -> action.transform(state) + is TrackerActions.SetMagStatus -> state.copy(magStatus = action.status) + is TrackerActions.SetStatus -> state.copy(status = action.status) + is TrackerActions.SetRotation -> state.copy( rawRotation = action.rotation ?: state.rawRotation, acceleration = action.acceleration ?: state.acceleration, diff --git a/server/core/src/main/java/dev/slimevr/tracker/config.kt b/server/core/src/main/java/dev/slimevr/tracker/config.kt index a51a4a1848..87d1d365b8 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/config.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/config.kt @@ -18,7 +18,7 @@ fun restoreFromConfig(state: TrackerState, config: TrackerConfig): TrackerState true -> MagnetometerStatus.ENABLED false -> MagnetometerStatus.DISABLED null -> state.magStatus - } + }, ) private fun applyStateToConfig(config: TrackerConfig, state: TrackerState) = config.copy( diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index add3ad94e8..7469a8a895 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -5,6 +5,7 @@ import dev.slimevr.config.Settings import dev.slimevr.config.TrackerConfig import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.context.debug.LoggingMiddleware import dev.slimevr.device.DeviceOrigin import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 @@ -14,8 +15,6 @@ import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.ImuType - - data class TrackerIdNum(val id: Int, val trackerNum: Int) data class TrackerState( @@ -84,10 +83,11 @@ class Tracker( completedRestCalibration = false, magStatus = MagnetometerStatus.NOT_SUPPORTED, ) - val trackerState = if (savedConfig != null) + val trackerState = if (savedConfig != null) { restoreFromConfig(baseState, savedConfig) - else + } else { baseState + } val behaviours = listOf( TrackerBasicBehaviour, @@ -95,7 +95,15 @@ class Tracker( TrackerTPSBehaviour, TrackerTapDetectionBehaviour, ) - val context = Context.create(initialState = trackerState, scope = scope, behaviours = behaviours) + val context = Context.create( + initialState = trackerState, + scope = scope, + behaviours = behaviours, + LoggingMiddleware( + "Tracker[$hardwareId]", +// block = setOf(TrackerActions.SetRotation::class) + ), + ) val tracker = Tracker(context = context, server) behaviours.forEach { it.observe(tracker) } return tracker diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index 50ba339f7f..4d6da7ef37 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -27,88 +27,94 @@ import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.TrackerId import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.TrackingChecklistNeedCalibration +import solarxr_protocol.rpc.TrackingChecklistSteamVRDisconnected import solarxr_protocol.rpc.TrackingChecklistStep import solarxr_protocol.rpc.TrackingChecklistStepId -import solarxr_protocol.rpc.TrackingChecklistSteamVRDisconnected import solarxr_protocol.rpc.TrackingChecklistTrackerError import solarxr_protocol.rpc.TrackingChecklistUnassignedHMD // Flat-maps a server state flow into a combined flow of all context states for a given collection. // Re-emits whenever any item's state changes or the collection itself changes. private inline fun allContextStates( - server: VRServer, - crossinline select: (VRServerState) -> Collection, - crossinline stateOf: (C) -> Flow, + server: VRServer, + crossinline select: (VRServerState) -> Collection, + crossinline stateOf: (C) -> Flow, ): Flow> = server.context.state.flatMapLatest { serverState -> - val items = select(serverState) - if (items.isEmpty()) return@flatMapLatest flowOf(emptyList()) - combine(items.map { item -> stateOf(item) }) { states -> states.toList() } + val items = select(serverState) + if (items.isEmpty()) return@flatMapLatest flowOf(emptyList()) + combine(items.map { item -> stateOf(item) }) { states -> states.toList() } } -private fun trackerStatesFlow(server: VRServer): Flow> = - allContextStates(server, { state -> state.trackers.values }) { tracker -> tracker.context.state } +private fun trackerStatesFlow(server: VRServer): Flow> = allContextStates(server, { state -> state.trackers.values }) { tracker -> tracker.context.state } -private fun deviceStatesFlow(server: VRServer): Flow> = - allContextStates(server, { state -> state.devices.values }) { device -> device.context.state } +private fun deviceStatesFlow(server: VRServer): Flow> = allContextStates(server, { state -> state.devices.values }) { device -> device.context.state } -private fun trackerIdOf(tracker: TrackerState): TrackerId = - TrackerId(trackerNum = tracker.id.toUByte(), deviceId = DeviceId(tracker.deviceId.toUByte())) +private fun trackerIdOf(tracker: TrackerState): TrackerId = TrackerId(trackerNum = tracker.id.toUByte(), deviceId = DeviceId(tracker.deviceId.toUByte())) class SteamVRCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { - override fun observe(receiver: TrackingChecklist) { - server.context.state - .map { state -> - val connected = state.drivers.isNotEmpty() - TrackingChecklistStep( - valid = connected, - enabled = true, - ignorable = true, - extraData = if (!connected) TrackingChecklistSteamVRDisconnected() else null, - ) - } - .distinctUntilChanged() - .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.STEAMVR_DISCONNECTED, step)) } - .launchIn(receiver.context.scope) - } + override fun observe(receiver: TrackingChecklist) { + server.context.state + .map { state -> + val connected = state.drivers.isNotEmpty() + TrackingChecklistStep( + valid = connected, + enabled = true, + ignorable = true, + extraData = if (!connected) TrackingChecklistSteamVRDisconnected() else null, + ) + } + .distinctUntilChanged() + .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.STEAMVR_DISCONNECTED, step)) } + .launchIn(receiver.context.scope) + } } class HMDCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { - private fun computeStep(trackers: List): TrackingChecklistStep { + private fun computeStep(trackers: List): TrackingChecklistStep { // FIXME: Most likely incomplete - val hasSteamVR = trackers.any { tracker -> tracker.origin == DeviceOrigin.DRIVER } - val hmdTracker = trackers.firstOrNull { tracker -> tracker.origin == DeviceOrigin.DRIVER && tracker.position != null } - val isAssigned = hmdTracker?.bodyPart == BodyPart.HEAD - return TrackingChecklistStep( - valid = isAssigned, - enabled = hasSteamVR, - ignorable = true, - extraData = if (!isAssigned) TrackingChecklistUnassignedHMD( - trackerId = hmdTracker?.let { tracker -> trackerIdOf(tracker) }, - ) else null, - ) - } + val hasSteamVR = trackers.any { tracker -> tracker.origin == DeviceOrigin.DRIVER } + val hmdTracker = trackers.firstOrNull { tracker -> tracker.origin == DeviceOrigin.DRIVER && tracker.position != null } + val isAssigned = hmdTracker?.bodyPart == BodyPart.HEAD + return TrackingChecklistStep( + valid = isAssigned, + enabled = hasSteamVR, + ignorable = true, + extraData = if (!isAssigned) { + TrackingChecklistUnassignedHMD( + trackerId = hmdTracker?.let { tracker -> trackerIdOf(tracker) }, + ) + } else { + null + }, + ) + } - override fun observe(receiver: TrackingChecklist) { - trackerStatesFlow(server) - .map { trackers -> computeStep(trackers) } - .distinctUntilChanged() - .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.UNASSIGNED_HMD, step)) } - .launchIn(receiver.context.scope) - } + override fun observe(receiver: TrackingChecklist) { + trackerStatesFlow(server) + .map { trackers -> computeStep(trackers) } + .distinctUntilChanged() + .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.UNASSIGNED_HMD, step)) } + .launchIn(receiver.context.scope) + } } class TrackerRestCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { private fun computeStep(trackers: List): TrackingChecklistStep { val uncalibratedTrackers = trackers.filter { tracker -> - (tracker.origin == DeviceOrigin.UDP || tracker.origin == DeviceOrigin.HID) - && tracker.status == TrackerStatus.OK && (tracker.completedRestCalibration != null && !tracker.completedRestCalibration) + (tracker.origin == DeviceOrigin.UDP || tracker.origin == DeviceOrigin.HID) && + tracker.status == TrackerStatus.OK && + (tracker.completedRestCalibration != null && !tracker.completedRestCalibration) } return TrackingChecklistStep( valid = uncalibratedTrackers.isEmpty(), enabled = trackers.isNotEmpty(), - extraData = if (!uncalibratedTrackers.isEmpty()) TrackingChecklistNeedCalibration( - trackersId = uncalibratedTrackers.map { tracker -> trackerIdOf(tracker) }, - ) else null, + extraData = if (!uncalibratedTrackers.isEmpty()) { + TrackingChecklistNeedCalibration( + trackersId = uncalibratedTrackers.map { tracker -> trackerIdOf(tracker) }, + ) + } else { + null + }, ) } @@ -122,48 +128,52 @@ class TrackerRestCheckBehaviour(private val server: VRServer) : TrackingChecklis } class TrackerErrorCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { - private fun computeStep(trackers: List): TrackingChecklistStep { - val errorTrackers = trackers - .filter { tracker -> tracker.status == TrackerStatus.ERROR && tracker.bodyPart != null } - .toSet() - return TrackingChecklistStep( - valid = errorTrackers.isEmpty(), - enabled = trackers.isNotEmpty(), - extraData = if (errorTrackers.isNotEmpty()) TrackingChecklistTrackerError( - trackersId = errorTrackers.map { tracker -> trackerIdOf(tracker) }, - ) else null, - ) - } + private fun computeStep(trackers: List): TrackingChecklistStep { + val errorTrackers = trackers + .filter { tracker -> tracker.status == TrackerStatus.ERROR && tracker.bodyPart != null } + .toSet() + return TrackingChecklistStep( + valid = errorTrackers.isEmpty(), + enabled = trackers.isNotEmpty(), + extraData = if (errorTrackers.isNotEmpty()) { + TrackingChecklistTrackerError( + trackersId = errorTrackers.map { tracker -> trackerIdOf(tracker) }, + ) + } else { + null + }, + ) + } - override fun observe(receiver: TrackingChecklist) { - trackerStatesFlow(server) - .map { trackers -> computeStep(trackers) } - .distinctUntilChanged() - .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.TRACKER_ERROR, step)) } - .launchIn(receiver.context.scope) - } + override fun observe(receiver: TrackingChecklist) { + trackerStatesFlow(server) + .map { trackers -> computeStep(trackers) } + .distinctUntilChanged() + .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.TRACKER_ERROR, step)) } + .launchIn(receiver.context.scope) + } } class VRChatSettingsCheckBehaviour( - private val server: VRServer, - private val skeleton: Skeleton, - private val vrcConfigManager: VRCConfigManager, + private val server: VRServer, + private val skeleton: Skeleton, + private val vrcConfigManager: VRCConfigManager, ) : TrackingChecklistBehaviourType { - private fun computeStep(vrc: VRCConfigState, userHeight: Double): TrackingChecklistStep { - val values = vrc.currentValues - if (!vrc.isSupported || values == null) return TrackingChecklistStep(valid = true, enabled = false) - val recommended = computeRecommendedValues(server, userHeight) - val validity = computeValidity(values, recommended) - return TrackingChecklistStep(valid = isVRCConfigValid(validity, vrc.mutedWarnings), enabled = true) - } + private fun computeStep(vrc: VRCConfigState, userHeight: Double): TrackingChecklistStep { + val values = vrc.currentValues + if (!vrc.isSupported || values == null) return TrackingChecklistStep(valid = true, enabled = false) + val recommended = computeRecommendedValues(server, userHeight) + val validity = computeValidity(values, recommended) + return TrackingChecklistStep(valid = isVRCConfigValid(validity, vrc.mutedWarnings), enabled = true) + } - override fun observe(receiver: TrackingChecklist) { - combine( - skeleton.context.state.map { state -> state.userHeight }, - vrcConfigManager.context.state, - ) { userHeight, vrc -> computeStep(vrc, userHeight) } - .distinctUntilChanged() - .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.VRCHAT_SETTINGS, step)) } - .launchIn(receiver.context.scope) - } + override fun observe(receiver: TrackingChecklist) { + combine( + skeleton.context.state.map { state -> state.userHeight }, + vrcConfigManager.context.state, + ) { userHeight, vrc -> computeStep(vrc, userHeight) } + .distinctUntilChanged() + .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.VRCHAT_SETTINGS, step)) } + .launchIn(receiver.context.scope) + } } diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt index b3b1111d8b..a191264dcf 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt @@ -8,47 +8,47 @@ import solarxr_protocol.rpc.TrackingChecklistStep import solarxr_protocol.rpc.TrackingChecklistStepId data class TrackingChecklistState( - val steps: Map = emptyMap(), + val steps: Map = emptyMap(), ) sealed interface TrackingChecklistActions { - data class UpdateStep(val id: TrackingChecklistStepId, val step: TrackingChecklistStep) : TrackingChecklistActions + data class UpdateStep(val id: TrackingChecklistStepId, val step: TrackingChecklistStep) : TrackingChecklistActions } typealias TrackingChecklistContext = Context typealias TrackingChecklistBehaviourType = Behaviour object ChecklistBaseBehaviour : TrackingChecklistBehaviourType { - override fun reduce(state: TrackingChecklistState, action: TrackingChecklistActions): TrackingChecklistState = when (action) { - is TrackingChecklistActions.UpdateStep -> state.copy(steps = state.steps + (action.id to action.step)) - } + override fun reduce(state: TrackingChecklistState, action: TrackingChecklistActions): TrackingChecklistState = when (action) { + is TrackingChecklistActions.UpdateStep -> state.copy(steps = state.steps + (action.id to action.step)) + } } class TrackingChecklist( - val context: TrackingChecklistContext, + val context: TrackingChecklistContext, ) { - fun startObserving(appContext: AppContextProvider) { - val stepBehaviours: List = buildList { - add(SteamVRCheckBehaviour(appContext.server)) - add(HMDCheckBehaviour(appContext.server)) - add(TrackerRestCheckBehaviour(appContext.server)) - add(TrackerErrorCheckBehaviour(appContext.server)) - appContext.vrcConfigManager?.let { add(VRChatSettingsCheckBehaviour(appContext.server, appContext.skeleton, it)) } - } + fun startObserving(appContext: AppContextProvider) { + val stepBehaviours: List = buildList { + add(SteamVRCheckBehaviour(appContext.server)) + add(HMDCheckBehaviour(appContext.server)) + add(TrackerRestCheckBehaviour(appContext.server)) + add(TrackerErrorCheckBehaviour(appContext.server)) + appContext.vrcConfigManager?.let { add(VRChatSettingsCheckBehaviour(appContext.server, appContext.skeleton, it)) } + } context.behaviours.addAll(stepBehaviours) context.observeAll(this) - } + } - companion object { - fun create(scope: CoroutineScope): TrackingChecklist { - val initialBehaviours = listOf(ChecklistBaseBehaviour) - val context = Context.create( - initialState = TrackingChecklistState(), - scope = scope, - behaviours = initialBehaviours, - ) - val checklist = TrackingChecklist(context) - return checklist - } - } + companion object { + fun create(scope: CoroutineScope): TrackingChecklist { + val initialBehaviours = listOf(ChecklistBaseBehaviour) + val context = Context.create( + initialState = TrackingChecklistState(), + scope = scope, + behaviours = initialBehaviours, + ) + val checklist = TrackingChecklist(context) + return checklist + } + } } diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 52dc58b5b9..43cd63ce0a 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -278,22 +278,28 @@ object SensorInfoBehaviour : UDPConnectionBehaviour { val existingTracker = receiver.getTracker(event.data.sensorId) if (existingTracker != null) { - existingTracker.context.dispatchAll(listOf( - TrackerActions.Update { copy(sensorType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration) }, - TrackerActions.SetStatus(event.data.status), - )) + existingTracker.context.dispatchAll( + listOf( + TrackerActions.Update { copy(sensorType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration) }, + TrackerActions.SetStatus(event.data.status), + ), + ) return@onPacket } val (tracker, isNew) = assignTracker(receiver, device, event) - tracker.context.dispatchAll(listOf( - TrackerActions.Update { copy(sensorType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration) }, - TrackerActions.SetStatus(event.data.status), - )) + tracker.context.dispatchAll( + listOf( + TrackerActions.Update { copy(sensorType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration) }, + TrackerActions.SetStatus(event.data.status), + ), + ) if (isNew && tracker.context.state.value.magStatus == MagnetometerStatus.NOT_SUPPORTED) { - tracker.context.dispatch(TrackerActions.SetMagStatus( - if (event.data.sensorConfig?.magSupported == true) MagnetometerStatus.DISABLED else MagnetometerStatus.NOT_SUPPORTED - )) + tracker.context.dispatch( + TrackerActions.SetMagStatus( + if (event.data.sensorConfig?.magSupported == true) MagnetometerStatus.DISABLED else MagnetometerStatus.NOT_SUPPORTED, + ), + ) } val remoteMagStatus = event.data.sensorConfig?.let { @@ -307,8 +313,9 @@ object SensorInfoBehaviour : UDPConnectionBehaviour { var desiredMagStatus = tracker.context.state.value.magStatus val globalMagEnabled = receiver.appContext.config.settings.context.state.value.data.globalMagEnabled if (remoteMagStatus != desiredMagStatus) { - if (globalMagEnabled && remoteMagStatus != MagnetometerStatus.ENABLED && desiredMagStatus != MagnetometerStatus.NOT_SUPPORTED) + if (globalMagEnabled && remoteMagStatus != MagnetometerStatus.ENABLED && desiredMagStatus != MagnetometerStatus.NOT_SUPPORTED) { desiredMagStatus = MagnetometerStatus.ENABLED + } receiver.context.dispatch( UDPConnectionActions.SetSensorConfig(sensorId = event.data.sensorId, flags = SensorConfigFlags(magStatus = desiredMagStatus)), ) @@ -367,11 +374,9 @@ object FlagsBehaviour : UDPConnectionBehaviour { override fun reduce( state: UDPConnectionState, action: UDPConnectionActions, - ): UDPConnectionState { - return when (action) { - is UDPConnectionActions.FirmwareFeatures -> state.copy(features = action.features) - else -> state - } + ): UDPConnectionState = when (action) { + is UDPConnectionActions.FirmwareFeatures -> state.copy(features = action.features) + else -> state } override fun observe(receiver: UDPConnection) { @@ -397,6 +402,7 @@ object SensorConfigBehaviour : UDPConnectionBehaviour { is UDPConnectionActions.SetSensorConfig -> state.copy( sensorConfigFlags = state.sensorConfigFlags + (action.sensorId to action.flags), ) + else -> state } @@ -409,8 +415,8 @@ object SensorConfigBehaviour : UDPConnectionBehaviour { SetConfigFlag( sensorId = sensorId, configType = SensorConfigType.MAGNETOMETER, - state = flags.magStatus == MagnetometerStatus.ENABLED - ) + state = flags.magStatus == MagnetometerStatus.ENABLED, + ), ) } } diff --git a/server/core/src/main/java/dev/slimevr/udp/packets.kt b/server/core/src/main/java/dev/slimevr/udp/packets.kt index b7abdfb1f0..a94608d08b 100644 --- a/server/core/src/main/java/dev/slimevr/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/udp/packets.kt @@ -25,16 +25,16 @@ enum class ServerFeatureFlags { PROTOCOL_BUNDLE_SUPPORT, /** Server can parse bundle packets with compact headers and packed IMU rotation/acceleration frames: - - `PACKET_BUNDLE_COMPACT` = 101 (0x65), - - `PACKET_ROTATION_AND_ACCELERATION` = 23 (0x17). */ - PROTOCOL_BUNDLE_COMPACT_SUPPORT + - `PACKET_BUNDLE_COMPACT` = 101 (0x65), + - `PACKET_ROTATION_AND_ACCELERATION` = 23 (0x17). */ + PROTOCOL_BUNDLE_COMPACT_SUPPORT, ; companion object { val flagsEnabled: Set = setOf( PROTOCOL_BUNDLE_SUPPORT, - PROTOCOL_BUNDLE_COMPACT_SUPPORT + PROTOCOL_BUNDLE_COMPACT_SUPPORT, ) val packed: ByteArray by lazy { @@ -57,7 +57,8 @@ class FirmwareFeatures { enum class FirmwareFeatureFlags { REMOTE_COMMAND, B64_WIFI_SCANNING, - SENSOR_CONFIG; + SENSOR_CONFIG, + ; companion object { // "Size + 7" ensures that 1-8 flags = 1 byte, 9-16 flags = 2 bytes, etc. @@ -188,7 +189,7 @@ data class Handshake( data class PacketBundle( val packets: List, -): PreHandshakePacket { +) : PreHandshakePacket { companion object { fun read(src: Source): PacketBundle = with(src) { val readPackets = mutableListOf() @@ -219,7 +220,7 @@ data class PacketBundle( data class PacketBundleCompact( val packets: List, -): PreHandshakePacket { +) : PreHandshakePacket { companion object { fun read(src: Source): PacketBundle = with(src) { val readPackets = mutableListOf() @@ -314,7 +315,7 @@ data class SensorInfo( val calibrationEnabled: Boolean, val calibrationSupported: Boolean, val tempGradientCalibrationEnabled: Boolean, - val tempGradientCalibrationSupported: Boolean + val tempGradientCalibrationSupported: Boolean, ) { companion object { fun fromUDP(raw: UShort) = with(raw) { @@ -418,7 +419,7 @@ data class FeatureFlags(val firmwareFeatures: FirmwareFeatures = FirmwareFeature FirmwareFeatures.from( length = src.remaining.toInt(), received = ByteBuffer.wrap(src.readByteArray()), - ) + ), ) } } diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index 511b19f390..be5a747a40 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -36,10 +36,9 @@ object UdpServerBaseBehaviour : UdpServerBehaviour { class UdpServer(val context: UdpServerContext) { fun startObserving() = context.observeAll(this) - fun findConnectionForDevice(deviceId: Int): UDPConnection? = - context.state.value.connections.values.find { conn -> - conn.context.state.value.deviceId == deviceId - } + fun findConnectionForDevice(deviceId: Int): UDPConnection? = context.state.value.connections.values.find { conn -> + conn.context.state.value.deviceId == deviceId + } fun startReceiving(appContext: AppContextProvider, scope: CoroutineScope) { scope.launch { @@ -103,4 +102,4 @@ class UdpServer(val context: UdpServerContext) { return server } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/vrchat/module.kt b/server/core/src/main/java/dev/slimevr/vrchat/module.kt index 5314a72d1d..6acf69962c 100644 --- a/server/core/src/main/java/dev/slimevr/vrchat/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrchat/module.kt @@ -106,16 +106,15 @@ fun computeRecommendedValues(server: VRServer, userHeight: Double): VRCConfigRec ) } -fun isVRCConfigValid(validity: VRCConfigValidity, mutedWarnings: List): Boolean = - (validity.legacyModeOk || "legacyModeOk" in mutedWarnings) && - (validity.shoulderTrackingOk || "shoulderTrackingOk" in mutedWarnings) && - (validity.shoulderWidthCompensationOk || "shoulderWidthCompensationOk" in mutedWarnings) && - (validity.userHeightOk || "userHeightOk" in mutedWarnings) && - (validity.calibrationRangeOk || "calibrationRangeOk" in mutedWarnings) && - (validity.calibrationVisualsOk || "calibrationVisualsOk" in mutedWarnings) && - (validity.trackerModelOk || "trackerModelOk" in mutedWarnings) && - (validity.spineModeOk || "spineModeOk" in mutedWarnings) && - (validity.avatarMeasurementTypeOk || "avatarMeasurementTypeOk" in mutedWarnings) +fun isVRCConfigValid(validity: VRCConfigValidity, mutedWarnings: List): Boolean = (validity.legacyModeOk || "legacyModeOk" in mutedWarnings) && + (validity.shoulderTrackingOk || "shoulderTrackingOk" in mutedWarnings) && + (validity.shoulderWidthCompensationOk || "shoulderWidthCompensationOk" in mutedWarnings) && + (validity.userHeightOk || "userHeightOk" in mutedWarnings) && + (validity.calibrationRangeOk || "calibrationRangeOk" in mutedWarnings) && + (validity.calibrationVisualsOk || "calibrationVisualsOk" in mutedWarnings) && + (validity.trackerModelOk || "trackerModelOk" in mutedWarnings) && + (validity.spineModeOk || "spineModeOk" in mutedWarnings) && + (validity.avatarMeasurementTypeOk || "avatarMeasurementTypeOk" in mutedWarnings) fun computeValidity(values: VRCConfigValues, recommended: VRCConfigRecommendedValues): VRCConfigValidity = VRCConfigValidity( legacyModeOk = values.legacyMode == recommended.legacyMode, diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index 1d968c5ece..f067ed509c 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -1,10 +1,10 @@ package dev.slimevr -import dev.slimevr.driver.DriverBridge -import dev.slimevr.feeder.FeederBridge import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.device.Device +import dev.slimevr.driver.DriverBridge +import dev.slimevr.feeder.FeederBridge import dev.slimevr.solarxr.SolarXRBridge import dev.slimevr.tracker.Tracker import kotlinx.coroutines.CoroutineScope @@ -63,4 +63,4 @@ class VRServer( return server } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/io/github/axisangles/ktmath/EulerAngles.kt b/server/core/src/main/java/io/github/axisangles/ktmath/EulerAngles.kt index b5f2d699d3..1d52bd01dd 100644 --- a/server/core/src/main/java/io/github/axisangles/ktmath/EulerAngles.kt +++ b/server/core/src/main/java/io/github/axisangles/ktmath/EulerAngles.kt @@ -72,6 +72,22 @@ class EulerAngles(val order: EulerOrder, val x: Float, val y: Float, val z: Floa } } + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is EulerAngles) return false + return order == other.order && x == other.x && y == other.y && z == other.z + } + + override fun hashCode(): Int { + var result = order.hashCode() + result = 31 * result + x.hashCode() + result = 31 * result + y.hashCode() + result = 31 * result + z.hashCode() + return result + } + + override fun toString(): String = "EulerAngles(order=$order, x=$x, y=$y, z=$z)" + // temp, replace with direct conversion later // fun toMatrix(): Matrix3 = this.toQuaternion().toMatrix() diff --git a/server/core/src/main/java/io/github/axisangles/ktmath/Matrix3.kt b/server/core/src/main/java/io/github/axisangles/ktmath/Matrix3.kt index cd44ed5edb..ea015ea223 100644 --- a/server/core/src/main/java/io/github/axisangles/ktmath/Matrix3.kt +++ b/server/core/src/main/java/io/github/axisangles/ktmath/Matrix3.kt @@ -461,6 +461,29 @@ class Matrix3 orthonormalize().toEulerAnglesAssumingOrthonormal(order) fun toObject() = ObjectMatrix3(xx, yx, zx, xy, yy, zy, xz, yz, zz) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Matrix3) return false + return xx == other.xx && yx == other.yx && zx == other.zx && + xy == other.xy && yy == other.yy && zy == other.zy && + xz == other.xz && yz == other.yz && zz == other.zz + } + + override fun hashCode(): Int { + var result = xx.hashCode() + result = 31 * result + yx.hashCode() + result = 31 * result + zx.hashCode() + result = 31 * result + xy.hashCode() + result = 31 * result + yy.hashCode() + result = 31 * result + zy.hashCode() + result = 31 * result + xz.hashCode() + result = 31 * result + yz.hashCode() + result = 31 * result + zz.hashCode() + return result + } + + override fun toString(): String = "Matrix3([$xx, $yx, $zx], [$xy, $yy, $zy], [$xz, $yz, $zz])" } data class ObjectMatrix3( diff --git a/server/core/src/main/java/io/github/axisangles/ktmath/Quaternion.kt b/server/core/src/main/java/io/github/axisangles/ktmath/Quaternion.kt index fffdad4c64..9c498fc60b 100644 --- a/server/core/src/main/java/io/github/axisangles/ktmath/Quaternion.kt +++ b/server/core/src/main/java/io/github/axisangles/ktmath/Quaternion.kt @@ -492,6 +492,22 @@ class Quaternion(val w: Float, val x: Float, val y: Float, val z: Float) { fun toEulerAngles(order: EulerOrder): EulerAngles = this.toMatrix().toEulerAnglesAssumingOrthonormal(order) fun toObject() = ObjectQuaternion(w, x, y, z) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Quaternion) return false + return w == other.w && x == other.x && y == other.y && z == other.z + } + + override fun hashCode(): Int { + var result = w.hashCode() + result = 31 * result + x.hashCode() + result = 31 * result + y.hashCode() + result = 31 * result + z.hashCode() + return result + } + + override fun toString(): String = "Quaternion(w=$w, x=$x, y=$y, z=$z)" } data class ObjectQuaternion(val w: Float, val x: Float, val y: Float, val z: Float) { diff --git a/server/core/src/main/java/io/github/axisangles/ktmath/Vector3.kt b/server/core/src/main/java/io/github/axisangles/ktmath/Vector3.kt index dabb1e5699..32d2750612 100644 --- a/server/core/src/main/java/io/github/axisangles/ktmath/Vector3.kt +++ b/server/core/src/main/java/io/github/axisangles/ktmath/Vector3.kt @@ -100,6 +100,21 @@ class Vector3(val x: Float, val y: Float, val z: Float) { fun angleTo(that: Vector3): Float = atan2(this.cross(that).len(), this.dot(that)) fun isNear(other: Vector3, maxError: Float = 1e-6f) = abs(x - other.x) <= maxError && abs(y - other.y) <= maxError && abs(z - other.z) <= maxError + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Vector3) return false + return x == other.x && y == other.y && z == other.z + } + + override fun hashCode(): Int { + var result = x.hashCode() + result = 31 * result + y.hashCode() + result = 31 * result + z.hashCode() + return result + } + + override fun toString(): String = "Vector3(x=$x, y=$y, z=$z)" } operator fun Float.times(that: Vector3): Vector3 = that * this diff --git a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt index c81b1bc235..4cfd216712 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt @@ -20,7 +20,7 @@ private fun serialJob(port: String, status: FirmwareUpdateStatus, progress: Int firmwareDeviceId = SerialDevicePort(port = port), status = status, progress = progress, - ) + ), ) class FirmwareManagerReducerTest { diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index 1fb923fe8d..e226428502 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -12,12 +12,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import solarxr_protocol.datatypes.TrackerStatus import org.hid4java.HidDevice import org.hid4java.HidManager import org.hid4java.HidServicesSpecification import org.hid4java.jna.HidApi import org.hid4java.jna.HidDeviceInfoStructure +import solarxr_protocol.datatypes.TrackerStatus private const val HID_POLL_INTERVAL_MS = 3000L diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt index 96d2ee9331..0e55252ea4 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt @@ -148,4 +148,4 @@ private fun parseSingleHIDPacket(data: ByteArray, i: Int): HIDPacket? { fun parseHIDPackets(data: ByteArray): List { if (data.size % HID_PACKET_SIZE != 0) return emptyList() return (0 until data.size / HID_PACKET_SIZE).mapNotNull { parseSingleHIDPacket(data, it * HID_PACKET_SIZE) } -} \ No newline at end of file +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index 6794412d6d..fc99b6cd15 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -37,7 +37,7 @@ suspend fun createUnixSolarXRSocket(appContext: AppContextProvider) = acceptUnix handleSolarXRBridge( appContext = appContext, messages = readFramedMessages(channel), - send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } } + send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, ) } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 7e2c9d5c41..8460288dd8 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -1,15 +1,15 @@ package dev.slimevr.desktop.ipc import dev.slimevr.AppContextProvider +import dev.slimevr.desktop.platform.Position +import dev.slimevr.desktop.platform.ProtobufMessage +import dev.slimevr.desktop.platform.TrackerAdded +import dev.slimevr.desktop.platform.Version import dev.slimevr.driver.DriverBridge import dev.slimevr.driver.DriverBridgeInbound import dev.slimevr.driver.DriverBridgeOutbound import dev.slimevr.feeder.FeederBridge import dev.slimevr.feeder.FeederBridgeInbound -import dev.slimevr.desktop.platform.Position -import dev.slimevr.desktop.platform.ProtobufMessage -import dev.slimevr.desktop.platform.TrackerAdded -import dev.slimevr.desktop.platform.Version import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt index 77d932c833..8a17499d7c 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt @@ -40,7 +40,7 @@ suspend fun createWindowsSolarXRPipe(appContext: AppContextProvider) = acceptWin handleSolarXRBridge( appContext = appContext, messages = readFramedMessages(handle), - send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } } + send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, ) } From 3336f6dff102ea2ae6029d9f38ba331f33e59b83 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 21 Apr 2026 06:49:08 +0200 Subject: [PATCH 070/350] Consistency --- server/core/src/main/java/dev/slimevr/context/context.kt | 2 +- .../main/java/dev/slimevr/context/debug/DebugMiddleware.kt | 2 +- .../java/dev/slimevr/context/debug/LoggingMiddleware.kt | 2 +- server/core/src/main/java/dev/slimevr/tracker/module.kt | 7 ++++--- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index 292cd8d418..0b16ce7e2d 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -45,7 +45,7 @@ class Context( mutableStateFlow.update { currentState -> actions.fold(currentState) { s, action -> reducer(s, action) } } - debugMiddleware.onDispatchBatch(caller, before, actions, mutableStateFlow.value) + debugMiddleware.onDispatchAll(caller, before, actions, mutableStateFlow.value) } fun observeAll(receiver: C) = behaviours.forEach { behaviour -> diff --git a/server/core/src/main/java/dev/slimevr/context/debug/DebugMiddleware.kt b/server/core/src/main/java/dev/slimevr/context/debug/DebugMiddleware.kt index 918778d229..a2a0f0a675 100644 --- a/server/core/src/main/java/dev/slimevr/context/debug/DebugMiddleware.kt +++ b/server/core/src/main/java/dev/slimevr/context/debug/DebugMiddleware.kt @@ -2,5 +2,5 @@ package dev.slimevr.context.debug interface DebugMiddleware { fun onDispatch(caller: String?, before: S, action: A, after: S) - fun onDispatchBatch(caller: String?, before: S, actions: List, after: S) + fun onDispatchAll(caller: String?, before: S, actions: List, after: S) } diff --git a/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt b/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt index 10542e14ed..4dbbe1a9d7 100644 --- a/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt +++ b/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt @@ -39,7 +39,7 @@ class LoggingMiddleware( println(formatLine("$tag $actionName$callerSuffix", before, after)) } - override fun onDispatchBatch(caller: String?, before: S, actions: List, after: S) { + override fun onDispatchAll(caller: String?, before: S, actions: List, after: S) { val visible = actions.filter { action -> isAllowed(action) } if (visible.isEmpty()) return if (!logNoOps && before == after) return diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 7469a8a895..2b2854f0bb 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -2,9 +2,9 @@ package dev.slimevr.tracker import dev.slimevr.VRServer import dev.slimevr.config.Settings -import dev.slimevr.config.TrackerConfig import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.context.debug.DiffStyle import dev.slimevr.context.debug.LoggingMiddleware import dev.slimevr.device.DeviceOrigin import io.github.axisangles.ktmath.Quaternion @@ -99,9 +99,10 @@ class Tracker( initialState = trackerState, scope = scope, behaviours = behaviours, - LoggingMiddleware( + debugMiddleware = LoggingMiddleware( "Tracker[$hardwareId]", -// block = setOf(TrackerActions.SetRotation::class) + block = setOf(TrackerActions.SetRotation::class), + diffStyle = DiffStyle.MULTILINE ), ) val tracker = Tracker(context = context, server) From 09724f1fa3b9b709ef8e77f90adb8094d9f585dd Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 22 Apr 2026 03:15:53 +0200 Subject: [PATCH 071/350] Packet loss (untested on hid) --- .../java/dev/slimevr/device/behaviours.kt | 5 ++- .../main/java/dev/slimevr/device/module.kt | 5 +++ .../main/java/dev/slimevr/hid/behaviours.kt | 10 ++++++ .../src/main/java/dev/slimevr/hid/module.kt | 1 + .../src/main/java/dev/slimevr/hid/packets.kt | 2 ++ .../main/java/dev/slimevr/solarxr/datafeed.kt | 3 ++ .../main/java/dev/slimevr/tracker/module.kt | 4 +-- .../main/java/dev/slimevr/udp/behaviours.kt | 34 ++++++++++++++++++- .../main/java/dev/slimevr/udp/connection.kt | 1 + .../java/dev/slimevr/desktop/hid/packets.kt | 2 ++ 10 files changed, 63 insertions(+), 4 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/device/behaviours.kt b/server/core/src/main/java/dev/slimevr/device/behaviours.kt index 064c19384f..fba8c2f071 100644 --- a/server/core/src/main/java/dev/slimevr/device/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/device/behaviours.kt @@ -4,7 +4,10 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach object DeviceStatsBehaviour : DeviceBehaviour { - override fun reduce(state: DeviceState, action: DeviceActions) = if (action is DeviceActions.Update) action.transform(state) else state + override fun reduce(state: DeviceState, action: DeviceActions) = when (action) { + is DeviceActions.Update -> action.transform(state) + is DeviceActions.PacketStats -> state.copy(packetsReceived = action.packetsReceived, packetsLost = action.packetsLost) + } override fun observe(receiver: DeviceContext) { receiver.state.onEach { diff --git a/server/core/src/main/java/dev/slimevr/device/module.kt b/server/core/src/main/java/dev/slimevr/device/module.kt index 5c33d4f005..e431942cd0 100644 --- a/server/core/src/main/java/dev/slimevr/device/module.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -29,10 +29,13 @@ data class DeviceState( val protocolVersion: Int, val status: TrackerStatus, val origin: DeviceOrigin, + val packetsReceived: Long, + val packetsLost: Long, ) sealed interface DeviceActions { data class Update(val transform: DeviceState.() -> DeviceState) : DeviceActions + data class PacketStats(val packetsReceived: Long, val packetsLost: Long) : DeviceActions } typealias DeviceContext = Context @@ -65,6 +68,8 @@ class Device( mcuType = McuType.Other, boardType = BoardType.UNKNOWN, firmware = null, + packetsReceived = 0L, + packetsLost = 0L, ) val behaviours = listOf(DeviceStatsBehaviour) diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 81790a386f..e42bd284fa 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -173,3 +173,13 @@ object HIDStatusBehaviour : HIDReceiverBehaviour { } } } + +object HIDPacketLossBehaviour : HIDReceiverBehaviour { + override fun observe(receiver: HIDReceiver) { + receiver.packetEvents.onPacket { packet -> + receiver.getDevice(packet.hidId)?.context?.dispatch( + DeviceActions.PacketStats(packetsReceived = packet.packetsReceived.toLong(), packetsLost = packet.packetsLost.toLong()), + ) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/hid/module.kt b/server/core/src/main/java/dev/slimevr/hid/module.kt index b1de2c14b6..228776a2a4 100644 --- a/server/core/src/main/java/dev/slimevr/hid/module.kt +++ b/server/core/src/main/java/dev/slimevr/hid/module.kt @@ -64,6 +64,7 @@ class HIDReceiver( HIDRotationBehaviour, HIDBatteryBehaviour, HIDStatusBehaviour, + HIDPacketLossBehaviour, ) val context = Context.create( diff --git a/server/core/src/main/java/dev/slimevr/hid/packets.kt b/server/core/src/main/java/dev/slimevr/hid/packets.kt index 08b888680f..7f9f7be8fe 100644 --- a/server/core/src/main/java/dev/slimevr/hid/packets.kt +++ b/server/core/src/main/java/dev/slimevr/hid/packets.kt @@ -48,6 +48,8 @@ data class HIDStatus( override val hidId: Int, val status: TrackerStatus, val rssi: Int, + val packetsReceived: Int, + val packetsLost: Int, ) : HIDPacket /** Full-precision Q15 quaternion + Q10 magnetometer (type 4). */ diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index e28fb90dd8..0b867e79c1 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -69,6 +69,9 @@ private fun createDevice( batteryPctEstimate = (device.batteryLevel * 100).toUInt().toUByte(), ping = device.ping?.toUShort(), rssi = device.signalStrength?.toShort(), + packetsReceived = device.packetsReceived.toInt(), + packetsLost = device.packetsLost.toInt(), + packetLoss = if (device.packetsReceived > 0) device.packetsLost.toFloat() / device.packetsReceived.toFloat() else null, ), hardwareInfo = HardwareInfo( mcuId = device.mcuType, diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 2b2854f0bb..ff2948df51 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -101,8 +101,8 @@ class Tracker( behaviours = behaviours, debugMiddleware = LoggingMiddleware( "Tracker[$hardwareId]", - block = setOf(TrackerActions.SetRotation::class), - diffStyle = DiffStyle.MULTILINE + block = setOf(TrackerActions.SetRotation::class), + diffStyle = DiffStyle.MULTILINE, ), ) val tracker = Tracker(context = context, server) diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 43cd63ce0a..9e6609b33a 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -46,12 +46,44 @@ object PacketBehaviour : UDPConnectionBehaviour { AppLogger.udp.warn("[${state.address}] WARN: Received packet with wrong packet number") return@onAny } else { - receiver.context.dispatch(UDPConnectionActions.LastPacket(time = now)) + receiver.context.dispatch(UDPConnectionActions.LastPacket(packetNum = packet.packetNumber, time = now)) } } } } +object PacketLossBehaviour : UDPConnectionBehaviour { + override fun observe(receiver: UDPConnection) { + var totalPacketsReceived = 0L + var acceptedPackets = 0L + var lastPacketCounterReset = System.currentTimeMillis() + var lastPacketNumber = 0L + + receiver.packetEvents.onAny { packet -> + val num = packet.packetNumber ?: return@onAny + val now = System.currentTimeMillis() + + if (now - lastPacketCounterReset >= 10_000L) { + totalPacketsReceived = 0L + acceptedPackets = 0L + lastPacketCounterReset = now + } + + totalPacketsReceived++ + val accepted = num == 0L || num > lastPacketNumber + if (accepted) { + lastPacketNumber = num + acceptedPackets++ + } + + receiver.getDevice()?.context?.dispatch( + DeviceActions.PacketStats(packetsReceived = totalPacketsReceived, packetsLost = totalPacketsReceived - acceptedPackets), + ) + } + } +} + + object PingBehaviour : UDPConnectionBehaviour { override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { is UDPConnectionActions.StartPing -> state.copy(lastPing = state.lastPing.copy(startTime = action.startTime, id = action.pingId)) diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 79237586f7..d7b2f81965 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -96,6 +96,7 @@ class UDPConnection( ): UDPConnection { val behaviours = listOf( PacketBehaviour, + PacketLossBehaviour, HandshakeBehaviour, TimeoutBehaviour, DisconnectBehaviour, diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt index 0e55252ea4..038d71db7b 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt @@ -118,6 +118,8 @@ private fun parseSingleHIDPacket(data: ByteArray, i: Int): HIDPacket? { hidId = hidId, status = TrackerStatus.fromValue((data[i + 2].toUByte().toInt() + 1).toUByte()) ?: TrackerStatus.OK, rssi = -data[i + 15].toUByte().toInt(), + packetsReceived = data[i + 4].toUByte().toInt(), + packetsLost = data[i + 5].toUByte().toInt(), ) 4 -> { From 951b96d54830f9170bb7d2be8ba2b7b2597f88c5 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 22 Apr 2026 03:31:41 +0200 Subject: [PATCH 072/350] Fix tracker status HID --- .../core/src/main/java/dev/slimevr/device/module.kt | 13 ++++++++++++- .../src/main/java/dev/slimevr/hid/behaviours.kt | 6 +++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/device/module.kt b/server/core/src/main/java/dev/slimevr/device/module.kt index e431942cd0..29e8c3bcfa 100644 --- a/server/core/src/main/java/dev/slimevr/device/module.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -2,6 +2,8 @@ package dev.slimevr.device import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.context.debug.DiffStyle +import dev.slimevr.context.debug.LoggingMiddleware import kotlinx.coroutines.CoroutineScope import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.BoardType @@ -73,7 +75,16 @@ class Device( ) val behaviours = listOf(DeviceStatsBehaviour) - val context = Context.create(initialState = deviceState, scope = scope, behaviours = behaviours) + val context = Context.create( + initialState = deviceState, + scope = scope, + behaviours = behaviours, + debugMiddleware = LoggingMiddleware( + "Device[$address]", + block = setOf(DeviceActions.PacketStats::class), + diffStyle = DiffStyle.MULTILINE, + ), + ) behaviours.forEach { it.observe(context) } return Device(context = context) } diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index e42bd284fa..eb2d3efcff 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -109,14 +109,14 @@ object HIDDeviceInfoBehaviour : HIDReceiverBehaviour { ) receiver.appContext.server.context.dispatch(VRServerActions.NewTracker(trackerId, newTracker)) receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, trackerId)) - AppLogger.hid.info("Registered HID tracker for device ${deviceState.address} (hidId=${packet.hidId})") } - - device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) }) } else { // HID does not have a rest calibration signal tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType, completedRestCalibration = true) }) + tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) } + + } } } From 428f2ba774b9b7e03a99fa0c60ee504dbc61844b Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 22 Apr 2026 03:53:29 +0200 Subject: [PATCH 073/350] Fix battery value HID --- server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt index 038d71db7b..1d3ee3571e 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt @@ -59,7 +59,7 @@ private fun decodeAccel(data: ByteArray, offset: Int): Vector3 { ) } -private fun decodeBattery(raw: Int): Float = if (raw == 128) 1f else (raw and 127).toFloat() +private fun decodeBattery(raw: Int): Float = if (raw == 128) 1f else (raw and 127).toFloat() / 100f private fun decodeBatteryVoltage(raw: Int): Float = (raw.toFloat() + 245f) / 100f From 791a3588934d809fce5440d8e39098146864d15a Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 22 Apr 2026 03:57:59 +0200 Subject: [PATCH 074/350] Move back hid packet parsing to core. it will be needed for android --- .../src/main/java/dev/slimevr/hid/packets.kt | 136 ++++++++++++++++ .../main/java/dev/slimevr/desktop/hid/hid.kt | 1 + .../java/dev/slimevr/desktop/hid/packets.kt | 153 ------------------ 3 files changed, 137 insertions(+), 153 deletions(-) delete mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt diff --git a/server/core/src/main/java/dev/slimevr/hid/packets.kt b/server/core/src/main/java/dev/slimevr/hid/packets.kt index 7f9f7be8fe..5faa90f387 100644 --- a/server/core/src/main/java/dev/slimevr/hid/packets.kt +++ b/server/core/src/main/java/dev/slimevr/hid/packets.kt @@ -6,6 +6,12 @@ import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.BoardType import solarxr_protocol.datatypes.hardware_info.ImuType import solarxr_protocol.datatypes.hardware_info.McuType +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt sealed interface HIDPacket { val hidId: Int @@ -67,3 +73,133 @@ data class HIDRotationButton( val acceleration: Vector3, val rssi: Int, ) : HIDPacket + +private const val HID_PACKET_SIZE = 16 + +private val AXES_OFFSET = Quaternion.fromRotationVector(-PI.toFloat() / 2f, 0f, 0f) + +private fun readLE16Signed(data: ByteArray, offset: Int): Int = data[offset + 1].toInt() shl 8 or data[offset].toUByte().toInt() + +private fun decodeQ15Quat(data: ByteArray, offset: Int): Quaternion { + val scale = 1f / 32768f + val x = readLE16Signed(data, offset).toShort().toFloat() * scale + val y = readLE16Signed(data, offset + 2).toShort().toFloat() * scale + val z = readLE16Signed(data, offset + 4).toShort().toFloat() * scale + val w = readLE16Signed(data, offset + 6).toShort().toFloat() * scale + return AXES_OFFSET * Quaternion(w, x, y, z) +} + +private fun decodeExpMapQuat(data: ByteArray, offset: Int): Quaternion { + val buf = ByteBuffer.wrap(data, offset, 4).order(ByteOrder.LITTLE_ENDIAN).int.toUInt() + val vx = ((buf and 1023u).toFloat() / 1024f) * 2f - 1f + val vy = ((buf shr 10 and 2047u).toFloat() / 2048f) * 2f - 1f + val vz = ((buf shr 21 and 2047u).toFloat() / 2048f) * 2f - 1f + val d = vx * vx + vy * vy + vz * vz + val invSqrtD = 1f / sqrt(d + 1e-6f) + val a = (PI.toFloat() / 2f) * d * invSqrtD + val s = sin(a) + val k = s * invSqrtD + return AXES_OFFSET * Quaternion(cos(a), k * vx, k * vy, k * vz) +} + +private fun decodeAccel(data: ByteArray, offset: Int): Vector3 { + val scale = 1f / 128f + return Vector3( + readLE16Signed(data, offset).toShort().toFloat() * scale, + readLE16Signed(data, offset + 2).toShort().toFloat() * scale, + readLE16Signed(data, offset + 4).toShort().toFloat() * scale, + ) +} + +private fun decodeBattery(raw: Int): Float = if (raw == 128) 1f else (raw and 127).toFloat() / 100f + +private fun decodeBatteryVoltage(raw: Int): Float = (raw.toFloat() + 245f) / 100f + +private fun parseSingleHIDPacket(data: ByteArray, i: Int): HIDPacket? { + val packetType = data[i].toUByte().toInt() + val hidId = data[i + 1].toUByte().toInt() + + return when (packetType) { + 255 -> { + val addr = ByteBuffer.wrap(data, i + 2, 8).order(ByteOrder.LITTLE_ENDIAN).long and 0x0000_FFFF_FFFF_FFFFL + HIDDeviceRegister(hidId, "%012X".format(addr)) + } + + 0 -> { + val batt = data[i + 2].toUByte().toInt() + val battV = data[i + 3].toUByte().toInt() + val brdId = data[i + 5].toUByte().toInt() + val mcuId = data[i + 6].toUByte().toInt() + val imuId = data[i + 8].toUByte().toInt() + val fwDate = data[i + 11].toUByte().toInt() shl 8 or data[i + 10].toUByte().toInt() + val fwMajor = data[i + 12].toUByte().toInt() + val fwMinor = data[i + 13].toUByte().toInt() + val fwPatch = data[i + 14].toUByte().toInt() + val rssi = data[i + 15].toUByte().toInt() + val fwYear = 2020 + (fwDate shr 9 and 127) + val fwMonth = fwDate shr 5 and 15 + val fwDay = fwDate and 31 + HIDDeviceInfo( + hidId = hidId, + imuType = ImuType.fromValue(imuId.toUShort()) ?: ImuType.Other, + boardType = BoardType.fromValue(brdId.toUShort()) ?: BoardType.UNKNOWN, + mcuType = McuType.fromValue(mcuId.toUShort()) ?: McuType.Other, + firmware = "%04d-%02d-%02d %d.%d.%d".format(fwYear, fwMonth, fwDay, fwMajor, fwMinor, fwPatch), + batteryLevel = decodeBattery(batt), + batteryVoltage = decodeBatteryVoltage(battV), + rssi = -rssi, + ) + } + + 1 -> HIDRotation( + hidId = hidId, + rotation = decodeQ15Quat(data, i + 2), + acceleration = decodeAccel(data, i + 10), + ) + + 2 -> HIDRotationBattery( + hidId = hidId, + rotation = decodeExpMapQuat(data, i + 5), + acceleration = decodeAccel(data, i + 9), + batteryLevel = decodeBattery(data[i + 2].toUByte().toInt()), + batteryVoltage = decodeBatteryVoltage(data[i + 3].toUByte().toInt()), + rssi = -data[i + 15].toUByte().toInt(), + ) + + 3 -> HIDStatus( + hidId = hidId, + status = TrackerStatus.fromValue((data[i + 2].toUByte().toInt() + 1).toUByte()) ?: TrackerStatus.OK, + rssi = -data[i + 15].toUByte().toInt(), + packetsReceived = data[i + 4].toUByte().toInt(), + packetsLost = data[i + 5].toUByte().toInt(), + ) + + 4 -> { + val scaleMag = 1000f / 1024f + HIDRotationMag( + hidId = hidId, + rotation = decodeQ15Quat(data, i + 2), + magnetometer = Vector3( + readLE16Signed(data, i + 10).toShort().toFloat() * scaleMag, + readLE16Signed(data, i + 12).toShort().toFloat() * scaleMag, + readLE16Signed(data, i + 14).toShort().toFloat() * scaleMag, + ), + ) + } + + 7 -> HIDRotationButton( + hidId = hidId, + button = data[i + 2].toUByte().toInt(), + rotation = decodeExpMapQuat(data, i + 5), + acceleration = decodeAccel(data, i + 9), + rssi = -data[i + 15].toUByte().toInt(), + ) + + else -> null + } +} + +fun parseHIDPackets(data: ByteArray): List { + if (data.size % HID_PACKET_SIZE != 0) return emptyList() + return (0 until data.size / HID_PACKET_SIZE).mapNotNull { parseSingleHIDPacket(data, it * HID_PACKET_SIZE) } +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index e226428502..45021a4957 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -4,6 +4,7 @@ import dev.slimevr.AppContextProvider import dev.slimevr.AppLogger import dev.slimevr.device.DeviceActions import dev.slimevr.hid.HIDReceiver +import dev.slimevr.hid.parseHIDPackets import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt deleted file mode 100644 index 1d3ee3571e..0000000000 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/packets.kt +++ /dev/null @@ -1,153 +0,0 @@ -package dev.slimevr.desktop.hid - -import dev.slimevr.hid.HIDDeviceInfo -import dev.slimevr.hid.HIDDeviceRegister -import dev.slimevr.hid.HIDPacket -import dev.slimevr.hid.HIDRotation -import dev.slimevr.hid.HIDRotationBattery -import dev.slimevr.hid.HIDRotationButton -import dev.slimevr.hid.HIDRotationMag -import dev.slimevr.hid.HIDStatus -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Quaternion.Companion.fromRotationVector -import io.github.axisangles.ktmath.Vector3 -import solarxr_protocol.datatypes.TrackerStatus -import solarxr_protocol.datatypes.hardware_info.BoardType -import solarxr_protocol.datatypes.hardware_info.ImuType -import solarxr_protocol.datatypes.hardware_info.McuType -import java.nio.ByteBuffer -import java.nio.ByteOrder -import kotlin.math.PI -import kotlin.math.cos -import kotlin.math.sin -import kotlin.math.sqrt - -private const val HID_PACKET_SIZE = 16 - -private val AXES_OFFSET = fromRotationVector(-PI.toFloat() / 2f, 0f, 0f) - -private fun readLE16Signed(data: ByteArray, offset: Int): Int = data[offset + 1].toInt() shl 8 or data[offset].toUByte().toInt() - -private fun decodeQ15Quat(data: ByteArray, offset: Int): Quaternion { - val scale = 1f / 32768f - val x = readLE16Signed(data, offset).toShort().toFloat() * scale - val y = readLE16Signed(data, offset + 2).toShort().toFloat() * scale - val z = readLE16Signed(data, offset + 4).toShort().toFloat() * scale - val w = readLE16Signed(data, offset + 6).toShort().toFloat() * scale - return AXES_OFFSET * Quaternion(w, x, y, z) -} - -private fun decodeExpMapQuat(data: ByteArray, offset: Int): Quaternion { - val buf = ByteBuffer.wrap(data, offset, 4).order(ByteOrder.LITTLE_ENDIAN).int.toUInt() - val vx = ((buf and 1023u).toFloat() / 1024f) * 2f - 1f - val vy = ((buf shr 10 and 2047u).toFloat() / 2048f) * 2f - 1f - val vz = ((buf shr 21 and 2047u).toFloat() / 2048f) * 2f - 1f - val d = vx * vx + vy * vy + vz * vz - val invSqrtD = 1f / sqrt(d + 1e-6f) - val a = (PI.toFloat() / 2f) * d * invSqrtD - val s = sin(a) - val k = s * invSqrtD - return AXES_OFFSET * Quaternion(cos(a), k * vx, k * vy, k * vz) -} - -private fun decodeAccel(data: ByteArray, offset: Int): Vector3 { - val scale = 1f / 128f - return Vector3( - readLE16Signed(data, offset).toShort().toFloat() * scale, - readLE16Signed(data, offset + 2).toShort().toFloat() * scale, - readLE16Signed(data, offset + 4).toShort().toFloat() * scale, - ) -} - -private fun decodeBattery(raw: Int): Float = if (raw == 128) 1f else (raw and 127).toFloat() / 100f - -private fun decodeBatteryVoltage(raw: Int): Float = (raw.toFloat() + 245f) / 100f - -private fun parseSingleHIDPacket(data: ByteArray, i: Int): HIDPacket? { - val packetType = data[i].toUByte().toInt() - val hidId = data[i + 1].toUByte().toInt() - - return when (packetType) { - 255 -> { - val addr = ByteBuffer.wrap(data, i + 2, 8).order(ByteOrder.LITTLE_ENDIAN).long and 0x0000_FFFF_FFFF_FFFFL - HIDDeviceRegister(hidId, "%012X".format(addr)) - } - - 0 -> { - val batt = data[i + 2].toUByte().toInt() - val battV = data[i + 3].toUByte().toInt() - val brdId = data[i + 5].toUByte().toInt() - val mcuId = data[i + 6].toUByte().toInt() - val imuId = data[i + 8].toUByte().toInt() - val fwDate = data[i + 11].toUByte().toInt() shl 8 or data[i + 10].toUByte().toInt() - val fwMajor = data[i + 12].toUByte().toInt() - val fwMinor = data[i + 13].toUByte().toInt() - val fwPatch = data[i + 14].toUByte().toInt() - val rssi = data[i + 15].toUByte().toInt() - val fwYear = 2020 + (fwDate shr 9 and 127) - val fwMonth = fwDate shr 5 and 15 - val fwDay = fwDate and 31 - HIDDeviceInfo( - hidId = hidId, - imuType = ImuType.fromValue(imuId.toUShort()) ?: ImuType.Other, - boardType = BoardType.fromValue(brdId.toUShort()) ?: BoardType.UNKNOWN, - mcuType = McuType.fromValue(mcuId.toUShort()) ?: McuType.Other, - firmware = "%04d-%02d-%02d %d.%d.%d".format(fwYear, fwMonth, fwDay, fwMajor, fwMinor, fwPatch), - batteryLevel = decodeBattery(batt), - batteryVoltage = decodeBatteryVoltage(battV), - rssi = -rssi, - ) - } - - 1 -> HIDRotation( - hidId = hidId, - rotation = decodeQ15Quat(data, i + 2), - acceleration = decodeAccel(data, i + 10), - ) - - 2 -> HIDRotationBattery( - hidId = hidId, - rotation = decodeExpMapQuat(data, i + 5), - acceleration = decodeAccel(data, i + 9), - batteryLevel = decodeBattery(data[i + 2].toUByte().toInt()), - batteryVoltage = decodeBatteryVoltage(data[i + 3].toUByte().toInt()), - rssi = -data[i + 15].toUByte().toInt(), - ) - - 3 -> HIDStatus( - hidId = hidId, - status = TrackerStatus.fromValue((data[i + 2].toUByte().toInt() + 1).toUByte()) ?: TrackerStatus.OK, - rssi = -data[i + 15].toUByte().toInt(), - packetsReceived = data[i + 4].toUByte().toInt(), - packetsLost = data[i + 5].toUByte().toInt(), - ) - - 4 -> { - val scaleMag = 1000f / 1024f - HIDRotationMag( - hidId = hidId, - rotation = decodeQ15Quat(data, i + 2), - magnetometer = Vector3( - readLE16Signed(data, i + 10).toShort().toFloat() * scaleMag, - readLE16Signed(data, i + 12).toShort().toFloat() * scaleMag, - readLE16Signed(data, i + 14).toShort().toFloat() * scaleMag, - ), - ) - } - - 7 -> HIDRotationButton( - hidId = hidId, - button = data[i + 2].toUByte().toInt(), - rotation = decodeExpMapQuat(data, i + 5), - acceleration = decodeAccel(data, i + 9), - rssi = -data[i + 15].toUByte().toInt(), - ) - - else -> null - } -} - -fun parseHIDPackets(data: ByteArray): List { - if (data.size % HID_PACKET_SIZE != 0) return emptyList() - return (0 until data.size / HID_PACKET_SIZE).mapNotNull { parseSingleHIDPacket(data, it * HID_PACKET_SIZE) } -} From a21e769d97f7f459d176fd496d16b87934c55ab6 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 29 Apr 2026 07:17:48 +0200 Subject: [PATCH 075/350] Context dispose / proper scope definition and naming --- .../src/main/java/dev/slimevr/config/module.kt | 1 + .../src/main/java/dev/slimevr/config/settings.kt | 7 ++++++- .../src/main/java/dev/slimevr/config/user.kt | 7 ++++++- .../src/main/java/dev/slimevr/context/context.kt | 16 ++++++++++++++-- .../src/main/java/dev/slimevr/device/module.kt | 1 + .../src/main/java/dev/slimevr/driver/module.kt | 10 ++++++++-- .../src/main/java/dev/slimevr/feeder/module.kt | 10 +++++++--- .../src/main/java/dev/slimevr/firmware/module.kt | 1 + .../java/dev/slimevr/heightcalibration/module.kt | 1 + .../core/src/main/java/dev/slimevr/hid/module.kt | 1 + .../main/java/dev/slimevr/provisioning/module.kt | 1 + .../main/java/dev/slimevr/serial/connection.kt | 2 ++ .../src/main/java/dev/slimevr/serial/module.kt | 1 + .../src/main/java/dev/slimevr/skeleton/module.kt | 1 + .../src/main/java/dev/slimevr/solarxr/module.kt | 8 ++++++-- .../src/main/java/dev/slimevr/tracker/module.kt | 1 + .../java/dev/slimevr/trackingchecklist/module.kt | 1 + .../src/main/java/dev/slimevr/udp/connection.kt | 1 + .../core/src/main/java/dev/slimevr/udp/server.kt | 1 + .../src/main/java/dev/slimevr/vrchat/module.kt | 1 + .../core/src/main/java/dev/slimevr/vrserver.kt | 1 + .../core/src/test/java/dev/slimevr/TestServer.kt | 2 ++ .../reducers/FirmwareManagerReducerTest.kt | 1 + .../heightcalibration/HeightCalibrationTest.kt | 1 + .../provisioning/ProvisioningManagerTest.kt | 1 + .../java/dev/slimevr/solarxr/DataFeedTest.kt | 1 + 26 files changed, 69 insertions(+), 11 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/config/module.kt b/server/core/src/main/java/dev/slimevr/config/module.kt index 96a88e91bf..0c09bb9479 100644 --- a/server/core/src/main/java/dev/slimevr/config/module.kt +++ b/server/core/src/main/java/dev/slimevr/config/module.kt @@ -67,6 +67,7 @@ class AppConfig( initialState = initialGlobal, scope = scope, behaviours = behaviours, + name = "GlobalConfig", ) behaviours.forEach { it.observe(globalContext) } diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 8553c2c132..be9aca5cd8 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -111,7 +111,12 @@ class Settings( val initialState = SettingsState(name = name, data = initialData) val behaviours = listOf(DefaultSettingsBehaviour) - val context = Context.create(initialState = initialState, scope = scope, behaviours = behaviours) + val context = Context.create( + initialState = initialState, + scope = scope, + behaviours = behaviours, + name = "Settings[$name]" + ) val settings = Settings(context, scope = scope, settingsDir = settingsDir) behaviours.forEach { it.observe(settings) } return settings diff --git a/server/core/src/main/java/dev/slimevr/config/user.kt b/server/core/src/main/java/dev/slimevr/config/user.kt index 54161b3836..95e0eb1bb0 100644 --- a/server/core/src/main/java/dev/slimevr/config/user.kt +++ b/server/core/src/main/java/dev/slimevr/config/user.kt @@ -83,7 +83,12 @@ class UserConfig( } val initialState = UserConfigState(name = name, data = initialData) - val context = Context.create(initialState = initialState, scope = scope, behaviours = listOf(DefaultUserBehaviour)) + val context = Context.create( + initialState = initialState, + scope = scope, + behaviours = listOf(DefaultUserBehaviour), + name = "UserConfig[$name]", + ) val userConfig = UserConfig(context, scope = scope, userConfigDir = userConfigDir) context.observeAll(userConfig) return userConfig diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index 0b16ce7e2d..176ff1598b 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -1,7 +1,11 @@ package dev.slimevr.context import dev.slimevr.context.debug.DebugMiddleware +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -16,12 +20,19 @@ interface Behaviour { class Context( private val mutableStateFlow: MutableStateFlow, val reducer: (S, A) -> S, - val scope: CoroutineScope, + private val parentScope: CoroutineScope, val behaviours: CopyOnWriteArrayList>, private val debugMiddleware: DebugMiddleware? = null, + val name: String, ) { + private val job = SupervisorJob(parentScope.coroutineContext[Job]) + val scope = CoroutineScope(parentScope.coroutineContext + job + CoroutineName(name)) val state: StateFlow = mutableStateFlow.asStateFlow() + fun dispose() { + job.cancel() + } + fun dispatch(action: A) { if (debugMiddleware == null) { mutableStateFlow.update { currentState -> reducer(currentState, action) } @@ -69,12 +80,13 @@ class Context( scope: CoroutineScope, behaviours: List>, debugMiddleware: DebugMiddleware? = null, + name: String, ): Context { val mutableStateFlow = MutableStateFlow(initialState) val reducer: (S, A) -> S = { currentState, action -> behaviours.fold(currentState) { s, b -> b.reduce(s, action) } } - return Context(mutableStateFlow, reducer, scope, CopyOnWriteArrayList(behaviours), if (debugEnabled) debugMiddleware else null) + return Context(mutableStateFlow, reducer, scope, CopyOnWriteArrayList(behaviours), if (debugEnabled) debugMiddleware else null, name) } } } diff --git a/server/core/src/main/java/dev/slimevr/device/module.kt b/server/core/src/main/java/dev/slimevr/device/module.kt index 29e8c3bcfa..98ba28c1cb 100644 --- a/server/core/src/main/java/dev/slimevr/device/module.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -84,6 +84,7 @@ class Device( block = setOf(DeviceActions.PacketStats::class), diffStyle = DiffStyle.MULTILINE, ), + name = "Device[$address]", ) behaviours.forEach { it.observe(context) } return Device(context = context) diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt index 67e1c687cd..5a5c03804c 100644 --- a/server/core/src/main/java/dev/slimevr/driver/module.kt +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -5,8 +5,10 @@ import dev.slimevr.EventDispatcher import dev.slimevr.VRServerActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import kotlinx.coroutines.CoroutineScope import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 + import kotlinx.coroutines.CoroutineScope data class DriverBridgeState( @@ -37,12 +39,15 @@ class DriverBridge( val inbound: EventDispatcher = EventDispatcher(), val outbound: EventDispatcher = EventDispatcher(), ) { + fun dispose() = context.dispose() + + fun startObserving() = context.observeAll(this) + fun disconnect() { + dispose() appContext.server.context.dispatch(VRServerActions.DriverDisconnected(id)) } - fun startObserving() = context.observeAll(this) - companion object { fun create(id: Int, appContext: AppContextProvider, scope: CoroutineScope): DriverBridge { val behaviours = listOf(DriverBaseBehaviour) @@ -51,6 +56,7 @@ class DriverBridge( initialState = DriverBridgeState(protocolVersion = 0), scope = scope, behaviours = behaviours, + name = "Driver[$id]" ) val bridge = DriverBridge(id = id, context = context, appContext = appContext) diff --git a/server/core/src/main/java/dev/slimevr/feeder/module.kt b/server/core/src/main/java/dev/slimevr/feeder/module.kt index 50f380d575..608e0cb9fa 100644 --- a/server/core/src/main/java/dev/slimevr/feeder/module.kt +++ b/server/core/src/main/java/dev/slimevr/feeder/module.kt @@ -5,9 +5,9 @@ import dev.slimevr.EventDispatcher import dev.slimevr.VRServerActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import kotlinx.coroutines.CoroutineScope import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 -import kotlinx.coroutines.CoroutineScope data class FeederBridgeState( val protocolVersion: Int, @@ -33,12 +33,15 @@ class FeederBridge( val appContext: AppContextProvider, val inbound: EventDispatcher = EventDispatcher(), ) { + fun dispose() = context.dispose() + + fun startObserving() = context.observeAll(this) + fun disconnect() { + dispose() appContext.server.context.dispatch(VRServerActions.FeederDisconnected(id)) } - fun startObserving() = context.observeAll(this) - companion object { fun create(id: Int, appContext: AppContextProvider, scope: CoroutineScope): FeederBridge { val behaviours = listOf(FeederBaseBehaviour) @@ -47,6 +50,7 @@ class FeederBridge( initialState = FeederBridgeState(protocolVersion = 0, firmware = null), scope = scope, behaviours = behaviours, + name = "Feeder[$id]" ) val bridge = FeederBridge(id = id, context = context, appContext = appContext) diff --git a/server/core/src/main/java/dev/slimevr/firmware/module.kt b/server/core/src/main/java/dev/slimevr/firmware/module.kt index 47b813ddd6..2ea0b0053f 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/module.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/module.kt @@ -127,6 +127,7 @@ class FirmwareManager( initialState = FirmwareManagerState(jobs = mapOf()), scope = scope, behaviours = behaviours, + name = "FirmwareManager", ) return FirmwareManager(context = context, serialServer = ctx.serialServer, scope = scope) } diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt index 51f88a472d..dfa2a0de7e 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt @@ -100,6 +100,7 @@ class HeightCalibrationManager( initialState = INITIAL_HEIGHT_CALIBRATION_STATE, scope = scope, behaviours = behaviours, + name = "HeightCalibration", ) return HeightCalibrationManager(context = context, serverContext = ctx.server, userConfig = ctx.config.userConfig) } diff --git a/server/core/src/main/java/dev/slimevr/hid/module.kt b/server/core/src/main/java/dev/slimevr/hid/module.kt index 228776a2a4..e85e63e0b3 100644 --- a/server/core/src/main/java/dev/slimevr/hid/module.kt +++ b/server/core/src/main/java/dev/slimevr/hid/module.kt @@ -71,6 +71,7 @@ class HIDReceiver( initialState = HIDReceiverState(serialNumber = serialNumber, trackers = mapOf()), scope = scope, behaviours = behaviours, + name = "HIDReceiver[$serialNumber]", ) val dispatcher = HIDPacketDispatcher() diff --git a/server/core/src/main/java/dev/slimevr/provisioning/module.kt b/server/core/src/main/java/dev/slimevr/provisioning/module.kt index 5bbfc4c28a..f20595864e 100644 --- a/server/core/src/main/java/dev/slimevr/provisioning/module.kt +++ b/server/core/src/main/java/dev/slimevr/provisioning/module.kt @@ -94,6 +94,7 @@ data class ProvisioningManager( initialState = INITIAL_STATE, scope = scope, behaviours = behaviours, + name = "ProvisioningManager", ) return ProvisioningManager( context = context, diff --git a/server/core/src/main/java/dev/slimevr/serial/connection.kt b/server/core/src/main/java/dev/slimevr/serial/connection.kt index 225e706ca7..1ba0be3b55 100644 --- a/server/core/src/main/java/dev/slimevr/serial/connection.kt +++ b/server/core/src/main/java/dev/slimevr/serial/connection.kt @@ -46,7 +46,9 @@ sealed interface SerialConnection { ), scope = scope, behaviours = behaviours, + name = "SerialConnection[${handle.descriptivePortName}]", ) + val conn = Console(context = context, handle = handle) behaviours.forEach { it.observe(conn) } return conn diff --git a/server/core/src/main/java/dev/slimevr/serial/module.kt b/server/core/src/main/java/dev/slimevr/serial/module.kt index d7a3beef0a..ed19e04413 100644 --- a/server/core/src/main/java/dev/slimevr/serial/module.kt +++ b/server/core/src/main/java/dev/slimevr/serial/module.kt @@ -115,6 +115,7 @@ class SerialServer( initialState = SerialServerState(availablePorts = mapOf(), connections = mapOf()), scope = scope, behaviours = behaviours, + name = "SerialServer", ) val server = SerialServer( context = context, diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 6ffd744490..17d69829bc 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -88,6 +88,7 @@ class Skeleton( initialState = DEFAULT_SKELETON_STATE, scope = scope, behaviours = behaviours, + name = "Skeleton", ) return Skeleton(context, MutableStateFlow(buildBones(context.state.value))) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 2f45cbf821..a1098364b6 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -49,11 +49,14 @@ class SolarXRBridge( val rpcDispatcher: EventDispatcher, val outbound: EventDispatcher = EventDispatcher(), ) { - suspend fun sendRpc(message: RpcMessage) = outbound.emit(MessageBundle(rpcMsgs = listOf(RpcMessageHeader(message = message)))) + fun dispose() = context.dispose() + suspend fun sendRpc(message: RpcMessage) = outbound.emit(MessageBundle(rpcMsgs = listOf(RpcMessageHeader(message = message)))) + suspend fun sendDataFeed(frame: DataFeedMessageHeader) = outbound.emit(MessageBundle(dataFeedMsgs = listOf(frame))) - + fun disconnect() { + dispose() appContext.server.context.dispatch(VRServerActions.SolarXRDisconnected(id)) } @@ -84,6 +87,7 @@ class SolarXRBridge( initialState = SolarXRBridgeState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), scope = scope, behaviours = buildBehaviours(appContext), + name = "SolarXR[$id]" ) val bridge = SolarXRBridge( diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index ff2948df51..b0bdd25adb 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -104,6 +104,7 @@ class Tracker( block = setOf(TrackerActions.SetRotation::class), diffStyle = DiffStyle.MULTILINE, ), + name = "Tracker[$hardwareId]", ) val tracker = Tracker(context = context, server) behaviours.forEach { it.observe(tracker) } diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt index a191264dcf..f348f52f55 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt @@ -46,6 +46,7 @@ class TrackingChecklist( initialState = TrackingChecklistState(), scope = scope, behaviours = initialBehaviours, + name = "TrackingChecklist", ) val checklist = TrackingChecklist(context) return checklist diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index d7b2f81965..e08212eac1 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -127,6 +127,7 @@ class UDPConnection( ), scope = scope, behaviours = behaviours, + name = "UDPConnection[$id]", ) val dispatcher = EventDispatcher> { it.data::class } diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index be5a747a40..a4aa60fdd1 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -96,6 +96,7 @@ class UdpServer(val context: UdpServerContext) { initialState = INITIAL_STATE, scope = scope, behaviours = listOf(UdpServerBaseBehaviour), + name = "UdpServer" ) val server = UdpServer(context) server.startObserving() diff --git a/server/core/src/main/java/dev/slimevr/vrchat/module.kt b/server/core/src/main/java/dev/slimevr/vrchat/module.kt index 6acf69962c..f471f5fa5c 100644 --- a/server/core/src/main/java/dev/slimevr/vrchat/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrchat/module.kt @@ -65,6 +65,7 @@ class VRCConfigManager( ), scope = scope, behaviours = behaviours, + name = "VRCConfig", ) scope.launch { diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index f067ed509c..c69df34947 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -57,6 +57,7 @@ class VRServer( ), scope = scope, behaviours = behaviours, + name = "VRServer" ) val server = VRServer(context = context) behaviours.forEach { it.observe(server) } diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index abbd03e79b..f43ebbcc37 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -51,6 +51,7 @@ fun buildTestUserConfig(scope: CoroutineScope): UserConfig { initialState = UserConfigState(data = UserConfigData(), name = "test"), scope = scope, behaviours = listOf(DefaultUserBehaviour), + name = "TestUserConfig", ) val userConfig = UserConfig(context, scope = scope, userConfigDir = tempDir) context.observeAll(userConfig) @@ -62,6 +63,7 @@ fun buildTestSkeleton(scope: CoroutineScope): Skeleton { initialState = DEFAULT_SKELETON_STATE, scope = scope, behaviours = listOf(ProportionsBehaviour()), + name = "TestSkeleton", ) val skeleton = Skeleton(context, MutableStateFlow(buildBones(context.state.value))) skeleton.startObserving() diff --git a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt index 4cfd216712..8e6cd6da1f 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt @@ -28,6 +28,7 @@ class FirmwareManagerReducerTest { initialState = FirmwareManagerState(jobs = mapOf()), behaviours = listOf(FirmwareManagerBaseBehaviour), scope = scope, + name = "FirmwareManagerReducerTest", ) @Test diff --git a/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt index 810b1bcb04..e009ca5edc 100644 --- a/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt @@ -42,6 +42,7 @@ private fun makeContext(scope: kotlinx.coroutines.CoroutineScope) = HeightCalibr initialState = INITIAL_HEIGHT_CALIBRATION_STATE, scope = scope, behaviours = listOf(CalibrationBehaviour), + name = "HeightCalibrationTest", ) // Launches a calibration session with the virtual-time clock, so that advancing virtual time diff --git a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt index 7fa4a4c803..db94b3c97e 100644 --- a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt +++ b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt @@ -28,6 +28,7 @@ private fun buildManager(serialServer: dev.slimevr.serial.SerialServer, scope: C initialState = ProvisioningManager.INITIAL_STATE, scope = scope, behaviours = listOf(ProvisioningManagerBaseBehaviour), + name = "ProvisioningManagerTest", ) return ProvisioningManager(context = context, serialServer = serialServer, scope = scope).also { it.startObserving() } } diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt index 65d0019a5b..80cebe263b 100644 --- a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -26,6 +26,7 @@ private fun testConn(backgroundScope: kotlinx.coroutines.CoroutineScope, onSend: initialState = SolarXRBridgeState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), scope = backgroundScope, behaviours = listOf(DataFeedInitBehaviour(server, skeleton)), + name = "SolarXRDataFeedTest", ) val bridge = SolarXRBridge( id = 1, From 834111c7698f67442361e19b1de31be897139bcf Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 29 Apr 2026 08:13:29 +0200 Subject: [PATCH 076/350] Basic AI Agent stuff as test --- .opencode/skills/compare-with-main/SKILL.md | 25 ++++++++ AGENTS.md | 64 +++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 .opencode/skills/compare-with-main/SKILL.md create mode 100644 AGENTS.md diff --git a/.opencode/skills/compare-with-main/SKILL.md b/.opencode/skills/compare-with-main/SKILL.md new file mode 100644 index 0000000000..5d4a15223a --- /dev/null +++ b/.opencode/skills/compare-with-main/SKILL.md @@ -0,0 +1,25 @@ +--- +name: compare-with-main +description: Compare current implementation with the original logic in the 'main' branch by searching for semantic matches, ensuring functionality and edge cases are preserved during rewrites. +compatibility: opencode +--- +## What I do +- **Identify Intent**: Determine the core logic or feature currently being worked on from the user's request. +- **Locate Original Logic**: + - Since file structures differ in rewrites, I will search the `main` branch for semantic matches using a multi-step discovery process: + 1. **File Discovery**: Use `git ls-tree -r main --name-only` to find files with relevant keywords. + 2. **Content Discovery**: Use `git grep` against the `main` branch to find specific functions or logic patterns. + 3. **Inspection**: Use `git show main:` to inspect the source code of candidates found in `main`. +- **Deep Comparison**: + - Once a candidate in `main` is found, I will compare: + - **State/Actions**: Are all original state properties and action types accounted for? + - **Reducers**: Does the new reducer handle the same state transitions and edge cases? + - **Side Effects**: Are all original coroutines, subscriptions, and event listeners present? +- **Provide a structured report**: + - ✅ **[MATCHED]**: Equivalent logic found. + - 🔄 **[CHANGED]**: Behavior or implementation differs (with explanation of why). + - ⚠️ **[MISSING]**: Logic or state from `main` that is absent in the current version. + - 🚨 **[EDGE CASE RISK]**: Identified discrepancies that might cause regression. + +## When to use me +Use this when you have just completed a refactor, rewrite, or significant feature implementation where the directory structure or filenames have changed compared to the `main` branch. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..e2fb6f9175 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,64 @@ +# OpenCode Agent Instructions + +## Environment & Tooling +- **Server (Kotlin/Java)**: Use `gradle` (`./gradlew`). + - Run: `./gradlew run` + - Build: `./gradlew shadowJar` + - Format: `./gradlew spotlessApply` +- **GUI (Electron/TS)**: Use `pnpm`. + - Install: `pnpm i` + - Dev: `pnpm gui` + - Build: `pnpm package:build` + - Format: `pnpm run lint:fix` +- **SolarXR Protocol (Submodule)**: Use `bash` and `pnpm`. + - Edit: Only `schema/` files. + - Generate: `./generate-flatbuffer.sh` (Linux/macOS). + - Sync: Run `pnpm i` in root after generation. + +## Architecture: The Reducer/Behaviour Pattern +The server uses a reactive reducer pattern for thread safety and observability. + +- **Core Primitives**: + - `Context`: Holds `StateFlow`, `CoroutineScope`, and `Behaviour`s. + - `Behaviour`: + - `reduce(state, action)`: **Pure function**. No side effects. + - `observe(receiver)`: **Side effects**. Launch coroutines using `receiver.context.scope`. +- **Lifecycle**: `Module.create()` (inert construction) $\rightarrow$ `module.startObserving()` (side-effect activation). +- **DI**: Uses `Phase1ContextProvider` $\rightarrow$ `AppContextProvider` hierarchy. +- **Communication**: Modules communicate via `dispatch(action)` or by observing another module's `StateFlow`. + +## Design & Coding Preferences +- **Function over Class**: Prefer plain functions or `object` over classes for single-purpose logic. +- **Function over Extension**: Use plain functions instead of extension functions unless the receiver is the primary subject. +- **Action Selection**: + - Use `Update` (with a lambda) for internal state changes that require no external reaction. + - Use **Named Actions** when multiple behaviours must react to a specific event. +- **State Management**: Only include data in `StateFlow` if it is required for observation by other modules. +- **Communication**: Decouple modules by using `dispatch` or observing `StateFlow` rather than direct method calls. + +## Agent Workflow +- **Phase 1: Discovery**: Use the `Glob` $\rightarrow$ `Grep` $\rightarrow$ `Read` pattern. Never guess file paths or contents. +- **Phase 2: Planning**: For complex tasks, use `todowrite` to create a checklist. Present the plan to the user before implementation. +- **Phase 3: Implementation**: Use `edit` for existing files; `write` for new files. +- **Phase 4: Verification**: Always run the relevant linting or testing tools before marking a task as complete. + +## Common Pitfalls +- **Coroutine Leaks**: Never launch coroutines in `observe` using a global scope; **always** use `receiver.context.scope`. +- **Direct State Mutation**: Never attempt to mutate state properties directly. Always use `dispatch(action)`. +- **Incomplete Cleanup**: Ensure any module owning a `Context` implements a `dispose()` method. + +## Conventions +- **Files**: `module.kt` (Class/State/Actions) and `behaviours.kt` (Implementations). +- **Actions**: Use `sealed interface`. +- **State**: Data classes must use `val` only. +- **Cleanup**: We use a direct `dispose()` method on modules instead of a `Disposable` interface. +- **Detailed Conventions**: See `server/README.md` for full coding style and architecture details. + +## Testing +- **Reducers**: Test by creating a `Context` with specific `Behaviour`s. +- **Modules**: Use `TestAppContext` and `buildTest...` helpers from `TestServer.kt`. + +## Verification Protocol +- **Linting**: Run `./gradlew spotlessApply` (Server) or `pnpm run lint:fix` (GUI). +- **Testing**: Identify and run the relevant test suite (e.g., `./gradlew test` or specific module tests). +- **Final Audit**: Perform a final `git diff` check to ensure no unintended changes were made. From 448f0754ad5133a281701cb714ca00d8c3daf8c9 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 29 Apr 2026 08:26:14 +0200 Subject: [PATCH 077/350] Remove extra import --- server/core/src/main/java/dev/slimevr/driver/module.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt index 5a5c03804c..297c3c854e 100644 --- a/server/core/src/main/java/dev/slimevr/driver/module.kt +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -9,8 +9,6 @@ import kotlinx.coroutines.CoroutineScope import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 -import kotlinx.coroutines.CoroutineScope - data class DriverBridgeState( val protocolVersion: Int, ) From cdd47d8b74edbb80f6ec567dd81ded4791399100 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 29 Apr 2026 11:06:17 +0200 Subject: [PATCH 078/350] Tracker allowlist --- .../java/dev/slimevr/config/behaviours.kt | 4 ++++ .../main/java/dev/slimevr/config/settings.kt | 5 ++++- .../main/java/dev/slimevr/driver/module.kt | 4 ++-- .../main/java/dev/slimevr/feeder/module.kt | 4 ++-- .../main/java/dev/slimevr/firmware/module.kt | 5 ++++- .../main/java/dev/slimevr/firmware/serial.kt | 7 ++++++ .../main/java/dev/slimevr/hid/behaviours.kt | 2 -- .../java/dev/slimevr/provisioning/module.kt | 5 ++++- .../java/dev/slimevr/provisioning/phases.kt | 5 +++++ .../src/main/java/dev/slimevr/solarxr/ipc.kt | 3 +++ .../java/dev/slimevr/solarxr/knowntrackers.kt | 22 +++++++++++++++++++ .../main/java/dev/slimevr/solarxr/module.kt | 3 ++- .../main/java/dev/slimevr/udp/behaviours.kt | 16 +++++++++++++- .../src/main/java/dev/slimevr/udp/server.kt | 2 +- .../src/main/java/dev/slimevr/vrserver.kt | 2 +- 15 files changed, 76 insertions(+), 13 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/knowntrackers.kt diff --git a/server/core/src/main/java/dev/slimevr/config/behaviours.kt b/server/core/src/main/java/dev/slimevr/config/behaviours.kt index bdc7289862..4b63110b25 100644 --- a/server/core/src/main/java/dev/slimevr/config/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/config/behaviours.kt @@ -17,6 +17,10 @@ object DefaultSettingsBehaviour : SettingsBehaviour { val existing = state.data.trackers[action.hardwareId] ?: TrackerConfig() state.copy(data = state.data.copy(trackers = state.data.trackers + (action.hardwareId to action.transform(existing)))) } + + is SettingsActions.AddAllowedUdpDevice -> state.copy(data = state.data.copy(allowedUdpDevices = state.data.allowedUdpDevices + action.mac)) + + is SettingsActions.RemoveAllowedUdpDevice -> state.copy(data = state.data.copy(allowedUdpDevices = state.data.allowedUdpDevices - action.mac)) } } diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index be9aca5cd8..632dab2051 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -45,6 +45,7 @@ data class SettingsConfigState( val mutedChecklistSteps: Set = emptySet(), val trackers: Map = emptyMap(), val globalMagEnabled: Boolean = true, + val allowedUdpDevices: Set = emptySet(), val version: Int = SETTINGS_CONFIG_VERSION, ) @@ -70,6 +71,8 @@ sealed interface SettingsActions { data class Update(val transform: SettingsConfigState.() -> SettingsConfigState) : SettingsActions data class LoadProfile(val newState: SettingsState) : SettingsActions data class UpdateTracker(val hardwareId: String, val transform: TrackerConfig.() -> TrackerConfig) : SettingsActions + data class AddAllowedUdpDevice(val mac: String) : SettingsActions + data class RemoveAllowedUdpDevice(val mac: String) : SettingsActions } typealias SettingsContext = Context @@ -115,7 +118,7 @@ class Settings( initialState = initialState, scope = scope, behaviours = behaviours, - name = "Settings[$name]" + name = "Settings[$name]", ) val settings = Settings(context, scope = scope, settingsDir = settingsDir) behaviours.forEach { it.observe(settings) } diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt index 297c3c854e..abe2203187 100644 --- a/server/core/src/main/java/dev/slimevr/driver/module.kt +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -5,9 +5,9 @@ import dev.slimevr.EventDispatcher import dev.slimevr.VRServerActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import kotlinx.coroutines.CoroutineScope import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.CoroutineScope data class DriverBridgeState( val protocolVersion: Int, @@ -54,7 +54,7 @@ class DriverBridge( initialState = DriverBridgeState(protocolVersion = 0), scope = scope, behaviours = behaviours, - name = "Driver[$id]" + name = "Driver[$id]", ) val bridge = DriverBridge(id = id, context = context, appContext = appContext) diff --git a/server/core/src/main/java/dev/slimevr/feeder/module.kt b/server/core/src/main/java/dev/slimevr/feeder/module.kt index 608e0cb9fa..35782a0df9 100644 --- a/server/core/src/main/java/dev/slimevr/feeder/module.kt +++ b/server/core/src/main/java/dev/slimevr/feeder/module.kt @@ -5,9 +5,9 @@ import dev.slimevr.EventDispatcher import dev.slimevr.VRServerActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import kotlinx.coroutines.CoroutineScope import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.CoroutineScope data class FeederBridgeState( val protocolVersion: Int, @@ -50,7 +50,7 @@ class FeederBridge( initialState = FeederBridgeState(protocolVersion = 0, firmware = null), scope = scope, behaviours = behaviours, - name = "Feeder[$id]" + name = "Feeder[$id]", ) val bridge = FeederBridge(id = id, context = context, appContext = appContext) diff --git a/server/core/src/main/java/dev/slimevr/firmware/module.kt b/server/core/src/main/java/dev/slimevr/firmware/module.kt index 2ea0b0053f..4422ab2361 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/module.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/module.kt @@ -2,6 +2,7 @@ package dev.slimevr.firmware import dev.slimevr.Phase1ContextProvider import dev.slimevr.VRServer +import dev.slimevr.config.Settings import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.serial.SerialServer @@ -42,6 +43,7 @@ typealias FirmwareManagerBehaviour = Behaviour context.dispatch( @@ -129,7 +132,7 @@ class FirmwareManager( behaviours = behaviours, name = "FirmwareManager", ) - return FirmwareManager(context = context, serialServer = ctx.serialServer, scope = scope) + return FirmwareManager(context = context, serialServer = ctx.serialServer, settings = ctx.config.settings, scope = scope) } } } diff --git a/server/core/src/main/java/dev/slimevr/firmware/serial.kt b/server/core/src/main/java/dev/slimevr/firmware/serial.kt index 2fd9421ae2..f6a6294957 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/serial.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/serial.kt @@ -3,6 +3,8 @@ package dev.slimevr.firmware import dev.llelievr.espflashkotlin.Flasher import dev.llelievr.espflashkotlin.FlashingProgressListener import dev.slimevr.VRServer +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions import dev.slimevr.serial.MAC_REGEX import dev.slimevr.serial.SerialConnection import dev.slimevr.serial.SerialServer @@ -30,6 +32,7 @@ suspend fun doSerialFlash( ssid: String?, password: String?, serialServer: SerialServer, + settings: Settings, server: VRServer, onStatus: suspend (FirmwareUpdateStatus, Int) -> Unit, scope: CoroutineScope, @@ -86,6 +89,7 @@ suspend fun doSerialFlash( ssid = ssid, password = password, serialServer = serialServer, + settings = settings, server = server, onStatus = onStatus, ) @@ -105,6 +109,7 @@ internal suspend fun doSerialFlashPostFlash( ssid: String?, password: String?, serialServer: SerialServer, + settings: Settings, server: VRServer, onStatus: suspend (FirmwareUpdateStatus, Int) -> Unit, ) { @@ -158,6 +163,8 @@ internal suspend fun doSerialFlashPostFlash( return } + settings.context.dispatch(SettingsActions.AddAllowedUdpDevice(macAddress)) + // provision with Wi-Fi credentials if (ssid == null || password == null) { onStatus(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED, 0) diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index eb2d3efcff..48ed591979 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -115,8 +115,6 @@ object HIDDeviceInfoBehaviour : HIDReceiverBehaviour { tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType, completedRestCalibration = true) }) tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) } - - } } } diff --git a/server/core/src/main/java/dev/slimevr/provisioning/module.kt b/server/core/src/main/java/dev/slimevr/provisioning/module.kt index f20595864e..360665bef1 100644 --- a/server/core/src/main/java/dev/slimevr/provisioning/module.kt +++ b/server/core/src/main/java/dev/slimevr/provisioning/module.kt @@ -2,6 +2,7 @@ package dev.slimevr.provisioning import dev.slimevr.Phase1ContextProvider import dev.slimevr.VRServer +import dev.slimevr.config.Settings import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.serial.SerialConnection @@ -34,6 +35,7 @@ typealias ProvisioningManagerBehaviour = Behaviour { bundle -> val fbb = FlatBufferBuilder(256) fbb.finish(bundle.encode(fbb)) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/knowntrackers.kt b/server/core/src/main/java/dev/slimevr/solarxr/knowntrackers.kt new file mode 100644 index 0000000000..ffbc13e0a8 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/knowntrackers.kt @@ -0,0 +1,22 @@ +package dev.slimevr.solarxr + +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions +import solarxr_protocol.rpc.AddUnknownDeviceRequest +import solarxr_protocol.rpc.ForgetDeviceRequest + +class KnownTrackersBehaviour( + private val settings: Settings, +) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { + receiver.rpcDispatcher.on { req -> + val mac = req.macAddress ?: return@on + settings.context.dispatch(SettingsActions.AddAllowedUdpDevice(mac)) + } + + receiver.rpcDispatcher.on { req -> + val mac = req.macAddress ?: return@on + settings.context.dispatch(SettingsActions.RemoveAllowedUdpDevice(mac)) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index a1098364b6..55f8343418 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -76,6 +76,7 @@ class SolarXRBridge( add(TrackingChecklistBehaviour(appContext.trackingChecklist, appContext.config.settings)) add(AssignTrackerBehaviour(appContext.server)) add(MagBehaviour(appContext)) + add(KnownTrackersBehaviour(appContext.config.settings)) } fun create( @@ -87,7 +88,7 @@ class SolarXRBridge( initialState = SolarXRBridgeState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), scope = scope, behaviours = buildBehaviours(appContext), - name = "SolarXR[$id]" + name = "SolarXR[$id]", ) val bridge = SolarXRBridge( diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 9e6609b33a..0e78eeaa51 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.rpc.UnknownDeviceHandshakeNotification import kotlin.random.Random internal const val CONNECTION_TIMEOUT_MS = 5000L @@ -83,7 +84,6 @@ object PacketLossBehaviour : UDPConnectionBehaviour { } } - object PingBehaviour : UDPConnectionBehaviour { override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { is UDPConnectionActions.StartPing -> state.copy(lastPing = state.lastPing.copy(startTime = action.startTime, id = action.pingId)) @@ -157,6 +157,20 @@ object HandshakeBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { receiver.packetEvents.onPacket { packet -> val state = receiver.context.state.value + val mac = packet.data.macString + + if (mac != null) { + val settings = receiver.appContext.config.settings.context.state.value.data + if (mac !in settings.allowedUdpDevices) { + AppLogger.udp.info("[${state.address}] Unknown MAC $mac, notifying solarxr") + receiver.appContext.server.context.scope.launch { + receiver.appContext.server.context.state.value.solarxr.values.forEach { bridge -> + bridge.sendRpc(UnknownDeviceHandshakeNotification(macAddress = mac)) + } + } + return@onPacket + } + } val device = if (state.deviceId == null) { findOrCreateDevice(receiver, state, packet.data) diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index a4aa60fdd1..354eb63ada 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -96,7 +96,7 @@ class UdpServer(val context: UdpServerContext) { initialState = INITIAL_STATE, scope = scope, behaviours = listOf(UdpServerBaseBehaviour), - name = "UdpServer" + name = "UdpServer", ) val server = UdpServer(context) server.startObserving() diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index c69df34947..1f05c880b5 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -57,7 +57,7 @@ class VRServer( ), scope = scope, behaviours = behaviours, - name = "VRServer" + name = "VRServer", ) val server = VRServer(context = context) behaviours.forEach { it.observe(server) } From 14e77b6af3c770d57b693cec101e7d09f0ef5787 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 29 Apr 2026 22:30:37 +0200 Subject: [PATCH 079/350] Basic bvh --- .../src/main/java/dev/slimevr/app-context.kt | 3 + .../main/java/dev/slimevr/bvh/behaviours.kt | 76 +++++++++++ .../src/main/java/dev/slimevr/bvh/module.kt | 40 ++++++ .../src/main/java/dev/slimevr/bvh/stream.kt | 127 ++++++++++++++++++ .../core/src/main/java/dev/slimevr/logger.kt | 1 + .../java/dev/slimevr/skeleton/behaviours.kt | 49 ++++--- .../main/java/dev/slimevr/skeleton/module.kt | 6 +- .../src/main/java/dev/slimevr/solarxr/bvh.kt | 31 +++++ .../main/java/dev/slimevr/solarxr/module.kt | 1 + .../src/main/java/dev/slimevr/desktop/Main.kt | 3 + 10 files changed, 320 insertions(+), 17 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/bvh/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/bvh/module.kt create mode 100644 server/core/src/main/java/dev/slimevr/bvh/stream.kt create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/bvh.kt diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index 03564300cd..22422ffa62 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -1,5 +1,6 @@ package dev.slimevr +import dev.slimevr.bvh.BVHManager import dev.slimevr.config.AppConfig import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager @@ -30,6 +31,7 @@ interface AppContextProvider : Phase1ContextProvider { val heightCalibrationManager: HeightCalibrationManager val trackingChecklist: TrackingChecklist val udpServer: UdpServer + val bvhManager: BVHManager fun startObserving() } @@ -44,6 +46,7 @@ class AppContext( override val heightCalibrationManager: HeightCalibrationManager, override val trackingChecklist: TrackingChecklist, override val udpServer: UdpServer, + override val bvhManager: BVHManager, ) : AppContextProvider { override fun startObserving() { skeleton.startObserving() diff --git a/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt b/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt new file mode 100644 index 0000000000..ca508250bf --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt @@ -0,0 +1,76 @@ +package dev.slimevr.bvh + +import dev.slimevr.AppLogger +import dev.slimevr.skeleton.Skeleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import java.io.File +import java.nio.file.Path + +private suspend fun resolveBvhFile(path: Path): File? = withContext(Dispatchers.IO) { + val file = path.toFile() + if (file.isDirectory) { + getBvhFileInDir(file) + } else { + if (file.name.lowercase().endsWith(".bvh")) file else null + } +} + +private suspend fun getBvhFileInDir(dir: File): File? = withContext(Dispatchers.IO) { + if (dir.isDirectory || dir.mkdirs()) { + var file: File + var index = 1 + do { + file = File(dir, "BVH-Recording${index++}.bvh") + } while (file.exists()) + file + } else { + AppLogger.bvh.error("Failed to create recording directory \"${dir.path}\"") + null + } +} + +class BVHRecordingBehaviour(private val skeleton: Skeleton) : BVHBehaviourType { + override fun reduce(state: BVHState, action: BVHActions) = when (action) { + is BVHActions.StartRecording -> state.copy(recording = true, recordingPath = action.path) + is BVHActions.StopRecording -> state.copy(recording = false, recordingPath = null) + } + + override fun observe(receiver: BVHManager) { + var stream: BvhStream? = null + + receiver.context.state + .map { it.recording to it.recordingPath } + .distinctUntilChanged() + .onEach { (recording, path) -> + if (recording && stream == null) { + val file = resolveBvhFile(path ?: return@onEach) ?: return@onEach + try { + stream = BvhStream(file).also { + it.writeHeader(skeleton.computed.value) + // TODO: we could write the initial T-Pose or whatever here + } + } catch (e: Exception) { + AppLogger.bvh.error("Failed to start BVH recording", e) + receiver.context.dispatch(BVHActions.StopRecording) + } + } else if (!recording && stream != null) { + try { + stream?.close() + } catch (e: Exception) { + AppLogger.bvh.error("Failed to finalize BVH recording", e) + } finally { + stream = null + } + } + }.launchIn(receiver.context.scope) + + skeleton.computed.onEach { bones -> + stream?.writeFrame(bones) + }.launchIn(receiver.context.scope) + } +} diff --git a/server/core/src/main/java/dev/slimevr/bvh/module.kt b/server/core/src/main/java/dev/slimevr/bvh/module.kt new file mode 100644 index 0000000000..834b698f5f --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/bvh/module.kt @@ -0,0 +1,40 @@ +package dev.slimevr.bvh + +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context +import dev.slimevr.skeleton.Skeleton +import kotlinx.coroutines.CoroutineScope +import java.nio.file.Path + +data class BVHState( + val recording: Boolean = false, + val recordingPath: Path? = null, +) + +sealed interface BVHActions { + data class StartRecording(val path: Path) : BVHActions + data object StopRecording : BVHActions +} + +typealias BVHContext = Context +typealias BVHBehaviourType = Behaviour + +class BVHManager(val context: BVHContext) { + val isRecording get() = context.state.value.recording + + fun startRecording(path: Path) = context.dispatch(BVHActions.StartRecording(path)) + fun stopRecording() = context.dispatch(BVHActions.StopRecording) + fun startObserving() = context.observeAll(this) + + companion object { + fun create(skeleton: Skeleton, scope: CoroutineScope): BVHManager { + val context = Context.create( + initialState = BVHState(), + scope = scope, + behaviours = listOf(BVHRecordingBehaviour(skeleton)), + name = "BVH", + ) + return BVHManager(context).also { manager -> manager.startObserving() } + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/bvh/stream.kt b/server/core/src/main/java/dev/slimevr/bvh/stream.kt new file mode 100644 index 0000000000..018ff8fe7d --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/bvh/stream.kt @@ -0,0 +1,127 @@ +package dev.slimevr.bvh + +import com.jme3.math.FastMath +import dev.slimevr.skeleton.BODY_PART_HIERARCHY_MAP +import dev.slimevr.skeleton.BONE_TAIL_OFFSETS +import dev.slimevr.skeleton.BoneState +import io.github.axisangles.ktmath.EulerOrder +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import solarxr_protocol.datatypes.BodyPart +import java.io.BufferedWriter +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStreamWriter + +private const val FRAME_COUNT_DIGITS = Long.MAX_VALUE.toString().length +private const val OFFSET_SCALE = 1f +private const val POSITION_SCALE = 1f + +class BvhStream(file: File) { + private val outputStream = FileOutputStream(file) + private val writer = BufferedWriter(OutputStreamWriter(outputStream), 4096) + private var frameCount = 0L + private var frameCountOffset = 0L + private var frameTimeOffset = 0L + private var lastFrameTime = System.currentTimeMillis() + private val frameIntervals = mutableListOf() + + suspend fun writeHeader(bones: Map) = withContext(Dispatchers.IO) { + writer.write("HIERARCHY\n") + writeBone(BodyPart.HEAD, null, bones, 0) + writer.write("MOTION\n") + writer.write("Frames: ") + writer.flush() + frameCountOffset = outputStream.channel.position() + frameCount = 0L + writer.write(getBufferedFrameCount(0L) + "\n") + writer.write("Frame Time: ") + writer.flush() + frameTimeOffset = outputStream.channel.position() + writer.write(getBufferedFrameInterval(0.01f) + "\n") + lastFrameTime = System.currentTimeMillis() + } + + suspend fun writeFrame(bones: Map) = withContext(Dispatchers.IO) { + val now = System.currentTimeMillis() + if (frameCount > 0) { + val interval = (now - lastFrameTime) / 1000f + frameIntervals.add(interval) + } + lastFrameTime = now + + val head = bones[BodyPart.HEAD] + val pos = head?.headPosition ?: Vector3.NULL + writer.write("${pos.x * POSITION_SCALE} ${pos.y * POSITION_SCALE} ${pos.z * POSITION_SCALE}") + writeRotations(BodyPart.HEAD, bones) + writer.newLine() + frameCount++ + } + + suspend fun close() = withContext(Dispatchers.IO) { + writer.flush() + val avgInterval = if (frameIntervals.isNotEmpty()) frameIntervals.average().toFloat() else 0.01f + + outputStream.channel.position(frameCountOffset) + writer.write(getBufferedFrameCount(frameCount)) + + outputStream.channel.position(frameTimeOffset) + writer.write(getBufferedFrameInterval(avgInterval)) + + writer.close() + } + + private fun getBufferedFrameCount(frameCount: Long): String { + val frameString = frameCount.toString() + val bufferCount = FRAME_COUNT_DIGITS - frameString.length + return if (bufferCount > 0) frameString + " ".repeat(bufferCount) else frameString + } + + private fun getBufferedFrameInterval(interval: Float): String { + val intervalString = String.format("%.4f", interval) + return intervalString.padEnd(9) + } + + private fun getBvhOffset(parentPart: BodyPart, bones: Map): Vector3 { + val tailOffsetLocal = BONE_TAIL_OFFSETS[parentPart] ?: return Vector3.NULL + val parentLength = bones[parentPart]?.length ?: 0f + return tailOffsetLocal.unit() * (parentLength * OFFSET_SCALE) + } + + private fun writeRotations(part: BodyPart, bones: Map) { + val bone = bones[part] + val rot = bone?.localRotation ?: Quaternion.IDENTITY + val angles = rot.toEulerAngles(EulerOrder.ZXY) + writer.write(" ${angles.z * FastMath.RAD_TO_DEG} ${angles.x * FastMath.RAD_TO_DEG} ${angles.y * FastMath.RAD_TO_DEG}") + BODY_PART_HIERARCHY_MAP[part]?.forEach { child -> writeRotations(child, bones) } + } + + private fun writeBone(part: BodyPart, parent: BodyPart?, bones: Map, depth: Int) { + val indent = "\t".repeat(depth) + val childIndent = "\t".repeat(depth + 1) + writer.write("$indent${if (parent == null) "ROOT" else "JOINT"} $part\n") + writer.write("$indent{\n") + + if (parent == null) { + writer.write("${childIndent}OFFSET 0.0 0.0 0.0\n") + writer.write("${childIndent}CHANNELS 6 Xposition Yposition Zposition Zrotation Xrotation Yrotation\n") + } else { + val offset = getBvhOffset(parent, bones) + writer.write("${childIndent}OFFSET ${offset.x} ${offset.y} ${offset.z}\n") + writer.write("${childIndent}CHANNELS 3 Zrotation Xrotation Yrotation\n") + } + + val children = BODY_PART_HIERARCHY_MAP[part] + if (children.isNullOrEmpty()) { + writer.write("${childIndent}End Site\n") + writer.write("$childIndent{\n") + writer.write("${" ".repeat(depth + 2)}OFFSET 0.0 0.0 0.0\n") + writer.write("$childIndent}\n") + } else { + children.forEach { child -> writeBone(child, part, bones, depth + 1) } + } + writer.write("$indent}\n") + } +} diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index ead2e5eba5..f606b5fc92 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -15,6 +15,7 @@ object AppLogger { val serial = logger("Serial") val firmware = logger("Firmware") val vrc = logger("VRChat") + val bvh = logger("BVH") init { loggingConfiguration { diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 6a166e60b5..7d630df876 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -14,6 +14,25 @@ import kotlin.math.cos import kotlin.math.sin import kotlin.random.Random +class BoneTransformBehaviour : SkeletonBehaviour { + override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { + is SkeletonActions.SetBoneRotation -> { + val bones = state.bones.toMutableMap() + val bone = bones[action.bodyPart] ?: return state + bones[action.bodyPart] = bone.copy(rotation = action.rotation) + state.copy(bones = bones) + } + + is SkeletonActions.SetBonePosition -> { + val positions = state.bonePositions.toMutableMap() + positions[action.bodyPart] = action.position + state.copy(bonePositions = positions) + } + + else -> state + } +} + class ProportionsBehaviour : SkeletonBehaviour { override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { is SkeletonActions.SetProportions -> { @@ -53,20 +72,6 @@ class HeightLogBehaviour : SkeletonBehaviour { } class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour { - - override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState { - val bones = state.bones.toMutableMap() - return when (action) { - is SkeletonActions.SetBoneRotation -> { - val bone = bones[action.bodyPart] ?: return state - bones[action.bodyPart] = bone.copy(rotation = action.rotation) - state.copy(bones = bones) - } - - else -> state - } - } - override fun observe(receiver: Skeleton) { receiver.context.scope.launch { val intervalMs = (1000f / inputHz).toLong() @@ -74,6 +79,8 @@ class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour while (true) { delay(intervalMs) val elapsed = (System.currentTimeMillis() - startTime) / 1000f + val state = receiver.context.state.value + receiver.context.dispatch( SkeletonActions.SetBoneRotation( BodyPart.CHEST, @@ -86,6 +93,17 @@ class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour Quaternion.fromRotationVector(Vector3(cos(elapsed + 1000), sin(elapsed + 1000), 0f)), ), ) + + val circleRadius = 0.5f + val circleX = cos(elapsed * 2f) * circleRadius + val circleZ = sin(elapsed * 2f) * circleRadius + val jumpHeight = maxOf(0f, sin(elapsed * 3f) * 0.3f) + receiver.context.dispatch( + SkeletonActions.SetBonePosition( + BodyPart.HEAD, + Vector3(circleX, state.userHeight.toFloat() + jumpHeight, circleZ), + ), + ) } } } @@ -104,7 +122,8 @@ class ComputedSkeletonBehaviour( val processed = processors .filter { processor -> processor.enabled } .fold(targetState) { state, processor -> processor.process(state) } - receiver.computed.value = buildBones(processed) + val rootHead = processed.bonePositions[BodyPart.HEAD] ?: Vector3(0f, targetState.userHeight.toFloat(), 0f) + receiver.computed.value = buildBones(processed, rootHead = rootHead) } } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 17d69829bc..d7c85b1fd9 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -26,7 +26,7 @@ data class BoneState( get() = tailPosition - headPosition } -data class SkeletonState(val bones: Map, val userHeight: Double) +data class SkeletonState(val bones: Map, val userHeight: Double, val bonePositions: Map = emptyMap()) val DEFAULT_SKELETON_STATE: SkeletonState = run { val bones = BONE_TAIL_OFFSETS.entries.associate { (bodyPart, tailOffset) -> @@ -57,6 +57,7 @@ fun buildBones(state: SkeletonState, rootHead: Vector3 = Vector3.NULL): Map) : SkeletonActions } @@ -71,7 +72,8 @@ class Skeleton( companion object { fun create(scope: CoroutineScope, ctx: Phase1ContextProvider): Skeleton { - val behaviours = listOf( + val behaviours = listOf( + BoneTransformBehaviour(), ProportionsBehaviour(), ScaledProportionsBehaviour(ctx.config.userConfig), HeightLogBehaviour(), diff --git a/server/core/src/main/java/dev/slimevr/solarxr/bvh.kt b/server/core/src/main/java/dev/slimevr/solarxr/bvh.kt new file mode 100644 index 0000000000..164bc8d6ea --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/bvh.kt @@ -0,0 +1,31 @@ +package dev.slimevr.solarxr + +import dev.slimevr.bvh.BVHManager +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import solarxr_protocol.rpc.RecordBVHRequest +import solarxr_protocol.rpc.RecordBVHStatus +import solarxr_protocol.rpc.RecordBVHStatusRequest +import java.nio.file.Path + +class BvhBehaviour(private val bvhManager: BVHManager) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { + receiver.rpcDispatcher.on { req -> + if (req.stop) { + bvhManager.stopRecording() + } else { + req.path?.let { path -> bvhManager.startRecording(Path.of(path)) } + } + receiver.sendRpc(RecordBVHStatus(recording = bvhManager.isRecording)) + } + + receiver.rpcDispatcher.on { + receiver.sendRpc(RecordBVHStatus(recording = bvhManager.isRecording)) + } + + bvhManager.context.state.drop(1) + .onEach { state -> receiver.sendRpc(RecordBVHStatus(recording = state.recording)) } + .launchIn(receiver.context.scope) + } +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 55f8343418..7aad018df7 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -77,6 +77,7 @@ class SolarXRBridge( add(AssignTrackerBehaviour(appContext.server)) add(MagBehaviour(appContext)) add(KnownTrackersBehaviour(appContext.config.settings)) + add(BvhBehaviour(appContext.bvhManager)) } fun create( 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 7fb4ba79dd..de8b4743ed 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -5,6 +5,7 @@ package dev.slimevr.desktop import dev.slimevr.AppContext import dev.slimevr.Phase1Context import dev.slimevr.VRServer +import dev.slimevr.bvh.BVHManager import dev.slimevr.config.AppConfig import dev.slimevr.desktop.hid.createDesktopHIDManager import dev.slimevr.desktop.ipc.createIpcServers @@ -36,6 +37,7 @@ fun main(args: Array) = runBlocking { val heightCalibrationManager = HeightCalibrationManager.create(ctx = phase1, scope = this) val trackingChecklist = TrackingChecklist.create(scope = this) val udpServer = UdpServer.create(scope = this) + val bvhManager = BVHManager.create(skeleton = skeleton, scope = this) val appContext = AppContext( server = server, @@ -48,6 +50,7 @@ fun main(args: Array) = runBlocking { heightCalibrationManager = heightCalibrationManager, trackingChecklist = trackingChecklist, udpServer = udpServer, + bvhManager = bvhManager, ) appContext.startObserving() From b336897bfe541816a75117b94f25f4259ac0fdec Mon Sep 17 00:00:00 2001 From: loucass003 Date: Fri, 1 May 2026 07:33:28 +0200 Subject: [PATCH 080/350] RawBone --- .../java/dev/slimevr/skeleton/behaviours.kt | 22 ++++++++--------- .../main/java/dev/slimevr/skeleton/module.kt | 24 +++++++++++++------ .../java/dev/slimevr/skeleton/processors.kt | 24 +++++++++---------- .../java/dev/slimevr/skeleton/proportions.kt | 6 ++--- .../dev/slimevr/solarxr/skeletonconfig.kt | 2 +- 5 files changed, 43 insertions(+), 35 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 7d630df876..fcab4875a6 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -12,23 +12,21 @@ import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart import kotlin.math.cos import kotlin.math.sin -import kotlin.random.Random class BoneTransformBehaviour : SkeletonBehaviour { override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { is SkeletonActions.SetBoneRotation -> { - val bones = state.bones.toMutableMap() + val bones = state.rawBones.toMutableMap() val bone = bones[action.bodyPart] ?: return state - bones[action.bodyPart] = bone.copy(rotation = action.rotation) - state.copy(bones = bones) + bones[action.bodyPart] = bone.copy(rawRotation = action.rotation) + state.copy(rawBones = bones) } - is SkeletonActions.SetBonePosition -> { - val positions = state.bonePositions.toMutableMap() - positions[action.bodyPart] = action.position - state.copy(bonePositions = positions) + val bones = state.rawBones.toMutableMap() + val bone = bones[action.bodyPart] ?: return state + bones[action.bodyPart] = bone.copy(rawPosition = action.position) + state.copy(rawBones = bones) } - else -> state } } @@ -36,10 +34,10 @@ class BoneTransformBehaviour : SkeletonBehaviour { class ProportionsBehaviour : SkeletonBehaviour { override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { is SkeletonActions.SetProportions -> { - val newBones = state.bones.mapValues { (bodyPart, bone) -> + val newBones = state.rawBones.mapValues { (bodyPart, bone) -> bone.copy(length = action.lengths[bodyPart] ?: bone.length) } - state.copy(bones = newBones, userHeight = computeUserHeight(newBones.mapValues { (_, bone) -> bone.length })) + state.copy(rawBones = newBones, userHeight = computeUserHeight(newBones.mapValues { (_, bone) -> bone.length })) } else -> state @@ -122,7 +120,7 @@ class ComputedSkeletonBehaviour( val processed = processors .filter { processor -> processor.enabled } .fold(targetState) { state, processor -> processor.process(state) } - val rootHead = processed.bonePositions[BodyPart.HEAD] ?: Vector3(0f, targetState.userHeight.toFloat(), 0f) + val rootHead = Vector3(0f, targetState.userHeight.toFloat(), 0f) // FIXME WRONG receiver.computed.value = buildBones(processed, rootHead = rootHead) } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index d7c85b1fd9..434ea49410 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -10,6 +10,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import solarxr_protocol.datatypes.BodyPart +data class RawBone( + val bodyPart: BodyPart, + val length: Float, + val rawRotation: Quaternion, + val rawPosition: Vector3 +) + data class BoneState( val bodyPart: BodyPart, val length: Float, @@ -22,11 +29,11 @@ data class BoneState( get() = parentBone?.let { it.rotation.inv() * rotation } ?: rotation val localHeadPosition: Vector3 get() = parentBone?.let { headPosition - it.tailPosition } ?: headPosition - val localTailPosition: Vector3 - get() = tailPosition - headPosition +// val localTailPosition: Vector3 +// get() = tailPosition - headPosition } -data class SkeletonState(val bones: Map, val userHeight: Double, val bonePositions: Map = emptyMap()) +data class SkeletonState(val rawBones: Map, val userHeight: Double) val DEFAULT_SKELETON_STATE: SkeletonState = run { val bones = BONE_TAIL_OFFSETS.entries.associate { (bodyPart, tailOffset) -> @@ -36,19 +43,22 @@ val DEFAULT_SKELETON_STATE: SkeletonState = run { } bodyPart to BoneState(bodyPart = bodyPart, length = tailOffset.len(), rotation = restRotation) } - SkeletonState(bones = bones, userHeight = computeUserHeight(bones.mapValues { (_, bone) -> bone.length })) + SkeletonState(rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, length = bone.length, rawPosition = Vector3.NULL) }, userHeight = computeUserHeight(bones.mapValues { (_, bone) -> bone.length })) } fun buildBones(state: SkeletonState, rootHead: Vector3 = Vector3.NULL): Map { val result = mutableMapOf() iterateBodyPartHierarchy().forEach { (parentPart, childPart) -> - val bone = state.bones[childPart] ?: return@forEach + val rawBone = state.rawBones[childPart] ?: return@forEach val tailDirection = BONE_TAIL_DIRECTIONS[childPart] ?: return@forEach val parentBone = parentPart?.let { result[it] } val head = parentBone?.tailPosition ?: rootHead - result[childPart] = bone.copy( + result[childPart] = BoneState( + bodyPart = rawBone.bodyPart, + length = rawBone.length, headPosition = head, - tailPosition = head + bone.rotation.sandwich(tailDirection * bone.length), + rotation = rawBone.rawRotation, + tailPosition = head + rawBone.rawRotation.sandwich(tailDirection * rawBone.length), parentBone = parentBone, ) } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt index 4ba99df0f1..5f77cbf721 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt @@ -15,17 +15,17 @@ class SmoothingProcessor(var smoothing: Float) : SkeletonProcessor { override fun process(state: SkeletonState): SkeletonState { val alpha = 1f - smoothing.coerceAtMost(0.99f) - smoothedRotations = state.bones.mapValues { (bodyPart, bone) -> - (smoothedRotations[bodyPart] ?: bone.rotation).lerpR(bone.rotation, alpha).unit() + smoothedRotations = state.rawBones.mapValues { (bodyPart, bone) -> + (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() } - smoothedLengths = state.bones.mapValues { (bodyPart, bone) -> + smoothedLengths = state.rawBones.mapValues { (bodyPart, bone) -> val prev = smoothedLengths[bodyPart] ?: bone.length prev + (bone.length - prev) * alpha } return state.copy( - bones = state.bones.mapValues { (bodyPart, bone) -> + rawBones = state.rawBones.mapValues { (bodyPart, bone) -> bone.copy( - rotation = smoothedRotations[bodyPart] ?: bone.rotation, + rawRotation = smoothedRotations[bodyPart] ?: bone.rawRotation, length = smoothedLengths[bodyPart] ?: bone.length, ) }, @@ -47,14 +47,14 @@ class PredictionProcessor(var predictionAmount: Float) : SkeletonProcessor { override fun process(state: SkeletonState): SkeletonState { val newVelocities = mutableMapOf() - val newBones = state.bones.mapValues { (bodyPart, bone) -> + val newBones = state.rawBones.mapValues { (bodyPart, bone) -> val prev = velocities[bodyPart] if (prev == null) { - newVelocities[bodyPart] = BoneVelocity(bone.rotation, Quaternion.IDENTITY, bone.length, 0f) + newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, Quaternion.IDENTITY, bone.length, 0f) return@mapValues bone } - val rotationDelta = if (bone.rotation !== prev.lastRotation) { - bone.rotation * prev.lastRotation.inv() + val rotationDelta = if (bone.rawRotation !== prev.lastRotation) { + bone.rawRotation * prev.lastRotation.inv() } else { prev.rotationDelta } @@ -63,14 +63,14 @@ class PredictionProcessor(var predictionAmount: Float) : SkeletonProcessor { } else { prev.lengthDelta } - newVelocities[bodyPart] = BoneVelocity(bone.rotation, rotationDelta, bone.length, lengthDelta) + newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, rotationDelta, bone.length, lengthDelta) val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, predictionAmount).unit() bone.copy( - rotation = (scaledDelta * bone.rotation).unit(), + rawRotation = (scaledDelta * bone.rawRotation).unit(), length = bone.length + lengthDelta * predictionAmount, ) } velocities = newVelocities - return state.copy(bones = newBones) + return state.copy(rawBones = newBones) } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt index 5625bf732f..5c032a5075 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt @@ -3,7 +3,7 @@ package dev.slimevr.skeleton import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.SkeletonBone -// Sum of default bone lengths for height-contributing bones (from Drillis and Contini 1966). +// Sum of default bone lengths for height-contributing bones // Used to normalize HEIGHT_SCALED_BONE_RATIOS. private const val DEFAULT_HEIGHT = 0.1f + 0.16f + 0.16f + 0.20f + 0.04f + 0.42f + 0.50f // = 1.58f @@ -23,7 +23,7 @@ val SKELETON_BONE_TO_BODY_PARTS: Map> = mapOf( SkeletonBone.LOWER_ARM to listOf(BodyPart.LEFT_LOWER_ARM, BodyPart.RIGHT_LOWER_ARM), ) -// Per-bone fraction of total standing height, derived from Drillis and Contini (1966). +// Per-bone fraction of total standing height, // Includes spine, legs, and arms, all bones whose length scales with user height. // Non-height bones (HEAD, FOOT_LENGTH) are absent; they keep fixed defaults from DEFAULT_SKELETON_STATE. private val HEIGHT_SCALED_BONE_RATIOS: Map = mapOf( @@ -63,7 +63,7 @@ fun computeAllDefaultProportionsByBone(height: Float): Map { .filter { (bone, _) -> bone !in HEIGHT_SCALED_BONE_RATIOS } .mapNotNull { (bone, bodyParts) -> val length = bodyParts - .mapNotNull { DEFAULT_SKELETON_STATE.bones[it]?.length } + .mapNotNull { DEFAULT_SKELETON_STATE.rawBones[it]?.length } .average().takeIf { !it.isNaN() }?.toFloat() ?: return@mapNotNull null bone.name to length }.toMap() diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt index 6a9eaa8a20..d7ff9c1b0b 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt @@ -24,7 +24,7 @@ class SkeletonBehaviour( private fun buildConfigResponse(): SkeletonConfigResponse { val proportions = userConfig.context.state.value.data.proportions - val bones = skeleton.context.state.value.bones + val bones = skeleton.context.state.value.rawBones val skeletonParts = SKELETON_BONE_TO_BODY_PARTS.mapNotNull { (skeletonBone, bodyParts) -> val length = proportions[skeletonBone.name] ?: bodyParts.mapNotNull { bones[it]?.length }.average().takeIf { !it.isNaN() }?.toFloat() From 3095ce48675956c75d58bc5a7ba0f4008622198e Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 5 May 2026 04:15:22 +0200 Subject: [PATCH 081/350] Network profile detection basics (untested) --- .../src/main/java/dev/slimevr/app-context.kt | 4 + .../java/dev/slimevr/networkprofile/module.kt | 63 ++++++ .../slimevr/trackingchecklist/behaviours.kt | 19 +- .../dev/slimevr/trackingchecklist/module.kt | 2 + .../src/main/java/dev/slimevr/desktop/Main.kt | 6 + .../desktop/networkprofile/network-profile.kt | 205 ++++++++++++++++++ 6 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 server/core/src/main/java/dev/slimevr/networkprofile/module.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index 22422ffa62..66e24d2ba7 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -4,6 +4,7 @@ import dev.slimevr.bvh.BVHManager import dev.slimevr.config.AppConfig import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager +import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.serial.SerialServer import dev.slimevr.skeleton.Skeleton @@ -27,6 +28,7 @@ interface AppContextProvider : Phase1ContextProvider { val skeleton: Skeleton val firmwareManager: FirmwareManager val vrcConfigManager: VRCConfigManager? + val networkProfileManager: NetworkProfileManager? val provisioningManager: ProvisioningManager val heightCalibrationManager: HeightCalibrationManager val trackingChecklist: TrackingChecklist @@ -42,6 +44,7 @@ class AppContext( override val skeleton: Skeleton, override val firmwareManager: FirmwareManager, override val vrcConfigManager: VRCConfigManager?, + override val networkProfileManager: NetworkProfileManager?, override val provisioningManager: ProvisioningManager, override val heightCalibrationManager: HeightCalibrationManager, override val trackingChecklist: TrackingChecklist, @@ -54,6 +57,7 @@ class AppContext( provisioningManager.startObserving() heightCalibrationManager.startObserving() vrcConfigManager?.startObserving() + networkProfileManager?.startObserving() trackingChecklist.startObserving(this) udpServer.startReceiving(this, server.context.scope) } diff --git a/server/core/src/main/java/dev/slimevr/networkprofile/module.kt b/server/core/src/main/java/dev/slimevr/networkprofile/module.kt new file mode 100644 index 0000000000..38a9e26e36 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/networkprofile/module.kt @@ -0,0 +1,63 @@ +package dev.slimevr.networkprofile + +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context +import kotlinx.coroutines.CoroutineScope + +data class NetworkInfo( + val name: String?, + val description: String?, + val category: NetworkCategory?, + val connectivity: Set?, + val connected: Boolean?, +) + +enum class NetworkCategory(val value: Int) { + PUBLIC(0), PRIVATE(1), DOMAIN_AUTHENTICATED(2); + companion object { fun fromInt(value: Int) = entries.find { it.value == value } } +} + +enum class ConnectivityFlags(val value: Int) { + DISCONNECTED(0), + IPV4_NOTRAFFIC(0x1), IPV6_NOTRAFFIC(0x2), + IPV4_SUBNET(0x10), IPV4_LOCALNETWORK(0x20), IPV4_INTERNET(0x40), + IPV6_SUBNET(0x100), IPV6_LOCALNETWORK(0x200), IPV6_INTERNET(0x400); + companion object { + fun fromInt(value: Int): Set = if (value == 0) setOf(DISCONNECTED) + else entries.filter { it != DISCONNECTED && (value and it.value) != 0 }.toSet() + } +} + +data class NetworkProfileState( + val publicNetworks: List = emptyList(), + val isSupported: Boolean = false, +) + +sealed interface NetworkProfileActions { + data class UpdateNetworks(val networks: List) : NetworkProfileActions +} + +typealias NetworkProfileContext = Context +typealias NetworkProfileBehaviourType = Behaviour + +object DefaultNetworkProfileBehaviour : NetworkProfileBehaviourType { + override fun reduce(state: NetworkProfileState, action: NetworkProfileActions) = when (action) { + is NetworkProfileActions.UpdateNetworks -> state.copy(publicNetworks = action.networks) + } +} + +class NetworkProfileManager(val context: NetworkProfileContext) { + fun startObserving() = context.observeAll(this) + + companion object { + fun create(scope: CoroutineScope, isSupported: Boolean): NetworkProfileManager { + val context = Context.create( + initialState = NetworkProfileState(isSupported = isSupported), + scope = scope, + behaviours = listOf(DefaultNetworkProfileBehaviour), + name = "NetworkProfile", + ) + return NetworkProfileManager(context) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index 4d6da7ef37..4d46b3cc42 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -6,6 +6,7 @@ import dev.slimevr.VRServer import dev.slimevr.VRServerState import dev.slimevr.device.DeviceOrigin import dev.slimevr.device.DeviceState +import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.skeleton.Skeleton import dev.slimevr.tracker.TrackerState import dev.slimevr.vrchat.VRCConfigManager @@ -16,7 +17,6 @@ import dev.slimevr.vrchat.isVRCConfigValid import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn @@ -177,3 +177,20 @@ class VRChatSettingsCheckBehaviour( .launchIn(receiver.context.scope) } } + +class NetworkProfileCheckBehaviour( + private val manager: NetworkProfileManager, +) : TrackingChecklistBehaviourType { + override fun observe(receiver: TrackingChecklist) { + manager.context.state + .map { state -> + TrackingChecklistStep( + valid = state.publicNetworks.isEmpty(), + enabled = state.isSupported, + ) + } + .distinctUntilChanged() + .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.NETWORK_PROFILE_PUBLIC, step)) } + .launchIn(receiver.context.scope) + } +} diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt index f348f52f55..49a7ac35e6 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt @@ -33,7 +33,9 @@ class TrackingChecklist( add(HMDCheckBehaviour(appContext.server)) add(TrackerRestCheckBehaviour(appContext.server)) add(TrackerErrorCheckBehaviour(appContext.server)) + appContext.vrcConfigManager?.let { add(VRChatSettingsCheckBehaviour(appContext.server, appContext.skeleton, it)) } + appContext.networkProfileManager?.let { add(NetworkProfileCheckBehaviour(it)) } } context.behaviours.addAll(stepBehaviours) context.observeAll(this) 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 de8b4743ed..c62f1c2feb 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -7,9 +7,11 @@ import dev.slimevr.Phase1Context import dev.slimevr.VRServer import dev.slimevr.bvh.BVHManager import dev.slimevr.config.AppConfig +import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.desktop.hid.createDesktopHIDManager import dev.slimevr.desktop.ipc.createIpcServers import dev.slimevr.desktop.ipc.createSolarXRWebsocketServer +import dev.slimevr.desktop.networkprofile.setupDesktopNetworkProfileChecker import dev.slimevr.desktop.serial.createDesktopSerialServer import dev.slimevr.desktop.vrchat.createDesktopVRCConfigManager import dev.slimevr.firmware.FirmwareManager @@ -19,6 +21,7 @@ import dev.slimevr.resolveConfigDirectory import dev.slimevr.skeleton.Skeleton import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -32,6 +35,7 @@ fun main(args: Array) = runBlocking { val firmwareManager = FirmwareManager.create(ctx = phase1, scope = this) val vrcConfigManager = createDesktopVRCConfigManager(ctx = phase1, scope = this) + val networkProfileManager = NetworkProfileManager.create(scope = this, isSupported = true) val skeleton = Skeleton.create(scope = this, ctx = phase1) val provisioningManager = ProvisioningManager.create(ctx = phase1, scope = this) val heightCalibrationManager = HeightCalibrationManager.create(ctx = phase1, scope = this) @@ -46,6 +50,7 @@ fun main(args: Array) = runBlocking { skeleton = skeleton, firmwareManager = firmwareManager, vrcConfigManager = vrcConfigManager, + networkProfileManager = networkProfileManager, provisioningManager = provisioningManager, heightCalibrationManager = heightCalibrationManager, trackingChecklist = trackingChecklist, @@ -58,6 +63,7 @@ fun main(args: Array) = runBlocking { launch { createDesktopHIDManager(appContext, this) } launch { createSolarXRWebsocketServer(appContext) } launch { createIpcServers(appContext) } + launch(Dispatchers.IO) { setupDesktopNetworkProfileChecker(this, networkProfileManager) } Unit } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt b/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt new file mode 100644 index 0000000000..69eb7dc037 --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt @@ -0,0 +1,205 @@ +package dev.slimevr.desktop.networkprofile + +import com.sun.jna.Callback +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.platform.win32.COM.COMUtils +import com.sun.jna.platform.win32.COM.Dispatch +import com.sun.jna.platform.win32.Guid.CLSID +import com.sun.jna.platform.win32.Guid.IID +import com.sun.jna.platform.win32.OaIdl.VARIANT_BOOLByReference +import com.sun.jna.platform.win32.Ole32 +import com.sun.jna.platform.win32.OleAuto +import com.sun.jna.platform.win32.WTypes +import com.sun.jna.platform.win32.WinNT.HRESULT +import com.sun.jna.ptr.IntByReference +import com.sun.jna.ptr.PointerByReference +import dev.slimevr.CURRENT_PLATFORM +import dev.slimevr.Platform +import dev.slimevr.networkprofile.ConnectivityFlags +import dev.slimevr.networkprofile.NetworkCategory +import dev.slimevr.networkprofile.NetworkInfo +import dev.slimevr.networkprofile.NetworkProfileActions +import dev.slimevr.networkprofile.NetworkProfileManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds + +interface Iphlpapi : Library { + fun NotifyIpInterfaceChange( + family: Int, + callback: Callback, + context: Pointer?, + initialNotification: Int, + notificationHandle: PointerByReference, + ): Int + + fun CancelMibChangeNotify2(handle: Pointer): Int + + companion object { + val INSTANCE: Iphlpapi = Native.load("Iphlpapi", Iphlpapi::class.java) + } +} + +@Suppress("ktlint:standard:backing-property-naming") +private class INetworkConnection(instance: Pointer?) : Dispatch(instance), AutoCloseable { + override fun close() { + Release() + } + fun getNetwork(): INetwork { + val pNetwork = PointerByReference() + val hr = _invokeNativeInt(7, arrayOf(pointer, pNetwork)) + COMUtils.checkRC(HRESULT(hr)) + return INetwork(pNetwork.value) + } +} + +@Suppress("ktlint:standard:backing-property-naming") +private class IEnumNetworkConnections(instance: Pointer?) : Dispatch(instance), AutoCloseable { + override fun close() { + Release() + } + fun next(): INetworkConnection? { + val ppNetworkConnections = PointerByReference() + val nFetched = IntByReference() + val hr = _invokeNativeInt(8, arrayOf(pointer, 1, ppNetworkConnections, nFetched)) + COMUtils.checkRC(HRESULT(hr)) + return if (nFetched.value == 0) null else INetworkConnection(ppNetworkConnections.value) + } +} + +@Suppress("ktlint:standard:backing-property-naming") +private class INetworkListManager(instance: Pointer?) : Dispatch(instance), AutoCloseable { + override fun close() { + Release() + } + fun getNetworkConnections(): IEnumNetworkConnections { + val pEnumNetworkConnections = PointerByReference() + val hr = _invokeNativeInt(9, arrayOf(pointer, pEnumNetworkConnections)) + COMUtils.checkRC(HRESULT(hr)) + return IEnumNetworkConnections(pEnumNetworkConnections.value) + } +} + +@Suppress("ktlint:standard:backing-property-naming") +private class INetwork(instance: Pointer?) : Dispatch(instance), AutoCloseable { + override fun close() { + Release() + } + + fun getName(): String? { + val pStr = PointerByReference() + val hr = _invokeNativeInt(7, arrayOf(pointer, pStr)) + COMUtils.checkRC(HRESULT(hr)) + val bstr = WTypes.BSTR(pStr.value) + val stringValue = bstr.value + OleAuto.INSTANCE.SysFreeString(bstr) + return stringValue + } + + fun getDescription(): String? { + val pStr = PointerByReference() + val hr = _invokeNativeInt(9, arrayOf(pointer, pStr)) + COMUtils.checkRC(HRESULT(hr)) + val bstr = WTypes.BSTR(pStr.value) + val stringValue = bstr.value + OleAuto.INSTANCE.SysFreeString(bstr) + return stringValue + } + + fun isConnected(): Boolean { + val bool = VARIANT_BOOLByReference() + val hr = _invokeNativeInt(16, arrayOf(pointer, bool)) + COMUtils.checkRC(HRESULT(hr)) + return bool.value.booleanValue() + } + + fun getConnectivity(): Set { + val connectivity = IntByReference() + val hr = _invokeNativeInt(17, arrayOf(pointer, connectivity)) + COMUtils.checkRC(HRESULT(hr)) + return ConnectivityFlags.fromInt(connectivity.value) + } + + fun getCategory(): NetworkCategory? { + val category = IntByReference() + val hr = _invokeNativeInt(18, arrayOf(pointer, category)) + COMUtils.checkRC(HRESULT(hr)) + return NetworkCategory.fromInt(category.value) + } +} + +private fun enumerateNetworks(): List { + return try { + Ole32.INSTANCE.CoInitializeEx(null, 0) + val clsid = CLSID("dcb00c01-570f-4a9b-8d69-199fdba5723b") + val iid = IID("dcb00000-570f-4a9b-8d69-199fdba5723b") + val ptr = PointerByReference() + COMUtils.checkRC(Ole32.INSTANCE.CoCreateInstance(clsid, null, WTypes.CLSCTX_ALL, iid, ptr)) + val mgr = INetworkListManager(ptr.value) + val result = mgr.getNetworkConnections().use { conns -> + generateSequence { conns.next() } + .map { conn -> + conn.use { + conn.getNetwork().use { network -> + NetworkInfo( + network.getName(), + network.getDescription(), + network.getCategory(), + network.getConnectivity(), + network.isConnected(), + ) + } + } + }.toList() + } + mgr.close() + Ole32.INSTANCE.CoUninitialize() + result + } catch (e: Exception) { + println(e.stackTraceToString()) + emptyList() + } +} + +suspend fun setupDesktopNetworkProfileChecker(scope: CoroutineScope, manager: NetworkProfileManager) { + if (CURRENT_PLATFORM != Platform.WINDOWS) return + + val notificationHandle = PointerByReference() + val callback = object : Callback { + @Suppress("UNUSED") + fun callback(context: Pointer, row: Pointer, notificationType: Int) { + scope.launch { + val networks = enumerateNetworks() + .filter { it.connected == true && it.category == NetworkCategory.PUBLIC } + manager.context.dispatch(NetworkProfileActions.UpdateNetworks(networks)) + } + } + } + + val result = Iphlpapi.INSTANCE.NotifyIpInterfaceChange( + 0, + callback, + null, + 1, + notificationHandle, + ) + + if (result == 0) { + val initialNetworks = enumerateNetworks() + .filter { it.connected == true && it.category == NetworkCategory.PUBLIC } + manager.context.dispatch(NetworkProfileActions.UpdateNetworks(initialNetworks)) + try { + delay(Long.MAX_VALUE.milliseconds) + } catch (e: Exception) { + val handle = notificationHandle.value + if (handle != null) { + try { + Iphlpapi.INSTANCE.CancelMibChangeNotify2(handle) + } catch (ignore: Exception) {} + } + } + } +} From 04a26ec539a3a9ba4b33faebe98583876d400f33 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 5 May 2026 04:16:11 +0200 Subject: [PATCH 082/350] Remove io for now. for consistency --- server/desktop/src/main/java/dev/slimevr/desktop/Main.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c62f1c2feb..2cdad28293 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -63,7 +63,7 @@ fun main(args: Array) = runBlocking { launch { createDesktopHIDManager(appContext, this) } launch { createSolarXRWebsocketServer(appContext) } launch { createIpcServers(appContext) } - launch(Dispatchers.IO) { setupDesktopNetworkProfileChecker(this, networkProfileManager) } + launch { setupDesktopNetworkProfileChecker(this, networkProfileManager) } Unit } From 6087178ecf1d4db05faf16fc2c6d2e6c7f3425a7 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 5 May 2026 04:29:31 +0200 Subject: [PATCH 083/350] Better debug middleware init --- server/core/src/main/java/dev/slimevr/context/context.kt | 5 ++++- .../java/dev/slimevr/context/debug/DebugMiddleware.kt | 3 +++ .../java/dev/slimevr/context/debug/LoggingMiddleware.kt | 9 +++++++-- server/core/src/main/java/dev/slimevr/device/module.kt | 1 - server/core/src/main/java/dev/slimevr/tracker/module.kt | 1 - 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index 176ff1598b..b71b0ea4af 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -86,7 +86,10 @@ class Context( val reducer: (S, A) -> S = { currentState, action -> behaviours.fold(currentState) { s, b -> b.reduce(s, action) } } - return Context(mutableStateFlow, reducer, scope, CopyOnWriteArrayList(behaviours), if (debugEnabled) debugMiddleware else null, name) + val middlewareToUse = if (debugEnabled) debugMiddleware else null + val context = Context(mutableStateFlow, reducer, scope, CopyOnWriteArrayList(behaviours), middlewareToUse, name) + middlewareToUse?.init(context) + return context } } } diff --git a/server/core/src/main/java/dev/slimevr/context/debug/DebugMiddleware.kt b/server/core/src/main/java/dev/slimevr/context/debug/DebugMiddleware.kt index a2a0f0a675..87cac68006 100644 --- a/server/core/src/main/java/dev/slimevr/context/debug/DebugMiddleware.kt +++ b/server/core/src/main/java/dev/slimevr/context/debug/DebugMiddleware.kt @@ -1,6 +1,9 @@ package dev.slimevr.context.debug +import dev.slimevr.context.Context + interface DebugMiddleware { + fun init(context: Context) {} fun onDispatch(caller: String?, before: S, action: A, after: S) fun onDispatchAll(caller: String?, before: S, actions: List, after: S) } diff --git a/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt b/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt index 4dbbe1a9d7..f95c281773 100644 --- a/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt +++ b/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt @@ -1,5 +1,6 @@ package dev.slimevr.context.debug +import dev.slimevr.context.Context import kotlin.reflect.KClass private object Ansi { @@ -16,13 +17,17 @@ private object Ansi { enum class DiffStyle { INLINE, MULTILINE } class LoggingMiddleware( - moduleName: String, private val logNoOps: Boolean = false, private val diffStyle: DiffStyle = DiffStyle.MULTILINE, private val allow: Set>? = null, private val block: Set> = emptySet(), ) : DebugMiddleware { - private val tag = "${Ansi.CYAN}[$moduleName]${Ansi.RESET}" + private var contextName = "" + private val tag get() = "${Ansi.CYAN}[$contextName]${Ansi.RESET}" + + override fun init(context: Context) { + contextName = context.name + } private fun isAllowed(action: A): Boolean { val klass = action!!::class diff --git a/server/core/src/main/java/dev/slimevr/device/module.kt b/server/core/src/main/java/dev/slimevr/device/module.kt index 98ba28c1cb..416418e365 100644 --- a/server/core/src/main/java/dev/slimevr/device/module.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -80,7 +80,6 @@ class Device( scope = scope, behaviours = behaviours, debugMiddleware = LoggingMiddleware( - "Device[$address]", block = setOf(DeviceActions.PacketStats::class), diffStyle = DiffStyle.MULTILINE, ), diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index b0bdd25adb..85cc852032 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -100,7 +100,6 @@ class Tracker( scope = scope, behaviours = behaviours, debugMiddleware = LoggingMiddleware( - "Tracker[$hardwareId]", block = setOf(TrackerActions.SetRotation::class), diffStyle = DiffStyle.MULTILINE, ), From be34dc981cb6120d8b08e8d707ebf2500c49ba36 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 5 May 2026 07:35:27 +0200 Subject: [PATCH 084/350] Better context management for dispose (allow for unit tests to work) + OSC basics + formating --- .../main/java/dev/slimevr/context/context.kt | 35 ++- .../context/debug/LoggingMiddleware.kt | 3 +- .../main/java/dev/slimevr/driver/module.kt | 8 +- .../main/java/dev/slimevr/feeder/module.kt | 8 +- .../java/dev/slimevr/networkprofile/module.kt | 30 ++- .../src/main/java/dev/slimevr/osc/codec.kt | 197 +++++++++++++++ .../src/main/java/dev/slimevr/osc/receiver.kt | 99 ++++++++ .../src/main/java/dev/slimevr/osc/sender.kt | 44 ++++ .../src/main/java/dev/slimevr/osc/types.kt | 41 +++ .../java/dev/slimevr/skeleton/behaviours.kt | 2 + .../main/java/dev/slimevr/skeleton/module.kt | 8 +- .../main/java/dev/slimevr/solarxr/module.kt | 9 +- .../src/test/java/dev/slimevr/TestServer.kt | 26 ++ .../dev/slimevr/firmware/DoSerialFlashTest.kt | 52 ++-- .../test/java/dev/slimevr/osc/CodecTest.kt | 237 ++++++++++++++++++ .../provisioning/ProvisioningManagerTest.kt | 3 +- .../src/main/java/dev/slimevr/desktop/Main.kt | 2 +- 17 files changed, 750 insertions(+), 54 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/osc/codec.kt create mode 100644 server/core/src/main/java/dev/slimevr/osc/receiver.kt create mode 100644 server/core/src/main/java/dev/slimevr/osc/sender.kt create mode 100644 server/core/src/main/java/dev/slimevr/osc/types.kt create mode 100644 server/core/src/test/java/dev/slimevr/osc/CodecTest.kt diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index b71b0ea4af..a725d6d446 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -5,7 +5,6 @@ import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,22 +16,37 @@ interface Behaviour { fun observe(receiver: C) {} } +class ManagedContext( + val context: Context, + private val supervisorJob: Job, +) { + fun dispose() = supervisorJob.cancel() + + companion object { + fun create( + initialState: S, + scope: CoroutineScope, + behaviours: List>, + debugMiddleware: DebugMiddleware? = null, + name: String, + ): ManagedContext { + val job = SupervisorJob(scope.coroutineContext[Job]) + val scopeWithJob = CoroutineScope(scope.coroutineContext + job) + val context = Context.create(initialState, scopeWithJob, behaviours, debugMiddleware, name) + return ManagedContext(context, job) + } + } +} + class Context( private val mutableStateFlow: MutableStateFlow, val reducer: (S, A) -> S, - private val parentScope: CoroutineScope, + val scope: CoroutineScope, val behaviours: CopyOnWriteArrayList>, private val debugMiddleware: DebugMiddleware? = null, - val name: String, ) { - private val job = SupervisorJob(parentScope.coroutineContext[Job]) - val scope = CoroutineScope(parentScope.coroutineContext + job + CoroutineName(name)) val state: StateFlow = mutableStateFlow.asStateFlow() - fun dispose() { - job.cancel() - } - fun dispatch(action: A) { if (debugMiddleware == null) { mutableStateFlow.update { currentState -> reducer(currentState, action) } @@ -86,8 +100,9 @@ class Context( val reducer: (S, A) -> S = { currentState, action -> behaviours.fold(currentState) { s, b -> b.reduce(s, action) } } + val scopeWithName = CoroutineScope(scope.coroutineContext + CoroutineName(name)) val middlewareToUse = if (debugEnabled) debugMiddleware else null - val context = Context(mutableStateFlow, reducer, scope, CopyOnWriteArrayList(behaviours), middlewareToUse, name) + val context = Context(mutableStateFlow, reducer, scopeWithName, CopyOnWriteArrayList(behaviours), middlewareToUse) middlewareToUse?.init(context) return context } diff --git a/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt b/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt index f95c281773..68c96778f0 100644 --- a/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt +++ b/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt @@ -1,6 +1,7 @@ package dev.slimevr.context.debug import dev.slimevr.context.Context +import kotlinx.coroutines.CoroutineName import kotlin.reflect.KClass private object Ansi { @@ -26,7 +27,7 @@ class LoggingMiddleware( private val tag get() = "${Ansi.CYAN}[$contextName]${Ansi.RESET}" override fun init(context: Context) { - contextName = context.name + contextName = context.scope.coroutineContext[CoroutineName]?.name ?: "Unknown" } private fun isAllowed(action: A): Boolean { diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt index abe2203187..943ec31285 100644 --- a/server/core/src/main/java/dev/slimevr/driver/module.kt +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -5,6 +5,7 @@ import dev.slimevr.EventDispatcher import dev.slimevr.VRServerActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.context.ManagedContext import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope @@ -36,8 +37,9 @@ class DriverBridge( val appContext: AppContextProvider, val inbound: EventDispatcher = EventDispatcher(), val outbound: EventDispatcher = EventDispatcher(), + private val managedContext: ManagedContext? = null, ) { - fun dispose() = context.dispose() + fun dispose() = managedContext?.dispose() fun startObserving() = context.observeAll(this) @@ -50,14 +52,14 @@ class DriverBridge( fun create(id: Int, appContext: AppContextProvider, scope: CoroutineScope): DriverBridge { val behaviours = listOf(DriverBaseBehaviour) - val context = Context.create( + val managedContext = ManagedContext.create( initialState = DriverBridgeState(protocolVersion = 0), scope = scope, behaviours = behaviours, name = "Driver[$id]", ) - val bridge = DriverBridge(id = id, context = context, appContext = appContext) + val bridge = DriverBridge(id = id, context = managedContext.context, appContext = appContext, managedContext = managedContext) bridge.startObserving() appContext.server.context.dispatch(VRServerActions.DriverConnected(bridge)) diff --git a/server/core/src/main/java/dev/slimevr/feeder/module.kt b/server/core/src/main/java/dev/slimevr/feeder/module.kt index 35782a0df9..3551fee387 100644 --- a/server/core/src/main/java/dev/slimevr/feeder/module.kt +++ b/server/core/src/main/java/dev/slimevr/feeder/module.kt @@ -5,6 +5,7 @@ import dev.slimevr.EventDispatcher import dev.slimevr.VRServerActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.context.ManagedContext import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope @@ -32,8 +33,9 @@ class FeederBridge( val context: FeederBridgeContext, val appContext: AppContextProvider, val inbound: EventDispatcher = EventDispatcher(), + private val managedContext: ManagedContext? = null, ) { - fun dispose() = context.dispose() + fun dispose() = managedContext?.dispose() fun startObserving() = context.observeAll(this) @@ -46,14 +48,14 @@ class FeederBridge( fun create(id: Int, appContext: AppContextProvider, scope: CoroutineScope): FeederBridge { val behaviours = listOf(FeederBaseBehaviour) - val context = Context.create( + val managedContext = ManagedContext.create( initialState = FeederBridgeState(protocolVersion = 0, firmware = null), scope = scope, behaviours = behaviours, name = "Feeder[$id]", ) - val bridge = FeederBridge(id = id, context = context, appContext = appContext) + val bridge = FeederBridge(id = id, context = managedContext.context, appContext = appContext, managedContext = managedContext) bridge.startObserving() appContext.server.context.dispatch(VRServerActions.FeederConnected(bridge)) diff --git a/server/core/src/main/java/dev/slimevr/networkprofile/module.kt b/server/core/src/main/java/dev/slimevr/networkprofile/module.kt index 38a9e26e36..cc1231cc84 100644 --- a/server/core/src/main/java/dev/slimevr/networkprofile/module.kt +++ b/server/core/src/main/java/dev/slimevr/networkprofile/module.kt @@ -13,18 +13,34 @@ data class NetworkInfo( ) enum class NetworkCategory(val value: Int) { - PUBLIC(0), PRIVATE(1), DOMAIN_AUTHENTICATED(2); - companion object { fun fromInt(value: Int) = entries.find { it.value == value } } + PUBLIC(0), + PRIVATE(1), + DOMAIN_AUTHENTICATED(2), + ; + + companion object { + fun fromInt(value: Int) = entries.find { it.value == value } + } } enum class ConnectivityFlags(val value: Int) { DISCONNECTED(0), - IPV4_NOTRAFFIC(0x1), IPV6_NOTRAFFIC(0x2), - IPV4_SUBNET(0x10), IPV4_LOCALNETWORK(0x20), IPV4_INTERNET(0x40), - IPV6_SUBNET(0x100), IPV6_LOCALNETWORK(0x200), IPV6_INTERNET(0x400); + IPV4_NOTRAFFIC(0x1), + IPV6_NOTRAFFIC(0x2), + IPV4_SUBNET(0x10), + IPV4_LOCALNETWORK(0x20), + IPV4_INTERNET(0x40), + IPV6_SUBNET(0x100), + IPV6_LOCALNETWORK(0x200), + IPV6_INTERNET(0x400), + ; + companion object { - fun fromInt(value: Int): Set = if (value == 0) setOf(DISCONNECTED) - else entries.filter { it != DISCONNECTED && (value and it.value) != 0 }.toSet() + fun fromInt(value: Int): Set = if (value == 0) { + setOf(DISCONNECTED) + } else { + entries.filter { it != DISCONNECTED && (value and it.value) != 0 }.toSet() + } } } diff --git a/server/core/src/main/java/dev/slimevr/osc/codec.kt b/server/core/src/main/java/dev/slimevr/osc/codec.kt new file mode 100644 index 0000000000..2199bd96c4 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/osc/codec.kt @@ -0,0 +1,197 @@ +package dev.slimevr.osc + +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets + +private const val BUNDLE_PREFIX = "#bundle " + +fun encodeMessage(msg: OscMessage): ByteArray { + val buf = ByteBuffer.allocate(65536) // max UDP packet + buf.order(java.nio.ByteOrder.BIG_ENDIAN) + + // Address + null terminator + val addressBytes = msg.address.toByteArray(StandardCharsets.US_ASCII) + buf.put(addressBytes) + buf.put(0.toByte()) // null terminator + val addressPadded = padToMultiple(addressBytes.size + 1, 4) + buf.put(ByteArray(addressPadded - addressBytes.size - 1)) + + // Type tag string + val typeTag = "," + msg.args.joinToString("") { it.typeTag.toString() } + val typeTagBytes = typeTag.toByteArray(StandardCharsets.US_ASCII) + buf.put(typeTagBytes) + buf.put(0.toByte()) + val typeTagPadded = padToMultiple(typeTagBytes.size + 1, 4) + buf.put(ByteArray(typeTagPadded - typeTagBytes.size - 1)) + + // Arguments + for (arg in msg.args) { + when (arg) { + is OscArg.Int -> buf.putInt(arg.value) + + is OscArg.Long -> buf.putLong(arg.value) + + is OscArg.Float -> buf.putFloat(arg.value) + + is OscArg.Double -> buf.putDouble(arg.value) + + is OscArg.String -> { + val strBytes = arg.value.toByteArray(StandardCharsets.US_ASCII) + buf.put(strBytes) + buf.put(0.toByte()) + val strPadded = padToMultiple(strBytes.size + 1, 4) + buf.put(ByteArray(strPadded - strBytes.size - 1)) + } + + is OscArg.Blob -> { + buf.putInt(arg.value.size) + buf.put(arg.value) + val blobPadded = padToMultiple(arg.value.size, 4) + buf.put(ByteArray(blobPadded - arg.value.size)) + } + + else -> {} // Impulse, Null, True, False have no arguments + } + } + + return buf.array().sliceArray(0 until buf.position()) +} + +fun decodeMessage(bytes: ByteArray, offset: Int = 0): Pair { + var pos = offset + val buf = ByteBuffer.wrap(bytes, offset, bytes.size - offset) + buf.order(java.nio.ByteOrder.BIG_ENDIAN) + + // Parse address + val addressStart = pos + while (pos < bytes.size && bytes[pos] != 0.toByte()) pos++ + val address = bytes.sliceArray(addressStart until pos).toString(StandardCharsets.US_ASCII) + pos++ // skip null terminator + pos = alignToMultiple(pos, 4) + + // Parse type tag + val typeTagStart = pos + while (pos < bytes.size && bytes[pos] != 0.toByte()) pos++ + val typeTag = bytes.sliceArray(typeTagStart until pos).toString(StandardCharsets.US_ASCII) + pos++ // skip null terminator + pos = alignToMultiple(pos, 4) + + // Parse arguments + val args = mutableListOf() + if (typeTag.startsWith(",")) { + buf.position(pos - offset) + for (i in 1 until typeTag.length) { + when (typeTag[i]) { + 'i' -> args.add(OscArg.Int(buf.int)) + + 'h' -> args.add(OscArg.Long(buf.long)) + + 'f' -> args.add(OscArg.Float(buf.float)) + + 'd' -> args.add(OscArg.Double(buf.double)) + + 's' -> { + val strStart = buf.position() + while (buf.position() < bytes.size && bytes[offset + buf.position()] != 0.toByte()) { + buf.position(buf.position() + 1) + } + val str = bytes.sliceArray(strStart + offset until offset + buf.position()).toString(StandardCharsets.US_ASCII) + args.add(OscArg.String(str)) + buf.position(buf.position() + 1) // skip null + val strPadding = padToMultiple(str.length + 1, 4) + buf.position(buf.position() + strPadding - str.length - 1) + } + + 'b' -> { + val size = buf.int + val blob = ByteArray(size) + buf.get(blob) + args.add(OscArg.Blob(blob)) + val blobPadding = padToMultiple(size, 4) + buf.position(buf.position() + blobPadding - size) + } + + 'I' -> args.add(OscArg.Impulse) + + 'N' -> args.add(OscArg.Null) + + 'T' -> args.add(OscArg.True) + + 'F' -> args.add(OscArg.False) + } + } + pos = offset + buf.position() + } + + return OscMessage(address, args) to pos +} + +fun encodeBundle(bundle: OscBundle): ByteArray { + val buf = ByteBuffer.allocate(65536) + buf.order(java.nio.ByteOrder.BIG_ENDIAN) + + // Bundle prefix + val prefixBytes = BUNDLE_PREFIX.toByteArray(StandardCharsets.US_ASCII) + buf.put(prefixBytes) + + // Timetag (8 bytes, NTP format) + buf.putLong(bundle.timetag) + + // Contents + for (content in bundle.contents) { + val contentBytes = when (content) { + is OscContent.Message -> encodeMessage(content.msg) + is OscContent.Bundle -> encodeBundle(content.bundle) + } + buf.putInt(contentBytes.size) + buf.put(contentBytes) + } + + return buf.array().sliceArray(0 until buf.position()) +} + +fun decodeBundle(bytes: ByteArray, offset: Int = 0): Pair { + val prefixBytes = BUNDLE_PREFIX.toByteArray(StandardCharsets.US_ASCII) + if (!bytes.sliceArray(offset until minOf(offset + 8, bytes.size)).contentEquals(prefixBytes)) { + throw IllegalArgumentException("Invalid bundle prefix") + } + + var pos = offset + 8 + val buf = ByteBuffer.wrap(bytes, pos, bytes.size - pos) + buf.order(java.nio.ByteOrder.BIG_ENDIAN) + + val timetag = buf.long + pos += 8 + + val contents = mutableListOf() + while (pos < bytes.size) { + buf.position(pos - offset - 8) + val elementSize = buf.int + pos += 4 + + if (elementSize <= 0 || pos + elementSize > bytes.size) break + + val element = bytes.sliceArray(pos until pos + elementSize) + try { + if (element.size >= 8 && element.sliceArray(0..7).contentEquals(prefixBytes)) { + val (bundle, _) = decodeBundle(bytes, pos) + contents.add(OscContent.Bundle(bundle)) + } else { + val (msg, _) = decodeMessage(bytes, pos) + contents.add(OscContent.Message(msg)) + } + } catch (e: Exception) { + break + } + pos += elementSize + } + + return OscBundle(timetag, contents) to pos +} + +private fun padToMultiple(value: Int, alignment: Int): Int { + val remainder = value % alignment + return if (remainder == 0) value else value + (alignment - remainder) +} + +private fun alignToMultiple(value: Int, alignment: Int): Int = padToMultiple(value, alignment) diff --git a/server/core/src/main/java/dev/slimevr/osc/receiver.kt b/server/core/src/main/java/dev/slimevr/osc/receiver.kt new file mode 100644 index 0000000000..a098b50dc1 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/osc/receiver.kt @@ -0,0 +1,99 @@ +package dev.slimevr.osc + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.DatagramPacket +import java.net.DatagramSocket + +class OscReceiver(private val port: Int) { + private var socket: DatagramSocket? = null + private var listenerScope: CoroutineScope? = null + + suspend fun listen(onMessage: (OscMessage) -> Unit) { + if (socket == null) { + socket = withContext(Dispatchers.IO) { + DatagramSocket(port) + } + } + val s = socket ?: return + + val scope = CoroutineScope(Dispatchers.IO) + listenerScope = scope + + scope.launch { + while (isActive) { + try { + val buffer = ByteArray(65536) + val packet = DatagramPacket(buffer, buffer.size) + s.receive(packet) + val bytes = packet.data.sliceArray(0 until packet.length) + + try { + val (msg, _) = decodeMessage(bytes) + onMessage(msg) + } catch (e: Exception) { + // Ignore malformed packets + } + } catch (e: Exception) { + if (isActive) { + // Socket closed or error + break + } + } + } + } + } + + suspend fun listenBundles(onBundle: (OscBundle) -> Unit) { + if (socket == null) { + socket = withContext(Dispatchers.IO) { + DatagramSocket(port) + } + } + val s = socket ?: return + + val scope = CoroutineScope(Dispatchers.IO) + listenerScope = scope + + scope.launch { + while (isActive) { + try { + val buffer = ByteArray(65536) + val packet = DatagramPacket(buffer, buffer.size) + s.receive(packet) + val bytes = packet.data.sliceArray(0 until packet.length) + + try { + // Try bundle first, fall back to message + val bundle = if (bytes.size >= 8 && bytes.sliceArray(0..7).contentEquals("#bundle ".toByteArray())) { + val (b, _) = decodeBundle(bytes) + b + } else { + // Wrap message in a bundle + val (msg, _) = decodeMessage(bytes) + OscBundle(1, listOf(OscContent.Message(msg))) + } + onBundle(bundle) + } catch (e: Exception) { + // Ignore malformed packets + } + } catch (e: Exception) { + if (isActive) { + break + } + } + } + } + } + + fun close() { + listenerScope?.cancel() + listenerScope = null + socket?.close() + socket = null + } +} diff --git a/server/core/src/main/java/dev/slimevr/osc/sender.kt b/server/core/src/main/java/dev/slimevr/osc/sender.kt new file mode 100644 index 0000000000..8b7c25c7d9 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/osc/sender.kt @@ -0,0 +1,44 @@ +package dev.slimevr.osc + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress + +class OscSender(private val address: String, private val port: Int) { + private var socket: DatagramSocket? = null + + private suspend fun ensureSocket() { + if (socket == null) { + socket = withContext(Dispatchers.IO) { + DatagramSocket() + } + } + } + + suspend fun send(message: OscMessage) { + val bytes = encodeMessage(message) + ensureSocket() + sendBytes(bytes) + } + + suspend fun send(bundle: OscBundle) { + val bytes = encodeBundle(bundle) + ensureSocket() + sendBytes(bytes) + } + + private suspend fun sendBytes(bytes: ByteArray) { + val s = socket ?: return + withContext(Dispatchers.IO) { + val packet = DatagramPacket(bytes, bytes.size, InetAddress.getByName(address), port) + s.send(packet) + } + } + + fun close() { + socket?.close() + socket = null + } +} diff --git a/server/core/src/main/java/dev/slimevr/osc/types.kt b/server/core/src/main/java/dev/slimevr/osc/types.kt new file mode 100644 index 0000000000..80277a9106 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/osc/types.kt @@ -0,0 +1,41 @@ +package dev.slimevr.osc + +sealed class OscArg { + data class Int(val value: kotlin.Int) : OscArg() + data class Long(val value: kotlin.Long) : OscArg() + data class Float(val value: kotlin.Float) : OscArg() + data class Double(val value: kotlin.Double) : OscArg() + data class String(val value: kotlin.String) : OscArg() + data class Blob(val value: ByteArray) : OscArg() { + override fun equals(other: Any?) = this === other || other is Blob && value.contentEquals(other.value) + override fun hashCode() = value.contentHashCode() + } + + data object Impulse : OscArg() + data object Null : OscArg() + data object True : OscArg() + data object False : OscArg() + + val typeTag: Char + get() = when (this) { + is Int -> 'i' + is Long -> 'h' + is Float -> 'f' + is Double -> 'd' + is String -> 's' + is Blob -> 'b' + Impulse -> 'I' + Null -> 'N' + True -> 'T' + False -> 'F' + } +} + +data class OscMessage(val address: String, val args: List = emptyList()) + +data class OscBundle(val timetag: Long, val contents: List = emptyList()) + +sealed interface OscContent { + data class Message(val msg: OscMessage) : OscContent + data class Bundle(val bundle: OscBundle) : OscContent +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index fcab4875a6..74640127cb 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -21,12 +21,14 @@ class BoneTransformBehaviour : SkeletonBehaviour { bones[action.bodyPart] = bone.copy(rawRotation = action.rotation) state.copy(rawBones = bones) } + is SkeletonActions.SetBonePosition -> { val bones = state.rawBones.toMutableMap() val bone = bones[action.bodyPart] ?: return state bones[action.bodyPart] = bone.copy(rawPosition = action.position) state.copy(rawBones = bones) } + else -> state } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 434ea49410..eca94ad0ce 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -14,7 +14,7 @@ data class RawBone( val bodyPart: BodyPart, val length: Float, val rawRotation: Quaternion, - val rawPosition: Vector3 + val rawPosition: Vector3, ) data class BoneState( @@ -29,8 +29,8 @@ data class BoneState( get() = parentBone?.let { it.rotation.inv() * rotation } ?: rotation val localHeadPosition: Vector3 get() = parentBone?.let { headPosition - it.tailPosition } ?: headPosition -// val localTailPosition: Vector3 -// get() = tailPosition - headPosition +// val localTailPosition: Vector3 +// get() = tailPosition - headPosition } data class SkeletonState(val rawBones: Map, val userHeight: Double) @@ -43,7 +43,7 @@ val DEFAULT_SKELETON_STATE: SkeletonState = run { } bodyPart to BoneState(bodyPart = bodyPart, length = tailOffset.len(), rotation = restRotation) } - SkeletonState(rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, length = bone.length, rawPosition = Vector3.NULL) }, userHeight = computeUserHeight(bones.mapValues { (_, bone) -> bone.length })) + SkeletonState(rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, length = bone.length, rawPosition = Vector3.NULL) }, userHeight = computeUserHeight(bones.mapValues { (_, bone) -> bone.length })) } fun buildBones(state: SkeletonState, rootHead: Vector3 = Vector3.NULL): Map { diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 7aad018df7..3d74099fdb 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -5,6 +5,7 @@ import dev.slimevr.EventDispatcher import dev.slimevr.VRServerActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.context.ManagedContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import solarxr_protocol.MessageBundle @@ -48,8 +49,9 @@ class SolarXRBridge( val dataFeedDispatcher: EventDispatcher, val rpcDispatcher: EventDispatcher, val outbound: EventDispatcher = EventDispatcher(), + private val managedContext: ManagedContext? = null, ) { - fun dispose() = context.dispose() + fun dispose() = managedContext?.dispose() suspend fun sendRpc(message: RpcMessage) = outbound.emit(MessageBundle(rpcMsgs = listOf(RpcMessageHeader(message = message)))) @@ -85,7 +87,7 @@ class SolarXRBridge( appContext: AppContextProvider, scope: CoroutineScope, ): SolarXRBridge { - val context = Context.create( + val managedContext = ManagedContext.create( initialState = SolarXRBridgeState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), scope = scope, behaviours = buildBehaviours(appContext), @@ -94,10 +96,11 @@ class SolarXRBridge( val bridge = SolarXRBridge( id = id, - context = context, + context = managedContext.context, appContext = appContext, dataFeedDispatcher = EventDispatcher(), rpcDispatcher = EventDispatcher(), + managedContext = managedContext, ) bridge.startObserving() return bridge diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index f43ebbcc37..1bec70a21e 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -1,14 +1,19 @@ package dev.slimevr import dev.llelievr.espflashkotlin.FlasherSerialInterface +import dev.slimevr.bvh.BVHManager import dev.slimevr.config.AppConfig import dev.slimevr.config.DefaultUserBehaviour +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsConfigState +import dev.slimevr.config.SettingsState import dev.slimevr.config.UserConfig import dev.slimevr.config.UserConfigData import dev.slimevr.config.UserConfigState import dev.slimevr.context.Context import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager +import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialServer @@ -44,6 +49,12 @@ fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create( fun buildTestVrServer(scope: CoroutineScope): VRServer = VRServer.create(scope) +fun buildTestVrServerStub(scope: CoroutineScope): VRServer { + val vrServer = VRServer.create(scope) + // Don't call startObserving() to avoid starting infinite flow observers + return vrServer +} + fun buildTestUserConfig(scope: CoroutineScope): UserConfig { val tempDir = Files.createTempDirectory("slimevr-test").toFile() tempDir.deleteOnExit() @@ -70,6 +81,19 @@ fun buildTestSkeleton(scope: CoroutineScope): Skeleton { return skeleton } +fun buildTestSettings(scope: CoroutineScope): Settings { + val initialState = SettingsState(data = SettingsConfigState(), name = "test") + val context = Context.create( + initialState = initialState, + scope = scope, + behaviours = emptyList(), + name = "Settings[test]", + ) + // Use a path in /tmp that won't actually be written to in tests + val fakeDir = java.io.File("/tmp/slimevr-test-${System.nanoTime()}") + return Settings(context, scope, fakeDir) +} + abstract class TestAppContext : AppContextProvider { override val config: AppConfig get() = error("not used in test") override val serialServer: SerialServer get() = error("not used in test") @@ -79,5 +103,7 @@ abstract class TestAppContext : AppContextProvider { override val heightCalibrationManager: HeightCalibrationManager get() = error("not used in test") override val trackingChecklist: TrackingChecklist get() = error("not used in test") override val udpServer: UdpServer get() = error("not used in test") + override val networkProfileManager: NetworkProfileManager? = null + override val bvhManager: BVHManager get() = error("not used in test") override fun startObserving() {} } diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt index faaaa95c8e..2e6b1b3220 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -3,21 +3,21 @@ package dev.slimevr.firmware import dev.llelievr.espflashkotlin.FlasherSerialInterface import dev.slimevr.VRServer import dev.slimevr.VRServerActions +import dev.slimevr.buildTestSettings +import dev.slimevr.buildTestVrServerStub import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialPortInfo import dev.slimevr.serial.SerialServer +import kotlinx.coroutines.CoroutineScope 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 import kotlin.test.Test import kotlin.test.assertEquals @@ -58,7 +58,7 @@ private fun buildSerialServer( // backgroundScope lets those run on the test scheduler but doesn't cause // UncompletedCoroutinesError when the test ends. private fun buildVrServer( - backgroundScope: kotlinx.coroutines.CoroutineScope, + backgroundScope: CoroutineScope, ): VRServer = VRServer.create(backgroundScope) class DoSerialFlashTest { @@ -66,6 +66,7 @@ class DoSerialFlashTest { @Test fun `emits ERROR_DEVICE_NOT_FOUND when port is not available`() = runTest { val server = buildSerialServer(this) + val vrServer = buildTestVrServerStub(backgroundScope) val statuses = mutableListOf() doSerialFlash( @@ -75,7 +76,8 @@ class DoSerialFlashTest { ssid = null, password = null, serialServer = server, - server = buildVrServer(backgroundScope), + settings = buildTestSettings(backgroundScope), + server = vrServer, onStatus = { s, _ -> statuses += s }, scope = this, ) @@ -97,7 +99,8 @@ class DoSerialFlashTest { ssid = null, password = null, serialServer = server, - server = buildVrServer(backgroundScope), + settings = buildTestSettings(backgroundScope), + server = buildTestVrServerStub(backgroundScope), onStatus = { s, _ -> statuses += s }, scope = this, ) @@ -118,7 +121,8 @@ class DoSerialFlashTest { ssid = null, password = null, serialServer = server, - server = buildVrServer(backgroundScope), + settings = buildTestSettings(backgroundScope), + server = buildTestVrServerStub(backgroundScope), onStatus = { s, _ -> statuses += s }, scope = this, ) @@ -139,7 +143,8 @@ class DoSerialFlashTest { ssid = "wifi", password = "pass", serialServer = server, - server = buildVrServer(backgroundScope), + settings = buildTestSettings(backgroundScope), + server = buildTestVrServerStub(backgroundScope), onStatus = { s, _ -> statuses += s }, ) @@ -161,7 +166,8 @@ class DoSerialFlashTest { ssid = "wifi", password = "pass", serialServer = server, - server = buildVrServer(backgroundScope), + settings = buildTestSettings(backgroundScope), + server = buildTestVrServerStub(backgroundScope), onStatus = { s, _ -> statuses += s }, ) } @@ -180,24 +186,25 @@ class DoSerialFlashTest { server.openConnection("COM1") val statuses = mutableListOf() - launch { + backgroundScope.launch { doSerialFlashPostFlash( portLocation = "COM1", needManualReboot = false, ssid = null, password = null, serialServer = server, - server = buildVrServer(backgroundScope), + settings = buildTestSettings(backgroundScope), + server = buildTestVrServerStub(backgroundScope), onStatus = { s, _ -> statuses += s }, ) } - launch { + backgroundScope.launch { delay(100) server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") } - advanceUntilIdle() + advanceTimeBy(500) assertEquals(FirmwareUpdateStatus.ERROR_PROVISIONING_FAILED, statuses.last()) } @@ -217,12 +224,13 @@ class DoSerialFlashTest { ssid = "wifi", password = "pass", serialServer = server, - server = buildVrServer(backgroundScope), + settings = buildTestSettings(backgroundScope), + server = buildTestVrServerStub(backgroundScope), onStatus = { s, _ -> statuses += s }, ) } - launch { + backgroundScope.launch { delay(100) server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") } @@ -249,12 +257,13 @@ class DoSerialFlashTest { ssid = "wifi", password = "pass", serialServer = server, - server = buildVrServer(backgroundScope), + settings = buildTestSettings(backgroundScope), + server = buildTestVrServerStub(backgroundScope), onStatus = { s, _ -> statuses += s }, ) } - launch { + backgroundScope.launch { delay(100) server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") delay(200) @@ -274,22 +283,23 @@ class DoSerialFlashTest { val server = buildSerialServer(this) server.onPortDetected(fakePort()) server.openConnection("COM1") - val vrServer = buildVrServer(backgroundScope) + val vrServer = buildTestVrServerStub(backgroundScope) val statuses = mutableListOf() - launch { + backgroundScope.launch { doSerialFlashPostFlash( portLocation = "COM1", needManualReboot = false, ssid = "wifi", password = "pass", serialServer = server, + settings = buildTestSettings(backgroundScope), server = vrServer, onStatus = { s, _ -> statuses += s }, ) } - launch { + backgroundScope.launch { delay(100) server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") delay(200) @@ -307,7 +317,7 @@ class DoSerialFlashTest { device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) }) } - advanceUntilIdle() + advanceTimeBy(1000) assertEquals(FirmwareUpdateStatus.DONE, statuses.last()) } diff --git a/server/core/src/test/java/dev/slimevr/osc/CodecTest.kt b/server/core/src/test/java/dev/slimevr/osc/CodecTest.kt new file mode 100644 index 0000000000..9cdb661962 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/osc/CodecTest.kt @@ -0,0 +1,237 @@ +package dev.slimevr.osc + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CodecTest { + @Test + fun testEncodeDecodeIntMessage() { + val msg = OscMessage("/test", listOf(OscArg.Int(42))) + val encoded = encodeMessage(msg) + val (decoded, _) = decodeMessage(encoded) + + assertEquals("/test", decoded.address) + assertEquals(1, decoded.args.size) + assertEquals(42, (decoded.args[0] as OscArg.Int).value) + } + + @Test + fun testEncodeDecodeFloatMessage() { + val msg = OscMessage("/test/float", listOf(OscArg.Float(3.14f))) + val encoded = encodeMessage(msg) + val (decoded, _) = decodeMessage(encoded) + + assertEquals("/test/float", decoded.address) + assertEquals(1, decoded.args.size) + val float = (decoded.args[0] as OscArg.Float).value + assertTrue((float - 3.14f) < 0.001f) + } + + @Test + fun testEncodeDecodeStringMessage() { + val msg = OscMessage("/address", listOf(OscArg.String("hello"))) + val encoded = encodeMessage(msg) + val (decoded, _) = decodeMessage(encoded) + + assertEquals("/address", decoded.address) + assertEquals(1, decoded.args.size) + assertEquals("hello", (decoded.args[0] as OscArg.String).value) + } + + @Test + fun testEncodeDecodeBlobMessage() { + val blobData = byteArrayOf(1, 2, 3, 4, 5) + val msg = OscMessage("/blob", listOf(OscArg.Blob(blobData))) + val encoded = encodeMessage(msg) + val (decoded, _) = decodeMessage(encoded) + + assertEquals("/blob", decoded.address) + assertEquals(1, decoded.args.size) + val decodedBlob = (decoded.args[0] as OscArg.Blob).value + assertTrue(decodedBlob.contentEquals(blobData)) + } + + @Test + fun testEncodeDecodeLongMessage() { + val msg = OscMessage("/long", listOf(OscArg.Long(9876543210L))) + val encoded = encodeMessage(msg) + val (decoded, _) = decodeMessage(encoded) + + assertEquals("/long", decoded.address) + assertEquals(1, decoded.args.size) + assertEquals(9876543210L, (decoded.args[0] as OscArg.Long).value) + } + + @Test + fun testEncodeDecodeDoubleMessage() { + val msg = OscMessage("/double", listOf(OscArg.Double(2.718281828))) + val encoded = encodeMessage(msg) + val (decoded, _) = decodeMessage(encoded) + + assertEquals("/double", decoded.address) + assertEquals(1, decoded.args.size) + val double = (decoded.args[0] as OscArg.Double).value + assertTrue((double - 2.718281828) < 0.000001) + } + + @Test + fun testEncodeDecode() { + val msg = OscMessage( + "/VMC/Ext/Bone/Pos", + listOf( + OscArg.String("Hips"), + OscArg.Float(1.0f), + OscArg.Float(2.0f), + OscArg.Float(3.0f), + OscArg.Float(0.0f), + OscArg.Float(0.0f), + OscArg.Float(0.707f), + OscArg.Float(0.707f), + ), + ) + val encoded = encodeMessage(msg) + val (decoded, _) = decodeMessage(encoded) + + assertEquals("/VMC/Ext/Bone/Pos", decoded.address) + assertEquals(8, decoded.args.size) + assertEquals("Hips", (decoded.args[0] as OscArg.String).value) + assertEquals(1.0f, (decoded.args[1] as OscArg.Float).value) + } + + @Test + fun testMultipleArgs() { + val msg = OscMessage( + "/test", + listOf( + OscArg.Int(1), + OscArg.String("two"), + OscArg.Float(3.0f), + OscArg.Long(4L), + ), + ) + val encoded = encodeMessage(msg) + val (decoded, _) = decodeMessage(encoded) + + assertEquals(4, decoded.args.size) + assertEquals(1, (decoded.args[0] as OscArg.Int).value) + assertEquals("two", (decoded.args[1] as OscArg.String).value) + assertEquals(3.0f, (decoded.args[2] as OscArg.Float).value) + assertEquals(4L, (decoded.args[3] as OscArg.Long).value) + } + + @Test + fun testEncodeDecodeBundle() { + val bundle = OscBundle( + 1, + listOf( + OscContent.Message(OscMessage("/msg1", listOf(OscArg.Int(42)))), + OscContent.Message(OscMessage("/msg2", listOf(OscArg.String("hello")))), + ), + ) + val encoded = encodeBundle(bundle) + val (decoded, _) = decodeBundle(encoded) + + assertEquals(1, decoded.timetag) + assertEquals(2, decoded.contents.size) + + val msg1 = (decoded.contents[0] as OscContent.Message).msg + assertEquals("/msg1", msg1.address) + assertEquals(42, (msg1.args[0] as OscArg.Int).value) + + val msg2 = (decoded.contents[1] as OscContent.Message).msg + assertEquals("/msg2", msg2.address) + assertEquals("hello", (msg2.args[0] as OscArg.String).value) + } + + @Test + fun testNestedBundles() { + val innerBundle = OscBundle( + 2, + listOf(OscContent.Message(OscMessage("/inner", listOf(OscArg.Int(99))))), + ) + val outerBundle = OscBundle( + 1, + listOf( + OscContent.Message(OscMessage("/outer", listOf(OscArg.Int(1)))), + OscContent.Bundle(innerBundle), + ), + ) + + val encoded = encodeBundle(outerBundle) + val (decoded, _) = decodeBundle(encoded) + + assertEquals(1, decoded.timetag) + assertEquals(2, decoded.contents.size) + + val outerMsg = (decoded.contents[0] as OscContent.Message).msg + assertEquals("/outer", outerMsg.address) + + val decodedInner = (decoded.contents[1] as OscContent.Bundle).bundle + assertEquals(2, decodedInner.timetag) + val innerMsg = (decodedInner.contents[0] as OscContent.Message).msg + assertEquals("/inner", innerMsg.address) + assertEquals(99, (innerMsg.args[0] as OscArg.Int).value) + } + + @Test + fun testEmptyMessage() { + val msg = OscMessage("/empty") + val encoded = encodeMessage(msg) + val (decoded, _) = decodeMessage(encoded) + + assertEquals("/empty", decoded.address) + assertEquals(0, decoded.args.size) + } + + @Test + fun testSpecialArgs() { + val msg = OscMessage( + "/special", + listOf(OscArg.True, OscArg.False, OscArg.Null, OscArg.Impulse), + ) + val encoded = encodeMessage(msg) + val (decoded, _) = decodeMessage(encoded) + + assertEquals(4, decoded.args.size) + assertEquals(OscArg.True, decoded.args[0]) + assertEquals(OscArg.False, decoded.args[1]) + assertEquals(OscArg.Null, decoded.args[2]) + assertEquals(OscArg.Impulse, decoded.args[3]) + } + + @Test + fun testAlignment() { + // Messages with different address lengths should be properly aligned + val short = OscMessage("/a", listOf(OscArg.Int(1))) + val long = OscMessage("/this/is/a/very/long/address", listOf(OscArg.Int(1))) + + val encodedShort = encodeMessage(short) + val encodedLong = encodeMessage(long) + + // Both should encode/decode correctly regardless of alignment + val (decodedShort, _) = decodeMessage(encodedShort) + val (decodedLong, _) = decodeMessage(encodedLong) + + assertEquals("/a", decodedShort.address) + assertEquals("/this/is/a/very/long/address", decodedLong.address) + } + + @Test + fun testOffsetParsing() { + val msg1 = OscMessage("/first", listOf(OscArg.Int(1))) + val msg2 = OscMessage("/second", listOf(OscArg.Int(2))) + + val encoded1 = encodeMessage(msg1) + val encoded2 = encodeMessage(msg2) + val combined = encoded1 + encoded2 + + // Parse first message from combined + val (decoded1, offset) = decodeMessage(combined, 0) + assertEquals("/first", decoded1.address) + + // Parse second message from offset + val (decoded2, _) = decodeMessage(combined, offset) + assertEquals("/second", decoded2.address) + } +} diff --git a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt index db94b3c97e..c890e70e83 100644 --- a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt +++ b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt @@ -3,6 +3,7 @@ package dev.slimevr.provisioning import dev.slimevr.VRServer import dev.slimevr.VRServerActions import dev.slimevr.buildTestSerialServer +import dev.slimevr.buildTestSettings import dev.slimevr.buildTestVrServer import dev.slimevr.context.Context import dev.slimevr.device.Device @@ -30,7 +31,7 @@ private fun buildManager(serialServer: dev.slimevr.serial.SerialServer, scope: C behaviours = listOf(ProvisioningManagerBaseBehaviour), name = "ProvisioningManagerTest", ) - return ProvisioningManager(context = context, serialServer = serialServer, scope = scope).also { it.startObserving() } + return ProvisioningManager(context = context, serialServer = serialServer, settings = buildTestSettings(scope), scope = scope).also { it.startObserving() } } // Injects a fully connected device into the VRServer, simulating a tracker appearing on the network. 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 2cdad28293..19c319a6bd 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -7,7 +7,6 @@ import dev.slimevr.Phase1Context import dev.slimevr.VRServer import dev.slimevr.bvh.BVHManager import dev.slimevr.config.AppConfig -import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.desktop.hid.createDesktopHIDManager import dev.slimevr.desktop.ipc.createIpcServers import dev.slimevr.desktop.ipc.createSolarXRWebsocketServer @@ -16,6 +15,7 @@ import dev.slimevr.desktop.serial.createDesktopSerialServer import dev.slimevr.desktop.vrchat.createDesktopVRCConfigManager import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager +import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resolveConfigDirectory import dev.slimevr.skeleton.Skeleton From 170c688db890ad8a11b2ad39f64c60273dc67f79 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 5 May 2026 08:04:25 +0200 Subject: [PATCH 085/350] Basic osc parser --- .../src/main/java/dev/slimevr/osc/codec.kt | 18 ++++++------ .../test/java/dev/slimevr/osc/CodecTest.kt | 28 +++++++++++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/osc/codec.kt b/server/core/src/main/java/dev/slimevr/osc/codec.kt index 2199bd96c4..88c5f367e6 100644 --- a/server/core/src/main/java/dev/slimevr/osc/codec.kt +++ b/server/core/src/main/java/dev/slimevr/osc/codec.kt @@ -79,7 +79,7 @@ fun decodeMessage(bytes: ByteArray, offset: Int = 0): Pair { // Parse arguments val args = mutableListOf() if (typeTag.startsWith(",")) { - buf.position(pos - offset) + buf.position(pos) for (i in 1 until typeTag.length) { when (typeTag[i]) { 'i' -> args.add(OscArg.Int(buf.int)) @@ -92,10 +92,10 @@ fun decodeMessage(bytes: ByteArray, offset: Int = 0): Pair { 's' -> { val strStart = buf.position() - while (buf.position() < bytes.size && bytes[offset + buf.position()] != 0.toByte()) { + while (buf.position() < bytes.size && bytes[buf.position()] != 0.toByte()) { buf.position(buf.position() + 1) } - val str = bytes.sliceArray(strStart + offset until offset + buf.position()).toString(StandardCharsets.US_ASCII) + val str = bytes.sliceArray(strStart until buf.position()).toString(StandardCharsets.US_ASCII) args.add(OscArg.String(str)) buf.position(buf.position() + 1) // skip null val strPadding = padToMultiple(str.length + 1, 4) @@ -156,24 +156,24 @@ fun decodeBundle(bytes: ByteArray, offset: Int = 0): Pair { throw IllegalArgumentException("Invalid bundle prefix") } - var pos = offset + 8 - val buf = ByteBuffer.wrap(bytes, pos, bytes.size - pos) + val buf = ByteBuffer.wrap(bytes) buf.order(java.nio.ByteOrder.BIG_ENDIAN) + var pos = offset + 8 + buf.position(pos) val timetag = buf.long pos += 8 val contents = mutableListOf() - while (pos < bytes.size) { - buf.position(pos - offset - 8) + while (pos + 4 <= bytes.size) { + buf.position(pos) val elementSize = buf.int pos += 4 if (elementSize <= 0 || pos + elementSize > bytes.size) break - val element = bytes.sliceArray(pos until pos + elementSize) try { - if (element.size >= 8 && element.sliceArray(0..7).contentEquals(prefixBytes)) { + if (elementSize >= 8 && bytes.sliceArray(pos until pos + 8).contentEquals(prefixBytes)) { val (bundle, _) = decodeBundle(bytes, pos) contents.add(OscContent.Bundle(bundle)) } else { diff --git a/server/core/src/test/java/dev/slimevr/osc/CodecTest.kt b/server/core/src/test/java/dev/slimevr/osc/CodecTest.kt index 9cdb661962..359658c2e8 100644 --- a/server/core/src/test/java/dev/slimevr/osc/CodecTest.kt +++ b/server/core/src/test/java/dev/slimevr/osc/CodecTest.kt @@ -120,6 +120,34 @@ class CodecTest { assertEquals(4L, (decoded.args[3] as OscArg.Long).value) } + @Test + fun testSimpleBundle() { + // Test with just one message to isolate the issue + val msg = OscMessage("/test", listOf(OscArg.Int(42))) + val encoded = encodeMessage(msg) + val (decoded, _) = decodeMessage(encoded) + assertEquals(42, (decoded.args[0] as OscArg.Int).value) + } + + @Test + fun testBundleWithOneMessage() { + val msg = OscMessage("/single", listOf(OscArg.Int(99))) + val bundle = OscBundle(5, listOf(OscContent.Message(msg))) + val encoded = encodeBundle(bundle) + + val (decoded, _) = decodeBundle(encoded) + + assertEquals(5, decoded.timetag) + assertEquals(1, decoded.contents.size) + + val decodedMsg = (decoded.contents[0] as OscContent.Message).msg + assertEquals("/single", decodedMsg.address) + assertTrue(decodedMsg.args.isNotEmpty(), "Message has no args!") + val arg = decodedMsg.args[0] + assertTrue(arg is OscArg.Int, "First arg is not Int, got: ${arg::class.simpleName}") + assertEquals(99, (arg as OscArg.Int).value, "Int arg value mismatch") + } + @Test fun testEncodeDecodeBundle() { val bundle = OscBundle( From ae4402b5b5c24603ad990b9905ec3f6eab429f8d Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 5 May 2026 17:38:19 +0200 Subject: [PATCH 086/350] VMC basics, no solarxr, need VMC space coordinates to be fixed --- server/core/build.gradle.kts | 1 + .../src/main/java/dev/slimevr/app-context.kt | 4 + .../main/java/dev/slimevr/config/settings.kt | 13 +- .../core/src/main/java/dev/slimevr/logger.kt | 1 + .../src/main/java/dev/slimevr/osc/codec.kt | 267 ++++++++---------- .../src/main/java/dev/slimevr/osc/receiver.kt | 99 ------- .../src/main/java/dev/slimevr/osc/sender.kt | 44 --- .../main/java/dev/slimevr/osc/transport.kt | 66 +++++ .../src/main/java/dev/slimevr/osc/types.kt | 2 +- .../main/java/dev/slimevr/skeleton/module.kt | 11 +- .../main/java/dev/slimevr/vmc/behaviours.kt | 185 ++++++++++++ .../src/main/java/dev/slimevr/vmc/mapping.kt | 52 ++++ .../src/main/java/dev/slimevr/vmc/module.kt | 46 +++ .../src/main/java/dev/slimevr/desktop/Main.kt | 4 +- .../desktop/networkprofile/network-profile.kt | 1 + 15 files changed, 485 insertions(+), 311 deletions(-) delete mode 100644 server/core/src/main/java/dev/slimevr/osc/receiver.kt delete mode 100644 server/core/src/main/java/dev/slimevr/osc/sender.kt create mode 100644 server/core/src/main/java/dev/slimevr/osc/transport.kt create mode 100644 server/core/src/main/java/dev/slimevr/vmc/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/vmc/mapping.kt create mode 100644 server/core/src/main/java/dev/slimevr/vmc/module.kt diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index c63b1b0ccb..d7758704d4 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { implementation("io.ktor:ktor-server-websockets-jvm:$ktor_version") implementation("io.ktor:ktor-server-content-negotiation-jvm:$ktor_version") implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:$ktor_version") + implementation("io.ktor:ktor-network:$ktor_version") implementation("io.ktor:ktor-utils:$ktor_version") api("com.github.loucass003:EspflashKotlin:v0.11.0") diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index 66e24d2ba7..0cd2c67d46 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -10,6 +10,7 @@ import dev.slimevr.serial.SerialServer import dev.slimevr.skeleton.Skeleton import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer +import dev.slimevr.vmc.VMCManager import dev.slimevr.vrchat.VRCConfigManager interface Phase1ContextProvider { @@ -34,6 +35,7 @@ interface AppContextProvider : Phase1ContextProvider { val trackingChecklist: TrackingChecklist val udpServer: UdpServer val bvhManager: BVHManager + val vmcManager: VMCManager fun startObserving() } @@ -50,6 +52,7 @@ class AppContext( override val trackingChecklist: TrackingChecklist, override val udpServer: UdpServer, override val bvhManager: BVHManager, + override val vmcManager: VMCManager, ) : AppContextProvider { override fun startObserving() { skeleton.startObserving() @@ -60,5 +63,6 @@ class AppContext( networkProfileManager?.startObserving() trackingChecklist.startObserving(this) udpServer.startReceiving(this, server.context.scope) + vmcManager.startObserving() } } diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 632dab2051..552027de2b 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -2,23 +2,13 @@ package dev.slimevr.config import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.vmc.VMCConfig import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.nullable -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.JsonDecoder -import kotlinx.serialization.json.JsonEncoder -import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject @@ -46,6 +36,7 @@ data class SettingsConfigState( val trackers: Map = emptyMap(), val globalMagEnabled: Boolean = true, val allowedUdpDevices: Set = emptySet(), + val vmcConfig: VMCConfig = VMCConfig(), val version: Int = SETTINGS_CONFIG_VERSION, ) diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index f606b5fc92..bbce48538e 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -16,6 +16,7 @@ object AppLogger { val firmware = logger("Firmware") val vrc = logger("VRChat") val bvh = logger("BVH") + val vmc = logger("VMC") init { loggingConfiguration { diff --git a/server/core/src/main/java/dev/slimevr/osc/codec.kt b/server/core/src/main/java/dev/slimevr/osc/codec.kt index 88c5f367e6..ccd9f542b1 100644 --- a/server/core/src/main/java/dev/slimevr/osc/codec.kt +++ b/server/core/src/main/java/dev/slimevr/osc/codec.kt @@ -1,197 +1,160 @@ package dev.slimevr.osc -import java.nio.ByteBuffer -import java.nio.charset.StandardCharsets +private val BUNDLE_PREFIX = "#bundle".encodeToByteArray() + byteArrayOf(0) -private const val BUNDLE_PREFIX = "#bundle " +private fun pad4(n: Int) = (n + 3) and 3.inv() + +private class Writer(capacity: Int = 65536) { + private val buf = ByteArray(capacity) + var pos = 0 + + fun byte(v: Byte) { + buf[pos++] = v + } + fun bytes(v: ByteArray) { + v.copyInto(buf, pos) + pos += v.size + } + fun int(v: Int) { + byte((v ushr 24).toByte()) + byte((v ushr 16).toByte()) + byte((v ushr 8).toByte()) + byte(v.toByte()) + } + fun long(v: Long) { + int((v ushr 32).toInt()) + int(v.toInt()) + } + fun float(v: Float) = int(v.toBits()) + fun double(v: Double) = long(v.toBits()) + fun string(s: String) { + val b = s.encodeToByteArray() + bytes(b) + byte(0) + repeat(pad4(b.size + 1) - b.size - 1) { byte(0) } + } + fun result() = buf.copyOfRange(0, pos) +} + +private class Reader(private val bytes: ByteArray, var pos: Int = 0) { + private fun byte() = bytes[pos++].toInt() + fun int() = (byte() and 0xFF shl 24) or (byte() and 0xFF shl 16) or (byte() and 0xFF shl 8) or (byte() and 0xFF) + fun long() = (int().toLong() and 0xFFFFFFFFL shl 32) or (int().toLong() and 0xFFFFFFFFL) + fun float() = Float.fromBits(int()) + fun double() = Double.fromBits(long()) + fun string(): String { + val start = pos + while (pos < bytes.size && bytes[pos] != 0.toByte()) pos++ + val s = bytes.decodeToString(start, pos) + pos++ // skip null + pos = pad4(pos) + return s + } + fun bytes(n: Int) = bytes.copyOfRange(pos, pos + n).also { pos += n } + fun hasPrefix(prefix: ByteArray) = pos + prefix.size <= bytes.size && + bytes.copyOfRange(pos, pos + prefix.size).contentEquals(prefix) +} fun encodeMessage(msg: OscMessage): ByteArray { - val buf = ByteBuffer.allocate(65536) // max UDP packet - buf.order(java.nio.ByteOrder.BIG_ENDIAN) - - // Address + null terminator - val addressBytes = msg.address.toByteArray(StandardCharsets.US_ASCII) - buf.put(addressBytes) - buf.put(0.toByte()) // null terminator - val addressPadded = padToMultiple(addressBytes.size + 1, 4) - buf.put(ByteArray(addressPadded - addressBytes.size - 1)) - - // Type tag string - val typeTag = "," + msg.args.joinToString("") { it.typeTag.toString() } - val typeTagBytes = typeTag.toByteArray(StandardCharsets.US_ASCII) - buf.put(typeTagBytes) - buf.put(0.toByte()) - val typeTagPadded = padToMultiple(typeTagBytes.size + 1, 4) - buf.put(ByteArray(typeTagPadded - typeTagBytes.size - 1)) - - // Arguments + val w = Writer() + w.string(msg.address) + w.string("," + msg.args.joinToString("") { it.typeTag.toString() }) for (arg in msg.args) { when (arg) { - is OscArg.Int -> buf.putInt(arg.value) + is OscArg.Int -> w.int(arg.value) - is OscArg.Long -> buf.putLong(arg.value) + is OscArg.Long -> w.long(arg.value) - is OscArg.Float -> buf.putFloat(arg.value) + is OscArg.Float -> w.float(arg.value) - is OscArg.Double -> buf.putDouble(arg.value) + is OscArg.Double -> w.double(arg.value) - is OscArg.String -> { - val strBytes = arg.value.toByteArray(StandardCharsets.US_ASCII) - buf.put(strBytes) - buf.put(0.toByte()) - val strPadded = padToMultiple(strBytes.size + 1, 4) - buf.put(ByteArray(strPadded - strBytes.size - 1)) - } + is OscArg.String -> w.string(arg.value) is OscArg.Blob -> { - buf.putInt(arg.value.size) - buf.put(arg.value) - val blobPadded = padToMultiple(arg.value.size, 4) - buf.put(ByteArray(blobPadded - arg.value.size)) + w.int(arg.value.size) + w.bytes(arg.value) + repeat(pad4(arg.value.size) - arg.value.size) { w.byte(0) } } - else -> {} // Impulse, Null, True, False have no arguments + else -> {} } } + return w.result() +} - return buf.array().sliceArray(0 until buf.position()) +fun encodeBundle(bundle: OscBundle): ByteArray { + val w = Writer() + w.bytes(BUNDLE_PREFIX) + w.long(bundle.timetag) + for (content in bundle.contents) { + val encoded = when (content) { + is OscContent.Message -> encodeMessage(content.msg) + is OscContent.Bundle -> encodeBundle(content.bundle) + } + w.int(encoded.size) + w.bytes(encoded) + } + return w.result() } fun decodeMessage(bytes: ByteArray, offset: Int = 0): Pair { - var pos = offset - val buf = ByteBuffer.wrap(bytes, offset, bytes.size - offset) - buf.order(java.nio.ByteOrder.BIG_ENDIAN) - - // Parse address - val addressStart = pos - while (pos < bytes.size && bytes[pos] != 0.toByte()) pos++ - val address = bytes.sliceArray(addressStart until pos).toString(StandardCharsets.US_ASCII) - pos++ // skip null terminator - pos = alignToMultiple(pos, 4) - - // Parse type tag - val typeTagStart = pos - while (pos < bytes.size && bytes[pos] != 0.toByte()) pos++ - val typeTag = bytes.sliceArray(typeTagStart until pos).toString(StandardCharsets.US_ASCII) - pos++ // skip null terminator - pos = alignToMultiple(pos, 4) - - // Parse arguments + val r = Reader(bytes, offset) + val address = r.string() + val typeTag = r.string() val args = mutableListOf() if (typeTag.startsWith(",")) { - buf.position(pos) - for (i in 1 until typeTag.length) { - when (typeTag[i]) { - 'i' -> args.add(OscArg.Int(buf.int)) - - 'h' -> args.add(OscArg.Long(buf.long)) - - 'f' -> args.add(OscArg.Float(buf.float)) - - 'd' -> args.add(OscArg.Double(buf.double)) - - 's' -> { - val strStart = buf.position() - while (buf.position() < bytes.size && bytes[buf.position()] != 0.toByte()) { - buf.position(buf.position() + 1) - } - val str = bytes.sliceArray(strStart until buf.position()).toString(StandardCharsets.US_ASCII) - args.add(OscArg.String(str)) - buf.position(buf.position() + 1) // skip null - val strPadding = padToMultiple(str.length + 1, 4) - buf.position(buf.position() + strPadding - str.length - 1) - } + for (c in typeTag.drop(1)) { + when (c) { + 'i' -> args += OscArg.Int(r.int()) - 'b' -> { - val size = buf.int - val blob = ByteArray(size) - buf.get(blob) - args.add(OscArg.Blob(blob)) - val blobPadding = padToMultiple(size, 4) - buf.position(buf.position() + blobPadding - size) - } + 'h' -> args += OscArg.Long(r.long()) - 'I' -> args.add(OscArg.Impulse) + 'f' -> args += OscArg.Float(r.float()) - 'N' -> args.add(OscArg.Null) + 'd' -> args += OscArg.Double(r.double()) - 'T' -> args.add(OscArg.True) + 's' -> args += OscArg.String(r.string()) - 'F' -> args.add(OscArg.False) - } - } - pos = offset + buf.position() - } + 'b' -> { + val size = r.int() + args += OscArg.Blob(r.bytes(size)) + r.pos += pad4(size) - size + } - return OscMessage(address, args) to pos -} + 'I' -> args += OscArg.Impulse -fun encodeBundle(bundle: OscBundle): ByteArray { - val buf = ByteBuffer.allocate(65536) - buf.order(java.nio.ByteOrder.BIG_ENDIAN) + 'N' -> args += OscArg.Null - // Bundle prefix - val prefixBytes = BUNDLE_PREFIX.toByteArray(StandardCharsets.US_ASCII) - buf.put(prefixBytes) + 'T' -> args += OscArg.True - // Timetag (8 bytes, NTP format) - buf.putLong(bundle.timetag) - - // Contents - for (content in bundle.contents) { - val contentBytes = when (content) { - is OscContent.Message -> encodeMessage(content.msg) - is OscContent.Bundle -> encodeBundle(content.bundle) + 'F' -> args += OscArg.False + } } - buf.putInt(contentBytes.size) - buf.put(contentBytes) } - - return buf.array().sliceArray(0 until buf.position()) + return OscMessage(address, args) to r.pos } fun decodeBundle(bytes: ByteArray, offset: Int = 0): Pair { - val prefixBytes = BUNDLE_PREFIX.toByteArray(StandardCharsets.US_ASCII) - if (!bytes.sliceArray(offset until minOf(offset + 8, bytes.size)).contentEquals(prefixBytes)) { - throw IllegalArgumentException("Invalid bundle prefix") - } - - val buf = ByteBuffer.wrap(bytes) - buf.order(java.nio.ByteOrder.BIG_ENDIAN) - - var pos = offset + 8 - buf.position(pos) - val timetag = buf.long - pos += 8 - + val r = Reader(bytes, offset) + if (!r.hasPrefix(BUNDLE_PREFIX)) throw IllegalArgumentException("Invalid bundle prefix") + r.pos += 8 + val timetag = r.long() val contents = mutableListOf() - while (pos + 4 <= bytes.size) { - buf.position(pos) - val elementSize = buf.int - pos += 4 - - if (elementSize <= 0 || pos + elementSize > bytes.size) break - + while (r.pos + 4 <= bytes.size) { + val elementSize = r.int() + if (elementSize <= 0 || r.pos + elementSize > bytes.size) break + val elemStart = r.pos try { - if (elementSize >= 8 && bytes.sliceArray(pos until pos + 8).contentEquals(prefixBytes)) { - val (bundle, _) = decodeBundle(bytes, pos) - contents.add(OscContent.Bundle(bundle)) + contents += if (Reader(bytes, elemStart).hasPrefix(BUNDLE_PREFIX)) { + OscContent.Bundle(decodeBundle(bytes, elemStart).first) } else { - val (msg, _) = decodeMessage(bytes, pos) - contents.add(OscContent.Message(msg)) + OscContent.Message(decodeMessage(bytes, elemStart).first) } - } catch (e: Exception) { + } catch (_: Exception) { break } - pos += elementSize + r.pos = elemStart + elementSize } - - return OscBundle(timetag, contents) to pos + return OscBundle(timetag, contents) to r.pos } - -private fun padToMultiple(value: Int, alignment: Int): Int { - val remainder = value % alignment - return if (remainder == 0) value else value + (alignment - remainder) -} - -private fun alignToMultiple(value: Int, alignment: Int): Int = padToMultiple(value, alignment) diff --git a/server/core/src/main/java/dev/slimevr/osc/receiver.kt b/server/core/src/main/java/dev/slimevr/osc/receiver.kt deleted file mode 100644 index a098b50dc1..0000000000 --- a/server/core/src/main/java/dev/slimevr/osc/receiver.kt +++ /dev/null @@ -1,99 +0,0 @@ -package dev.slimevr.osc - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.net.DatagramPacket -import java.net.DatagramSocket - -class OscReceiver(private val port: Int) { - private var socket: DatagramSocket? = null - private var listenerScope: CoroutineScope? = null - - suspend fun listen(onMessage: (OscMessage) -> Unit) { - if (socket == null) { - socket = withContext(Dispatchers.IO) { - DatagramSocket(port) - } - } - val s = socket ?: return - - val scope = CoroutineScope(Dispatchers.IO) - listenerScope = scope - - scope.launch { - while (isActive) { - try { - val buffer = ByteArray(65536) - val packet = DatagramPacket(buffer, buffer.size) - s.receive(packet) - val bytes = packet.data.sliceArray(0 until packet.length) - - try { - val (msg, _) = decodeMessage(bytes) - onMessage(msg) - } catch (e: Exception) { - // Ignore malformed packets - } - } catch (e: Exception) { - if (isActive) { - // Socket closed or error - break - } - } - } - } - } - - suspend fun listenBundles(onBundle: (OscBundle) -> Unit) { - if (socket == null) { - socket = withContext(Dispatchers.IO) { - DatagramSocket(port) - } - } - val s = socket ?: return - - val scope = CoroutineScope(Dispatchers.IO) - listenerScope = scope - - scope.launch { - while (isActive) { - try { - val buffer = ByteArray(65536) - val packet = DatagramPacket(buffer, buffer.size) - s.receive(packet) - val bytes = packet.data.sliceArray(0 until packet.length) - - try { - // Try bundle first, fall back to message - val bundle = if (bytes.size >= 8 && bytes.sliceArray(0..7).contentEquals("#bundle ".toByteArray())) { - val (b, _) = decodeBundle(bytes) - b - } else { - // Wrap message in a bundle - val (msg, _) = decodeMessage(bytes) - OscBundle(1, listOf(OscContent.Message(msg))) - } - onBundle(bundle) - } catch (e: Exception) { - // Ignore malformed packets - } - } catch (e: Exception) { - if (isActive) { - break - } - } - } - } - } - - fun close() { - listenerScope?.cancel() - listenerScope = null - socket?.close() - socket = null - } -} diff --git a/server/core/src/main/java/dev/slimevr/osc/sender.kt b/server/core/src/main/java/dev/slimevr/osc/sender.kt deleted file mode 100644 index 8b7c25c7d9..0000000000 --- a/server/core/src/main/java/dev/slimevr/osc/sender.kt +++ /dev/null @@ -1,44 +0,0 @@ -package dev.slimevr.osc - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import java.net.DatagramPacket -import java.net.DatagramSocket -import java.net.InetAddress - -class OscSender(private val address: String, private val port: Int) { - private var socket: DatagramSocket? = null - - private suspend fun ensureSocket() { - if (socket == null) { - socket = withContext(Dispatchers.IO) { - DatagramSocket() - } - } - } - - suspend fun send(message: OscMessage) { - val bytes = encodeMessage(message) - ensureSocket() - sendBytes(bytes) - } - - suspend fun send(bundle: OscBundle) { - val bytes = encodeBundle(bundle) - ensureSocket() - sendBytes(bytes) - } - - private suspend fun sendBytes(bytes: ByteArray) { - val s = socket ?: return - withContext(Dispatchers.IO) { - val packet = DatagramPacket(bytes, bytes.size, InetAddress.getByName(address), port) - s.send(packet) - } - } - - fun close() { - socket?.close() - socket = null - } -} diff --git a/server/core/src/main/java/dev/slimevr/osc/transport.kt b/server/core/src/main/java/dev/slimevr/osc/transport.kt new file mode 100644 index 0000000000..1e90b13efe --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/osc/transport.kt @@ -0,0 +1,66 @@ +package dev.slimevr.osc + +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.BoundDatagramSocket +import io.ktor.network.sockets.ConnectedDatagramSocket +import io.ktor.network.sockets.Datagram +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.aSocket +import kotlinx.coroutines.Dispatchers +import kotlinx.io.Buffer +import kotlinx.io.readByteArray + +private val selectorManager = SelectorManager(Dispatchers.IO) + +private fun ByteArray.toBuffer() = Buffer().apply { write(this@toBuffer) } + +class OscSender(private val address: String, private val port: Int) { + private var socket: ConnectedDatagramSocket? = null + + private suspend fun socket(): ConnectedDatagramSocket { + if (socket == null) socket = aSocket(selectorManager).udp().connect(InetSocketAddress(address, port)) + return socket!! + } + + suspend fun send(message: OscMessage) = socket().send(Datagram(encodeMessage(message).toBuffer(), InetSocketAddress(address, port))) + suspend fun send(bundle: OscBundle) = socket().send(Datagram(encodeBundle(bundle).toBuffer(), InetSocketAddress(address, port))) + + fun close() { + socket?.close() + socket = null + } +} + +class OscReceiver(private val port: Int) { + private var socket: BoundDatagramSocket? = null + private var running = false + + private suspend fun socket(): BoundDatagramSocket { + if (socket == null) socket = aSocket(selectorManager).udp().bind(InetSocketAddress("0.0.0.0", port)) + return socket!! + } + + suspend fun listenBundles(onBundle: (OscBundle) -> Unit) { + val s = socket() + running = true + while (running) { + try { + val bytes = s.receive().packet.readByteArray() + val bundle = try { + decodeBundle(bytes).first + } catch (_: IllegalArgumentException) { + OscBundle(1, listOf(OscContent.Message(decodeMessage(bytes).first))) + } + onBundle(bundle) + } catch (_: Exception) { + if (!running) break + } + } + } + + fun close() { + running = false + socket?.close() + socket = null + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/osc/types.kt b/server/core/src/main/java/dev/slimevr/osc/types.kt index 80277a9106..d9247a4bd7 100644 --- a/server/core/src/main/java/dev/slimevr/osc/types.kt +++ b/server/core/src/main/java/dev/slimevr/osc/types.kt @@ -7,7 +7,7 @@ sealed class OscArg { data class Double(val value: kotlin.Double) : OscArg() data class String(val value: kotlin.String) : OscArg() data class Blob(val value: ByteArray) : OscArg() { - override fun equals(other: Any?) = this === other || other is Blob && value.contentEquals(other.value) + override fun equals(other: Any?) = this === other || (other is Blob && value.contentEquals(other.value)) override fun hashCode() = value.contentHashCode() } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index eca94ad0ce..ad83f9ab0c 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -46,11 +46,16 @@ val DEFAULT_SKELETON_STATE: SkeletonState = run { SkeletonState(rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, length = bone.length, rawPosition = Vector3.NULL) }, userHeight = computeUserHeight(bones.mapValues { (_, bone) -> bone.length })) } -fun buildBones(state: SkeletonState, rootHead: Vector3 = Vector3.NULL): Map { +fun buildBones( + state: SkeletonState, + rootHead: Vector3 = Vector3.NULL, + hierarchy: Sequence> = iterateBodyPartHierarchy(), + tailDirections: Map = BONE_TAIL_DIRECTIONS, +): Map { val result = mutableMapOf() - iterateBodyPartHierarchy().forEach { (parentPart, childPart) -> + hierarchy.forEach { (parentPart, childPart) -> val rawBone = state.rawBones[childPart] ?: return@forEach - val tailDirection = BONE_TAIL_DIRECTIONS[childPart] ?: return@forEach + val tailDirection = tailDirections[childPart] ?: return@forEach val parentBone = parentPart?.let { result[it] } val head = parentBone?.tailPosition ?: rootHead result[childPart] = BoneState( diff --git a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt new file mode 100644 index 0000000000..78cc4ca123 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt @@ -0,0 +1,185 @@ +package dev.slimevr.vmc + +import dev.slimevr.AppLogger +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions +import dev.slimevr.context.Behaviour +import dev.slimevr.osc.OscArg +import dev.slimevr.osc.OscBundle +import dev.slimevr.osc.OscContent +import dev.slimevr.osc.OscMessage +import dev.slimevr.osc.OscReceiver +import dev.slimevr.osc.OscSender +import dev.slimevr.skeleton.BONE_TAIL_DIRECTIONS +import dev.slimevr.skeleton.BoneState +import dev.slimevr.skeleton.Skeleton +import dev.slimevr.skeleton.buildBones +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import solarxr_protocol.datatypes.BodyPart + +class VMCOutputBehaviour( + private val skeleton: Skeleton, + private val settings: Settings, +) : VMCBehaviourType { + private val initTime = System.currentTimeMillis() + + override fun reduce(state: VMCState, action: VMCActions) = when (action) { + is VMCActions.UpdateConfig -> state.copy(config = action.config) + } + + override fun observe(receiver: VMCManager) { + var sender: OscSender? = null + + receiver.context.state + .map { it.config } + .distinctUntilChanged() + .onEach { config -> + settings.context.dispatch(SettingsActions.Update { copy(vmcConfig = config) }) + }.launchIn(receiver.context.scope) + + receiver.context.state + .map { Triple(it.config.enabled, it.config.portOut, it.config.address) } + .distinctUntilChanged() + .onEach { (enabled, port, addr) -> + sender?.close() + sender = null + if (enabled) { + sender = OscSender(addr, port) + AppLogger.vmc.info("VMC output started: $addr:$port") + receiver.context.scope.launch { + try { + sender?.send(OscMessage("/VMC/Ext/Req", emptyList())) + } catch (e: Exception) { + AppLogger.vmc.error("Failed to send init Req", e) + } + } + } + }.launchIn(receiver.context.scope) + + skeleton.computed + .onEach { bones -> + val s = sender ?: return@onEach + val config = receiver.context.state.value.config + val currentTime = System.currentTimeMillis() + receiver.context.scope.launch { + try { + s.send(buildBundle(bones, config, currentTime)) + } catch (e: Exception) { + AppLogger.vmc.error("Failed to send VMC frame", e) + } + } + }.launchIn(receiver.context.scope) + } + + private fun buildBundle(bones: Map, config: VMCConfig, currentTime: Long): OscBundle = OscBundle(1L, buildMessages(bones, config, currentTime).map { OscContent.Message(it) }.toList()) + + // TODO: coordinate space conversion to VMC space + private fun boneMessage(address: String, name: String, pos: Vector3, rot: Quaternion): OscMessage = OscMessage( + address, + listOf( + OscArg.String(name), + OscArg.Float(pos.x), + OscArg.Float(pos.y), + OscArg.Float(pos.z), + OscArg.Float(rot.x), + OscArg.Float(rot.y), + OscArg.Float(rot.z), + OscArg.Float(rot.w), + ), + ) + + private fun buildMessages(bones: Map, config: VMCConfig, currentTime: Long): Sequence = sequence { + val hipPos = bones[BodyPart.HIP]?.headPosition ?: Vector3.NULL + val vmcBones = buildBones( + skeleton.context.state.value, + rootHead = hipPos, + hierarchy = iterateVMCHierarchy(), + tailDirections = BONE_TAIL_DIRECTIONS, + ) + + val time = (currentTime - initTime) / 1000f + yield(OscMessage("/VMC/Ext/T", listOf(OscArg.Float(time)))) + yield(OscMessage("/VMC/Ext/OK", listOf(OscArg.Int(1)))) + yield( + OscMessage( + "/VMC/Ext/Root/Pos", + listOf( + OscArg.String("root"), + OscArg.Float(0f), + OscArg.Float(0f), + OscArg.Float(0f), + OscArg.Float(0f), + OscArg.Float(0f), + OscArg.Float(0f), + OscArg.Float(1f), + ), + ), + ) + + // TODO: implement mirrorTracking + for ((bodyPart, unityName) in BODY_PART_TO_UNITY_BONE) { + val bone = vmcBones[bodyPart] ?: continue + yield(boneMessage("/VMC/Ext/Bone/Pos", unityName, bone.localHeadPosition, bone.localRotation)) + } + + val headBone = vmcBones[BodyPart.HEAD] + if (headBone != null) { + yield(boneMessage("/VMC/Ext/Hmd/Pos", "human://HEAD", headBone.localHeadPosition, headBone.localRotation)) + } + } +} + +class VMCInputBehaviour : Behaviour { + override fun reduce(state: VMCState, action: VMCActions) = when (action) { + is VMCActions.UpdateConfig -> state.copy(config = action.config) + } + + override fun observe(receiver: VMCManager) { + var oscReceiver: OscReceiver? = null + + receiver.context.state + .map { it.config.portIn } + .distinctUntilChanged() + .onEach { portIn -> + oscReceiver?.close() + oscReceiver = null + if (receiver.context.state.value.config.enabled) { + oscReceiver = OscReceiver(portIn) + AppLogger.vmc.info("VMC input listening on port $portIn") + receiver.context.scope.launch { + try { + oscReceiver?.listenBundles { bundle -> + for (content in bundle.contents) { + when (content) { + is OscContent.Message -> handleMessage(content.msg) + + is OscContent.Bundle -> content.bundle.contents.forEach { + if (it is OscContent.Message) handleMessage(it.msg) + } + } + } + } + } catch (e: Exception) { + AppLogger.vmc.error("VMC receiver error: ${e.message}", e) + } + } + } + }.launchIn(receiver.context.scope) + } + + private fun handleMessage(msg: OscMessage) { + when (msg.address) { + "/VMC/Ext/Bone/Pos" -> { /* TODO: Handle incoming bone rotation data */ } + "/VMC/Ext/Hmd/Pos" -> { /* TODO: Handle incoming HMD position + rotation */ } + "/VMC/Ext/Con/Pos" -> { /* TODO: Handle incoming controller position + rotation */ } + "/VMC/Ext/Tra/Pos" -> { /* TODO: Handle incoming tracker position + rotation */ } + "/VMC/Ext/Root/Pos" -> { /* TODO: Handle incoming root position/rotation offset */ } + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt new file mode 100644 index 0000000000..285d4ebfd4 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt @@ -0,0 +1,52 @@ +package dev.slimevr.vmc + +import solarxr_protocol.datatypes.BodyPart + +val BODY_PART_TO_UNITY_BONE: Map = mapOf( + BodyPart.HEAD to "Head", + BodyPart.NECK to "Neck", + BodyPart.UPPER_CHEST to "UpperChest", + BodyPart.CHEST to "Chest", + BodyPart.WAIST to "Spine", + BodyPart.HIP to "Hips", + BodyPart.LEFT_SHOULDER to "LeftShoulder", + BodyPart.RIGHT_SHOULDER to "RightShoulder", + BodyPart.LEFT_UPPER_ARM to "LeftUpperArm", + BodyPart.RIGHT_UPPER_ARM to "RightUpperArm", + BodyPart.LEFT_LOWER_ARM to "LeftLowerArm", + BodyPart.RIGHT_LOWER_ARM to "RightLowerArm", + BodyPart.LEFT_HAND to "LeftHand", + BodyPart.RIGHT_HAND to "RightHand", + BodyPart.LEFT_UPPER_LEG to "LeftUpperLeg", + BodyPart.RIGHT_UPPER_LEG to "RightUpperLeg", + BodyPart.LEFT_LOWER_LEG to "LeftLowerLeg", + BodyPart.RIGHT_LOWER_LEG to "RightLowerLeg", + BodyPart.LEFT_FOOT to "LeftFoot", + BodyPart.RIGHT_FOOT to "RightFoot", +) + +// HIP-rooted hierarchy for VMC. The new skeleton is HEAD-rooted but VMC/Unity expects HIP-rooted. +val VMC_HIERARCHY_MAP: Map> = mapOf( + BodyPart.HIP to arrayOf(BodyPart.WAIST, BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG), + BodyPart.WAIST to arrayOf(BodyPart.CHEST), + BodyPart.CHEST to arrayOf(BodyPart.UPPER_CHEST), + BodyPart.UPPER_CHEST to arrayOf(BodyPart.NECK), + BodyPart.NECK to arrayOf(BodyPart.HEAD, BodyPart.LEFT_SHOULDER, BodyPart.RIGHT_SHOULDER), + BodyPart.LEFT_UPPER_LEG to arrayOf(BodyPart.LEFT_LOWER_LEG), + BodyPart.RIGHT_UPPER_LEG to arrayOf(BodyPart.RIGHT_LOWER_LEG), + BodyPart.LEFT_LOWER_LEG to arrayOf(BodyPart.LEFT_FOOT), + BodyPart.RIGHT_LOWER_LEG to arrayOf(BodyPart.RIGHT_FOOT), + BodyPart.LEFT_SHOULDER to arrayOf(BodyPart.LEFT_UPPER_ARM), + BodyPart.RIGHT_SHOULDER to arrayOf(BodyPart.RIGHT_UPPER_ARM), + BodyPart.LEFT_UPPER_ARM to arrayOf(BodyPart.LEFT_LOWER_ARM), + BodyPart.RIGHT_UPPER_ARM to arrayOf(BodyPart.RIGHT_LOWER_ARM), + BodyPart.LEFT_LOWER_ARM to arrayOf(BodyPart.LEFT_HAND), + BodyPart.RIGHT_LOWER_ARM to arrayOf(BodyPart.RIGHT_HAND), +) + +private suspend fun SequenceScope>.visitVMC(parent: BodyPart?, bone: BodyPart) { + yield(parent to bone) + VMC_HIERARCHY_MAP[bone]?.forEach { visitVMC(bone, it) } +} + +fun iterateVMCHierarchy() = sequence { visitVMC(null, BodyPart.HIP) } diff --git a/server/core/src/main/java/dev/slimevr/vmc/module.kt b/server/core/src/main/java/dev/slimevr/vmc/module.kt new file mode 100644 index 0000000000..6e8a1b9d3b --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vmc/module.kt @@ -0,0 +1,46 @@ +package dev.slimevr.vmc + +import dev.slimevr.Phase1ContextProvider +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context +import dev.slimevr.skeleton.Skeleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.Serializable + +@Serializable +data class VMCConfig( + val enabled: Boolean = true, + val portOut: Int = 39539, + val portIn: Int = 39540, + val address: String = "172.17.0.1", + val mirrorTracking: Boolean = false, +) + +data class VMCState(val config: VMCConfig = VMCConfig()) + +sealed interface VMCActions { + data class UpdateConfig(val config: VMCConfig) : VMCActions +} + +typealias VMCContext = Context +typealias VMCBehaviourType = Behaviour + +class VMCManager(val context: VMCContext) { + fun startObserving() = context.observeAll(this) + + companion object { + fun create(skeleton: Skeleton, ctx: Phase1ContextProvider, scope: CoroutineScope): VMCManager { + val settings = ctx.config.settings + val context = Context.create( + initialState = VMCState(config = settings.context.state.value.data.vmcConfig), + scope = scope, + behaviours = listOf( + VMCOutputBehaviour(skeleton, settings), + VMCInputBehaviour(), + ), + name = "VMC", + ) + return VMCManager(context) + } + } +} 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 19c319a6bd..aa1aa411a9 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -21,7 +21,7 @@ import dev.slimevr.resolveConfigDirectory import dev.slimevr.skeleton.Skeleton import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer -import kotlinx.coroutines.Dispatchers +import dev.slimevr.vmc.VMCManager import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -42,6 +42,7 @@ fun main(args: Array) = runBlocking { val trackingChecklist = TrackingChecklist.create(scope = this) val udpServer = UdpServer.create(scope = this) val bvhManager = BVHManager.create(skeleton = skeleton, scope = this) + val vmcManager = VMCManager.create(skeleton = skeleton, ctx = phase1, scope = this) val appContext = AppContext( server = server, @@ -56,6 +57,7 @@ fun main(args: Array) = runBlocking { trackingChecklist = trackingChecklist, udpServer = udpServer, bvhManager = bvhManager, + vmcManager = vmcManager, ) appContext.startObserving() diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt b/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt index 69eb7dc037..4e88a20f29 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds +@Suppress("ktlint:standard:function-naming") interface Iphlpapi : Library { fun NotifyIpInterfaceChange( family: Int, From 3749b77c751a70a160e89a49a8b6ff5a3a0d8398 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 5 May 2026 17:40:25 +0200 Subject: [PATCH 087/350] Diable VMC by default --- server/core/src/main/java/dev/slimevr/vmc/module.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/vmc/module.kt b/server/core/src/main/java/dev/slimevr/vmc/module.kt index 6e8a1b9d3b..a2d9d42873 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/module.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/module.kt @@ -9,7 +9,7 @@ import kotlinx.serialization.Serializable @Serializable data class VMCConfig( - val enabled: Boolean = true, + val enabled: Boolean = false, val portOut: Int = 39539, val portIn: Int = 39540, val address: String = "172.17.0.1", From 6fe10bf71d6289d85521fc564b1556867f2c5a83 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Wed, 6 May 2026 04:43:20 -0400 Subject: [PATCH 088/350] Spotless format --- .../main/java/dev/slimevr/osc/transport.kt | 2 +- .../desktop/networkprofile/network-profile.kt | 74 ++++++++++--------- 2 files changed, 41 insertions(+), 35 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/osc/transport.kt b/server/core/src/main/java/dev/slimevr/osc/transport.kt index 1e90b13efe..dba70e8538 100644 --- a/server/core/src/main/java/dev/slimevr/osc/transport.kt +++ b/server/core/src/main/java/dev/slimevr/osc/transport.kt @@ -63,4 +63,4 @@ class OscReceiver(private val port: Int) { socket?.close() socket = null } -} \ No newline at end of file +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt b/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt index 4e88a20f29..b4b0b58bba 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt @@ -45,7 +45,9 @@ interface Iphlpapi : Library { } @Suppress("ktlint:standard:backing-property-naming") -private class INetworkConnection(instance: Pointer?) : Dispatch(instance), AutoCloseable { +private class INetworkConnection(instance: Pointer?) : + Dispatch(instance), + AutoCloseable { override fun close() { Release() } @@ -58,7 +60,9 @@ private class INetworkConnection(instance: Pointer?) : Dispatch(instance), AutoC } @Suppress("ktlint:standard:backing-property-naming") -private class IEnumNetworkConnections(instance: Pointer?) : Dispatch(instance), AutoCloseable { +private class IEnumNetworkConnections(instance: Pointer?) : + Dispatch(instance), + AutoCloseable { override fun close() { Release() } @@ -72,7 +76,9 @@ private class IEnumNetworkConnections(instance: Pointer?) : Dispatch(instance), } @Suppress("ktlint:standard:backing-property-naming") -private class INetworkListManager(instance: Pointer?) : Dispatch(instance), AutoCloseable { +private class INetworkListManager(instance: Pointer?) : + Dispatch(instance), + AutoCloseable { override fun close() { Release() } @@ -85,7 +91,9 @@ private class INetworkListManager(instance: Pointer?) : Dispatch(instance), Auto } @Suppress("ktlint:standard:backing-property-naming") -private class INetwork(instance: Pointer?) : Dispatch(instance), AutoCloseable { +private class INetwork(instance: Pointer?) : + Dispatch(instance), + AutoCloseable { override fun close() { Release() } @@ -132,37 +140,35 @@ private class INetwork(instance: Pointer?) : Dispatch(instance), AutoCloseable { } } -private fun enumerateNetworks(): List { - return try { - Ole32.INSTANCE.CoInitializeEx(null, 0) - val clsid = CLSID("dcb00c01-570f-4a9b-8d69-199fdba5723b") - val iid = IID("dcb00000-570f-4a9b-8d69-199fdba5723b") - val ptr = PointerByReference() - COMUtils.checkRC(Ole32.INSTANCE.CoCreateInstance(clsid, null, WTypes.CLSCTX_ALL, iid, ptr)) - val mgr = INetworkListManager(ptr.value) - val result = mgr.getNetworkConnections().use { conns -> - generateSequence { conns.next() } - .map { conn -> - conn.use { - conn.getNetwork().use { network -> - NetworkInfo( - network.getName(), - network.getDescription(), - network.getCategory(), - network.getConnectivity(), - network.isConnected(), - ) - } +private fun enumerateNetworks(): List = try { + Ole32.INSTANCE.CoInitializeEx(null, 0) + val clsid = CLSID("dcb00c01-570f-4a9b-8d69-199fdba5723b") + val iid = IID("dcb00000-570f-4a9b-8d69-199fdba5723b") + val ptr = PointerByReference() + COMUtils.checkRC(Ole32.INSTANCE.CoCreateInstance(clsid, null, WTypes.CLSCTX_ALL, iid, ptr)) + val mgr = INetworkListManager(ptr.value) + val result = mgr.getNetworkConnections().use { conns -> + generateSequence { conns.next() } + .map { conn -> + conn.use { + conn.getNetwork().use { network -> + NetworkInfo( + network.getName(), + network.getDescription(), + network.getCategory(), + network.getConnectivity(), + network.isConnected(), + ) } - }.toList() - } - mgr.close() - Ole32.INSTANCE.CoUninitialize() - result - } catch (e: Exception) { - println(e.stackTraceToString()) - emptyList() - } + } + }.toList() + } + mgr.close() + Ole32.INSTANCE.CoUninitialize() + result +} catch (e: Exception) { + println(e.stackTraceToString()) + emptyList() } suspend fun setupDesktopNetworkProfileChecker(scope: CoroutineScope, manager: NetworkProfileManager) { From 7ddb3e74e632d953033fcd2b52a14996a90393af Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Wed, 6 May 2026 04:57:00 -0400 Subject: [PATCH 089/350] More dynamic body proportions --- .../java/dev/slimevr/skeleton/behaviours.kt | 11 +- .../dev/slimevr/skeleton/bodypartstructure.kt | 12 +- .../java/dev/slimevr/skeleton/bonegeometry.kt | 69 +------- .../main/java/dev/slimevr/skeleton/module.kt | 7 +- .../java/dev/slimevr/skeleton/proportions.kt | 160 ++++++++++++------ .../dev/slimevr/solarxr/skeletonconfig.kt | 10 +- .../main/java/dev/slimevr/solarxr/vrchat.kt | 2 +- .../slimevr/trackingchecklist/behaviours.kt | 2 +- .../main/java/dev/slimevr/vrchat/module.kt | 4 +- 9 files changed, 141 insertions(+), 136 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 74640127cb..245a209879 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -36,10 +36,11 @@ class BoneTransformBehaviour : SkeletonBehaviour { class ProportionsBehaviour : SkeletonBehaviour { override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { is SkeletonActions.SetProportions -> { + val bones = action.lengths.toBodyPartOffsets() val newBones = state.rawBones.mapValues { (bodyPart, bone) -> - bone.copy(length = action.lengths[bodyPart] ?: bone.length) + bone.copy(length = bones[bodyPart]?.len() ?: bone.length) } - state.copy(rawBones = newBones, userHeight = computeUserHeight(newBones.mapValues { (_, bone) -> bone.length })) + state.copy(rawBones = newBones, userHeight = action.lengths.height()) } else -> state @@ -53,7 +54,7 @@ class ScaledProportionsBehaviour(private val userConfig: UserConfig) : SkeletonB .distinctUntilChanged() .onEach { proportions -> if (proportions.isNotEmpty()) { - receiver.context.dispatch(SkeletonActions.SetProportions(expandProportions(proportions))) + receiver.context.dispatch(SkeletonActions.SetProportions(configToSkeletonBoneValues(proportions))) } } .launchIn(receiver.context.scope) @@ -101,7 +102,7 @@ class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour receiver.context.dispatch( SkeletonActions.SetBonePosition( BodyPart.HEAD, - Vector3(circleX, state.userHeight.toFloat() + jumpHeight, circleZ), + Vector3(circleX, state.userHeight + jumpHeight, circleZ), ), ) } @@ -122,7 +123,7 @@ class ComputedSkeletonBehaviour( val processed = processors .filter { processor -> processor.enabled } .fold(targetState) { state, processor -> processor.process(state) } - val rootHead = Vector3(0f, targetState.userHeight.toFloat(), 0f) // FIXME WRONG + val rootHead = Vector3(0f, targetState.userHeight, 0f) // FIXME WRONG receiver.computed.value = buildBones(processed, rootHead = rootHead) } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt b/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt index efbfb8a986..8399e399ed 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt @@ -64,12 +64,14 @@ val BODY_PART_HIERARCHY_MAP = mapOf( BodyPart.RIGHT_LOWER_LEG to arrayOf(BodyPart.RIGHT_FOOT), ) -private suspend fun SequenceScope>.visitBodyPart(parentBone: BodyPart?, bone: BodyPart) { - yield(Pair(parentBone, bone)) +private suspend fun SequenceScope>.visitBodyPart(parentBone: BodyPart?, bone: BodyPart, onlyChildren: Boolean) { + if (!onlyChildren) { + yield(Pair(parentBone, bone)) + } val children = BODY_PART_HIERARCHY_MAP[bone] ?: return - for (child in children) visitBodyPart(bone, child) + for (child in children) visitBodyPart(bone, child, false) } -fun iterateBodyPartHierarchy() = sequence { - visitBodyPart(null, BodyPart.HEAD) +fun iterateBodyPartHierarchy(root: BodyPart = BodyPart.HEAD, onlyChildren: Boolean = false) = sequence { + visitBodyPart(null, root, onlyChildren) } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt b/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt index d8b1eea01a..48ddefa387 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt @@ -1,70 +1,13 @@ package dev.slimevr.skeleton -import com.jme3.math.FastMath -import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.BodyPart -internal val BONE_TAIL_OFFSETS: Map = run { - val offsets = mutableMapOf() - iterateBodyPartHierarchy().forEach { (_, child) -> - val length = when (child) { - BodyPart.HEAD, BodyPart.NECK -> 0.1f +private val FINGER_OFFSETS = ( + iterateBodyPartHierarchy(BodyPart.LEFT_HAND, true) + + iterateBodyPartHierarchy(BodyPart.RIGHT_HAND, true) + ).map { it.second }.associateWith { Vector3(0f, -0.025f, 0f) } - BodyPart.LEFT_SHOULDER, BodyPart.RIGHT_SHOULDER -> 0.175f +val BONE_TAIL_OFFSETS: Map = DEFAULT_PROPORTIONS.toBodyPartOffsets() + FINGER_OFFSETS - BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM, - BodyPart.LEFT_LOWER_ARM, BodyPart.RIGHT_LOWER_ARM, - -> 0.26f - - BodyPart.LEFT_HAND, BodyPart.RIGHT_HAND -> 0.13f - - BodyPart.LEFT_THUMB_METACARPAL, BodyPart.LEFT_THUMB_PROXIMAL, BodyPart.LEFT_THUMB_DISTAL, - BodyPart.LEFT_INDEX_DISTAL, BodyPart.LEFT_INDEX_INTERMEDIATE, BodyPart.LEFT_INDEX_PROXIMAL, - BodyPart.LEFT_MIDDLE_DISTAL, BodyPart.LEFT_MIDDLE_INTERMEDIATE, BodyPart.LEFT_MIDDLE_PROXIMAL, - BodyPart.LEFT_RING_DISTAL, BodyPart.LEFT_RING_INTERMEDIATE, BodyPart.LEFT_RING_PROXIMAL, - BodyPart.LEFT_LITTLE_DISTAL, BodyPart.LEFT_LITTLE_INTERMEDIATE, BodyPart.LEFT_LITTLE_PROXIMAL, - - BodyPart.RIGHT_THUMB_METACARPAL, BodyPart.RIGHT_THUMB_PROXIMAL, BodyPart.RIGHT_THUMB_DISTAL, - BodyPart.RIGHT_INDEX_PROXIMAL, BodyPart.RIGHT_INDEX_INTERMEDIATE, BodyPart.RIGHT_INDEX_DISTAL, - BodyPart.RIGHT_MIDDLE_PROXIMAL, BodyPart.RIGHT_MIDDLE_INTERMEDIATE, BodyPart.RIGHT_MIDDLE_DISTAL, - BodyPart.RIGHT_RING_PROXIMAL, BodyPart.RIGHT_RING_INTERMEDIATE, BodyPart.RIGHT_RING_DISTAL, - BodyPart.RIGHT_LITTLE_PROXIMAL, BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_DISTAL, - -> 0.025f - - BodyPart.UPPER_CHEST, BodyPart.CHEST -> 0.16f - - BodyPart.WAIST -> 0.2f - - BodyPart.HIP -> 0.04f - - BodyPart.LEFT_HIP, BodyPart.RIGHT_HIP -> 0.13f - - BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG -> 0.42f - - BodyPart.LEFT_LOWER_LEG, BodyPart.RIGHT_LOWER_LEG -> 0.5f - - BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> 0.05f - - else -> 0.1f - } - val restRotation = when (child) { - BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> Quaternion.rotationAroundXAxis(FastMath.HALF_PI) - else -> Quaternion.IDENTITY - } - val offset = when (child) { - BodyPart.HEAD -> Vector3(0f, 0f, length) - BodyPart.LEFT_HAND, BodyPart.RIGHT_HAND -> Vector3(0f, 0f, -length) - BodyPart.LEFT_SHOULDER -> Vector3(-length, -0.08f, 0f) - BodyPart.RIGHT_SHOULDER -> Vector3(length, -0.08f, 0f) - BodyPart.LEFT_HIP -> Vector3(-length, 0f, 0f) - BodyPart.RIGHT_HIP -> Vector3(length, 0f, 0f) - else -> Vector3(0f, -length, 0f) - } - offsets[child] = restRotation.inv().sandwich(offset) - } - offsets -} - -internal val BONE_TAIL_DIRECTIONS: Map = - BONE_TAIL_OFFSETS.mapValues { (_, offset) -> offset.unit() } +val BONE_TAIL_DIRECTIONS: Map = BONE_TAIL_OFFSETS.mapValues { (_, vec) -> vec.unit() } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index ad83f9ab0c..afa8557163 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -9,6 +9,7 @@ import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.SkeletonBone data class RawBone( val bodyPart: BodyPart, @@ -33,7 +34,7 @@ data class BoneState( // get() = tailPosition - headPosition } -data class SkeletonState(val rawBones: Map, val userHeight: Double) +data class SkeletonState(val rawBones: Map, val userHeight: Float) val DEFAULT_SKELETON_STATE: SkeletonState = run { val bones = BONE_TAIL_OFFSETS.entries.associate { (bodyPart, tailOffset) -> @@ -43,7 +44,7 @@ val DEFAULT_SKELETON_STATE: SkeletonState = run { } bodyPart to BoneState(bodyPart = bodyPart, length = tailOffset.len(), rotation = restRotation) } - SkeletonState(rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, length = bone.length, rawPosition = Vector3.NULL) }, userHeight = computeUserHeight(bones.mapValues { (_, bone) -> bone.length })) + SkeletonState(rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, length = bone.length, rawPosition = Vector3.NULL) }, userHeight = DEFAULT_HEIGHT) } fun buildBones( @@ -73,7 +74,7 @@ fun buildBones( sealed interface SkeletonActions { data class SetBoneRotation(val bodyPart: BodyPart, val rotation: Quaternion) : SkeletonActions data class SetBonePosition(val bodyPart: BodyPart, val position: Vector3) : SkeletonActions - data class SetProportions(val lengths: Map) : SkeletonActions + data class SetProportions(val lengths: Map) : SkeletonActions } typealias SkeletonContext = Context diff --git a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt index 5c032a5075..61375c6362 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt @@ -1,76 +1,134 @@ package dev.slimevr.skeleton +import io.github.axisangles.ktmath.Vector3 +import io.github.axisangles.ktmath.times import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.SkeletonBone +import kotlin.collections.map +import kotlin.collections.plus + +// Placeholder, move this to config defaults somehow +val DEFAULT_PROPORTIONS = mapOf( + SkeletonBone.HEAD to 0.1f, + SkeletonBone.NECK to 0.1f, + SkeletonBone.UPPER_CHEST to 0.16f, + SkeletonBone.CHEST to 0.16f, + SkeletonBone.CHEST_OFFSET to 0f, + SkeletonBone.WAIST to 0.2f, + SkeletonBone.HIP to 0.04f, + SkeletonBone.HIP_OFFSET to 0f, + SkeletonBone.HIPS_WIDTH to 0.26f, + SkeletonBone.UPPER_LEG to 0.42f, + SkeletonBone.LOWER_LEG to 0.5f, + SkeletonBone.FOOT_LENGTH to 0.05f, + SkeletonBone.FOOT_SHIFT to -0.05f, + SkeletonBone.SKELETON_OFFSET to 0f, + SkeletonBone.SHOULDERS_DISTANCE to 0.08f, + SkeletonBone.SHOULDERS_WIDTH to 0.35f, + SkeletonBone.UPPER_ARM to 0.26f, + SkeletonBone.LOWER_ARM to 0.26f, + SkeletonBone.HAND_Y to 0.035f, + SkeletonBone.HAND_Z to 0.13f, + SkeletonBone.ELBOW_OFFSET to 0f, +) // Sum of default bone lengths for height-contributing bones // Used to normalize HEIGHT_SCALED_BONE_RATIOS. -private const val DEFAULT_HEIGHT = 0.1f + 0.16f + 0.16f + 0.20f + 0.04f + 0.42f + 0.50f // = 1.58f +val DEFAULT_HEIGHT = DEFAULT_PROPORTIONS.height() // = 1.58f -// Maps each solarxr SkeletonBone to the BodyPart(s) it controls in the skeleton. -// Symmetric bones (legs, arms) map to both left and right sides. -val SKELETON_BONE_TO_BODY_PARTS: Map> = mapOf( - SkeletonBone.HEAD to listOf(BodyPart.HEAD), - SkeletonBone.NECK to listOf(BodyPart.NECK), - SkeletonBone.UPPER_CHEST to listOf(BodyPart.UPPER_CHEST), - SkeletonBone.CHEST to listOf(BodyPart.CHEST), - SkeletonBone.WAIST to listOf(BodyPart.WAIST), - SkeletonBone.HIP to listOf(BodyPart.HIP), - SkeletonBone.UPPER_LEG to listOf(BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG), - SkeletonBone.LOWER_LEG to listOf(BodyPart.LEFT_LOWER_LEG, BodyPart.RIGHT_LOWER_LEG), - SkeletonBone.FOOT_LENGTH to listOf(BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT), - SkeletonBone.UPPER_ARM to listOf(BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM), - SkeletonBone.LOWER_ARM to listOf(BodyPart.LEFT_LOWER_ARM, BodyPart.RIGHT_LOWER_ARM), +// Set of SkeletonBones whose lengths sum to standing height (spine + legs). +// Arms are excluded: they scale with height but are not part of the height measurement. +private val HEIGHT_CONTRIBUTING_BONES: Set = setOf( + SkeletonBone.NECK, + SkeletonBone.UPPER_CHEST, + SkeletonBone.CHEST, + SkeletonBone.WAIST, + SkeletonBone.HIP, + SkeletonBone.UPPER_LEG, + SkeletonBone.LOWER_LEG, ) -// Per-bone fraction of total standing height, -// Includes spine, legs, and arms, all bones whose length scales with user height. +// Per-bone fraction of total standing height, includes spine, legs, and arms, all bones +// whose length scales with user height. // Non-height bones (HEAD, FOOT_LENGTH) are absent; they keep fixed defaults from DEFAULT_SKELETON_STATE. -private val HEIGHT_SCALED_BONE_RATIOS: Map = mapOf( - SkeletonBone.NECK to 0.1f / DEFAULT_HEIGHT, - SkeletonBone.UPPER_CHEST to 0.16f / DEFAULT_HEIGHT, - SkeletonBone.CHEST to 0.16f / DEFAULT_HEIGHT, - SkeletonBone.WAIST to 0.20f / DEFAULT_HEIGHT, - SkeletonBone.HIP to 0.04f / DEFAULT_HEIGHT, - SkeletonBone.UPPER_LEG to 0.42f / DEFAULT_HEIGHT, - SkeletonBone.LOWER_LEG to 0.50f / DEFAULT_HEIGHT, - SkeletonBone.UPPER_ARM to 0.26f / DEFAULT_HEIGHT, - SkeletonBone.LOWER_ARM to 0.26f / DEFAULT_HEIGHT, +private val HEIGHT_SCALED_BONE_RATIOS: Map = ( + HEIGHT_CONTRIBUTING_BONES + setOf(SkeletonBone.UPPER_ARM, SkeletonBone.LOWER_ARM) + ).associateWith { (DEFAULT_PROPORTIONS[it] ?: 0f) / DEFAULT_HEIGHT } + +// Maps each SolarXR SkeletonBone to the BodyPart(s) it controls in the skeleton with vectors for offset directions. +private val SKELETON_BONE_FLOAT_TO_BODY_PARTS: Map> = mapOf( + SkeletonBone.HEAD to mapOf(BodyPart.HEAD to Vector3.POS_Z), + SkeletonBone.NECK to mapOf(BodyPart.NECK to Vector3.NEG_Y), + SkeletonBone.UPPER_CHEST to mapOf(BodyPart.UPPER_CHEST to Vector3.NEG_Y), + SkeletonBone.CHEST to mapOf(BodyPart.CHEST to Vector3.NEG_Y), + SkeletonBone.CHEST_OFFSET to mapOf(), + SkeletonBone.WAIST to mapOf(BodyPart.WAIST to Vector3.NEG_Y), + SkeletonBone.HIP to mapOf(BodyPart.HIP to Vector3.NEG_Y), + SkeletonBone.HIP_OFFSET to mapOf(), + SkeletonBone.HIPS_WIDTH to mapOf(BodyPart.LEFT_HIP to Vector3.NEG_X / 2f, BodyPart.RIGHT_HIP to Vector3.POS_X / 2f), + SkeletonBone.UPPER_LEG to mapOf(BodyPart.LEFT_UPPER_LEG to Vector3.NEG_Y, BodyPart.RIGHT_UPPER_LEG to Vector3.NEG_Y), + SkeletonBone.LOWER_LEG to mapOf(BodyPart.LEFT_LOWER_LEG to Vector3.NEG_Y, BodyPart.RIGHT_LOWER_LEG to Vector3.NEG_Y), + SkeletonBone.FOOT_LENGTH to mapOf(BodyPart.LEFT_FOOT to Vector3.NEG_Z, BodyPart.RIGHT_FOOT to Vector3.NEG_Z), + SkeletonBone.FOOT_SHIFT to mapOf(BodyPart.LEFT_LOWER_LEG to Vector3.NEG_Z, BodyPart.RIGHT_LOWER_LEG to Vector3.NEG_Z), + SkeletonBone.SKELETON_OFFSET to mapOf(), + SkeletonBone.SHOULDERS_DISTANCE to mapOf(BodyPart.LEFT_SHOULDER to Vector3.NEG_Y, BodyPart.RIGHT_SHOULDER to Vector3.NEG_Y), + SkeletonBone.SHOULDERS_WIDTH to mapOf(BodyPart.LEFT_SHOULDER to Vector3.NEG_X / 2f, BodyPart.RIGHT_SHOULDER to Vector3.POS_X / 2f), + SkeletonBone.UPPER_ARM to mapOf(BodyPart.LEFT_UPPER_ARM to Vector3.NEG_Y, BodyPart.RIGHT_UPPER_ARM to Vector3.NEG_Y), + SkeletonBone.LOWER_ARM to mapOf(BodyPart.LEFT_LOWER_ARM to Vector3.NEG_Y, BodyPart.RIGHT_LOWER_ARM to Vector3.NEG_Y), + SkeletonBone.HAND_Y to mapOf(BodyPart.LEFT_HAND to Vector3.NEG_Y, BodyPart.RIGHT_HAND to Vector3.NEG_Y), + SkeletonBone.HAND_Z to mapOf(BodyPart.LEFT_HAND to Vector3.NEG_Z, BodyPart.RIGHT_HAND to Vector3.NEG_Z), + SkeletonBone.ELBOW_OFFSET to mapOf(), ) -// Subset of HEIGHT_SCALED_BONE_RATIOS whose lengths actually sum to standing height (spine + legs). -// Arms are excluded: they scale with height but are not part of the height measurement. -// For symmetric bones (UPPER_LEG, LOWER_LEG) computeUserHeight takes the larger side. -private val HEIGHT_CONTRIBUTING_BONES: Set = - HEIGHT_SCALED_BONE_RATIOS.keys - setOf(SkeletonBone.UPPER_ARM, SkeletonBone.LOWER_ARM) +private val BODY_PART_VECTOR_TO_SKELETON_BONES: Map> = SKELETON_BONE_FLOAT_TO_BODY_PARTS + .flatMap { (cfg, bones) -> + // Invert map, splitting entries [ List>> ] + // Vector also needs to be inverted ((vec/len)/len)==(1/vec) + bones.map { (bone, vec) -> bone to (cfg to vec / vec.lenSq()) } + } + // Merge entries, creating a map again [ Map>> ] + .groupBy({ it.first }, { it.second }) + // Transform the values into maps [ Map> ] + .mapValues { it.value.toMap() } + +// Maps each SolarXR SkeletonBone to the BodyPart(s) it controls in the skeleton. +// Symmetric bones (legs, arms) map to both left and right sides. +val SKELETON_BONE_TO_BODY_PARTS: Map> = SKELETON_BONE_FLOAT_TO_BODY_PARTS.mapValues { it.value.keys } // Sums the HEIGHT_CONTRIBUTING_BONES lengths to derive standing height. -// For symmetric bones (legs) the larger side is used. -fun computeUserHeight(lengths: Map): Double = HEIGHT_CONTRIBUTING_BONES.sumOf { bone -> - val bodyParts = SKELETON_BONE_TO_BODY_PARTS[bone] ?: return@sumOf 0.0 - bodyParts.maxOfOrNull { lengths[it] ?: 0f }?.toDouble() ?: 0.0 -} +fun Map.height(): Float = HEIGHT_CONTRIBUTING_BONES.sumOf { bone -> + this[bone]?.toDouble() ?: 0.0 +}.toFloat() // Returns proportions keyed by SkeletonBone.name for config storage. // Only height-scaled bones are included. -fun computeDefaultProportionsByBone(height: Float): Map = HEIGHT_SCALED_BONE_RATIOS.mapKeys { (bone, _) -> bone.name } +fun computeDefaultProportionsByBone(height: Float): Map = HEIGHT_SCALED_BONE_RATIOS + .mapKeys { (bone, _) -> bone.name } .mapValues { (_, ratio) -> height * ratio } // Returns proportions for all tracked bones: height-scaled + default lengths for the rest. fun computeAllDefaultProportionsByBone(height: Float): Map { + val nonScaled = DEFAULT_PROPORTIONS.mapKeys { (bone, _) -> bone.name } val heightScaled = computeDefaultProportionsByBone(height) - val nonScaled = SKELETON_BONE_TO_BODY_PARTS - .filter { (bone, _) -> bone !in HEIGHT_SCALED_BONE_RATIOS } - .mapNotNull { (bone, bodyParts) -> - val length = bodyParts - .mapNotNull { DEFAULT_SKELETON_STATE.rawBones[it]?.length } - .average().takeIf { !it.isNaN() }?.toFloat() ?: return@mapNotNull null - bone.name to length - }.toMap() - return heightScaled + nonScaled + return nonScaled + heightScaled +} + +fun Map.toBodyPartOffsets(): Map = this + .flatMap { (cfg, length) -> + SKELETON_BONE_FLOAT_TO_BODY_PARTS[cfg]?.map { (bone, vec) -> bone to length * vec } ?: emptyList() + } + .groupBy({ it.first }, { it.second }) + .mapValues { it.value.fold(Vector3.NULL) { acc, value -> acc + value } } + +fun Map.toSkeletonBoneValues(): Map = this + .flatMap { (bone, vec) -> + BODY_PART_VECTOR_TO_SKELETON_BONES[bone]?.map { (cfg, cfgVec) -> cfg to vec.hadamard(cfgVec).len() } ?: emptyList() + } + .groupBy({ it.first }, { it.second }) + .mapValues { it.value.sum() } + +fun configToSkeletonBoneValues(proportions: Map): Map = proportions.mapKeys { + SkeletonBone.entries.firstOrNull { cfg -> cfg.name == it.key } ?: SkeletonBone.NONE } -fun expandProportions(proportions: Map): Map = proportions.entries.flatMap { (boneName, length) -> - val bone = SkeletonBone.entries.firstOrNull { it.name == boneName } ?: return@flatMap emptyList() - (SKELETON_BONE_TO_BODY_PARTS[bone] ?: return@flatMap emptyList()).map { bodyPart -> bodyPart to length } -}.toMap() +fun expandProportions(proportions: Map): Map = configToSkeletonBoneValues(proportions).toBodyPartOffsets() diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt index d7ff9c1b0b..b5e808c030 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt @@ -6,8 +6,8 @@ import dev.slimevr.skeleton.SKELETON_BONE_TO_BODY_PARTS import dev.slimevr.skeleton.Skeleton import dev.slimevr.skeleton.computeAllDefaultProportionsByBone import dev.slimevr.skeleton.computeDefaultProportionsByBone -import dev.slimevr.skeleton.computeUserHeight -import dev.slimevr.skeleton.expandProportions +import dev.slimevr.skeleton.configToSkeletonBoneValues +import dev.slimevr.skeleton.height import solarxr_protocol.rpc.ChangeSettingsRequest import solarxr_protocol.rpc.ChangeSkeletonConfigRequest import solarxr_protocol.rpc.SkeletonConfigRequest @@ -31,11 +31,11 @@ class SkeletonBehaviour( ?: return@mapNotNull null SkeletonPart(bone = skeletonBone, value = length) } - val expanded = expandProportions(proportions) + val expanded = configToSkeletonBoneValues(proportions) val userHeight = if (expanded.isNotEmpty()) { - computeUserHeight(expanded).toFloat() + expanded.height() } else { - skeleton.context.state.value.userHeight.toFloat() + skeleton.context.state.value.userHeight } return SkeletonConfigResponse(skeletonParts = skeletonParts, userHeight = userHeight) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt index b6766ec5d6..b50dc98b0f 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt @@ -15,7 +15,7 @@ import solarxr_protocol.rpc.VRCConfigStateRequest class VrcBehaviour( private val vrcManager: VRCConfigManager, private val server: VRServer, - private val userHeight: () -> Double, + private val userHeight: () -> Float, ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { fun buildCurrentResponse(): VRCConfigStateChangeResponse { diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index 4d46b3cc42..5e9fde2f72 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -159,7 +159,7 @@ class VRChatSettingsCheckBehaviour( private val skeleton: Skeleton, private val vrcConfigManager: VRCConfigManager, ) : TrackingChecklistBehaviourType { - private fun computeStep(vrc: VRCConfigState, userHeight: Double): TrackingChecklistStep { + private fun computeStep(vrc: VRCConfigState, userHeight: Float): TrackingChecklistStep { val values = vrc.currentValues if (!vrc.isSupported || values == null) return TrackingChecklistStep(valid = true, enabled = false) val recommended = computeRecommendedValues(server, userHeight) diff --git a/server/core/src/main/java/dev/slimevr/vrchat/module.kt b/server/core/src/main/java/dev/slimevr/vrchat/module.kt index f471f5fa5c..4c223e7a05 100644 --- a/server/core/src/main/java/dev/slimevr/vrchat/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrchat/module.kt @@ -77,7 +77,7 @@ class VRCConfigManager( } } -fun computeRecommendedValues(server: VRServer, userHeight: Double): VRCConfigRecommendedValues { +fun computeRecommendedValues(server: VRServer, userHeight: Float): VRCConfigRecommendedValues { val trackers = server.context.state.value.trackers.values fun hasTracker(bodyPart: BodyPart) = trackers.any { it.context.state.value.bodyPart == bodyPart } @@ -97,7 +97,7 @@ fun computeRecommendedValues(server: VRServer, userHeight: Double): VRCConfigRec shoulderTrackingDisabled = (!hasLeftHandWithPosition || !hasRightHandWithPosition || isMissingAnArmTracker) && ((hasLeftHandWithPosition && hasRightHandWithPosition) || isMissingAShoulderTracker), - userHeight = userHeight.toFloat(), + userHeight = userHeight, calibrationRange = 0.2f, trackerModel = VRCTrackerModel.AXIS, spineMode = listOf(VRCSpineMode.LOCK_HIP, VRCSpineMode.LOCK_HEAD), From 3ae788e8af67faf17ec2b4aba4915e479d1b3001 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Wed, 6 May 2026 05:04:43 -0400 Subject: [PATCH 090/350] Fix val order --- .../java/dev/slimevr/skeleton/proportions.kt | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt index 61375c6362..1d0602241d 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt @@ -32,10 +32,6 @@ val DEFAULT_PROPORTIONS = mapOf( SkeletonBone.ELBOW_OFFSET to 0f, ) -// Sum of default bone lengths for height-contributing bones -// Used to normalize HEIGHT_SCALED_BONE_RATIOS. -val DEFAULT_HEIGHT = DEFAULT_PROPORTIONS.height() // = 1.58f - // Set of SkeletonBones whose lengths sum to standing height (spine + legs). // Arms are excluded: they scale with height but are not part of the height measurement. private val HEIGHT_CONTRIBUTING_BONES: Set = setOf( @@ -48,13 +44,6 @@ private val HEIGHT_CONTRIBUTING_BONES: Set = setOf( SkeletonBone.LOWER_LEG, ) -// Per-bone fraction of total standing height, includes spine, legs, and arms, all bones -// whose length scales with user height. -// Non-height bones (HEAD, FOOT_LENGTH) are absent; they keep fixed defaults from DEFAULT_SKELETON_STATE. -private val HEIGHT_SCALED_BONE_RATIOS: Map = ( - HEIGHT_CONTRIBUTING_BONES + setOf(SkeletonBone.UPPER_ARM, SkeletonBone.LOWER_ARM) - ).associateWith { (DEFAULT_PROPORTIONS[it] ?: 0f) / DEFAULT_HEIGHT } - // Maps each SolarXR SkeletonBone to the BodyPart(s) it controls in the skeleton with vectors for offset directions. private val SKELETON_BONE_FLOAT_TO_BODY_PARTS: Map> = mapOf( SkeletonBone.HEAD to mapOf(BodyPart.HEAD to Vector3.POS_Z), @@ -95,6 +84,17 @@ private val BODY_PART_VECTOR_TO_SKELETON_BONES: Map> = SKELETON_BONE_FLOAT_TO_BODY_PARTS.mapValues { it.value.keys } +// Sum of default bone lengths for height-contributing bones +// Used to normalize HEIGHT_SCALED_BONE_RATIOS. +val DEFAULT_HEIGHT = DEFAULT_PROPORTIONS.height() // = 1.58f + +// Per-bone fraction of total standing height, includes spine, legs, and arms, all bones +// whose length scales with user height. +// Non-height bones (HEAD, FOOT_LENGTH) are absent; they keep fixed defaults from DEFAULT_SKELETON_STATE. +private val HEIGHT_SCALED_BONE_RATIOS: Map = ( + HEIGHT_CONTRIBUTING_BONES + setOf(SkeletonBone.UPPER_ARM, SkeletonBone.LOWER_ARM) + ).associateWith { (DEFAULT_PROPORTIONS[it] ?: 0f) / DEFAULT_HEIGHT } + // Sums the HEIGHT_CONTRIBUTING_BONES lengths to derive standing height. fun Map.height(): Float = HEIGHT_CONTRIBUTING_BONES.sumOf { bone -> this[bone]?.toDouble() ?: 0.0 From 6c7d6add672a82dc3cf7f9883c4358957e8aeb48 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 6 May 2026 14:26:45 +0200 Subject: [PATCH 091/350] Fix flake --- flake.lock | 33 +++++++++++++++++++++++++-------- flake.nix | 17 ++++++++++------- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/flake.lock b/flake.lock index 9be99d6b96..ab3ad00667 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1762980239, - "narHash": "sha256-8oNVE8TrD19ulHinjaqONf9QWCKK+w4url56cdStMpM=", + "lastModified": 1777988971, + "narHash": "sha256-qIoWPDs+0/8JecyYgE3gpKQxW/4bLW/gp45vow9ioCQ=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "52a2caecc898d0b46b2b905f058ccc5081f842da", + "rev": "0678d8986be1661af6bb555f3489f2fdfc31f6ff", "type": "github" }, "original": { @@ -19,6 +19,22 @@ } }, "nixpkgs": { + "locked": { + "lastModified": 1777954456, + "narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-jdk24": { "locked": { "lastModified": 1756787288, "narHash": "sha256-rw/PHa1cqiePdBxhF66V7R+WAP8WekQ0mCDG4CFqT8Y=", @@ -29,18 +45,18 @@ }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", "repo": "nixpkgs", + "rev": "d0fc30899600b9b3466ddb260fd83deb486c32f1", "type": "github" } }, "nixpkgs-lib": { "locked": { - "lastModified": 1761765539, - "narHash": "sha256-b0yj6kfvO8ApcSE+QmA6mUfu8IYG6/uU28OFn4PaC8M=", + "lastModified": 1777168982, + "narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "719359f4562934ae99f5443f20aa06c2ffff91fc", + "rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14", "type": "github" }, "original": { @@ -52,7 +68,8 @@ "root": { "inputs": { "flake-parts": "flake-parts", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "nixpkgs-jdk24": "nixpkgs-jdk24" } } }, diff --git a/flake.nix b/flake.nix index 55d3764279..a46b028380 100644 --- a/flake.nix +++ b/flake.nix @@ -3,16 +3,19 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + # Pinned to a revision that still ships temurin jdk-24 + nixpkgs-jdk24.url = "github:NixOS/nixpkgs/d0fc30899600b9b3466ddb260fd83deb486c32f1"; flake-parts.url = "github:hercules-ci/flake-parts"; }; - outputs = inputs@{ self, nixpkgs, flake-parts, ... }: + outputs = inputs@{ self, nixpkgs, nixpkgs-jdk24, flake-parts, ... }: flake-parts.lib.mkFlake { inherit inputs; } { systems = [ "x86_64-linux" ]; - perSystem = { pkgs, ... }: + perSystem = { pkgs, system, ... }: let - java = pkgs.javaPackages.compiler.temurin-bin.jdk-24; + jdkPkgs = import nixpkgs-jdk24 { inherit system; }; + java = jdkPkgs.javaPackages.compiler.temurin-bin.jdk-24; runtimeLibs = pkgs: (with pkgs; [ java @@ -20,15 +23,15 @@ alsa-lib at-spi2-atk at-spi2-core cairo cups dbus expat gdk-pixbuf glib gtk3 libdrm libgbm libglvnd libnotify libxkbcommon mesa nspr nss pango systemd vulkan-loader - wayland xorg.libX11 xorg.libXcomposite xorg.libXdamage - xorg.libXext xorg.libXfixes xorg.libXrandr xorg.libxcb - xorg.libxshmfence libusb1 udev libxcrypt-legacy + wayland libx11 libxcomposite libxdamage + libxext libxfixes libxrandr libxcb + libxshmfence libusb1 udev libxcrypt-legacy rpm fpm wineWow64Packages.stable zlib squashfsTools fakeroot libarchive icu nodejs_22 pnpm pkg-config python3 gcc gnumake binutils git - pkgs.nodePackages.node-gyp-build + node-gyp-build ]); slimeShell = pkgs.buildFHSEnv { From 4446b8d689a9e190ec97490189491071f43ad0a5 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 7 May 2026 10:15:26 +0200 Subject: [PATCH 092/350] VRM model loading + VMC settings in solarxr --- .../main/java/dev/slimevr/solarxr/module.kt | 2 + .../main/java/dev/slimevr/solarxr/settings.kt | 24 ++++ .../src/main/java/dev/slimevr/solarxr/vmc.kt | 44 ++++++ .../main/java/dev/slimevr/vmc/behaviours.kt | 70 ++++++---- .../src/main/java/dev/slimevr/vmc/mapping.kt | 130 +++++++++++++++++- .../src/main/java/dev/slimevr/vmc/module.kt | 4 +- .../core/src/main/java/dev/slimevr/vmc/vrm.kt | 107 ++++++++++++++ 7 files changed, 355 insertions(+), 26 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/settings.kt create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/vmc.kt create mode 100644 server/core/src/main/java/dev/slimevr/vmc/vrm.kt diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 3d74099fdb..83b4cd9521 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -72,6 +72,8 @@ class SolarXRBridge( appContext.vrcConfigManager?.let { vrc -> add(VrcBehaviour(vrc, appContext.server, userHeight = { appContext.skeleton.context.state.value.userHeight })) } + add(VmcBehaviour(appContext.vmcManager)) + add(SettingsBehaviour(appContext.vmcManager)) add(HeightCalibrationBehaviour(appContext.heightCalibrationManager)) add(ProvisioningBehaviour(appContext.server, appContext.provisioningManager)) add(SkeletonBehaviour(appContext.config.userConfig, appContext.skeleton)) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/settings.kt b/server/core/src/main/java/dev/slimevr/solarxr/settings.kt new file mode 100644 index 0000000000..e300033d32 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/settings.kt @@ -0,0 +1,24 @@ +package dev.slimevr.solarxr + +import dev.slimevr.vmc.VMCManager +import solarxr_protocol.rpc.SettingsRequest +import solarxr_protocol.rpc.SettingsResponse + +// Central handler for SettingsRequest. Aggregates per-feature settings into one SettingsResponse +// so the GUI receives a single response. Each feature builder lives next to its behaviour +// (e.g. buildVmcOscSettings in vmc.kt). +// TODO: Would be nice to split settings based on their solarxr behaviour. +// TODO: Almost all build functions could be their own request. making it easier to work with +class SettingsBehaviour( + private val vmcManager: VMCManager, +) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { + receiver.rpcDispatcher.on { + receiver.sendRpc( + SettingsResponse( + vmcOsc = buildVmcOscSettings(vmcManager.context.state.value.config), + ), + ) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt new file mode 100644 index 0000000000..1ac0fad7d1 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt @@ -0,0 +1,44 @@ +package dev.slimevr.solarxr + +import dev.slimevr.vmc.VMCActions +import dev.slimevr.vmc.VMCConfig +import dev.slimevr.vmc.VMCManager +import solarxr_protocol.rpc.ChangeSettingsRequest +import solarxr_protocol.rpc.OSCSettings +import solarxr_protocol.rpc.VMCOSCSettings + +class VmcBehaviour( + private val vmcManager: VMCManager, +) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { + receiver.rpcDispatcher.on { req -> + val vmc = req.vmcOsc ?: return@on + val osc = vmc.oscSettings ?: return@on + vmcManager.context.dispatch( + VMCActions.UpdateConfig( + VMCConfig( + enabled = osc.enabled, + portIn = osc.portIn.toInt(), + portOut = osc.portOut.toInt(), + address = osc.address.orEmpty(), + mirrorTracking = vmc.mirrorTracking, + anchorAtHips = vmc.anchorHip, + vrmJson = vmc.vrmJson?.ifEmpty { null }, + ), + ), + ) + } + } +} + +fun buildVmcOscSettings(config: VMCConfig): VMCOSCSettings = VMCOSCSettings( + oscSettings = OSCSettings( + enabled = config.enabled, + portIn = config.portIn.toUShort(), + portOut = config.portOut.toUShort(), + address = config.address, + ), + vrmJson = config.vrmJson, + anchorHip = config.anchorAtHips, + mirrorTracking = config.mirrorTracking, +) diff --git a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt index 78cc4ca123..ae5bf81fb7 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt @@ -10,10 +10,8 @@ import dev.slimevr.osc.OscContent import dev.slimevr.osc.OscMessage import dev.slimevr.osc.OscReceiver import dev.slimevr.osc.OscSender -import dev.slimevr.skeleton.BONE_TAIL_DIRECTIONS import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.Skeleton -import dev.slimevr.skeleton.buildBones import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.flow.distinctUntilChanged @@ -35,6 +33,7 @@ class VMCOutputBehaviour( override fun observe(receiver: VMCManager) { var sender: OscSender? = null + var vrmGeometry: VrmGeometry? = null receiver.context.state .map { it.config } @@ -43,6 +42,20 @@ class VMCOutputBehaviour( settings.context.dispatch(SettingsActions.Update { copy(vmcConfig = config) }) }.launchIn(receiver.context.scope) + receiver.context.state + .map { it.config.vrmJson } + .distinctUntilChanged() + .onEach { json -> + vrmGeometry = json?.takeIf { value -> value.isNotEmpty() }?.let { value -> + try { + buildVrmGeometry(VrmReader(value)) + } catch (e: Exception) { + AppLogger.vmc.error("Failed to parse VRM JSON", e) + null + } + } + }.launchIn(receiver.context.scope) + receiver.context.state .map { Triple(it.config.enabled, it.config.portOut, it.config.address) } .distinctUntilChanged() @@ -67,9 +80,10 @@ class VMCOutputBehaviour( val s = sender ?: return@onEach val config = receiver.context.state.value.config val currentTime = System.currentTimeMillis() + val vrm = vrmGeometry receiver.context.scope.launch { try { - s.send(buildBundle(bones, config, currentTime)) + s.send(buildBundle(bones, config, currentTime, vrm)) } catch (e: Exception) { AppLogger.vmc.error("Failed to send VMC frame", e) } @@ -77,32 +91,25 @@ class VMCOutputBehaviour( }.launchIn(receiver.context.scope) } - private fun buildBundle(bones: Map, config: VMCConfig, currentTime: Long): OscBundle = OscBundle(1L, buildMessages(bones, config, currentTime).map { OscContent.Message(it) }.toList()) + private fun buildBundle(bones: Map, config: VMCConfig, currentTime: Long, vrm: VrmGeometry?): OscBundle = + OscBundle(1L, buildMessages(bones, config, currentTime, vrm).map { msg -> OscContent.Message(msg) }.toList()) - // TODO: coordinate space conversion to VMC space + // Z-axis handedness flip for SlimeVR (RH) to Unity/VMC (LH). private fun boneMessage(address: String, name: String, pos: Vector3, rot: Quaternion): OscMessage = OscMessage( address, listOf( OscArg.String(name), OscArg.Float(pos.x), OscArg.Float(pos.y), - OscArg.Float(pos.z), + OscArg.Float(-pos.z), OscArg.Float(rot.x), OscArg.Float(rot.y), - OscArg.Float(rot.z), - OscArg.Float(rot.w), + OscArg.Float(-rot.z), + OscArg.Float(-rot.w), ), ) - private fun buildMessages(bones: Map, config: VMCConfig, currentTime: Long): Sequence = sequence { - val hipPos = bones[BodyPart.HIP]?.headPosition ?: Vector3.NULL - val vmcBones = buildBones( - skeleton.context.state.value, - rootHead = hipPos, - hierarchy = iterateVMCHierarchy(), - tailDirections = BONE_TAIL_DIRECTIONS, - ) - + private fun buildMessages(bones: Map, config: VMCConfig, currentTime: Long, vrm: VrmGeometry?): Sequence = sequence { val time = (currentTime - initTime) / 1000f yield(OscMessage("/VMC/Ext/T", listOf(OscArg.Float(time)))) yield(OscMessage("/VMC/Ext/OK", listOf(OscArg.Int(1)))) @@ -122,15 +129,30 @@ class VMCOutputBehaviour( ), ) - // TODO: implement mirrorTracking - for ((bodyPart, unityName) in BODY_PART_TO_UNITY_BONE) { - val bone = vmcBones[bodyPart] ?: continue - yield(boneMessage("/VMC/Ext/Bone/Pos", unityName, bone.localHeadPosition, bone.localRotation)) + // Hip world position. + // With VRM data: anchor at HEAD: scale our skeleton head into VRM + // units and walk back to the hip via the avatar's own bind pose so the model stays + // grounded at its native proportions. + // Without VRM data: fall back to absolute world hip. + val hipPos = if (vrm != null) { + val userHeight = skeleton.context.state.value.userHeight + val scale = if (userHeight > 0f) vrm.height / userHeight else 1f + val ourHead = bones[BodyPart.HEAD]?.headPosition ?: Vector3.NULL + ourHead * scale - vrm.headOffsetFromHip + } else { + bones[BodyPart.HIP]?.headPosition ?: Vector3.NULL } - val headBone = vmcBones[BodyPart.HEAD] - if (headBone != null) { - yield(boneMessage("/VMC/Ext/Hmd/Pos", "human://HEAD", headBone.localHeadPosition, headBone.localRotation)) + // TODO: implement mirrorTracking and anchor at hip + for ((bodyPart, unityName) in BODY_PART_TO_UNITY_BONE) { + val bone = bones[bodyPart] ?: continue + val parent = VMC_BONE_PARENTS[bodyPart]?.let { bones[it] } + val pos = when { + parent == null -> hipPos + vrm != null -> vrm.bindOffsets[bodyPart] ?: Vector3.NULL + else -> vmcLocalPosition(bone, parent) + } + yield(boneMessage("/VMC/Ext/Bone/Pos", unityName, pos, vmcLocalRotation(bone, parent))) } } } diff --git a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt index 285d4ebfd4..cd64d676e9 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt @@ -1,7 +1,12 @@ package dev.slimevr.vmc +import com.jme3.math.FastMath +import dev.slimevr.skeleton.BoneState +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.BodyPart + val BODY_PART_TO_UNITY_BONE: Map = mapOf( BodyPart.HEAD to "Head", BodyPart.NECK to "Neck", @@ -23,9 +28,39 @@ val BODY_PART_TO_UNITY_BONE: Map = mapOf( BodyPart.RIGHT_LOWER_LEG to "RightLowerLeg", BodyPart.LEFT_FOOT to "LeftFoot", BodyPart.RIGHT_FOOT to "RightFoot", + BodyPart.LEFT_THUMB_METACARPAL to "LeftThumbProximal", + BodyPart.LEFT_THUMB_PROXIMAL to "LeftThumbIntermediate", + BodyPart.LEFT_THUMB_DISTAL to "LeftThumbDistal", + BodyPart.LEFT_INDEX_PROXIMAL to "LeftIndexProximal", + BodyPart.LEFT_INDEX_INTERMEDIATE to "LeftIndexIntermediate", + BodyPart.LEFT_INDEX_DISTAL to "LeftIndexDistal", + BodyPart.LEFT_MIDDLE_PROXIMAL to "LeftMiddleProximal", + BodyPart.LEFT_MIDDLE_INTERMEDIATE to "LeftMiddleIntermediate", + BodyPart.LEFT_MIDDLE_DISTAL to "LeftMiddleDistal", + BodyPart.LEFT_RING_PROXIMAL to "LeftRingProximal", + BodyPart.LEFT_RING_INTERMEDIATE to "LeftRingIntermediate", + BodyPart.LEFT_RING_DISTAL to "LeftRingDistal", + BodyPart.LEFT_LITTLE_PROXIMAL to "LeftLittleProximal", + BodyPart.LEFT_LITTLE_INTERMEDIATE to "LeftLittleIntermediate", + BodyPart.LEFT_LITTLE_DISTAL to "LeftLittleDistal", + BodyPart.RIGHT_THUMB_METACARPAL to "RightThumbProximal", + BodyPart.RIGHT_THUMB_PROXIMAL to "RightThumbIntermediate", + BodyPart.RIGHT_THUMB_DISTAL to "RightThumbDistal", + BodyPart.RIGHT_INDEX_PROXIMAL to "RightIndexProximal", + BodyPart.RIGHT_INDEX_INTERMEDIATE to "RightIndexIntermediate", + BodyPart.RIGHT_INDEX_DISTAL to "RightIndexDistal", + BodyPart.RIGHT_MIDDLE_PROXIMAL to "RightMiddleProximal", + BodyPart.RIGHT_MIDDLE_INTERMEDIATE to "RightMiddleIntermediate", + BodyPart.RIGHT_MIDDLE_DISTAL to "RightMiddleDistal", + BodyPart.RIGHT_RING_PROXIMAL to "RightRingProximal", + BodyPart.RIGHT_RING_INTERMEDIATE to "RightRingIntermediate", + BodyPart.RIGHT_RING_DISTAL to "RightRingDistal", + BodyPart.RIGHT_LITTLE_PROXIMAL to "RightLittleProximal", + BodyPart.RIGHT_LITTLE_INTERMEDIATE to "RightLittleIntermediate", + BodyPart.RIGHT_LITTLE_DISTAL to "RightLittleDistal", ) -// HIP-rooted hierarchy for VMC. The new skeleton is HEAD-rooted but VMC/Unity expects HIP-rooted. +// HIP-rooted hierarchy. VMC/Unity expects this; our skeleton is HEAD-rooted. val VMC_HIERARCHY_MAP: Map> = mapOf( BodyPart.HIP to arrayOf(BodyPart.WAIST, BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG), BodyPart.WAIST to arrayOf(BodyPart.CHEST), @@ -42,6 +77,40 @@ val VMC_HIERARCHY_MAP: Map> = mapOf( BodyPart.RIGHT_UPPER_ARM to arrayOf(BodyPart.RIGHT_LOWER_ARM), BodyPart.LEFT_LOWER_ARM to arrayOf(BodyPart.LEFT_HAND), BodyPart.RIGHT_LOWER_ARM to arrayOf(BodyPart.RIGHT_HAND), + BodyPart.LEFT_HAND to arrayOf( + BodyPart.LEFT_THUMB_METACARPAL, + BodyPart.LEFT_INDEX_PROXIMAL, + BodyPart.LEFT_MIDDLE_PROXIMAL, + BodyPart.LEFT_RING_PROXIMAL, + BodyPart.LEFT_LITTLE_PROXIMAL, + ), + BodyPart.LEFT_THUMB_METACARPAL to arrayOf(BodyPart.LEFT_THUMB_PROXIMAL), + BodyPart.LEFT_THUMB_PROXIMAL to arrayOf(BodyPart.LEFT_THUMB_DISTAL), + BodyPart.LEFT_INDEX_PROXIMAL to arrayOf(BodyPart.LEFT_INDEX_INTERMEDIATE), + BodyPart.LEFT_INDEX_INTERMEDIATE to arrayOf(BodyPart.LEFT_INDEX_DISTAL), + BodyPart.LEFT_MIDDLE_PROXIMAL to arrayOf(BodyPart.LEFT_MIDDLE_INTERMEDIATE), + BodyPart.LEFT_MIDDLE_INTERMEDIATE to arrayOf(BodyPart.LEFT_MIDDLE_DISTAL), + BodyPart.LEFT_RING_PROXIMAL to arrayOf(BodyPart.LEFT_RING_INTERMEDIATE), + BodyPart.LEFT_RING_INTERMEDIATE to arrayOf(BodyPart.LEFT_RING_DISTAL), + BodyPart.LEFT_LITTLE_PROXIMAL to arrayOf(BodyPart.LEFT_LITTLE_INTERMEDIATE), + BodyPart.LEFT_LITTLE_INTERMEDIATE to arrayOf(BodyPart.LEFT_LITTLE_DISTAL), + BodyPart.RIGHT_HAND to arrayOf( + BodyPart.RIGHT_THUMB_METACARPAL, + BodyPart.RIGHT_INDEX_PROXIMAL, + BodyPart.RIGHT_MIDDLE_PROXIMAL, + BodyPart.RIGHT_RING_PROXIMAL, + BodyPart.RIGHT_LITTLE_PROXIMAL, + ), + BodyPart.RIGHT_THUMB_METACARPAL to arrayOf(BodyPart.RIGHT_THUMB_PROXIMAL), + BodyPart.RIGHT_THUMB_PROXIMAL to arrayOf(BodyPart.RIGHT_THUMB_DISTAL), + BodyPart.RIGHT_INDEX_PROXIMAL to arrayOf(BodyPart.RIGHT_INDEX_INTERMEDIATE), + BodyPart.RIGHT_INDEX_INTERMEDIATE to arrayOf(BodyPart.RIGHT_INDEX_DISTAL), + BodyPart.RIGHT_MIDDLE_PROXIMAL to arrayOf(BodyPart.RIGHT_MIDDLE_INTERMEDIATE), + BodyPart.RIGHT_MIDDLE_INTERMEDIATE to arrayOf(BodyPart.RIGHT_MIDDLE_DISTAL), + BodyPart.RIGHT_RING_PROXIMAL to arrayOf(BodyPart.RIGHT_RING_INTERMEDIATE), + BodyPart.RIGHT_RING_INTERMEDIATE to arrayOf(BodyPart.RIGHT_RING_DISTAL), + BodyPart.RIGHT_LITTLE_PROXIMAL to arrayOf(BodyPart.RIGHT_LITTLE_INTERMEDIATE), + BodyPart.RIGHT_LITTLE_INTERMEDIATE to arrayOf(BodyPart.RIGHT_LITTLE_DISTAL), ) private suspend fun SequenceScope>.visitVMC(parent: BodyPart?, bone: BodyPart) { @@ -50,3 +119,62 @@ private suspend fun SequenceScope>.visitVMC(parent: Bo } fun iterateVMCHierarchy() = sequence { visitVMC(null, BodyPart.HIP) } + +val VMC_BONE_PARENTS: Map = + iterateVMCHierarchy().associate { (parent, child) -> child to parent } + +// Per-bone rest offset, subtracted from the live world rotation before computing the VMC local. +// Foot cancels R(90deg,X) baked into DEFAULT_SKELETON_STATE. Arms remap our hanging rest (NEG_Y) +// to the VRM rig's T-pose rest direction so the avatar isn't stuck at T regardless of our pose. +val VMC_REST_ROTATIONS: Map = run { + val leftArm = Quaternion.rotationAroundZAxis(-FastMath.HALF_PI) + val rightArm = Quaternion.rotationAroundZAxis(FastMath.HALF_PI) + val foot = Quaternion.rotationAroundXAxis(FastMath.HALF_PI) + val leftFingers = listOf( + BodyPart.LEFT_THUMB_METACARPAL, BodyPart.LEFT_THUMB_PROXIMAL, BodyPart.LEFT_THUMB_DISTAL, + BodyPart.LEFT_INDEX_PROXIMAL, BodyPart.LEFT_INDEX_INTERMEDIATE, BodyPart.LEFT_INDEX_DISTAL, + BodyPart.LEFT_MIDDLE_PROXIMAL, BodyPart.LEFT_MIDDLE_INTERMEDIATE, BodyPart.LEFT_MIDDLE_DISTAL, + BodyPart.LEFT_RING_PROXIMAL, BodyPart.LEFT_RING_INTERMEDIATE, BodyPart.LEFT_RING_DISTAL, + BodyPart.LEFT_LITTLE_PROXIMAL, BodyPart.LEFT_LITTLE_INTERMEDIATE, BodyPart.LEFT_LITTLE_DISTAL, + ) + val rightFingers = listOf( + BodyPart.RIGHT_THUMB_METACARPAL, BodyPart.RIGHT_THUMB_PROXIMAL, BodyPart.RIGHT_THUMB_DISTAL, + BodyPart.RIGHT_INDEX_PROXIMAL, BodyPart.RIGHT_INDEX_INTERMEDIATE, BodyPart.RIGHT_INDEX_DISTAL, + BodyPart.RIGHT_MIDDLE_PROXIMAL, BodyPart.RIGHT_MIDDLE_INTERMEDIATE, BodyPart.RIGHT_MIDDLE_DISTAL, + BodyPart.RIGHT_RING_PROXIMAL, BodyPart.RIGHT_RING_INTERMEDIATE, BodyPart.RIGHT_RING_DISTAL, + BodyPart.RIGHT_LITTLE_PROXIMAL, BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_DISTAL, + ) + mapOf( + BodyPart.LEFT_FOOT to foot, + BodyPart.RIGHT_FOOT to foot, + BodyPart.LEFT_UPPER_ARM to leftArm, + BodyPart.LEFT_LOWER_ARM to leftArm, + BodyPart.LEFT_HAND to leftArm, + BodyPart.RIGHT_UPPER_ARM to rightArm, + BodyPart.RIGHT_LOWER_ARM to rightArm, + BodyPart.RIGHT_HAND to rightArm, + ) + leftFingers.associateWith { leftArm } + rightFingers.associateWith { rightArm } +} + +private fun restAdjustedWorld(bone: BoneState): Quaternion { + val rest = VMC_REST_ROTATIONS[bone.bodyPart] ?: return bone.rotation + return bone.rotation * rest.inv() +} + +// Local rotation relative to the VMC parent. Rest offset is applied to both bone and parent +// before taking the relative rotation. Parent is explicit because VMC hierarchy differs from +// the skeleton's head-rooted parentBone. +fun vmcLocalRotation(bone: BoneState, parent: BoneState?): Quaternion { + val adjusted = restAdjustedWorld(bone) + if (parent == null) return adjusted + return restAdjustedWorld(parent).inv() * adjusted +} + +// Local position relative to the VMC parent. Overrides the VRM bind-pose offset so each bone +// lands at our skeleton's world position when the receiver honors it. HIP root is handled by +// the caller since the root has no parent in VMC. +fun vmcLocalPosition(bone: BoneState, parent: BoneState): Vector3 { + val parentAdjusted = restAdjustedWorld(parent) + return parentAdjusted.inv().sandwich(bone.headPosition - parent.headPosition) +} + diff --git a/server/core/src/main/java/dev/slimevr/vmc/module.kt b/server/core/src/main/java/dev/slimevr/vmc/module.kt index a2d9d42873..445dfb3a2d 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/module.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/module.kt @@ -9,11 +9,13 @@ import kotlinx.serialization.Serializable @Serializable data class VMCConfig( - val enabled: Boolean = false, + val enabled: Boolean = true, val portOut: Int = 39539, val portIn: Int = 39540, val address: String = "172.17.0.1", val mirrorTracking: Boolean = false, + val anchorAtHips: Boolean = false, + val vrmJson: String? = null, ) data class VMCState(val config: VMCConfig = VMCConfig()) diff --git a/server/core/src/main/java/dev/slimevr/vmc/vrm.kt b/server/core/src/main/java/dev/slimevr/vmc/vrm.kt new file mode 100644 index 0000000000..cd0fa99c7a --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vmc/vrm.kt @@ -0,0 +1,107 @@ +package dev.slimevr.vmc + +import io.github.axisangles.ktmath.Vector3 +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import solarxr_protocol.datatypes.BodyPart + +private val vrmJsonParser = Json { ignoreUnknownKeys = true } + +class VrmReader(json: String) { + private val data: GLTF = vrmJsonParser.decodeFromString(json) + + // VRM 1.0 humanBones, lowercased for case-insensitive lookup. Null if the model is VRM 0.x. + private val v1Bones: Map? = data.extensions.vrmV1?.humanoid?.humanBones + ?.mapKeys { (key, _) -> key.lowercase() } + ?.mapValues { (_, value) -> value.node } + + fun offsetForBone(unityBoneName: String): Vector3? { + val nodeIndex = v1Bones?.get(unityBoneName.lowercase()) + ?: data.extensions.vrmV0?.humanoid?.humanBones + ?.firstOrNull { bone -> bone.bone.equals(unityBoneName, ignoreCase = true) } + ?.node + ?: return null + val translation = data.nodes.getOrNull(nodeIndex)?.translation ?: return null + return Vector3(translation[0].toFloat(), translation[1].toFloat(), translation[2].toFloat()) + } +} + +@Serializable +data class GLTF( + val extensions: Extensions, + val nodes: List, +) + +@Serializable +data class Extensions( + @SerialName("VRM") + val vrmV0: VRMV0? = null, + @SerialName("VRMC_vrm") + val vrmV1: VRMV1? = null, +) + +@Serializable +data class VRMV1( + val specVersion: String, + val humanoid: HumanoidV1, +) + +@Serializable +data class HumanoidV1( + val humanBones: Map, +) + +@Serializable +data class HumanBoneV1( + val node: Int, +) + +@Serializable +data class VRMV0( + val humanoid: HumanoidV0, +) + +@Serializable +data class HumanoidV0( + val humanBones: List, +) + +@Serializable +data class HumanBoneV0( + val bone: String, + val node: Int, +) + +@Serializable +data class Node( + val translation: List? = null, + val rotation: List? = null, + val scale: List? = null, + val children: List = emptyList(), +) + +// VRM bind-pose geometry derived from a parsed VRM JSON. Used to anchor the avatar at HEAD, +// scale our skeleton positions into VRM units, and override per-bone positions with the +// model's own bind pose so the mesh isn't stretched. +data class VrmGeometry( + val bindOffsets: Map, + val height: Float, + val headOffsetFromHip: Vector3, +) + +fun buildVrmGeometry(reader: VrmReader): VrmGeometry { + val bindOffsets = BODY_PART_TO_UNITY_BONE.mapValues { (_, unityName) -> + reader.offsetForBone(unityName) ?: Vector3.NULL + } + fun offset(bodyPart: BodyPart) = bindOffsets[bodyPart] ?: Vector3.NULL + + val headOffsetFromHip = offset(BodyPart.WAIST) + offset(BodyPart.CHEST) + offset(BodyPart.UPPER_CHEST) + offset(BodyPart.NECK) + offset(BodyPart.HEAD) + val height = offset(BodyPart.HIP).y + headOffsetFromHip.y + + return VrmGeometry( + bindOffsets = bindOffsets, + height = height, + headOffsetFromHip = headOffsetFromHip, + ) +} \ No newline at end of file From e657d97480bf8a20dede798fda7d41e625fccd9e Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 7 May 2026 21:41:03 +0200 Subject: [PATCH 093/350] Hip anchor + mirroring + better vrm loading --- .../main/java/dev/slimevr/skeleton/module.kt | 2 +- .../main/java/dev/slimevr/vmc/behaviours.kt | 78 ++++++++--------- .../src/main/java/dev/slimevr/vmc/mapping.kt | 87 +++++++++++++++---- .../core/src/main/java/dev/slimevr/vmc/vrm.kt | 16 ++-- 4 files changed, 113 insertions(+), 70 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index afa8557163..255e3e3564 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -93,7 +93,7 @@ class Skeleton( ProportionsBehaviour(), ScaledProportionsBehaviour(ctx.config.userConfig), HeightLogBehaviour(), - YouSpinMeRightRoundBehaviour(inputHz = 10f), + YouSpinMeRightRoundBehaviour(inputHz = 50f), ComputedSkeletonBehaviour( processors = listOf( // PredictionProcessor(predictionAmount = 0.3f), diff --git a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt index ae5bf81fb7..60058d9928 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt @@ -91,11 +91,9 @@ class VMCOutputBehaviour( }.launchIn(receiver.context.scope) } - private fun buildBundle(bones: Map, config: VMCConfig, currentTime: Long, vrm: VrmGeometry?): OscBundle = - OscBundle(1L, buildMessages(bones, config, currentTime, vrm).map { msg -> OscContent.Message(msg) }.toList()) + private fun buildBundle(bones: Map, config: VMCConfig, currentTime: Long, vrm: VrmGeometry?): OscBundle = OscBundle(1L, buildMessages(bones, config, currentTime, vrm).map { msg -> OscContent.Message(msg) }.toList()) - // Z-axis handedness flip for SlimeVR (RH) to Unity/VMC (LH). - private fun boneMessage(address: String, name: String, pos: Vector3, rot: Quaternion): OscMessage = OscMessage( + private fun transformMessage(address: String, name: String, pos: Vector3, rot: Quaternion): OscMessage = OscMessage( address, listOf( OscArg.String(name), @@ -113,46 +111,42 @@ class VMCOutputBehaviour( val time = (currentTime - initTime) / 1000f yield(OscMessage("/VMC/Ext/T", listOf(OscArg.Float(time)))) yield(OscMessage("/VMC/Ext/OK", listOf(OscArg.Int(1)))) - yield( - OscMessage( - "/VMC/Ext/Root/Pos", - listOf( - OscArg.String("root"), - OscArg.Float(0f), - OscArg.Float(0f), - OscArg.Float(0f), - OscArg.Float(0f), - OscArg.Float(0f), - OscArg.Float(0f), - OscArg.Float(1f), - ), - ), - ) - - // Hip world position. - // With VRM data: anchor at HEAD: scale our skeleton head into VRM - // units and walk back to the hip via the avatar's own bind pose so the model stays - // grounded at its native proportions. - // Without VRM data: fall back to absolute world hip. - val hipPos = if (vrm != null) { - val userHeight = skeleton.context.state.value.userHeight - val scale = if (userHeight > 0f) vrm.height / userHeight else 1f - val ourHead = bones[BodyPart.HEAD]?.headPosition ?: Vector3.NULL - ourHead * scale - vrm.headOffsetFromHip - } else { - bones[BodyPart.HIP]?.headPosition ?: Vector3.NULL - } - // TODO: implement mirrorTracking and anchor at hip - for ((bodyPart, unityName) in BODY_PART_TO_UNITY_BONE) { - val bone = bones[bodyPart] ?: continue - val parent = VMC_BONE_PARENTS[bodyPart]?.let { bones[it] } - val pos = when { - parent == null -> hipPos - vrm != null -> vrm.bindOffsets[bodyPart] ?: Vector3.NULL - else -> vmcLocalPosition(bone, parent) + val rootPos = vmcRootPosition(bones, config, vrm) + yield(transformMessage("/VMC/Ext/Root/Pos", "root", rootPos, Quaternion.IDENTITY)) + + for ((targetBodyPart, unityName) in BODY_PART_TO_UNITY_BONE) { + val targetParentBodyPart = VMC_BONE_PARENTS[targetBodyPart] + val trackingBodyPart = if (config.mirrorTracking) vmcMirrorSource(targetBodyPart) else targetBodyPart + val trackingBone = bones[trackingBodyPart] ?: continue + + if (targetParentBodyPart == null) { + val pos = vrm?.hipLocalPosition ?: Vector3.NULL + val rot = vmcLocalRotation(trackingBone, null, targetBodyPart, null, config.mirrorTracking) + yield(transformMessage("/VMC/Ext/Bone/Pos", unityName, pos, rot)) + continue + } + + val trackingParentBodyPart = if (config.mirrorTracking) { + vmcMirrorSource(targetParentBodyPart) + } else { + targetParentBodyPart + } + val trackingParent = bones[trackingParentBodyPart] ?: continue + + val pos = if (vrm != null) { + vrm.bindOffsets[targetBodyPart] ?: Vector3.NULL + } else { + vmcLocalPosition(trackingBone, trackingParent, targetParentBodyPart, config.mirrorTracking) } - yield(boneMessage("/VMC/Ext/Bone/Pos", unityName, pos, vmcLocalRotation(bone, parent))) + val rot = vmcLocalRotation( + trackingBone, + trackingParent, + targetBodyPart, + targetParentBodyPart, + config.mirrorTracking, + ) + yield(transformMessage("/VMC/Ext/Bone/Pos", unityName, pos, rot)) } } } diff --git a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt index cd64d676e9..b414dba0ed 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt @@ -6,7 +6,6 @@ import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.BodyPart - val BODY_PART_TO_UNITY_BONE: Map = mapOf( BodyPart.HEAD to "Head", BodyPart.NECK to "Neck", @@ -123,6 +122,37 @@ fun iterateVMCHierarchy() = sequence { visitVMC(null, BodyPart.HIP) } val VMC_BONE_PARENTS: Map = iterateVMCHierarchy().associate { (parent, child) -> child to parent } +val VMC_MIRROR_BONE_PAIRS: List> = listOf( + BodyPart.LEFT_SHOULDER to BodyPart.RIGHT_SHOULDER, + BodyPart.LEFT_UPPER_ARM to BodyPart.RIGHT_UPPER_ARM, + BodyPart.LEFT_LOWER_ARM to BodyPart.RIGHT_LOWER_ARM, + BodyPart.LEFT_HAND to BodyPart.RIGHT_HAND, + BodyPart.LEFT_UPPER_LEG to BodyPart.RIGHT_UPPER_LEG, + BodyPart.LEFT_LOWER_LEG to BodyPart.RIGHT_LOWER_LEG, + BodyPart.LEFT_FOOT to BodyPart.RIGHT_FOOT, + BodyPart.LEFT_THUMB_METACARPAL to BodyPart.RIGHT_THUMB_METACARPAL, + BodyPart.LEFT_THUMB_PROXIMAL to BodyPart.RIGHT_THUMB_PROXIMAL, + BodyPart.LEFT_THUMB_DISTAL to BodyPart.RIGHT_THUMB_DISTAL, + BodyPart.LEFT_INDEX_PROXIMAL to BodyPart.RIGHT_INDEX_PROXIMAL, + BodyPart.LEFT_INDEX_INTERMEDIATE to BodyPart.RIGHT_INDEX_INTERMEDIATE, + BodyPart.LEFT_INDEX_DISTAL to BodyPart.RIGHT_INDEX_DISTAL, + BodyPart.LEFT_MIDDLE_PROXIMAL to BodyPart.RIGHT_MIDDLE_PROXIMAL, + BodyPart.LEFT_MIDDLE_INTERMEDIATE to BodyPart.RIGHT_MIDDLE_INTERMEDIATE, + BodyPart.LEFT_MIDDLE_DISTAL to BodyPart.RIGHT_MIDDLE_DISTAL, + BodyPart.LEFT_RING_PROXIMAL to BodyPart.RIGHT_RING_PROXIMAL, + BodyPart.LEFT_RING_INTERMEDIATE to BodyPart.RIGHT_RING_INTERMEDIATE, + BodyPart.LEFT_RING_DISTAL to BodyPart.RIGHT_RING_DISTAL, + BodyPart.LEFT_LITTLE_PROXIMAL to BodyPart.RIGHT_LITTLE_PROXIMAL, + BodyPart.LEFT_LITTLE_INTERMEDIATE to BodyPart.RIGHT_LITTLE_INTERMEDIATE, + BodyPart.LEFT_LITTLE_DISTAL to BodyPart.RIGHT_LITTLE_DISTAL, +) + +val VMC_MIRROR_BONES: Map = VMC_MIRROR_BONE_PAIRS + .flatMap { (left, right) -> listOf(left to right, right to left) } + .toMap() + +fun vmcMirrorSource(bodyPart: BodyPart): BodyPart = VMC_MIRROR_BONES[bodyPart] ?: bodyPart + // Per-bone rest offset, subtracted from the live world rotation before computing the VMC local. // Foot cancels R(90deg,X) baked into DEFAULT_SKELETON_STATE. Arms remap our hanging rest (NEG_Y) // to the VRM rig's T-pose rest direction so the avatar isn't stuck at T regardless of our pose. @@ -153,28 +183,51 @@ val VMC_REST_ROTATIONS: Map = run { BodyPart.RIGHT_UPPER_ARM to rightArm, BodyPart.RIGHT_LOWER_ARM to rightArm, BodyPart.RIGHT_HAND to rightArm, - ) + leftFingers.associateWith { leftArm } + rightFingers.associateWith { rightArm } + ) + + leftFingers.associateWith { leftArm } + + rightFingers.associateWith { rightArm } } -private fun restAdjustedWorld(bone: BoneState): Quaternion { - val rest = VMC_REST_ROTATIONS[bone.bodyPart] ?: return bone.rotation - return bone.rotation * rest.inv() +fun vmcMirrorPosition(pos: Vector3): Vector3 = Vector3(-pos.x, pos.y, pos.z) + +fun vmcMirrorRotation(rot: Quaternion): Quaternion = Quaternion(rot.w, rot.x, -rot.y, -rot.z) + +private fun restAdjustedWorld( + bone: BoneState, + restBodyPart: BodyPart = bone.bodyPart, + mirror: Boolean = false, +): Quaternion { + val world = if (mirror) vmcMirrorRotation(bone.rotation) else bone.rotation + val rest = VMC_REST_ROTATIONS[restBodyPart] ?: return world + return world * rest.inv() } -// Local rotation relative to the VMC parent. Rest offset is applied to both bone and parent -// before taking the relative rotation. Parent is explicit because VMC hierarchy differs from -// the skeleton's head-rooted parentBone. -fun vmcLocalRotation(bone: BoneState, parent: BoneState?): Quaternion { - val adjusted = restAdjustedWorld(bone) +fun vmcLocalRotation( + bone: BoneState, + parent: BoneState?, + restBodyPart: BodyPart, + restParentBodyPart: BodyPart?, + mirror: Boolean, +): Quaternion { + val adjusted = restAdjustedWorld(bone, restBodyPart, mirror) if (parent == null) return adjusted - return restAdjustedWorld(parent).inv() * adjusted + return restAdjustedWorld(parent, restParentBodyPart ?: parent.bodyPart, mirror).inv() * adjusted } -// Local position relative to the VMC parent. Overrides the VRM bind-pose offset so each bone -// lands at our skeleton's world position when the receiver honors it. HIP root is handled by -// the caller since the root has no parent in VMC. -fun vmcLocalPosition(bone: BoneState, parent: BoneState): Vector3 { - val parentAdjusted = restAdjustedWorld(parent) - return parentAdjusted.inv().sandwich(bone.headPosition - parent.headPosition) +fun vmcLocalPosition( + bone: BoneState, + parent: BoneState, + restParentBodyPart: BodyPart, + mirror: Boolean, +): Vector3 { + val parentAdjusted = restAdjustedWorld(parent, restParentBodyPart, mirror) + val localPosition = bone.headPosition - parent.headPosition + return parentAdjusted.inv().sandwich(if (mirror) vmcMirrorPosition(localPosition) else localPosition) } +fun vmcRootPosition(bones: Map, config: VMCConfig, vrm: VrmGeometry?): Vector3 { + val userHip = bones[BodyPart.HIP]?.headPosition ?: Vector3.NULL + val hipLocalPosition = vrm?.hipLocalPosition ?: Vector3.NULL + val targetHip = if (config.anchorAtHips) Vector3.NULL else userHip + return (if (config.mirrorTracking) vmcMirrorPosition(targetHip) else targetHip) - hipLocalPosition +} diff --git a/server/core/src/main/java/dev/slimevr/vmc/vrm.kt b/server/core/src/main/java/dev/slimevr/vmc/vrm.kt index cd0fa99c7a..93bd13fdbb 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/vrm.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/vrm.kt @@ -81,13 +81,11 @@ data class Node( val children: List = emptyList(), ) -// VRM bind-pose geometry derived from a parsed VRM JSON. Used to anchor the avatar at HEAD, -// scale our skeleton positions into VRM units, and override per-bone positions with the -// model's own bind pose so the mesh isn't stretched. +// VRM bind-pose geometry derived from a parsed VRM JSON. Used to keep the avatar's +// local bone offsets aligned with the model's own proportions. data class VrmGeometry( val bindOffsets: Map, - val height: Float, - val headOffsetFromHip: Vector3, + val hipLocalPosition: Vector3, ) fun buildVrmGeometry(reader: VrmReader): VrmGeometry { @@ -96,12 +94,10 @@ fun buildVrmGeometry(reader: VrmReader): VrmGeometry { } fun offset(bodyPart: BodyPart) = bindOffsets[bodyPart] ?: Vector3.NULL - val headOffsetFromHip = offset(BodyPart.WAIST) + offset(BodyPart.CHEST) + offset(BodyPart.UPPER_CHEST) + offset(BodyPart.NECK) + offset(BodyPart.HEAD) - val height = offset(BodyPart.HIP).y + headOffsetFromHip.y + val hipLocalPosition = offset(BodyPart.HIP) return VrmGeometry( bindOffsets = bindOffsets, - height = height, - headOffsetFromHip = headOffsetFromHip, + hipLocalPosition = hipLocalPosition, ) -} \ No newline at end of file +} From d492061c0ad9263c3b8e7e433f7c93f5b2f81699 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Fri, 8 May 2026 23:07:07 -0400 Subject: [PATCH 094/350] Rename & cleanup proportions --- .../src/main/java/dev/slimevr/bvh/stream.kt | 4 ++-- .../java/dev/slimevr/skeleton/behaviours.kt | 4 ++-- .../java/dev/slimevr/skeleton/bonegeometry.kt | 4 ++-- .../main/java/dev/slimevr/skeleton/module.kt | 4 ++-- .../java/dev/slimevr/skeleton/proportions.kt | 18 ++++++++---------- .../java/dev/slimevr/solarxr/skeletonconfig.kt | 4 ++-- 6 files changed, 18 insertions(+), 20 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/bvh/stream.kt b/server/core/src/main/java/dev/slimevr/bvh/stream.kt index 018ff8fe7d..6a6300a85b 100644 --- a/server/core/src/main/java/dev/slimevr/bvh/stream.kt +++ b/server/core/src/main/java/dev/slimevr/bvh/stream.kt @@ -2,8 +2,8 @@ package dev.slimevr.bvh import com.jme3.math.FastMath import dev.slimevr.skeleton.BODY_PART_HIERARCHY_MAP -import dev.slimevr.skeleton.BONE_TAIL_OFFSETS import dev.slimevr.skeleton.BoneState +import dev.slimevr.skeleton.DEFAULT_BONE_OFFSETS import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 @@ -85,7 +85,7 @@ class BvhStream(file: File) { } private fun getBvhOffset(parentPart: BodyPart, bones: Map): Vector3 { - val tailOffsetLocal = BONE_TAIL_OFFSETS[parentPart] ?: return Vector3.NULL + val tailOffsetLocal = DEFAULT_BONE_OFFSETS[parentPart] ?: return Vector3.NULL val parentLength = bones[parentPart]?.length ?: 0f return tailOffsetLocal.unit() * (parentLength * OFFSET_SCALE) } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 245a209879..ad8f435d34 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -36,7 +36,7 @@ class BoneTransformBehaviour : SkeletonBehaviour { class ProportionsBehaviour : SkeletonBehaviour { override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { is SkeletonActions.SetProportions -> { - val bones = action.lengths.toBodyPartOffsets() + val bones = action.lengths.toBoneOffsets() val newBones = state.rawBones.mapValues { (bodyPart, bone) -> bone.copy(length = bones[bodyPart]?.len() ?: bone.length) } @@ -54,7 +54,7 @@ class ScaledProportionsBehaviour(private val userConfig: UserConfig) : SkeletonB .distinctUntilChanged() .onEach { proportions -> if (proportions.isNotEmpty()) { - receiver.context.dispatch(SkeletonActions.SetProportions(configToSkeletonBoneValues(proportions))) + receiver.context.dispatch(SkeletonActions.SetProportions(configToBoneValues(proportions))) } } .launchIn(receiver.context.scope) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt b/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt index 48ddefa387..aa31856806 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt @@ -8,6 +8,6 @@ private val FINGER_OFFSETS = ( iterateBodyPartHierarchy(BodyPart.RIGHT_HAND, true) ).map { it.second }.associateWith { Vector3(0f, -0.025f, 0f) } -val BONE_TAIL_OFFSETS: Map = DEFAULT_PROPORTIONS.toBodyPartOffsets() + FINGER_OFFSETS +val DEFAULT_BONE_OFFSETS: Map = DEFAULT_PROPORTIONS.toBoneOffsets() + FINGER_OFFSETS -val BONE_TAIL_DIRECTIONS: Map = BONE_TAIL_OFFSETS.mapValues { (_, vec) -> vec.unit() } +val DEFAULT_BONE_DIRECTIONS: Map = DEFAULT_BONE_OFFSETS.mapValues { (_, vec) -> vec.unit() } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 255e3e3564..b91b6b81bc 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -37,7 +37,7 @@ data class BoneState( data class SkeletonState(val rawBones: Map, val userHeight: Float) val DEFAULT_SKELETON_STATE: SkeletonState = run { - val bones = BONE_TAIL_OFFSETS.entries.associate { (bodyPart, tailOffset) -> + val bones = DEFAULT_BONE_OFFSETS.entries.associate { (bodyPart, tailOffset) -> val restRotation = when (bodyPart) { BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> Quaternion.rotationAroundXAxis(FastMath.HALF_PI) else -> Quaternion.IDENTITY @@ -51,7 +51,7 @@ fun buildBones( state: SkeletonState, rootHead: Vector3 = Vector3.NULL, hierarchy: Sequence> = iterateBodyPartHierarchy(), - tailDirections: Map = BONE_TAIL_DIRECTIONS, + tailDirections: Map = DEFAULT_BONE_DIRECTIONS, ): Map { val result = mutableMapOf() hierarchy.forEach { (parentPart, childPart) -> diff --git a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt index 1d0602241d..22373eb84a 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt @@ -45,7 +45,7 @@ private val HEIGHT_CONTRIBUTING_BONES: Set = setOf( ) // Maps each SolarXR SkeletonBone to the BodyPart(s) it controls in the skeleton with vectors for offset directions. -private val SKELETON_BONE_FLOAT_TO_BODY_PARTS: Map> = mapOf( +private val BONE_VALUE_TO_OFFSETS: Map> = mapOf( SkeletonBone.HEAD to mapOf(BodyPart.HEAD to Vector3.POS_Z), SkeletonBone.NECK to mapOf(BodyPart.NECK to Vector3.NEG_Y), SkeletonBone.UPPER_CHEST to mapOf(BodyPart.UPPER_CHEST to Vector3.NEG_Y), @@ -69,7 +69,7 @@ private val SKELETON_BONE_FLOAT_TO_BODY_PARTS: Map> = SKELETON_BONE_FLOAT_TO_BODY_PARTS +private val BONE_OFFSET_TO_VALUES: Map> = BONE_VALUE_TO_OFFSETS .flatMap { (cfg, bones) -> // Invert map, splitting entries [ List>> ] // Vector also needs to be inverted ((vec/len)/len)==(1/vec) @@ -82,7 +82,7 @@ private val BODY_PART_VECTOR_TO_SKELETON_BONES: Map> = SKELETON_BONE_FLOAT_TO_BODY_PARTS.mapValues { it.value.keys } +val SKELETON_BONE_TO_BODY_PARTS: Map> = BONE_VALUE_TO_OFFSETS.mapValues { it.value.keys } // Sum of default bone lengths for height-contributing bones // Used to normalize HEIGHT_SCALED_BONE_RATIOS. @@ -113,22 +113,20 @@ fun computeAllDefaultProportionsByBone(height: Float): Map { return nonScaled + heightScaled } -fun Map.toBodyPartOffsets(): Map = this +fun Map.toBoneOffsets(): Map = this .flatMap { (cfg, length) -> - SKELETON_BONE_FLOAT_TO_BODY_PARTS[cfg]?.map { (bone, vec) -> bone to length * vec } ?: emptyList() + BONE_VALUE_TO_OFFSETS[cfg]?.map { (bone, vec) -> bone to length * vec } ?: emptyList() } .groupBy({ it.first }, { it.second }) .mapValues { it.value.fold(Vector3.NULL) { acc, value -> acc + value } } -fun Map.toSkeletonBoneValues(): Map = this +fun Map.toBoneValues(): Map = this .flatMap { (bone, vec) -> - BODY_PART_VECTOR_TO_SKELETON_BONES[bone]?.map { (cfg, cfgVec) -> cfg to vec.hadamard(cfgVec).len() } ?: emptyList() + BONE_OFFSET_TO_VALUES[bone]?.map { (cfg, cfgVec) -> cfg to vec.hadamard(cfgVec).len() } ?: emptyList() } .groupBy({ it.first }, { it.second }) .mapValues { it.value.sum() } -fun configToSkeletonBoneValues(proportions: Map): Map = proportions.mapKeys { +fun configToBoneValues(proportions: Map): Map = proportions.mapKeys { SkeletonBone.entries.firstOrNull { cfg -> cfg.name == it.key } ?: SkeletonBone.NONE } - -fun expandProportions(proportions: Map): Map = configToSkeletonBoneValues(proportions).toBodyPartOffsets() diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt index b5e808c030..5def9ba281 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt @@ -6,7 +6,7 @@ import dev.slimevr.skeleton.SKELETON_BONE_TO_BODY_PARTS import dev.slimevr.skeleton.Skeleton import dev.slimevr.skeleton.computeAllDefaultProportionsByBone import dev.slimevr.skeleton.computeDefaultProportionsByBone -import dev.slimevr.skeleton.configToSkeletonBoneValues +import dev.slimevr.skeleton.configToBoneValues import dev.slimevr.skeleton.height import solarxr_protocol.rpc.ChangeSettingsRequest import solarxr_protocol.rpc.ChangeSkeletonConfigRequest @@ -31,7 +31,7 @@ class SkeletonBehaviour( ?: return@mapNotNull null SkeletonPart(bone = skeletonBone, value = length) } - val expanded = configToSkeletonBoneValues(proportions) + val expanded = configToBoneValues(proportions) val userHeight = if (expanded.isNotEmpty()) { expanded.height() } else { From 9d3afda7392a6e57992ba075305655956226e052 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Sat, 9 May 2026 16:22:53 +0200 Subject: [PATCH 095/350] Current vrcosc progress --- server/core/build.gradle.kts | 3 +- .../src/main/java/dev/slimevr/app-context.kt | 4 + .../main/java/dev/slimevr/config/settings.kt | 66 +++- .../main/java/dev/slimevr/config/vrcosc.kt | 32 ++ .../main/java/dev/slimevr/device/module.kt | 2 + .../core/src/main/java/dev/slimevr/logger.kt | 1 + .../src/main/java/dev/slimevr/osc/types.kt | 8 + .../java/dev/slimevr/oscquery/discovery.kt | 150 ++++++++ .../main/java/dev/slimevr/oscquery/http.kt | 68 ++++ .../main/java/dev/slimevr/oscquery/model.kt | 93 +++++ .../main/java/dev/slimevr/oscquery/server.kt | 104 ++++++ .../main/java/dev/slimevr/oscquery/tree.kt | 64 ++++ .../main/java/dev/slimevr/solarxr/module.kt | 1 + .../main/java/dev/slimevr/solarxr/vrcosc.kt | 120 +++++++ .../src/main/java/dev/slimevr/vrcosc/input.kt | 241 +++++++++++++ .../main/java/dev/slimevr/vrcosc/module.kt | 129 +++++++ .../main/java/dev/slimevr/vrcosc/oscquery.kt | 201 +++++++++++ .../main/java/dev/slimevr/vrcosc/output.kt | 336 ++++++++++++++++++ .../main/java/dev/slimevr/vrcosc/settings.kt | 26 ++ .../src/main/java/dev/slimevr/vrcosc/utils.kt | 40 +++ .../src/test/java/dev/slimevr/TestServer.kt | 4 + .../dev/slimevr/oscquery/OscQueryModelTest.kt | 69 ++++ .../slimevr/oscquery/OscQueryServerTest.kt | 105 ++++++ .../src/main/java/dev/slimevr/desktop/Main.kt | 3 + 24 files changed, 1866 insertions(+), 4 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/config/vrcosc.kt create mode 100644 server/core/src/main/java/dev/slimevr/oscquery/discovery.kt create mode 100644 server/core/src/main/java/dev/slimevr/oscquery/http.kt create mode 100644 server/core/src/main/java/dev/slimevr/oscquery/model.kt create mode 100644 server/core/src/main/java/dev/slimevr/oscquery/server.kt create mode 100644 server/core/src/main/java/dev/slimevr/oscquery/tree.kt create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt create mode 100644 server/core/src/main/java/dev/slimevr/vrcosc/input.kt create mode 100644 server/core/src/main/java/dev/slimevr/vrcosc/module.kt create mode 100644 server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt create mode 100644 server/core/src/main/java/dev/slimevr/vrcosc/output.kt create mode 100644 server/core/src/main/java/dev/slimevr/vrcosc/settings.kt create mode 100644 server/core/src/main/java/dev/slimevr/vrcosc/utils.kt create mode 100644 server/core/src/test/java/dev/slimevr/oscquery/OscQueryModelTest.kt create mode 100644 server/core/src/test/java/dev/slimevr/oscquery/OscQueryServerTest.kt diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index d7758704d4..ea66305173 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -83,8 +83,7 @@ dependencies { // Allow the use of reflection implementation(kotlin("reflect")) - // Jitpack - implementation("com.github.SlimeVR:oscquery-kt:566a0cba58") + implementation("com.appstractive:dns-sd-kt:1.1.0") testImplementation(kotlin("test")) // Use JUnit test framework diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index 0cd2c67d46..4a2977288d 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -12,6 +12,7 @@ import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer import dev.slimevr.vmc.VMCManager import dev.slimevr.vrchat.VRCConfigManager +import dev.slimevr.vrcosc.VRCOSCManager interface Phase1ContextProvider { val server: VRServer @@ -36,6 +37,7 @@ interface AppContextProvider : Phase1ContextProvider { val udpServer: UdpServer val bvhManager: BVHManager val vmcManager: VMCManager + val vrcOscManager: VRCOSCManager fun startObserving() } @@ -53,6 +55,7 @@ class AppContext( override val udpServer: UdpServer, override val bvhManager: BVHManager, override val vmcManager: VMCManager, + override val vrcOscManager: VRCOSCManager, ) : AppContextProvider { override fun startObserving() { skeleton.startObserving() @@ -64,5 +67,6 @@ class AppContext( trackingChecklist.startObserving(this) udpServer.startReceiving(this, server.context.scope) vmcManager.startObserving() + vrcOscManager.startObserving() } } diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 552027de2b..f805bb85c9 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject @@ -16,7 +18,7 @@ import kotlinx.serialization.json.jsonPrimitive import solarxr_protocol.datatypes.BodyPart import java.io.File -private const val SETTINGS_CONFIG_VERSION = 1 +private const val SETTINGS_CONFIG_VERSION = 2 @Serializable data class TrackerConfig( @@ -36,18 +38,78 @@ data class SettingsConfigState( val trackers: Map = emptyMap(), val globalMagEnabled: Boolean = true, val allowedUdpDevices: Set = emptySet(), + val vrcOscConfig: VRCOSCConfig = defaultVrcOscConfig(), val vmcConfig: VMCConfig = VMCConfig(), val version: Int = SETTINGS_CONFIG_VERSION, ) +private fun defaultVrcOscConfig() = VRCOSCConfig() + private fun migrateSettingsConfig(json: JsonObject): JsonObject { val version = json["version"]?.jsonPrimitive?.intOrNull ?: 0 return when { - // add migration branches here as: version < N -> migrateSettingsConfig(...) + version < 2 -> migrateSettingsConfig(migrateSettingsConfigV2(json)) else -> json } } +private fun migrateSettingsConfigV2(json: JsonObject): JsonObject { + val vrcOscConfig = json["vrcOscConfig"]?.jsonObject + val migratedVrcOscConfig = if (vrcOscConfig == null || "manualNetwork" in vrcOscConfig) { + vrcOscConfig?.let(::normalizeVrcOscConfig) + } else { + val portIn = vrcOscConfig["portIn"]?.jsonPrimitive?.intOrNull ?: 9001 + val portOut = vrcOscConfig["portOut"]?.jsonPrimitive?.intOrNull ?: 9000 + val address = vrcOscConfig["address"]?.jsonPrimitive?.contentOrNull.orEmpty() + val oscqueryEnabled = vrcOscConfig["oscqueryEnabled"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: true + val useAutomaticNetwork = + oscqueryEnabled && + portIn == 9001 && + portOut == 9000 && + (address.isBlank() || address == "127.0.0.1" || address == "localhost") + + buildMap { + vrcOscConfig["enabled"]?.let { put("enabled", it) } + vrcOscConfig["trackers"]?.let { put("trackers", it) } + if (!useAutomaticNetwork) { + put( + "manualNetwork", + JsonObject( + mapOf( + "portIn" to JsonPrimitive(portIn), + "portOut" to JsonPrimitive(portOut), + "address" to JsonPrimitive(address), + ), + ), + ) + } + }.let(::JsonObject) + } + + return JsonObject( + json.toMutableMap().apply { + put("version", JsonPrimitive(SETTINGS_CONFIG_VERSION)) + if (migratedVrcOscConfig != null) put("vrcOscConfig", migratedVrcOscConfig) + }, + ) +} + +private fun normalizeVrcOscConfig(vrcOscConfig: JsonObject): JsonObject { + val manualNetwork = vrcOscConfig["manualNetwork"]?.jsonObject ?: return vrcOscConfig + val normalizedManualNetwork = JsonObject( + mapOf( + "portIn" to (manualNetwork["portIn"] ?: JsonPrimitive(DEFAULT_VRC_OSC_PORT_IN)), + "portOut" to (manualNetwork["portOut"] ?: JsonPrimitive(DEFAULT_VRC_OSC_PORT_OUT)), + "address" to (manualNetwork["address"] ?: JsonPrimitive(DEFAULT_VRC_OSC_ADDRESS)), + ), + ) + return JsonObject( + vrcOscConfig.toMutableMap().apply { + put("manualNetwork", normalizedManualNetwork) + }, + ) +} + private fun parseAndMigrateSettingsConfig(raw: String): SettingsConfigState { val json = jsonConfig.parseToJsonElement(raw).jsonObject return jsonConfig.decodeFromJsonElement(migrateSettingsConfig(json)) diff --git a/server/core/src/main/java/dev/slimevr/config/vrcosc.kt b/server/core/src/main/java/dev/slimevr/config/vrcosc.kt new file mode 100644 index 0000000000..613a36fe62 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/config/vrcosc.kt @@ -0,0 +1,32 @@ +package dev.slimevr.config + +import kotlinx.serialization.Serializable + +internal const val DEFAULT_VRC_OSC_PORT_IN: Int = 9001 +internal const val DEFAULT_VRC_OSC_PORT_OUT: Int = 9000 +internal const val DEFAULT_VRC_OSC_ADDRESS: String = "127.0.0.1" + +@Serializable +data class VRCOSCTrackers( + val head: Boolean = false, + val chest: Boolean = false, + val waist: Boolean = true, + val knees: Boolean = false, + val feet: Boolean = true, + val elbows: Boolean = false, + val hands: Boolean = false, +) + +@Serializable +data class VRCOSCManualNetworkConfig( + val portIn: Int, + val portOut: Int, + val address: String, +) + +@Serializable +data class VRCOSCConfig( + val enabled: Boolean = false, + val manualNetwork: VRCOSCManualNetworkConfig? = null, + val trackers: VRCOSCTrackers = VRCOSCTrackers(), +) diff --git a/server/core/src/main/java/dev/slimevr/device/module.kt b/server/core/src/main/java/dev/slimevr/device/module.kt index 416418e365..48f54f877c 100644 --- a/server/core/src/main/java/dev/slimevr/device/module.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -14,6 +14,8 @@ enum class DeviceOrigin { FEEDER, UDP, HID, + OSC, + VRC, } data class DeviceState( diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index bbce48538e..07d89e458a 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -17,6 +17,7 @@ object AppLogger { val vrc = logger("VRChat") val bvh = logger("BVH") val vmc = logger("VMC") + val oscQuery = logger("OSCQuery") init { loggingConfiguration { diff --git a/server/core/src/main/java/dev/slimevr/osc/types.kt b/server/core/src/main/java/dev/slimevr/osc/types.kt index d9247a4bd7..e9ea69ade6 100644 --- a/server/core/src/main/java/dev/slimevr/osc/types.kt +++ b/server/core/src/main/java/dev/slimevr/osc/types.kt @@ -29,6 +29,14 @@ sealed class OscArg { True -> 'T' False -> 'F' } + + fun asFloatOrNull(): kotlin.Float? = when (this) { + is Float -> value + is Double -> value.toFloat() + is Int -> value.toFloat() + is Long -> value.toFloat() + else -> null + } } data class OscMessage(val address: String, val args: List = emptyList()) diff --git a/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt b/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt new file mode 100644 index 0000000000..067bc28935 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt @@ -0,0 +1,150 @@ +package dev.slimevr.oscquery + +import dev.slimevr.AppLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.Inet4Address +import java.net.InetAddress +import java.net.NetworkInterface +import java.util.Collections +import javax.jmdns.JmDNS +import javax.jmdns.ServiceEvent +import javax.jmdns.ServiceListener + +private const val OSC_JSON_SERVICE_TYPE = "_oscjson._tcp.local." + +/** + * Listens for OSCQuery (`_oscjson._tcp` by default) services on the local + * network and exposes the resolved set as a [StateFlow]. + * + * Binds one JmDNS instance per IPv4 site-local interface — `JmDNS.create()` + * with no arguments only listens on a single auto-picked NIC, which on + * multi-NIC hosts (Wi-Fi + Ethernet, virtual adapters) frequently misses the + * LAN the headset is actually on. + */ +class OscQueryDiscovery( + private val serviceType: String = OSC_JSON_SERVICE_TYPE, + private val serviceFilter: (String) -> Boolean = { true }, +) { + private val mutableServices = MutableStateFlow>(emptyList()) + val services: StateFlow> = mutableServices.asStateFlow() + + private var job: Job? = null + + fun start(scope: CoroutineScope) { + if (job != null) return + job = scope.launch(Dispatchers.IO) { + val instances = mutableListOf() + try { + val addresses = enumerateBindAddresses() + if (addresses.isEmpty()) { + AppLogger.oscQuery.warn("OSCQuery discovery: no usable interface addresses found, falling back to default JmDNS bind") + } + + for (address in addresses.ifEmpty { listOf(null) }) { + val instance = try { + JmDNS.create(address, null) + } catch (e: Exception) { + AppLogger.oscQuery.error("OSCQuery discovery: failed to create JmDNS on $address", e) + continue + } + instance.addServiceListener(serviceType, listener(this, instance)) + instances.add(instance) + } + + awaitCancellation() + } finally { + withContext(NonCancellable) { + for (instance in instances) { + try { + instance.close() + } catch (e: Exception) { + AppLogger.oscQuery.error("OSCQuery discovery: failed to close JmDNS instance", e) + } + } + mutableServices.value = emptyList() + } + } + } + } + + fun close() { + job?.cancel() + job = null + } + + // JmDNS callbacks fire on its own threads and can't be suspend. The shims + // hand work back to the coroutine scope so log ordering and state mutations + // stay sequential. + private fun listener(scope: CoroutineScope, instance: JmDNS) = object : ServiceListener { + override fun serviceAdded(event: ServiceEvent) { + instance.requestServiceInfo(event.type, event.name) + } + + override fun serviceRemoved(event: ServiceEvent) { + scope.launch { onRemoved(event.name) } + } + + override fun serviceResolved(event: ServiceEvent) { + scope.launch { onResolved(event) } + } + } + + private fun onRemoved(name: String) { + if (!serviceFilter(name)) return + mutableServices.value = mutableServices.value.filterNot { entry -> entry.name == name } + } + + private suspend fun onResolved(event: ServiceEvent) { + val name = event.name + if (!serviceFilter(name)) return + val info = event.info ?: return + + val addresses = info.inet4Addresses?.map { addr -> addr.hostAddress }?.toList().orEmpty() + if (addresses.isEmpty()) { + // This JmDNS instance only resolved IPv6 entries — another instance on + // a different interface will have the IPv4 view. + return + } + + val resolved = OscQueryService( + name = name, + addresses = addresses, + host = info.inetAddresses?.firstOrNull()?.canonicalHostName.orEmpty(), + type = event.type, + port = info.port, + txt = buildMap { + info.propertyNames?.iterator()?.forEach { key -> + val value = info.getPropertyString(key) + put(key.toString(), value.orEmpty()) + } + }, + ) + AppLogger.oscQuery.info("OSCQuery discovered service: name=$name address=${addresses.first()} port=${info.port}") + mutableServices.value = mutableServices.value + .filterNot { existing -> existing.name == name } + .plus(resolved) + .sortedBy { entry -> entry.name } + } +} + +private fun enumerateBindAddresses(): List = try { + Collections.list(NetworkInterface.getNetworkInterfaces()) + .asSequence() + .filter { iface -> iface.isUp && !iface.isLoopback && !iface.isVirtual } + .flatMap { iface -> Collections.list(iface.inetAddresses).asSequence() } + .filterIsInstance() + .filter { address -> !address.isLoopbackAddress && address.isSiteLocalAddress } + .distinct() + .toList() +} catch (_: Exception) { + emptyList() +} diff --git a/server/core/src/main/java/dev/slimevr/oscquery/http.kt b/server/core/src/main/java/dev/slimevr/oscquery/http.kt new file mode 100644 index 0000000000..c2da71574c --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/oscquery/http.kt @@ -0,0 +1,68 @@ +package dev.slimevr.oscquery + +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.netty.Netty +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.param +import io.ktor.server.routing.routing +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +internal class OscQueryHttpServer( + private val hostInfo: () -> OscQueryHostInfo, + private val tree: OscQueryTree, +) { + private val mutex = Mutex() + private var engine: EmbeddedServer<*, *>? = null + private var boundPort: UShort = 0u + + val port: UShort + get() = boundPort + + suspend fun start(scope: CoroutineScope, address: String = "0.0.0.0", port: UShort = 0u): UShort = mutex.withLock { + if (engine != null) return boundPort + + val started = embeddedServer(Netty, host = address, port = port.toInt()) { + routing { + // `GET /?HOST_INFO` returns server host info. Must come before the + // plain root route so ktor matches the parameterised route first. + param("HOST_INFO") { get("/") { respondHostInfo(call) } } + get("/") { respondNode(call, "/") } + get("/{path...}") { + val path = call.parameters.getAll("path").orEmpty().joinToString("/", prefix = "/") + respondNode(call, path) + } + } + }.start(wait = false) + + engine = started + boundPort = started.engine.resolvedConnectors().firstOrNull()?.port?.toUShort() ?: port + boundPort + } + + fun close() { + val started = engine + engine = null + boundPort = 0u + started?.stop(gracePeriodMillis = 1_000, timeoutMillis = 2_000) + } + + private suspend fun respondHostInfo(call: ApplicationCall) { + call.respondText(oscQueryJson.encodeToString(hostInfo()), ContentType.Application.Json) + } + + private suspend fun respondNode(call: ApplicationCall, path: String) { + val node = tree.find(path) + if (node == null) { + call.respondText("Not Found", ContentType.Text.Plain, status = HttpStatusCode.NotFound) + return + } + call.respondText(oscQueryJson.encodeToString(node), ContentType.Application.Json) + } +} diff --git a/server/core/src/main/java/dev/slimevr/oscquery/model.kt b/server/core/src/main/java/dev/slimevr/oscquery/model.kt new file mode 100644 index 0000000000..f8daacc5e6 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/oscquery/model.kt @@ -0,0 +1,93 @@ +package dev.slimevr.oscquery + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement + +internal val oscQueryJson = Json { + encodeDefaults = true + ignoreUnknownKeys = true + // OSCQuery clients (notably VRChat) treat omitted fields as "absent" rather + // than "null"; some refuse responses peppered with explicit null values. + explicitNulls = false +} + +enum class OscQueryTransport { + TCP, + UDP, +} + +enum class OscQueryExtension { + ACCESS, + VALUE, + RANGE, + DESCRIPTION, + TAGS, + EXTENDED_TYPE, + UNIT, + CRITICAL, + CLIPMODE, + OVERLOADS, + HTML, + LISTEN, +} + +@Serializable +data class OscQueryHostInfo( + @SerialName("NAME") + val name: String, + @SerialName("OSC_IP") + val oscIp: String, + @SerialName("OSC_PORT") + val oscPort: UShort, + @SerialName("OSC_TRANSPORT") + val oscTransport: OscQueryTransport = OscQueryTransport.UDP, + @SerialName("WS_IP") + val websocketIp: String? = null, + @SerialName("WS_PORT") + val websocketPort: UShort? = null, + @SerialName("EXTENSIONS") + val extensions: Map = mapOf(OscQueryExtension.VALUE to true), +) + +object OscQueryAccess { + const val READ = 1 + const val WRITE = 2 + const val READ_WRITE = READ or WRITE +} + +@Serializable +data class OscQueryNode( + @SerialName("FULL_PATH") + val fullPath: String, + @SerialName("TYPE") + val type: String? = null, + @SerialName("ACCESS") + val access: Int? = null, + @SerialName("VALUE") + val value: List? = null, + @SerialName("DESCRIPTION") + val description: String? = null, + @SerialName("CONTENTS") + val contents: Map? = null, +) { + val name: String + get() = fullPath.substringAfterLast('/').ifEmpty { "/" } + + val parentPath: String + get() { + if (fullPath == "/") return "/" + val parentEnd = fullPath.lastIndexOf('/').coerceAtLeast(0) + return fullPath.substring(0, parentEnd).ifEmpty { "/" } + } +} + +data class OscQueryService( + val name: String, + val addresses: List, + val host: String, + val type: String, + val port: Int, + val txt: Map, +) diff --git a/server/core/src/main/java/dev/slimevr/oscquery/server.kt b/server/core/src/main/java/dev/slimevr/oscquery/server.kt new file mode 100644 index 0000000000..bd83b59d19 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/oscquery/server.kt @@ -0,0 +1,104 @@ +package dev.slimevr.oscquery + +import com.appstractive.dnssd.NetService +import com.appstractive.dnssd.createNetService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext + +private const val OSC_JSON_SERVICE_TYPE = "_oscjson._tcp" +private const val OSC_UDP_SERVICE_TYPE = "_osc._udp" + +typealias OscQueryServiceFactory = ( + type: String, + name: String, + port: Int, + priority: Int, + weight: Int, + addresses: List?, + txt: Map, +) -> NetService + +class OscQueryServer( + private val name: String, + private val address: String, + private var oscPort: UShort, + private val tree: OscQueryTree = OscQueryTree(), + private val transport: OscQueryTransport = OscQueryTransport.UDP, + private val serviceFactory: OscQueryServiceFactory = ::createNetService, +) { + private val httpServer = OscQueryHttpServer(hostInfo = ::buildHostInfo, tree = tree) + private var oscQueryService: NetService? = null + private var oscService: NetService? = null + private var started = false + + val oscQueryPort: UShort + get() = httpServer.port + + fun addNode(node: OscQueryNode) = tree.add(node) + + fun removeNode(path: String): Boolean = tree.remove(path) + + suspend fun start(scope: CoroutineScope) { + if (started) return + val httpPort = httpServer.start(scope) + oscQueryService = createOscQueryService(httpPort).also { it.register() } + oscService = createOscService(oscPort).also { it.register() } + started = true + } + + suspend fun updateOscPort(port: UShort) { + if (oscPort == port) return + oscPort = port + if (!started) return + withContext(NonCancellable) { + oscService?.unregister() + oscService = createOscService(port).also { it.register() } + } + } + + suspend fun close() { + withContext(NonCancellable) { + oscService?.unregister() + oscService = null + oscQueryService?.unregister() + oscQueryService = null + } + httpServer.close() + started = false + } + + private fun createOscQueryService(httpPort: UShort) = createService( + type = OSC_JSON_SERVICE_TYPE, + name = "$name-$httpPort", + port = httpPort.toInt(), + txt = mapOf( + "txtvers" to "1", + "osc_transport" to transport.name.lowercase(), + "osc_port" to oscPort.toString(), + ), + ) + + private fun createOscService(port: UShort) = createService( + type = OSC_UDP_SERVICE_TYPE, + name = "$name-$oscQueryPort", + port = port.toInt(), + txt = mapOf( + "txtvers" to "1", + "oscquery_port" to oscQueryPort.toString(), + ), + ) + + // Pin the advertised address to our resolved local IP. Otherwise dns-sd-kt + // falls back to `getLocalAddresses().firstOrNull()` which iterates every NIC + // and may pick a virtual / wrong-subnet adapter — making the service + // unreachable from headsets on the actual LAN. + private fun createService(type: String, name: String, port: Int, txt: Map): NetService = serviceFactory(type, name, port, 0, 1, listOf(address), txt) + + private fun buildHostInfo() = OscQueryHostInfo( + name = name, + oscIp = address, + oscPort = oscPort, + oscTransport = transport, + ) +} diff --git a/server/core/src/main/java/dev/slimevr/oscquery/tree.kt b/server/core/src/main/java/dev/slimevr/oscquery/tree.kt new file mode 100644 index 0000000000..ef9d99006a --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/oscquery/tree.kt @@ -0,0 +1,64 @@ +package dev.slimevr.oscquery + +class OscQueryTree(nodes: Iterable = emptyList()) { + private val nodesByPath = mutableMapOf() + + init { + nodesByPath["/"] = OscQueryNode(fullPath = "/", contents = emptyMap()) + nodes.forEach(::add) + } + + fun add(node: OscQueryNode) { + require(node.fullPath.startsWith("/")) { "OSCQuery node path must start with '/': ${node.fullPath}" } + require(node.fullPath.length == 1 || !node.fullPath.endsWith("/")) { + "OSCQuery node path must not end with '/': ${node.fullPath}" + } + + ensureParent(node.parentPath) + nodesByPath[node.fullPath] = node + rebuildContents() + } + + fun remove(path: String): Boolean { + val normalized = normalizePath(path) + if (normalized == "/") return false + val removed = nodesByPath.remove(normalized) != null + if (removed) { + val prefix = "$normalized/" + nodesByPath.keys.filter { it.startsWith(prefix) }.forEach { nodesByPath.remove(it) } + rebuildContents() + } + return removed + } + + fun find(path: String): OscQueryNode? = nodesByPath[normalizePath(path)] + + private fun ensureParent(path: String) { + if (nodesByPath.containsKey(path)) return + if (path != "/") ensureParent(OscQueryNode(path).parentPath) + nodesByPath[path] = OscQueryNode(fullPath = path) + } + + private fun rebuildContents() { + val source = nodesByPath.toMap() + + fun rebuildNode(path: String): OscQueryNode { + val node = source.getValue(path) + val children = source.values + .filter { it.fullPath != path && it.parentPath == path } + .sortedBy { it.name } + .associate { it.name to rebuildNode(it.fullPath) } + return node.copy(contents = children.ifEmpty { null }) + } + + val rebuilt = source.keys.associateWith(::rebuildNode) + nodesByPath.clear() + nodesByPath.putAll(rebuilt) + } +} + +internal fun normalizePath(path: String): String { + val withoutQuery = path.substringBefore('?') + val prefixed = if (withoutQuery.startsWith('/')) withoutQuery else "/$withoutQuery" + return prefixed.trimEnd('/').ifEmpty { "/" } +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 83b4cd9521..66e086a885 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -72,6 +72,7 @@ class SolarXRBridge( appContext.vrcConfigManager?.let { vrc -> add(VrcBehaviour(vrc, appContext.server, userHeight = { appContext.skeleton.context.state.value.userHeight })) } + add(VrcOscBehaviour(appContext.vrcOscManager)) add(VmcBehaviour(appContext.vmcManager)) add(SettingsBehaviour(appContext.vmcManager)) add(HeightCalibrationBehaviour(appContext.heightCalibrationManager)) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt new file mode 100644 index 0000000000..f2a258f897 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt @@ -0,0 +1,120 @@ +package dev.slimevr.solarxr + +import dev.slimevr.config.VRCOSCConfig +import dev.slimevr.config.VRCOSCManualNetworkConfig +import dev.slimevr.config.VRCOSCTrackers +import dev.slimevr.vrcosc.VRCOSCActions +import dev.slimevr.vrcosc.VRCOSCManager +import dev.slimevr.vrcosc.VRCOSCStatus +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.sample +import solarxr_protocol.rpc.ChangeVRCOSCSettingsRequest +import solarxr_protocol.rpc.OSCTrackersSetting +import solarxr_protocol.rpc.VRCOSCNetworkSettings +import solarxr_protocol.rpc.VRCOSCSettings +import solarxr_protocol.rpc.VRCOSCSettingsRequest +import solarxr_protocol.rpc.VRCOSCSettingsResponse +import solarxr_protocol.rpc.VRCOSCStatusChangeResponse +import solarxr_protocol.rpc.VRCOSCStatusRequest +import solarxr_protocol.rpc.VRCOSCDiscoveredTarget as RpcVRCOSCDiscoveredTarget + +private const val STATUS_SAMPLE_MS = 300L + +internal class VrcOscBehaviour( + private val vrcOscManager: VRCOSCManager, +) : SolarXRBridgeBehaviour { + @OptIn(FlowPreview::class) + override fun observe(receiver: SolarXRBridge) { + // Rate-limit status pushes so brief output-state flips (e.g. a single + // successful retry packet between two failures) do not reach the GUI. + // `sample` emits at most one value per window without starving when + // updates keep coming faster than the window. + vrcOscManager.context.state + .map { state -> state.status } + .drop(1) + .sample(STATUS_SAMPLE_MS) + .onEach { status -> receiver.sendRpc(buildStatusResponse(status, vrcOscManager.context.state.value.config.enabled)) } + .launchIn(receiver.context.scope) + + receiver.rpcDispatcher.on { + receiver.sendRpc(VRCOSCSettingsResponse(settings = buildVrcOscSettings(vrcOscManager.context.state.value.config))) + } + + receiver.rpcDispatcher.on { + val state = vrcOscManager.context.state.value + receiver.sendRpc(buildStatusResponse(state.status, state.config.enabled)) + } + + receiver.rpcDispatcher.on { req -> + val vrcOsc = req.settings ?: return@on + val trackers = vrcOsc.trackers + vrcOscManager.context.dispatch( + VRCOSCActions.UpdateConfig( + VRCOSCConfig( + enabled = vrcOsc.enabled, + manualNetwork = vrcOsc.manualNetwork?.let { network -> + VRCOSCManualNetworkConfig( + portIn = network.portIn.toInt(), + portOut = network.portOut.toInt(), + address = network.address.orEmpty(), + ) + }, + trackers = VRCOSCTrackers( + head = trackers?.head ?: false, + chest = trackers?.chest ?: false, + waist = trackers?.waist ?: true, + knees = trackers?.knees ?: false, + feet = trackers?.feet ?: true, + elbows = trackers?.elbows ?: false, + hands = trackers?.hands ?: false, + ), + ), + ), + ) + } + } +} + +internal fun buildStatusResponse(status: VRCOSCStatus, enabled: Boolean) = VRCOSCStatusChangeResponse( + enabled = enabled, + inputState = status.inputState, + inputPort = status.inputPort?.toUShort(), + inputError = status.inputError, + lastReceivedInputMillis = status.lastReceivedInputMillis?.toULong(), + outputState = status.outputState, + outputError = status.outputError, + targetAddress = status.targetAddress, + targetPort = status.targetPort?.toUShort(), + targetSource = status.targetSource, + lastFrameSentMillis = status.lastFrameSentMillis?.toULong(), + oscqueryState = status.oscQueryState, + oscqueryAdvertisedPort = status.oscQueryAdvertisedPort?.toUShort(), + oscqueryError = status.oscQueryError, + discoveredTargets = status.discoveredTargets.map { target -> + RpcVRCOSCDiscoveredTarget(name = target.name, address = target.address, portOut = target.portOut.toUShort()) + }, +) + +internal fun buildVrcOscSettings(config: VRCOSCConfig): VRCOSCSettings = VRCOSCSettings( + trackers = OSCTrackersSetting( + head = config.trackers.head, + chest = config.trackers.chest, + waist = config.trackers.waist, + knees = config.trackers.knees, + feet = config.trackers.feet, + elbows = config.trackers.elbows, + hands = config.trackers.hands, + ), + enabled = config.enabled, + manualNetwork = config.manualNetwork?.let { manual -> + VRCOSCNetworkSettings( + portIn = manual.portIn.toUShort(), + portOut = manual.portOut.toUShort(), + address = manual.address, + ) + }, +) diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt new file mode 100644 index 0000000000..270c006477 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt @@ -0,0 +1,241 @@ +package dev.slimevr.vrcosc + +import dev.slimevr.AppLogger +import dev.slimevr.Phase1ContextProvider +import dev.slimevr.VRServerActions +import dev.slimevr.device.Device +import dev.slimevr.device.DeviceActions +import dev.slimevr.device.DeviceOrigin +import dev.slimevr.osc.OscArg +import dev.slimevr.osc.OscBundle +import dev.slimevr.osc.OscContent +import dev.slimevr.osc.OscMessage +import dev.slimevr.osc.OscReceiver +import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerActions +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.rpc.VRCOSCInputState + +/** + * Holds the long-lived VRC VRSystem device + head/wrist trackers. Trackers are + * created lazily when their first packet arrives and reused afterwards. + * + * The runtime cannot remove existing devices/trackers from the VRServer, so on + * disable we only mark them DISCONNECTED — they are reused on the next enable. + */ +private class VRSystemTrackerRegistry( + private val ctx: Phase1ContextProvider, + private val manager: VRCOSCManager, +) { + private var deviceId: Int? = null + private val trackerIds = mutableMapOf() + + fun trackerFor(tracker: VRSystemTracker): Tracker { + trackerIds[tracker]?.let { id -> ctx.server.getTracker(id)?.let { existing -> return existing } } + + val device = findOrCreateDevice() + val trackerId = ctx.server.nextHandle() + val bodyPart = when (tracker) { + VRSystemTracker.HEAD -> BodyPart.HEAD + VRSystemTracker.LEFT_WRIST -> BodyPart.LEFT_HAND + VRSystemTracker.RIGHT_WRIST -> BodyPart.RIGHT_HAND + } + val trackerName = when (tracker) { + VRSystemTracker.HEAD -> "VRChat head" + VRSystemTracker.LEFT_WRIST -> "VRChat left hand" + VRSystemTracker.RIGHT_WRIST -> "VRChat right hand" + } + val runtimeTracker = Tracker.create( + id = trackerId, + deviceId = device.context.state.value.id, + sensorType = null, + hardwareId = "vrcosc:vrsystem:${tracker.name.lowercase()}", + origin = DeviceOrigin.VRC, + scope = manager.context.scope, + server = ctx.server, + settings = ctx.config.settings, + ) + ctx.server.context.dispatch(VRServerActions.NewTracker(trackerId, runtimeTracker)) + runtimeTracker.context.dispatch( + TrackerActions.Update { + copy( + name = trackerName, + customName = trackerName, + bodyPart = bodyPart, + status = TrackerStatus.OK, + ) + }, + ) + trackerIds[tracker] = trackerId + return runtimeTracker + } + + fun setStatus(status: TrackerStatus) { + deviceId?.let { id -> ctx.server.getDevice(id) } + ?.context?.dispatch(DeviceActions.Update { copy(status = status) }) + for ((_, trackerId) in trackerIds) { + ctx.server.getTracker(trackerId)?.context?.dispatch(TrackerActions.Update { copy(status = status) }) + } + } + + private fun findOrCreateDevice(): Device { + deviceId?.let { id -> ctx.server.getDevice(id)?.let { return it } } + + val id = ctx.server.nextHandle() + val device = Device.create( + scope = manager.context.scope, + id = id, + address = "vrchat-vrsystem", + origin = DeviceOrigin.VRC, + protocolVersion = 0, + ) + device.context.dispatch( + DeviceActions.Update { + copy( + name = "VRC VRSystem", + status = TrackerStatus.OK, + ) + }, + ) + ctx.server.context.dispatch(VRServerActions.NewDevice(id, device)) + deviceId = id + return device + } +} + +class VRCOSCInputBehaviour( + private val ctx: Phase1ContextProvider, +) : VRCOSCBehaviour { + override fun reduce(state: VRCOSCState, action: VRCOSCActions) = when (action) { + is VRCOSCActions.SetInput -> state.copy( + status = state.status.copy( + inputState = action.state, + inputPort = action.port, + inputError = action.error, + ), + ) + + is VRCOSCActions.SetLastReceivedInput -> state.copy( + status = state.status.copy(lastReceivedInputMillis = action.millis), + ) + + else -> state + } + + override fun observe(receiver: VRCOSCManager) { + val registry = VRSystemTrackerRegistry(ctx, receiver) + var oscReceiver: OscReceiver? = null + + receiver.context.state + .map { state -> Pair(state.config.enabled, vrcOscPortIn(state.config)) } + .distinctUntilChanged() + .onEach { (enabled, portIn) -> + oscReceiver?.close() + oscReceiver = null + + if (!enabled) { + registry.setStatus(TrackerStatus.DISCONNECTED) + receiver.context.dispatch(VRCOSCActions.SetInput(state = VRCOSCInputState.IDLE)) + return@onEach + } + + val newReceiver = OscReceiver(portIn) + oscReceiver = newReceiver + receiver.context.dispatch( + VRCOSCActions.SetInput(state = VRCOSCInputState.LISTENING, port = portIn), + ) + AppLogger.vrc.info("VRChat OSC input listening on port $portIn") + + receiver.context.scope.launch { + try { + newReceiver.listenBundles { bundle -> handleBundle(bundle, registry, receiver, portIn) } + } catch (e: Exception) { + dispatchInputError( + receiver = receiver, + port = portIn, + message = "VRChat OSC receiver error", + throwable = e, + ) + } + } + }.launchIn(receiver.context.scope) + } + + private suspend fun dispatchInputError( + receiver: VRCOSCManager, + port: Int?, + message: String, + throwable: Throwable, + ) { + AppLogger.vrc.error(message, throwable) + receiver.context.dispatch( + VRCOSCActions.SetInput( + state = VRCOSCInputState.ERROR, + port = port, + error = formatExceptionMessage(message, throwable), + ), + ) + } + + private fun handleBundle( + bundle: OscBundle, + registry: VRSystemTrackerRegistry, + receiver: VRCOSCManager, + portIn: Int, + ) { + for (content in bundle.contents) { + when (content) { + is OscContent.Message -> handleIncomingMessage(content.msg, registry, receiver, portIn) + + is OscContent.Bundle -> for (nested in content.bundle.contents) { + if (nested is OscContent.Message) handleIncomingMessage(nested.msg, registry, receiver, portIn) + } + } + } + } + + private fun handleIncomingMessage( + message: OscMessage, + registry: VRSystemTrackerRegistry, + receiver: VRCOSCManager, + portIn: Int, + ) { + if (!message.address.startsWith("$TRACKING_VRSYSTEM_PATH/")) return + val tracker = when (message.address) { + "$TRACKING_VRSYSTEM_PATH/head/pose" -> VRSystemTracker.HEAD + "$TRACKING_VRSYSTEM_PATH/leftwrist/pose" -> VRSystemTracker.LEFT_WRIST + "$TRACKING_VRSYSTEM_PATH/rightwrist/pose" -> VRSystemTracker.RIGHT_WRIST + else -> return + } + + val position = parsePosition(message.args) ?: return + val rotation = parseVrcEulerRotation(message.args, startIndex = 3) ?: return + val runtimeTracker = registry.trackerFor(tracker) + runtimeTracker.context.dispatchAll( + listOf( + TrackerActions.Update { + copy( + position = position, + status = TrackerStatus.OK, + ) + }, + TrackerActions.SetRotation(rotation = rotation), + ), + ) + registry.setStatus(TrackerStatus.OK) + receiver.context.dispatchAll( + listOf( + VRCOSCActions.SetInput(state = VRCOSCInputState.LISTENING, port = portIn), + VRCOSCActions.SetLastReceivedInput(System.currentTimeMillis()), + ), + ) + } +} diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt new file mode 100644 index 0000000000..d13240ec4f --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt @@ -0,0 +1,129 @@ +package dev.slimevr.vrcosc + +import dev.slimevr.EventDispatcher +import dev.slimevr.Phase1ContextProvider +import dev.slimevr.config.VRCOSCConfig +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context +import dev.slimevr.skeleton.Skeleton +import io.github.axisangles.ktmath.Quaternion +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import solarxr_protocol.rpc.VRCOSCInputState +import solarxr_protocol.rpc.VRCOSCOscQueryState +import solarxr_protocol.rpc.VRCOSCOutputState +import solarxr_protocol.rpc.VRCOSCTargetSource + +data class OscSenderTarget( + val address: String, + val port: Int, +) + +data class VRCOSCDiscoveredTargetInfo( + val name: String, + val address: String, + val portOut: Int, +) + +/** + * Status of the VRC OSC subsystem. Mirrors the solarxr `VRCOSCStatusChangeResponse` + * one-to-one and reuses the generated enums, so `solarxr/vrcosc.kt` is a pass-through. + */ +data class VRCOSCStatus( + val inputState: VRCOSCInputState = VRCOSCInputState.IDLE, + val inputPort: Int? = null, + val inputError: String? = null, + val lastReceivedInputMillis: Long? = null, + + val outputState: VRCOSCOutputState = VRCOSCOutputState.IDLE, + val outputError: String? = null, + val targetAddress: String? = null, + val targetPort: Int? = null, + val targetSource: VRCOSCTargetSource = VRCOSCTargetSource.NONE, + val lastFrameSentMillis: Long? = null, + + val oscQueryState: VRCOSCOscQueryState = VRCOSCOscQueryState.DISABLED, + val oscQueryAdvertisedPort: Int? = null, + val oscQueryError: String? = null, + val discoveredTargets: List = emptyList(), +) + +data class VRCOSCState( + val config: VRCOSCConfig = VRCOSCConfig(), + val status: VRCOSCStatus = VRCOSCStatus(), +) + +enum class VRSystemTracker { + HEAD, + LEFT_WRIST, + RIGHT_WRIST, +} + +sealed interface VRCOSCActions { + data class UpdateConfig(val config: VRCOSCConfig) : VRCOSCActions + + data class SetInput( + val state: VRCOSCInputState, + val port: Int? = null, + val error: String? = null, + ) : VRCOSCActions + data class SetLastReceivedInput(val millis: Long) : VRCOSCActions + + data class SetOutput( + val state: VRCOSCOutputState, + val targetAddress: String? = null, + val targetPort: Int? = null, + val targetSource: VRCOSCTargetSource = VRCOSCTargetSource.NONE, + val error: String? = null, + ) : VRCOSCActions + data class SetLastFrameSent(val millis: Long) : VRCOSCActions + + data class SetOscQuery( + val state: VRCOSCOscQueryState, + val advertisedPort: Int? = null, + val error: String? = null, + ) : VRCOSCActions + data class SetDiscoveredTargets(val targets: List) : VRCOSCActions +} + +sealed interface VRCOSCEvent { + data class YawAlign(val headRotation: Quaternion) : VRCOSCEvent +} + +typealias VRCOSCContext = Context +typealias VRCOSCBehaviour = Behaviour + +class VRCOSCManager( + val context: VRCOSCContext, + val events: EventDispatcher = EventDispatcher(), +) { + fun startObserving() = context.observeAll(this) + + fun yawAlign(headRotation: Quaternion) { + context.scope.launch { + events.emit(VRCOSCEvent.YawAlign(headRotation)) + } + } + + companion object { + fun create(skeleton: Skeleton, ctx: Phase1ContextProvider, scope: CoroutineScope): VRCOSCManager { + val settings = ctx.config.settings + val initialConfig = settings.context.state.value.data.vrcOscConfig + val context = Context.create( + initialState = VRCOSCState( + config = initialConfig, + status = VRCOSCStatus(), + ), + scope = scope, + behaviours = listOf( + VRCOSCSettingsBehaviour(settings), + VRCOSCOutputBehaviour(skeleton), + VRCOSCInputBehaviour(ctx), + VRCOSCOscQueryBehaviour(), + ), + name = "VRCOSC", + ) + return VRCOSCManager(context) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt b/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt new file mode 100644 index 0000000000..ab86b74b33 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt @@ -0,0 +1,201 @@ +package dev.slimevr.vrcosc + +import dev.slimevr.AppLogger +import dev.slimevr.config.DEFAULT_VRC_OSC_PORT_OUT +import dev.slimevr.oscquery.OscQueryAccess +import dev.slimevr.oscquery.OscQueryDiscovery +import dev.slimevr.oscquery.OscQueryNode +import dev.slimevr.oscquery.OscQueryServer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.withContext +import solarxr_protocol.rpc.VRCOSCOscQueryState + +private const val VRCHAT_SERVICE_PREFIX = "VRChat-Client" + +class VRCOSCOscQueryBehaviour : VRCOSCBehaviour { + private class OscQueryRuntime( + val localIp: String, + ) { + var server: OscQueryServer? = null + var discovery: OscQueryDiscovery? = null + var discoveryJob: Job? = null + } + + override fun reduce(state: VRCOSCState, action: VRCOSCActions) = when (action) { + is VRCOSCActions.SetOscQuery -> state.copy( + status = state.status.copy( + oscQueryState = action.state, + oscQueryAdvertisedPort = action.advertisedPort, + oscQueryError = action.error, + ), + ) + + is VRCOSCActions.SetDiscoveredTargets -> state.copy( + status = state.status.copy(discoveredTargets = action.targets), + ) + + else -> state + } + + override fun observe(receiver: VRCOSCManager) { + val runtime = OscQueryRuntime(resolveLocalIp()) + + receiver.context.state + .map { state -> Triple(state.config.enabled, state.config.manualNetwork == null, vrcOscPortIn(state.config)) } + .distinctUntilChanged() + .onEach { (enabled, automatic, portIn) -> + if (!enabled || !automatic) { + stopOscQuery(receiver, runtime) + return@onEach + } + + syncOscQueryServer(receiver, runtime, portIn) + startDiscovery(receiver, runtime) + }.launchIn(receiver.context.scope) + } + + private fun resolveLocalIp(): String = try { + val candidates = java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces()) + .asSequence() + .filter { iface -> iface.isUp && !iface.isLoopback && !iface.isVirtual } + .flatMap { iface -> java.util.Collections.list(iface.inetAddresses).asSequence() } + .filter { address -> !address.isLoopbackAddress && !address.hostAddress.contains(':') } + .toList() + + val siteLocal = candidates.firstOrNull { address -> address.isSiteLocalAddress } + (siteLocal ?: candidates.firstOrNull())?.hostAddress + ?: java.net.InetAddress.getLoopbackAddress().hostAddress + } catch (_: Exception) { + java.net.InetAddress.getLoopbackAddress().hostAddress + } + + private suspend fun stopOscQuery(receiver: VRCOSCManager, runtime: OscQueryRuntime) { + runtime.discoveryJob?.cancel() + runtime.discoveryJob = null + runtime.discovery?.close() + runtime.discovery = null + try { + runtime.server?.close() + } catch (e: Exception) { + AppLogger.vrc.error("Failed to stop VRChat OSCQuery", e) + } + runtime.server = null + + receiver.context.dispatchAll( + listOf( + VRCOSCActions.SetDiscoveredTargets(emptyList()), + VRCOSCActions.SetOscQuery(state = VRCOSCOscQueryState.DISABLED), + ), + ) + } + + private suspend fun syncOscQueryServer(receiver: VRCOSCManager, runtime: OscQueryRuntime, portIn: Int) { + val existing = runtime.server + if (existing == null) { + try { + val newServer = OscQueryServer( + name = "SlimeVR-Server", + address = runtime.localIp, + oscPort = portIn.toUShort(), + ) + newServer.addNode(OscQueryNode(TRACKING_VRSYSTEM_PATH, access = OscQueryAccess.WRITE)) + withContext(Dispatchers.IO) { newServer.start(receiver.context.scope) } + runtime.server = newServer + AppLogger.vrc.info("VRChat OSCQuery started on http://${runtime.localIp}:${newServer.oscQueryPort}") + receiver.context.dispatch( + VRCOSCActions.SetOscQuery( + state = VRCOSCOscQueryState.SEARCHING, + advertisedPort = newServer.oscQueryPort.toInt(), + ), + ) + } catch (e: Exception) { + dispatchOscQueryError( + receiver = receiver, + message = "Failed to start VRChat OSCQuery server", + throwable = e, + ) + runtime.server = null + } + return + } + + try { + existing.updateOscPort(portIn.toUShort()) + } catch (e: Exception) { + dispatchOscQueryError( + receiver = receiver, + message = "Failed to update VRChat OSCQuery port", + throwable = e, + advertisedPort = existing.oscQueryPort.toInt(), + ) + } + } + + private suspend fun startDiscovery(receiver: VRCOSCManager, runtime: OscQueryRuntime) { + if (runtime.discovery != null) return + + val discovery = OscQueryDiscovery(serviceFilter = { serviceName -> serviceName.startsWith(VRCHAT_SERVICE_PREFIX) }) + runtime.discovery = discovery + + try { + discovery.start(receiver.context.scope) + } catch (e: Exception) { + discovery.close() + runtime.discovery = null + dispatchOscQueryError( + receiver = receiver, + message = "Failed to start VRChat OSCQuery discovery", + throwable = e, + advertisedPort = runtime.server?.oscQueryPort?.toInt(), + ) + return + } + + runtime.discoveryJob = discovery.services + .onEach { services -> + val targets = services.mapNotNull { service -> + val address = service.addresses.firstOrNull().orEmpty().ifEmpty { service.host } + if (address.isBlank()) { + null + } else { + VRCOSCDiscoveredTargetInfo( + name = service.name, + address = address, + portOut = DEFAULT_VRC_OSC_PORT_OUT, + ) + } + } + val advertisedPort = runtime.server?.oscQueryPort?.toInt() + receiver.context.dispatchAll( + listOf( + VRCOSCActions.SetDiscoveredTargets(targets), + VRCOSCActions.SetOscQuery( + state = if (targets.isEmpty()) VRCOSCOscQueryState.SEARCHING else VRCOSCOscQueryState.FOUND, + advertisedPort = advertisedPort, + ), + ), + ) + }.launchIn(receiver.context.scope) + } + + private suspend fun dispatchOscQueryError( + receiver: VRCOSCManager, + message: String, + throwable: Throwable, + advertisedPort: Int? = null, + ) { + AppLogger.vrc.error(message, throwable) + receiver.context.dispatch( + VRCOSCActions.SetOscQuery( + state = VRCOSCOscQueryState.ERROR, + advertisedPort = advertisedPort, + error = formatExceptionMessage(message, throwable), + ), + ) + } +} diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt new file mode 100644 index 0000000000..a8f3dcf2f8 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt @@ -0,0 +1,336 @@ +package dev.slimevr.vrcosc + +import dev.slimevr.AppLogger +import dev.slimevr.config.VRCOSCConfig +import dev.slimevr.osc.OscArg +import dev.slimevr.osc.OscBundle +import dev.slimevr.osc.OscContent +import dev.slimevr.osc.OscMessage +import dev.slimevr.osc.OscSender +import dev.slimevr.skeleton.BoneState +import dev.slimevr.skeleton.Skeleton +import io.github.axisangles.ktmath.EulerOrder +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.VRCOSCOutputState +import solarxr_protocol.rpc.VRCOSCTargetSource +import kotlin.math.PI + +private const val FRAME_RETRY_DELAY_MS = 5_000L + +class VRCOSCOutputBehaviour( + private val skeleton: Skeleton, +) : VRCOSCBehaviour { + private class OutputRuntime { + var sender: OscSender? = null + var frameSendFailureActive = false + var nextFrameRetryAt = 0L + } + + override fun reduce(state: VRCOSCState, action: VRCOSCActions) = when (action) { + is VRCOSCActions.SetOutput -> state.copy( + status = state.status.copy( + outputState = action.state, + targetAddress = action.targetAddress, + targetPort = action.targetPort, + targetSource = action.targetSource, + outputError = action.error, + ), + ) + + is VRCOSCActions.SetLastFrameSent -> state.copy( + status = state.status.copy(lastFrameSentMillis = action.millis), + ) + + else -> state + } + + override fun observe(receiver: VRCOSCManager) { + val runtime = OutputRuntime() + + val configFlow = receiver.context.state.map { state -> state.config }.distinctUntilChanged() + val discoveredFlow = receiver.context.state.map { state -> state.status.discoveredTargets }.distinctUntilChanged() + + configFlow.combine(discoveredFlow) { config, discoveredTargets -> + Triple( + config.enabled, + resolveTarget(config, discoveredTargets), + resolveTargetSource(config, discoveredTargets), + ) + } + .distinctUntilChanged() + .onEach { (enabled, target, source) -> + applyTarget(receiver, runtime, enabled, target, source) + } + .launchIn(receiver.context.scope) + + skeleton.computed + .onEach { bones -> sendFrame(receiver, runtime, bones) } + .launchIn(receiver.context.scope) + + receiver.events.on { event -> + sendYawAlign(receiver, runtime, event.headRotation) + } + } + + private suspend fun applyTarget( + receiver: VRCOSCManager, + runtime: OutputRuntime, + enabled: Boolean, + target: OscSenderTarget?, + source: VRCOSCTargetSource, + ) { + runtime.sender?.close() + runtime.sender = null + runtime.frameSendFailureActive = false + runtime.nextFrameRetryAt = 0L + + if (!enabled || target == null) { + receiver.context.dispatch( + VRCOSCActions.SetOutput( + state = VRCOSCOutputState.IDLE, + targetAddress = target?.address, + targetPort = target?.port, + targetSource = source, + error = null, + ), + ) + return + } + + val sender = try { + OscSender(target.address, target.port).also { + AppLogger.vrc.info("VRChat OSC output started: ${target.address}:${target.port}") + } + } catch (e: Exception) { + dispatchOutputError( + receiver = receiver, + message = "Failed to create VRChat OSC sender", + throwable = e, + targetAddress = target.address, + targetPort = target.port, + targetSource = source, + ) + return + } + + runtime.sender = sender + receiver.context.dispatch( + VRCOSCActions.SetOutput( + state = VRCOSCOutputState.READY, + targetAddress = target.address, + targetPort = target.port, + targetSource = source, + error = null, + ), + ) + } + + private suspend fun sendFrame( + receiver: VRCOSCManager, + runtime: OutputRuntime, + bones: Map, + ) { + val sender = runtime.sender ?: return + val state = receiver.context.state.value + if (!state.config.enabled) return + + val now = System.currentTimeMillis() + if (runtime.frameSendFailureActive && now < runtime.nextFrameRetryAt) return + + val bundle = buildOutgoingBundle(bones, state.config) ?: return + + try { + sender.send(bundle) + receiver.context.dispatch(VRCOSCActions.SetLastFrameSent(System.currentTimeMillis())) + if (runtime.frameSendFailureActive) { + runtime.frameSendFailureActive = false + runtime.nextFrameRetryAt = 0L + AppLogger.vrc.info("VRChat OSC frame send recovered") + receiver.context.dispatch( + VRCOSCActions.SetOutput( + state = VRCOSCOutputState.READY, + targetAddress = state.status.targetAddress, + targetPort = state.status.targetPort, + targetSource = state.status.targetSource, + error = null, + ), + ) + } + } catch (e: Exception) { + runtime.nextFrameRetryAt = System.currentTimeMillis() + FRAME_RETRY_DELAY_MS + if (runtime.frameSendFailureActive) return + + runtime.frameSendFailureActive = true + dispatchOutputError( + receiver = receiver, + message = "Failed to send VRChat OSC frame", + throwable = e, + targetAddress = state.status.targetAddress, + targetPort = state.status.targetPort, + targetSource = state.status.targetSource, + ) + } + } + + private suspend fun sendYawAlign( + receiver: VRCOSCManager, + runtime: OutputRuntime, + headRotation: Quaternion, + ) { + val sender = runtime.sender ?: return + try { + sender.send(buildYawAlignMessage(headRotation)) + } catch (e: Exception) { + val state = receiver.context.state.value + dispatchOutputError( + receiver = receiver, + message = "Failed to send VRChat yaw align", + throwable = e, + targetAddress = state.status.targetAddress, + targetPort = state.status.targetPort, + targetSource = state.status.targetSource, + ) + } + } + + private suspend fun dispatchOutputError( + receiver: VRCOSCManager, + message: String, + throwable: Throwable, + targetAddress: String?, + targetPort: Int?, + targetSource: VRCOSCTargetSource, + ) { + AppLogger.vrc.error(message, throwable) + receiver.context.dispatch( + VRCOSCActions.SetOutput( + state = VRCOSCOutputState.ERROR, + targetAddress = targetAddress, + targetPort = targetPort, + targetSource = targetSource, + error = formatExceptionMessage(message, throwable), + ), + ) + } + + private fun resolveTarget( + config: VRCOSCConfig, + discoveredTargets: List, + ): OscSenderTarget? { + config.manualNetwork?.let { manual -> + return OscSenderTarget(address = manual.address, port = manual.portOut) + } + + val discoveredTarget = discoveredTargets.firstOrNull() ?: return null + return OscSenderTarget( + address = discoveredTarget.address, + port = discoveredTarget.portOut, + ) + } + + private fun resolveTargetSource( + config: VRCOSCConfig, + discoveredTargets: List, + ): VRCOSCTargetSource = when { + config.manualNetwork != null -> VRCOSCTargetSource.MANUAL + discoveredTargets.isNotEmpty() -> VRCOSCTargetSource.DISCOVERED + else -> VRCOSCTargetSource.NONE + } + + private fun buildOutgoingBundle( + bones: Map, + config: VRCOSCConfig, + ): OscBundle? { + val messages = buildList { + for ((bodyPart, trackerId) in trackerIdsByBodyPart) { + if (!shouldSendTracker(bodyPart, config)) continue + + val bone = bones[bodyPart] ?: continue + add( + OscContent.Message( + positionMessage("/tracking/trackers/$trackerId/position", bone.headPosition), + ), + ) + add( + OscContent.Message( + rotationMessage("/tracking/trackers/$trackerId/rotation", bone.rotation), + ), + ) + } + + bones[BodyPart.HEAD]?.let { head -> + add( + OscContent.Message( + positionMessage("/tracking/trackers/head/position", head.headPosition), + ), + ) + } + } + + if (messages.isEmpty()) return null + return OscBundle(1L, messages) + } + + private fun shouldSendTracker(bodyPart: BodyPart, config: VRCOSCConfig): Boolean = when (bodyPart) { + BodyPart.HIP -> config.trackers.waist + BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> config.trackers.feet + BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG -> config.trackers.knees + BodyPart.UPPER_CHEST -> config.trackers.chest + BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM -> config.trackers.elbows + else -> false + } + + private fun positionMessage(address: String, position: Vector3) = OscMessage( + address, + listOf( + OscArg.Float(position.x), + OscArg.Float(position.y), + OscArg.Float(-position.z), + ), + ) + + private fun rotationMessage(address: String, rotation: Quaternion): OscMessage { + val (_, x, y, z) = Quaternion(rotation.w, -rotation.x, -rotation.y, rotation.z) + .toEulerAngles(EulerOrder.YXZ) + return OscMessage( + address, + listOf( + OscArg.Float(x * 180f / PI.toFloat()), + OscArg.Float(y * 180f / PI.toFloat()), + OscArg.Float(z * 180f / PI.toFloat()), + ), + ) + } + + private fun buildYawAlignMessage(headRotation: Quaternion): OscMessage { + val (_, _, yaw, _) = headRotation.toEulerAngles(EulerOrder.YXZ) + return OscMessage( + "/tracking/trackers/head/rotation", + listOf( + OscArg.Float(0f), + OscArg.Float(-yaw * 180f / PI.toFloat()), + OscArg.Float(0f), + ), + ) + } + + private companion object { + private val trackerIdsByBodyPart = mapOf( + BodyPart.HIP to 1, + BodyPart.LEFT_FOOT to 2, + BodyPart.RIGHT_FOOT to 3, + BodyPart.LEFT_UPPER_LEG to 4, + BodyPart.RIGHT_UPPER_LEG to 5, + BodyPart.UPPER_CHEST to 6, + BodyPart.LEFT_UPPER_ARM to 7, + BodyPart.RIGHT_UPPER_ARM to 8, + ) + } +} diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/settings.kt b/server/core/src/main/java/dev/slimevr/vrcosc/settings.kt new file mode 100644 index 0000000000..26d9f24836 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vrcosc/settings.kt @@ -0,0 +1,26 @@ +package dev.slimevr.vrcosc + +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach + +class VRCOSCSettingsBehaviour( + private val settings: Settings, +) : VRCOSCBehaviour { + override fun reduce(state: VRCOSCState, action: VRCOSCActions) = when (action) { + is VRCOSCActions.UpdateConfig -> state.copy(config = action.config) + else -> state + } + + override fun observe(receiver: VRCOSCManager) { + receiver.context.state + .map { state -> state.config } + .distinctUntilChanged() + .onEach { config -> + settings.context.dispatch(SettingsActions.Update { copy(vrcOscConfig = config) }) + }.launchIn(receiver.context.scope) + } +} diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/utils.kt b/server/core/src/main/java/dev/slimevr/vrcosc/utils.kt new file mode 100644 index 0000000000..14b1eebcfb --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vrcosc/utils.kt @@ -0,0 +1,40 @@ +package dev.slimevr.vrcosc + +import dev.slimevr.config.DEFAULT_VRC_OSC_PORT_IN +import dev.slimevr.config.VRCOSCConfig +import dev.slimevr.osc.OscArg +import io.github.axisangles.ktmath.EulerAngles +import io.github.axisangles.ktmath.EulerOrder +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import kotlin.math.PI + +internal const val TRACKING_VRSYSTEM_PATH: String = "/tracking/vrsystem" + +internal fun vrcOscPortIn(config: VRCOSCConfig): Int = config.manualNetwork?.portIn ?: DEFAULT_VRC_OSC_PORT_IN + +internal fun parsePosition(args: List, startIndex: Int = 0): Vector3? { + val x = args.getOrNull(startIndex)?.asFloatOrNull() ?: return null + val y = args.getOrNull(startIndex + 1)?.asFloatOrNull() ?: return null + val z = args.getOrNull(startIndex + 2)?.asFloatOrNull() ?: return null + return Vector3(x, y, -z) +} + +internal fun parseVrcEulerRotation(args: List, startIndex: Int = 0): Quaternion? { + val x = args.getOrNull(startIndex)?.asFloatOrNull() ?: return null + val y = args.getOrNull(startIndex + 1)?.asFloatOrNull() ?: return null + val z = args.getOrNull(startIndex + 2)?.asFloatOrNull() ?: return null + val (w, rx, ry, rz) = EulerAngles( + EulerOrder.YXZ, + x * PI.toFloat() / 180f, + y * PI.toFloat() / 180f, + z * PI.toFloat() / 180f, + ).toQuaternion() + return Quaternion(w, -rx, -ry, rz) +} + +internal fun formatExceptionMessage(prefix: String, throwable: Throwable): String { + val detail = throwable.message?.takeIf { it.isNotBlank() } + ?: throwable::class.simpleName.orEmpty() + return if (detail.isBlank()) prefix else "$prefix: $detail" +} diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 1bec70a21e..464da5d82b 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -23,7 +23,9 @@ import dev.slimevr.skeleton.Skeleton import dev.slimevr.skeleton.buildBones import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer +import dev.slimevr.vmc.VMCManager import dev.slimevr.vrchat.VRCConfigManager +import dev.slimevr.vrcosc.VRCOSCManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import java.nio.file.Files @@ -105,5 +107,7 @@ abstract class TestAppContext : AppContextProvider { override val udpServer: UdpServer get() = error("not used in test") override val networkProfileManager: NetworkProfileManager? = null override val bvhManager: BVHManager get() = error("not used in test") + override val vmcManager: VMCManager get() = error("not used in test") + override val vrcOscManager: VRCOSCManager get() = error("not used in test") override fun startObserving() {} } diff --git a/server/core/src/test/java/dev/slimevr/oscquery/OscQueryModelTest.kt b/server/core/src/test/java/dev/slimevr/oscquery/OscQueryModelTest.kt new file mode 100644 index 0000000000..d8ee11addd --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/oscquery/OscQueryModelTest.kt @@ -0,0 +1,69 @@ +package dev.slimevr.oscquery + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class OscQueryModelTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun hostInfoJsonUsesOscQueryFieldNames() { + val rendered = json.encodeToString( + OscQueryHostInfo( + name = "SlimeVR", + oscIp = "127.0.0.1", + oscPort = 9001u, + ), + ) + val obj = json.parseToJsonElement(rendered).jsonObject + + assertEquals("SlimeVR", obj["NAME"]?.jsonPrimitive?.content) + assertEquals("127.0.0.1", obj["OSC_IP"]?.jsonPrimitive?.content) + assertEquals(9001, obj["OSC_PORT"]?.jsonPrimitive?.content?.toInt()) + } + + @Test + fun nodeJsonUsesOscQueryFieldNames() { + val rendered = json.encodeToString( + OscQueryNode( + fullPath = "/avatar/parameters/Test", + type = "f", + access = OscQueryAccess.READ_WRITE, + value = listOf(JsonPrimitive(0.5f)), + description = "Test parameter", + ), + ) + val obj = json.parseToJsonElement(rendered).jsonObject + + assertEquals("/avatar/parameters/Test", obj["FULL_PATH"]?.jsonPrimitive?.content) + assertEquals("f", obj["TYPE"]?.jsonPrimitive?.content) + assertEquals(3, obj["ACCESS"]?.jsonPrimitive?.content?.toInt()) + assertEquals(0.5f, obj["VALUE"]?.jsonArray?.single()?.jsonPrimitive?.content?.toFloat()) + assertEquals("Test parameter", obj["DESCRIPTION"]?.jsonPrimitive?.content) + } + + @Test + fun treeBuildsParentsAndFindsNodes() { + val tree = OscQueryTree() + tree.add(OscQueryNode(fullPath = "/tracking/vrsystem")) + + val root = assertNotNull(tree.find("/")) + val tracking = assertNotNull(tree.find("/tracking")) + val vrsystem = assertNotNull(tree.find("/tracking/vrsystem")) + + assertEquals("tracking", root.contents?.keys?.single()) + assertEquals("vrsystem", tracking.contents?.keys?.single()) + assertEquals("vrsystem", root.contents?.get("tracking")?.contents?.keys?.single()) + assertEquals("/tracking/vrsystem", vrsystem.fullPath) + assertEquals(vrsystem, tree.find("/tracking/vrsystem?HOST_INFO")) + assertNull(tree.find("/tracking/unknown")) + } +} diff --git a/server/core/src/test/java/dev/slimevr/oscquery/OscQueryServerTest.kt b/server/core/src/test/java/dev/slimevr/oscquery/OscQueryServerTest.kt new file mode 100644 index 0000000000..f506732873 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/oscquery/OscQueryServerTest.kt @@ -0,0 +1,105 @@ +package dev.slimevr.oscquery + +import com.appstractive.dnssd.NetService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.net.URI +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class OscQueryServerTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun httpRoutesAndServiceLifecycleAreWired() = runBlocking { + val createdServices = mutableListOf() + val server = OscQueryServer( + name = "SlimeVR", + address = "127.0.0.1", + oscPort = 9000u, + serviceFactory = { type, name, port, _, _, _, txt -> + FakeNetService(type = type, name = name, port = port, txt = txt).also(createdServices::add) + }, + ) + server.addNode(OscQueryNode(fullPath = "/tracking/vrsystem")) + + try { + server.start(this) + + assertTrue(server.oscQueryPort > 0u) + assertEquals(2, createdServices.size) + assertEquals("_oscjson._tcp", createdServices[0].type) + assertEquals("_osc._udp", createdServices[1].type) + assertTrue(createdServices.all { it.isRegistered.value }) + + val hostInfo = json.parseToJsonElement(readUrl("http://127.0.0.1:${server.oscQueryPort}/HOST_INFO")).jsonObject + assertEquals("SlimeVR", hostInfo["NAME"]?.jsonPrimitive?.content) + assertEquals(9000, hostInfo["OSC_PORT"]?.jsonPrimitive?.content?.toInt()) + + val root = json.parseToJsonElement(readUrl("http://127.0.0.1:${server.oscQueryPort}/")).jsonObject + assertEquals("/", root["FULL_PATH"]?.jsonPrimitive?.content) + assertEquals(root["CONTENTS"]?.jsonObject?.containsKey("tracking"), true) + + val node = json.parseToJsonElement(readUrl("http://127.0.0.1:${server.oscQueryPort}/tracking/vrsystem")).jsonObject + assertEquals("/tracking/vrsystem", node["FULL_PATH"]?.jsonPrimitive?.content) + + val notFound = readStatus("http://127.0.0.1:${server.oscQueryPort}/missing") + assertEquals(404, notFound) + + server.updateOscPort(9100u) + assertEquals(3, createdServices.size) + assertEquals(createdServices[1].unregisterCount, 1) + assertEquals(9100, createdServices[2].port) + assertTrue(createdServices[2].isRegistered.value) + } finally { + server.close() + assertTrue(createdServices.all { it.unregistered.value }) + } + } +} + +private suspend fun readUrl(url: String): String = withContext(Dispatchers.IO) { + URI(url).toURL().openStream() +}.bufferedReader().use { it.readText() } + +private suspend fun readStatus(url: String): Int { + val connection = withContext(Dispatchers.IO) { + URI(url).toURL().openConnection() + } as java.net.HttpURLConnection + return try { + connection.requestMethod = "GET" + connection.responseCode + } finally { + connection.disconnect() + } +} + +private class FakeNetService( + override val name: String, + override val domain: String = "", + override val type: String, + override val port: Int, + private val txt: Map, +) : NetService { + override val isRegistered = MutableStateFlow(false) + val unregistered = MutableStateFlow(false) + var registerCount = 0 + var unregisterCount = 0 + + override suspend fun register(timeoutInMs: Long) { + registerCount++ + isRegistered.value = true + } + + override suspend fun unregister() { + unregisterCount++ + unregistered.value = true + isRegistered.value = false + } +} 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 aa1aa411a9..d5bc1e5808 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -22,6 +22,7 @@ import dev.slimevr.skeleton.Skeleton import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer import dev.slimevr.vmc.VMCManager +import dev.slimevr.vrcosc.VRCOSCManager import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -43,6 +44,7 @@ fun main(args: Array) = runBlocking { val udpServer = UdpServer.create(scope = this) val bvhManager = BVHManager.create(skeleton = skeleton, scope = this) val vmcManager = VMCManager.create(skeleton = skeleton, ctx = phase1, scope = this) + val vrcOscManager = VRCOSCManager.create(skeleton = skeleton, ctx = phase1, scope = this) val appContext = AppContext( server = server, @@ -58,6 +60,7 @@ fun main(args: Array) = runBlocking { udpServer = udpServer, bvhManager = bvhManager, vmcManager = vmcManager, + vrcOscManager = vrcOscManager, ) appContext.startObserving() From 6d625d1d25315091ead7ce70d1228e2c04911683 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Sat, 9 May 2026 16:40:36 +0200 Subject: [PATCH 096/350] VRC OSC gui changes and reorganisation of code --- gui/public/i18n/en/translation.ftl | 34 + .../trackers-assign/TrackerAssignment.tsx | 2 - .../settings/pages/VRCOSCSettings.tsx | 672 ++++++++++++++---- .../src/main/java/dev/slimevr/vrcosc/input.kt | 28 +- .../main/java/dev/slimevr/vrcosc/module.kt | 5 +- .../java/dev/slimevr/vrcosc/output-encoder.kt | 100 +++ .../main/java/dev/slimevr/vrcosc/output.kt | 107 +-- solarxr-protocol | 2 +- 8 files changed, 698 insertions(+), 252 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt diff --git a/gui/public/i18n/en/translation.ftl b/gui/public/i18n/en/translation.ftl index 7158573da2..abb0aa8d0e 100644 --- a/gui/public/i18n/en/translation.ftl +++ b/gui/public/i18n/en/translation.ftl @@ -849,6 +849,40 @@ settings-osc-vrchat-network-trackers-knees = Knees settings-osc-vrchat-network-trackers-feet = Feet settings-osc-vrchat-network-trackers-elbows = Elbows +## VRChat OSC status +settings-osc-vrchat-status-title = Status +settings-osc-vrchat-status-disabled = Disabled +settings-osc-vrchat-status-input = Input +settings-osc-vrchat-status-output = Output +settings-osc-vrchat-status-oscquery = OSCQuery +settings-osc-vrchat-status-input-idle = Not listening +settings-osc-vrchat-status-input-listening = Listening on port {$port} +settings-osc-vrchat-status-input-last-data = Last data from VRChat: {$elapsed} +settings-osc-vrchat-status-input-no-data = No data received from VRChat yet +settings-osc-vrchat-status-output-idle = No target +settings-osc-vrchat-status-output-sending = Sending to {$address}:{$port} ({$source}) +settings-osc-vrchat-status-output-target = Target {$address}:{$port} ({$source}) +settings-osc-vrchat-status-output-last-frame = Last frame sent: {$elapsed} +settings-osc-vrchat-status-output-no-frame = No frame sent yet +settings-osc-vrchat-status-source-manual = manual +settings-osc-vrchat-status-source-auto = auto-detected +settings-osc-vrchat-status-oscquery-disabled = OSCQuery off (manual network mode) +settings-osc-vrchat-status-oscquery-advertising = Advertising on port {$port} +settings-osc-vrchat-status-oscquery-searching = No VRChat clients found yet +settings-osc-vrchat-status-oscquery-discovered-title = Discovered VRChat clients: +settings-osc-vrchat-status-oscquery-switch = Switch +settings-osc-vrchat-status-network-mode = Network mode +settings-osc-vrchat-status-network-mode-description = Automatic mode discovers VRChat through OSCQuery. Manual mode uses the configured address and ports. +settings-osc-vrchat-status-network-mode-toggle = Manual network settings +settings-osc-vrchat-status-network-manual-description = Configure the local input port and the VRChat OSC target manually. +settings-osc-vrchat-status-badge-idle = Idle +settings-osc-vrchat-status-badge-listening = Listening +settings-osc-vrchat-status-badge-ready = Ready +settings-osc-vrchat-status-badge-found = Found +settings-osc-vrchat-status-badge-searching = Searching +settings-osc-vrchat-status-badge-disabled = Disabled +settings-osc-vrchat-status-badge-error = Error + ## VMC OSC settings settings-osc-vmc = Virtual Motion Capture # This cares about multilines diff --git a/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx b/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx index 2039a06fd1..b2fd4e7be6 100644 --- a/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx +++ b/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx @@ -112,7 +112,6 @@ export function TrackersAssignPage() { null, null, null, - null, newTapSettings ) ); @@ -128,7 +127,6 @@ export function TrackersAssignPage() { null, null, null, - null, newTapSettings ) ); diff --git a/gui/src/components/settings/pages/VRCOSCSettings.tsx b/gui/src/components/settings/pages/VRCOSCSettings.tsx index 3dbac7b236..2354d6d15a 100644 --- a/gui/src/components/settings/pages/VRCOSCSettings.tsx +++ b/gui/src/components/settings/pages/VRCOSCSettings.tsx @@ -1,34 +1,45 @@ import { Localized, useLocalization } from '@fluent/react'; -import { useEffect } from 'react'; +import { yupResolver } from '@hookform/resolvers/yup'; +import classNames from 'classnames'; +import { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; +import { boolean, number, object, string } from 'yup'; import { - ChangeSettingsRequestT, - OSCSettingsT, + ChangeVRCOSCSettingsRequestT, OSCTrackersSettingT, RpcMessage, - SettingsRequestT, - SettingsResponseT, + VRCOSCInputState, + VRCOSCNetworkSettingsT, + VRCOSCOscQueryState, + VRCOSCOutputState, + VRCOSCSettingsRequestT, + VRCOSCSettingsResponseT, VRCOSCSettingsT, + VRCOSCStatusChangeResponseT, + VRCOSCStatusRequestT, + VRCOSCTargetSource, + type VRCOSCDiscoveredTargetT, } from 'solarxr-protocol'; -import { useWebsocketAPI } from '@/hooks/websocket-api'; +import { Button } from '@/components/commons/Button'; import { CheckBox } from '@/components/commons/Checkbox'; -import { VRCIcon } from '@/components/commons/icon/VRCIcon'; import { Input } from '@/components/commons/Input'; import { Typography } from '@/components/commons/Typography'; +import { VRCIcon } from '@/components/commons/icon/VRCIcon'; import { SettingsPageLayout, SettingsPagePaneLayout, } from '@/components/settings/SettingsPageLayout'; -import { yupResolver } from '@hookform/resolvers/yup'; -import { boolean, object } from 'yup'; -import { - OSCSettings, - useOscSettingsValidator, -} from '@/hooks/osc-setting-validator'; +import { useWebsocketAPI } from '@/hooks/websocket-api'; interface VRCOSCSettingsForm { vrchat: { - oscSettings: OSCSettings; + enabled: boolean; + useManualNetwork: boolean; + manualNetwork: { + portIn: number; + portOut: number; + address: string; + }; trackers: { head: boolean; chest: boolean; @@ -38,14 +49,14 @@ interface VRCOSCSettingsForm { hands: boolean; waist: boolean; }; - oscqueryEnabled: boolean; }; } -const defaultValues = { +const createDefaultValues = (): VRCOSCSettingsForm => ({ vrchat: { - oscSettings: { - enabled: false, + enabled: false, + useManualNetwork: false, + manualNetwork: { portIn: 9001, portOut: 9000, address: '127.0.0.1', @@ -59,23 +70,351 @@ const defaultValues = { hands: false, waist: false, }, - oscqueryEnabled: true, }, +}); + +function asString(value: string | Uint8Array | null | undefined): string { + return typeof value === 'string' ? value : ''; +} + +function formatElapsedTime(timestamp: bigint | null | undefined, now: number) { + if (!timestamp) return null; + + const elapsedSeconds = Math.max( + 0, + Math.floor((now - Number(timestamp)) / 1000) + ); + if (elapsedSeconds < 5) return 'just now'; + if (elapsedSeconds < 60) return `${elapsedSeconds}s ago`; + + const elapsedMinutes = Math.floor(elapsedSeconds / 60); + if (elapsedMinutes < 60) return `${elapsedMinutes}m ago`; + + const elapsedHours = Math.floor(elapsedMinutes / 60); + return `${elapsedHours}h ago`; +} + +type BadgeKind = + | 'idle' + | 'listening' + | 'ready' + | 'found' + | 'searching' + | 'disabled' + | 'error'; + +const BADGE_LABEL_KEY: Record = { + idle: 'settings-osc-vrchat-status-badge-idle', + listening: 'settings-osc-vrchat-status-badge-listening', + ready: 'settings-osc-vrchat-status-badge-ready', + found: 'settings-osc-vrchat-status-badge-found', + searching: 'settings-osc-vrchat-status-badge-searching', + disabled: 'settings-osc-vrchat-status-badge-disabled', + error: 'settings-osc-vrchat-status-badge-error', +}; + +const BADGE_CLASSES: Record = { + idle: 'bg-background-50', + listening: 'bg-status-success', + ready: 'bg-status-success', + found: 'bg-status-success', + searching: 'bg-status-special', + disabled: 'bg-background-50', + error: 'bg-status-critical', }; +function StatusBadge({ kind }: { kind: BadgeKind }) { + return ( + +

+ + + ); +} + +function StatusRow({ + label, + badge, + children, +}: { + label: string; + badge: BadgeKind; + children?: React.ReactNode; +}) { + return ( +
+
+ {label} + +
+ {children} +
+ ); +} + +function inputBadge( + state: VRCOSCInputState, + lastReceivedMillis: bigint | null | undefined +): BadgeKind { + switch (state) { + case VRCOSCInputState.LISTENING: + return lastReceivedMillis ? 'listening' : 'ready'; + case VRCOSCInputState.ERROR: + return 'error'; + default: + return 'idle'; + } +} + +function outputBadge(state: VRCOSCOutputState): BadgeKind { + switch (state) { + case VRCOSCOutputState.READY: + return 'ready'; + case VRCOSCOutputState.ERROR: + return 'error'; + default: + return 'idle'; + } +} + +function oscQueryBadge(state: VRCOSCOscQueryState): BadgeKind { + switch (state) { + case VRCOSCOscQueryState.FOUND: + return 'found'; + case VRCOSCOscQueryState.SEARCHING: + return 'searching'; + case VRCOSCOscQueryState.ERROR: + return 'error'; + default: + return 'disabled'; + } +} + +function StatusCard({ + status, + now, + onSwitchToTarget, +}: { + status: VRCOSCStatusChangeResponseT; + now: number; + onSwitchToTarget: (target: VRCOSCDiscoveredTargetT) => void; +}) { + const { l10n } = useLocalization(); + + const inputState = status.inputState ?? VRCOSCInputState.IDLE; + const outputState = status.outputState ?? VRCOSCOutputState.IDLE; + const oscQueryState = status.oscqueryState ?? VRCOSCOscQueryState.DISABLED; + const targetSource = status.targetSource ?? VRCOSCTargetSource.NONE; + + const sourceLabel = + targetSource === VRCOSCTargetSource.MANUAL + ? l10n.getString('settings-osc-vrchat-status-source-manual') + : targetSource === VRCOSCTargetSource.DISCOVERED + ? l10n.getString('settings-osc-vrchat-status-source-auto') + : ''; + + const lastInputElapsed = formatElapsedTime( + status.lastReceivedInputMillis, + now + ); + const lastFrameElapsed = formatElapsedTime(status.lastFrameSentMillis, now); + + return ( +
+ + {inputState === VRCOSCInputState.IDLE ? ( + + {l10n.getString('settings-osc-vrchat-status-input-idle')} + + ) : ( + <> + + {l10n.getString('settings-osc-vrchat-status-input-listening', { + port: status.inputPort ?? 0, + })} + + {inputState === VRCOSCInputState.ERROR && status.inputError ? ( + + {asString(status.inputError)} + + ) : lastInputElapsed ? ( + + {l10n.getString('settings-osc-vrchat-status-input-last-data', { + elapsed: lastInputElapsed, + })} + + ) : ( + + {l10n.getString('settings-osc-vrchat-status-input-no-data')} + + )} + + )} + + + + {outputState === VRCOSCOutputState.IDLE ? ( + + {l10n.getString('settings-osc-vrchat-status-output-idle')} + + ) : ( + <> + + {l10n.getString( + outputState === VRCOSCOutputState.READY + ? 'settings-osc-vrchat-status-output-sending' + : 'settings-osc-vrchat-status-output-target', + { + address: asString(status.targetAddress), + port: status.targetPort ?? 0, + source: sourceLabel, + } + )} + + {outputState === VRCOSCOutputState.ERROR && status.outputError ? ( + + {asString(status.outputError)} + + ) : lastFrameElapsed ? ( + + {l10n.getString( + 'settings-osc-vrchat-status-output-last-frame', + { elapsed: lastFrameElapsed } + )} + + ) : ( + + {l10n.getString('settings-osc-vrchat-status-output-no-frame')} + + )} + + )} + + + + {oscQueryState === VRCOSCOscQueryState.DISABLED ? ( + + {l10n.getString('settings-osc-vrchat-status-oscquery-disabled')} + + ) : oscQueryState === VRCOSCOscQueryState.ERROR ? ( + + {asString(status.oscqueryError)} + + ) : ( + <> + + {l10n.getString( + 'settings-osc-vrchat-status-oscquery-advertising', + { port: status.oscqueryAdvertisedPort ?? 0 } + )} + + {oscQueryState === VRCOSCOscQueryState.FOUND && + status.discoveredTargets.length > 0 ? ( + <> + + {l10n.getString( + 'settings-osc-vrchat-status-oscquery-discovered-title' + )} + +
    + {status.discoveredTargets.map((target, index) => ( +
  • + + {asString(target.name)} ({asString(target.address)}: + {target.portOut}) + + {status.discoveredTargets.length > 1 && ( + + )} +
  • + ))} +
+ + ) : ( + + {l10n.getString( + 'settings-osc-vrchat-status-oscquery-searching' + )} + + )} + + )} +
+
+ ); +} + export function VRCOSCSettings() { const { l10n } = useLocalization(); const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); - const { oscValidator } = useOscSettingsValidator(); + const [status, setStatus] = useState( + null + ); + const [now, setNow] = useState(() => Date.now()); + + const bannedPorts = [6969, 21110]; + const portValidator = useMemo( + () => + number() + .typeError(' ') + .required() + .test( + 'ports-dont-match', + l10n.getString('settings-osc-common-network-ports_match_error'), + (_port, context) => context.parent.portIn != context.parent.portOut + ) + .notOneOf(bannedPorts, (context) => + l10n.getString('settings-osc-common-network-port_banned_error', { + port: context.originalValue, + }) + ), + [l10n] + ); - const { reset, control, watch, handleSubmit } = useForm({ - defaultValues, + const { reset, control, watch, handleSubmit, setValue } = useForm({ + defaultValues: createDefaultValues(), reValidateMode: 'onChange', mode: 'onChange', resolver: yupResolver( object({ vrchat: object({ - oscSettings: oscValidator, + enabled: boolean().required(), + useManualNetwork: boolean().required(), + manualNetwork: object({ + portIn: portValidator, + portOut: portValidator, + address: string() + .required(' ') + .matches( + /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|$)){4}$/i, + { message: ' ' } + ), + }), trackers: object({ head: boolean().required(), chest: boolean().required(), @@ -85,31 +424,34 @@ export function VRCOSCSettings() { hands: boolean().required(), waist: boolean().required(), }), - oscqueryEnabled: boolean().required(), }), }) ), }); + const enabled = watch('vrchat.enabled'); + const useManualNetwork = watch('vrchat.useManualNetwork'); + + useEffect(() => { + const interval = window.setInterval(() => setNow(Date.now()), 1_000); + return () => window.clearInterval(interval); + }, []); + const onSubmit = (values: VRCOSCSettingsForm) => { - const settings = new ChangeSettingsRequestT(); - - if (values.vrchat) { - const vrcOsc = new VRCOSCSettingsT(); - - vrcOsc.oscSettings = Object.assign( - new OSCSettingsT(), - values.vrchat.oscSettings - ); - vrcOsc.trackers = Object.assign( - new OSCTrackersSettingT(), - values.vrchat.trackers - ); - vrcOsc.oscqueryEnabled = values.vrchat.oscqueryEnabled; - - settings.vrcOsc = vrcOsc; - } - sendRPCPacket(RpcMessage.ChangeSettingsRequest, settings); + const req = new ChangeVRCOSCSettingsRequestT(); + const vrcOsc = new VRCOSCSettingsT(); + + vrcOsc.enabled = values.vrchat.enabled; + vrcOsc.trackers = Object.assign( + new OSCTrackersSettingT(), + values.vrchat.trackers + ); + vrcOsc.manualNetwork = values.vrchat.useManualNetwork + ? Object.assign(new VRCOSCNetworkSettingsT(), values.vrchat.manualNetwork) + : null; + + req.settings = vrcOsc; + sendRPCPacket(RpcMessage.ChangeVRCOSCSettingsRequest, req); }; useEffect(() => { @@ -118,34 +460,44 @@ export function VRCOSCSettings() { }, []); useEffect(() => { - sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT()); + sendRPCPacket( + RpcMessage.VRCOSCSettingsRequest, + new VRCOSCSettingsRequestT() + ); + sendRPCPacket(RpcMessage.VRCOSCStatusRequest, new VRCOSCStatusRequestT()); }, []); - useRPCPacket(RpcMessage.SettingsResponse, (settings: SettingsResponseT) => { - const formData: VRCOSCSettingsForm = defaultValues; - if (settings.vrcOsc) { - if (settings.vrcOsc.oscSettings) { - formData.vrchat.oscSettings.enabled = - settings.vrcOsc.oscSettings.enabled; - if (settings.vrcOsc.oscSettings.portIn) - formData.vrchat.oscSettings.portIn = - settings.vrcOsc.oscSettings.portIn; - if (settings.vrcOsc.oscSettings.portOut) - formData.vrchat.oscSettings.portOut = - settings.vrcOsc.oscSettings.portOut; - if (settings.vrcOsc.oscSettings.address) - formData.vrchat.oscSettings.address = - settings.vrcOsc.oscSettings.address.toString(); + useRPCPacket( + RpcMessage.VRCOSCSettingsResponse, + (response: VRCOSCSettingsResponseT) => { + const settings = response.settings; + const formData = createDefaultValues(); + if (settings) { + formData.vrchat.enabled = settings.enabled; + if (settings.trackers) formData.vrchat.trackers = settings.trackers; + if (settings.manualNetwork) { + formData.vrchat.useManualNetwork = true; + formData.vrchat.manualNetwork.portIn = settings.manualNetwork.portIn; + formData.vrchat.manualNetwork.portOut = + settings.manualNetwork.portOut; + if (settings.manualNetwork.address) { + formData.vrchat.manualNetwork.address = asString( + settings.manualNetwork.address + ); + } + } } - if (settings.vrcOsc.trackers) - formData.vrchat.trackers = settings.vrcOsc.trackers; - - formData.vrchat.oscqueryEnabled = settings.vrcOsc.oscqueryEnabled; + reset(formData); } + ); - reset(formData); - }); + useRPCPacket( + RpcMessage.VRCOSCStatusChangeResponse, + (response: VRCOSCStatusChangeResponseT) => { + setStatus(response); + } + ); return ( @@ -165,6 +517,7 @@ export function VRCOSCSettings() { ))}
+ {l10n.getString('settings-osc-vrchat-enable')} @@ -178,91 +531,38 @@ export function VRCOSCSettings() { variant="toggle" outlined control={control} - name="vrchat.oscSettings.enabled" + name="vrchat.enabled" label={l10n.getString('settings-osc-vrchat-enable-label')} /> - - {l10n.getString('settings-osc-vrchat-oscqueryEnabled')} - -
- - {l10n - .getString('settings-osc-vrchat-oscqueryEnabled-description') - .split('\n') - .map((line, i) => ( - {line} - ))} - -
-
- -
- - - {l10n.getString('settings-osc-vrchat-network')} - -
- - {l10n.getString('settings-osc-vrchat-network-description-v1')} - -
-
- - - - - + + {l10n.getString('settings-osc-vrchat-status-title')} + + { + setValue('vrchat.useManualNetwork', true, { + shouldDirty: true, + }); + setValue( + 'vrchat.manualNetwork.address', + asString(target.address), + { shouldDirty: true } + ); + setValue( + 'vrchat.manualNetwork.portOut', + target.portOut, + { shouldDirty: true } + ); + }} /> - -
- - {l10n.getString('settings-osc-vrchat-network-address')} - -
- - {l10n.getString( - 'settings-osc-vrchat-network-address-description-v1' - )} - -
-
- -
+ + )} + {l10n.getString('settings-osc-vrchat-network-trackers')} @@ -320,6 +620,90 @@ export function VRCOSCSettings() { )} /> + + + {l10n.getString('settings-osc-vrchat-status-network-mode')} + +
+ + {l10n.getString( + 'settings-osc-vrchat-status-network-mode-description' + )} + +
+
+ +
+ + {useManualNetwork && ( + <> + + {l10n.getString('settings-osc-vrchat-network')} + +
+ + {l10n.getString( + 'settings-osc-vrchat-status-network-manual-description' + )} + +
+
+ + + + + + +
+ + {l10n.getString('settings-osc-vrchat-network-address')} + +
+ + {l10n.getString( + 'settings-osc-vrchat-network-address-description-v1' + )} + +
+
+ +
+ + )} diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt index 270c006477..37e8a39603 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt @@ -147,7 +147,17 @@ class VRCOSCInputBehaviour( return@onEach } - val newReceiver = OscReceiver(portIn) + val newReceiver = try { + OscReceiver(portIn) + } catch (e: Exception) { + dispatchInputError( + receiver = receiver, + port = portIn, + message = "Failed to start VRChat OSC receiver", + throwable = e, + ) + return@onEach + } oscReceiver = newReceiver receiver.context.dispatch( VRCOSCActions.SetInput(state = VRCOSCInputState.LISTENING, port = portIn), @@ -191,13 +201,19 @@ class VRCOSCInputBehaviour( receiver: VRCOSCManager, portIn: Int, ) { - for (content in bundle.contents) { + handleBundleContents(bundle.contents, registry, receiver, portIn) + } + + private fun handleBundleContents( + contents: List, + registry: VRSystemTrackerRegistry, + receiver: VRCOSCManager, + portIn: Int, + ) { + for (content in contents) { when (content) { is OscContent.Message -> handleIncomingMessage(content.msg, registry, receiver, portIn) - - is OscContent.Bundle -> for (nested in content.bundle.contents) { - if (nested is OscContent.Message) handleIncomingMessage(nested.msg, registry, receiver, portIn) - } + is OscContent.Bundle -> handleBundleContents(content.bundle.contents, registry, receiver, portIn) } } } diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt index d13240ec4f..e301658aad 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt @@ -26,8 +26,9 @@ data class VRCOSCDiscoveredTargetInfo( ) /** - * Status of the VRC OSC subsystem. Mirrors the solarxr `VRCOSCStatusChangeResponse` - * one-to-one and reuses the generated enums, so `solarxr/vrcosc.kt` is a pass-through. + * Internal runtime status for the VRC OSC subsystem. + * Kept close to the SolarXR status response shape so the bridge mapping stays small, + * but this remains a server-side state model rather than an RPC transport type. */ data class VRCOSCStatus( val inputState: VRCOSCInputState = VRCOSCInputState.IDLE, diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt new file mode 100644 index 0000000000..18b350dd62 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt @@ -0,0 +1,100 @@ +package dev.slimevr.vrcosc + +import dev.slimevr.config.VRCOSCConfig +import dev.slimevr.osc.OscArg +import dev.slimevr.osc.OscBundle +import dev.slimevr.osc.OscContent +import dev.slimevr.osc.OscMessage +import dev.slimevr.skeleton.BoneState +import io.github.axisangles.ktmath.EulerOrder +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import solarxr_protocol.datatypes.BodyPart +import kotlin.math.PI + +private val trackerIdsByBodyPart = mapOf( + BodyPart.HIP to 1, + BodyPart.LEFT_FOOT to 2, + BodyPart.RIGHT_FOOT to 3, + BodyPart.LEFT_UPPER_LEG to 4, + BodyPart.RIGHT_UPPER_LEG to 5, + BodyPart.UPPER_CHEST to 6, + BodyPart.LEFT_UPPER_ARM to 7, + BodyPart.RIGHT_UPPER_ARM to 8, +) + +internal fun buildOutgoingBundle( + bones: Map, + config: VRCOSCConfig, +): OscBundle? { + val messages = buildList { + for ((bodyPart, trackerId) in trackerIdsByBodyPart) { + if (!shouldSendTracker(bodyPart, config)) continue + + val bone = bones[bodyPart] ?: continue + add( + OscContent.Message( + positionMessage("/tracking/trackers/$trackerId/position", bone.headPosition), + ), + ) + add( + OscContent.Message( + rotationMessage("/tracking/trackers/$trackerId/rotation", bone.rotation), + ), + ) + } + + bones[BodyPart.HEAD]?.let { head -> + add( + OscContent.Message( + positionMessage("/tracking/trackers/head/position", head.headPosition), + ), + ) + } + } + + return messages.takeIf { it.isNotEmpty() }?.let { OscBundle(1L, it) } +} + +internal fun buildYawAlignMessage(headRotation: Quaternion): OscMessage { + val (_, _, yaw, _) = headRotation.toEulerAngles(EulerOrder.YXZ) + return OscMessage( + "/tracking/trackers/head/rotation", + listOf( + OscArg.Float(0f), + OscArg.Float(-yaw * 180f / PI.toFloat()), + OscArg.Float(0f), + ), + ) +} + +private fun shouldSendTracker(bodyPart: BodyPart, config: VRCOSCConfig): Boolean = when (bodyPart) { + BodyPart.HIP -> config.trackers.waist + BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> config.trackers.feet + BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG -> config.trackers.knees + BodyPart.UPPER_CHEST -> config.trackers.chest + BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM -> config.trackers.elbows + else -> false +} + +private fun positionMessage(address: String, position: Vector3) = OscMessage( + address, + listOf( + OscArg.Float(position.x), + OscArg.Float(position.y), + OscArg.Float(-position.z), + ), +) + +private fun rotationMessage(address: String, rotation: Quaternion): OscMessage { + val (_, x, y, z) = Quaternion(rotation.w, -rotation.x, -rotation.y, rotation.z) + .toEulerAngles(EulerOrder.YXZ) + return OscMessage( + address, + listOf( + OscArg.Float(x * 180f / PI.toFloat()), + OscArg.Float(y * 180f / PI.toFloat()), + OscArg.Float(z * 180f / PI.toFloat()), + ), + ) +} diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt index a8f3dcf2f8..8a4f4e1d16 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt @@ -2,16 +2,10 @@ package dev.slimevr.vrcosc import dev.slimevr.AppLogger import dev.slimevr.config.VRCOSCConfig -import dev.slimevr.osc.OscArg -import dev.slimevr.osc.OscBundle -import dev.slimevr.osc.OscContent -import dev.slimevr.osc.OscMessage import dev.slimevr.osc.OscSender import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.Skeleton -import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn @@ -20,7 +14,6 @@ import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.VRCOSCOutputState import solarxr_protocol.rpc.VRCOSCTargetSource -import kotlin.math.PI private const val FRAME_RETRY_DELAY_MS = 5_000L @@ -54,6 +47,12 @@ class VRCOSCOutputBehaviour( override fun observe(receiver: VRCOSCManager) { val runtime = OutputRuntime() + observeTargetChanges(receiver, runtime) + observeFrames(receiver, runtime) + observeYawAlign(receiver, runtime) + } + + private fun observeTargetChanges(receiver: VRCOSCManager, runtime: OutputRuntime) { val configFlow = receiver.context.state.map { state -> state.config }.distinctUntilChanged() val discoveredFlow = receiver.context.state.map { state -> state.status.discoveredTargets }.distinctUntilChanged() @@ -69,11 +68,15 @@ class VRCOSCOutputBehaviour( applyTarget(receiver, runtime, enabled, target, source) } .launchIn(receiver.context.scope) + } + private fun observeFrames(receiver: VRCOSCManager, runtime: OutputRuntime) { skeleton.computed .onEach { bones -> sendFrame(receiver, runtime, bones) } .launchIn(receiver.context.scope) + } + private fun observeYawAlign(receiver: VRCOSCManager, runtime: OutputRuntime) { receiver.events.on { event -> sendYawAlign(receiver, runtime, event.headRotation) } @@ -243,94 +246,4 @@ class VRCOSCOutputBehaviour( discoveredTargets.isNotEmpty() -> VRCOSCTargetSource.DISCOVERED else -> VRCOSCTargetSource.NONE } - - private fun buildOutgoingBundle( - bones: Map, - config: VRCOSCConfig, - ): OscBundle? { - val messages = buildList { - for ((bodyPart, trackerId) in trackerIdsByBodyPart) { - if (!shouldSendTracker(bodyPart, config)) continue - - val bone = bones[bodyPart] ?: continue - add( - OscContent.Message( - positionMessage("/tracking/trackers/$trackerId/position", bone.headPosition), - ), - ) - add( - OscContent.Message( - rotationMessage("/tracking/trackers/$trackerId/rotation", bone.rotation), - ), - ) - } - - bones[BodyPart.HEAD]?.let { head -> - add( - OscContent.Message( - positionMessage("/tracking/trackers/head/position", head.headPosition), - ), - ) - } - } - - if (messages.isEmpty()) return null - return OscBundle(1L, messages) - } - - private fun shouldSendTracker(bodyPart: BodyPart, config: VRCOSCConfig): Boolean = when (bodyPart) { - BodyPart.HIP -> config.trackers.waist - BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> config.trackers.feet - BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG -> config.trackers.knees - BodyPart.UPPER_CHEST -> config.trackers.chest - BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM -> config.trackers.elbows - else -> false - } - - private fun positionMessage(address: String, position: Vector3) = OscMessage( - address, - listOf( - OscArg.Float(position.x), - OscArg.Float(position.y), - OscArg.Float(-position.z), - ), - ) - - private fun rotationMessage(address: String, rotation: Quaternion): OscMessage { - val (_, x, y, z) = Quaternion(rotation.w, -rotation.x, -rotation.y, rotation.z) - .toEulerAngles(EulerOrder.YXZ) - return OscMessage( - address, - listOf( - OscArg.Float(x * 180f / PI.toFloat()), - OscArg.Float(y * 180f / PI.toFloat()), - OscArg.Float(z * 180f / PI.toFloat()), - ), - ) - } - - private fun buildYawAlignMessage(headRotation: Quaternion): OscMessage { - val (_, _, yaw, _) = headRotation.toEulerAngles(EulerOrder.YXZ) - return OscMessage( - "/tracking/trackers/head/rotation", - listOf( - OscArg.Float(0f), - OscArg.Float(-yaw * 180f / PI.toFloat()), - OscArg.Float(0f), - ), - ) - } - - private companion object { - private val trackerIdsByBodyPart = mapOf( - BodyPart.HIP to 1, - BodyPart.LEFT_FOOT to 2, - BodyPart.RIGHT_FOOT to 3, - BodyPart.LEFT_UPPER_LEG to 4, - BodyPart.RIGHT_UPPER_LEG to 5, - BodyPart.UPPER_CHEST to 6, - BodyPart.LEFT_UPPER_ARM to 7, - BodyPart.RIGHT_UPPER_ARM to 8, - ) - } } diff --git a/solarxr-protocol b/solarxr-protocol index 6ef8a144ff..c3dfef44ae 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 6ef8a144ffc57bcadf5168740aa0b49c50faa823 +Subproject commit c3dfef44ae6269a4e90efc946ae04092f07fbb86 From 4b7af0bbedbef8ce9d8d7284d215febe43f4c357 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Sat, 9 May 2026 14:38:07 -0500 Subject: [PATCH 097/350] Start bindings provider automatically --- .../core/src/main/java/dev/slimevr/logger.kt | 1 + .../java/dev/slimevr/desktop/ipc/protocol.kt | 64 ++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index 07d89e458a..e73ed36fe1 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -11,6 +11,7 @@ object AppLogger { val device = logger("Device") val udp = logger("UDPConnection") val solarxr = logger("SolarXR") + val steamvr = logger("SteamVR") val hid = logger("HID") val serial = logger("Serial") val firmware = logger("Firmware") diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 8460288dd8..b807e7ff5e 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -1,6 +1,9 @@ package dev.slimevr.desktop.ipc import dev.slimevr.AppContextProvider +import dev.slimevr.AppLogger +import dev.slimevr.CURRENT_PLATFORM +import dev.slimevr.Platform import dev.slimevr.desktop.platform.Position import dev.slimevr.desktop.platform.ProtobufMessage import dev.slimevr.desktop.platform.TrackerAdded @@ -11,12 +14,66 @@ import dev.slimevr.driver.DriverBridgeOutbound import dev.slimevr.feeder.FeederBridge import dev.slimevr.feeder.FeederBridgeInbound import io.github.axisangles.ktmath.Quaternion +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.exists +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds -const val PROTOCOL_VERSION = 5 +const val PROTOCOL_VERSION = 2 + +private fun getBindingsProviderPath(): Path? { + val executableName = when (CURRENT_PLATFORM) { + Platform.WINDOWS -> "SlimeVR-Bindings-Provider.exe" + Platform.LINUX -> "slimevr-bindings-provider" + else -> return null + } + + // First we want to try to find it in the working directory, its location on + // Steam/Windows/portable. + val workingDir = System.getProperty("user.dir") + val binaryPath = Path(workingDir, executableName) + if (binaryPath.exists()) return binaryPath + + // Then look through PATH to find the binary. + // PATH shouldn't be null, but if it is just gracefully fail + val path = System.getenv("PATH") ?: return null + val separator = System.getProperty("path.separator") + for (path in path.split(separator)) { + val binaryPath = Path(path, executableName) + if (binaryPath.exists()) return binaryPath + } + + // :( + return null +} + +suspend fun startBindingProvider() = withContext(Dispatchers.IO) { + val path = getBindingsProviderPath() + if (path == null) { + AppLogger.steamvr.warn("Failed to find bindings provider") + return@withContext + } + + val proc = try { + ProcessBuilder(path.toString()).start() + } catch (e: Exception) { + AppLogger.steamvr.error(e, "Failed to start bindings provider") + return@withContext + } + AppLogger.steamvr.info("Started bindings provider (PID ${proc.pid()})") + proc.waitFor() + + AppLogger.steamvr.info("Bindings provider exited with code ${proc.exitValue()}") +} suspend fun handleDriverConnection( appContext: AppContextProvider, @@ -45,6 +102,11 @@ suspend fun handleDriverConnection( val msg = ProtobufMessage.ADAPTER.decode(bytes) msg.version?.let { ver -> bridge.inbound.emit(DriverBridgeInbound.Version(ver.protocol_version)) + if (ver.protocol_version >= 2) { + launch { + startBindingProvider() + } + } } msg.position?.let { pos -> bridge.inbound.emit(DriverBridgeInbound.TrackerPosition(trackerId = pos.tracker_id, rotation = Quaternion(w = pos.qw, x = pos.qx, y = pos.qy, z = pos.qz), position = null)) From 0907fe50dbeba9bc901bc7b767dfc17d94db4a68 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Sun, 10 May 2026 18:43:56 +0200 Subject: [PATCH 098/350] Opencode skills sucks. Bye bye --- .opencode/skills/compare-with-main/SKILL.md | 25 --------------------- 1 file changed, 25 deletions(-) delete mode 100644 .opencode/skills/compare-with-main/SKILL.md diff --git a/.opencode/skills/compare-with-main/SKILL.md b/.opencode/skills/compare-with-main/SKILL.md deleted file mode 100644 index 5d4a15223a..0000000000 --- a/.opencode/skills/compare-with-main/SKILL.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: compare-with-main -description: Compare current implementation with the original logic in the 'main' branch by searching for semantic matches, ensuring functionality and edge cases are preserved during rewrites. -compatibility: opencode ---- -## What I do -- **Identify Intent**: Determine the core logic or feature currently being worked on from the user's request. -- **Locate Original Logic**: - - Since file structures differ in rewrites, I will search the `main` branch for semantic matches using a multi-step discovery process: - 1. **File Discovery**: Use `git ls-tree -r main --name-only` to find files with relevant keywords. - 2. **Content Discovery**: Use `git grep` against the `main` branch to find specific functions or logic patterns. - 3. **Inspection**: Use `git show main:` to inspect the source code of candidates found in `main`. -- **Deep Comparison**: - - Once a candidate in `main` is found, I will compare: - - **State/Actions**: Are all original state properties and action types accounted for? - - **Reducers**: Does the new reducer handle the same state transitions and edge cases? - - **Side Effects**: Are all original coroutines, subscriptions, and event listeners present? -- **Provide a structured report**: - - ✅ **[MATCHED]**: Equivalent logic found. - - 🔄 **[CHANGED]**: Behavior or implementation differs (with explanation of why). - - ⚠️ **[MISSING]**: Logic or state from `main` that is absent in the current version. - - 🚨 **[EDGE CASE RISK]**: Identified discrepancies that might cause regression. - -## When to use me -Use this when you have just completed a refactor, rewrite, or significant feature implementation where the directory structure or filenames have changed compared to the `main` branch. From abc5c7b38c11ce4f231da504bd56421c1298d66c Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 11 May 2026 00:41:28 +0200 Subject: [PATCH 099/350] solarxr upgrade --- server/core/build.gradle.kts | 3 +-- .../main/java/dev/slimevr/firmware/serial.kt | 3 ++- .../src/main/java/dev/slimevr/solarxr/bvh.kt | 2 +- .../main/java/dev/slimevr/solarxr/datafeed.kt | 21 +++++++++-------- .../main/java/dev/slimevr/solarxr/firmware.kt | 2 +- .../src/main/java/dev/slimevr/solarxr/ipc.kt | 3 ++- .../src/main/java/dev/slimevr/solarxr/mag.kt | 23 +++++++++++-------- .../main/java/dev/slimevr/solarxr/module.kt | 3 ++- .../main/java/dev/slimevr/solarxr/serial.kt | 2 +- .../dev/slimevr/solarxr/skeletonconfig.kt | 4 +++- .../dev/slimevr/solarxr/trackerassignment.kt | 4 ++-- .../dev/slimevr/solarxr/trackingchecklist.kt | 2 +- .../src/main/java/dev/slimevr/solarxr/vmc.kt | 12 ++++++---- .../main/java/dev/slimevr/solarxr/vrcosc.kt | 10 ++++---- .../main/java/dev/slimevr/vrchat/module.kt | 22 +++++++++--------- server/desktop/build.gradle.kts | 4 ++-- .../src/main/java/dev/slimevr/desktop/Main.kt | 4 +++- settings.gradle.kts | 7 +++++- solarxr-protocol | 2 +- 19 files changed, 77 insertions(+), 56 deletions(-) diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index ea66305173..8eefbc78ae 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -57,8 +57,7 @@ allprojects { } dependencies { - implementation(project(":solarxr-protocol")) - + implementation(project(":solarxr-protocol:generated")) implementation("com.google.flatbuffers:flatbuffers-java:22.10.26") implementation("com.illposed.osc:javaosc-core:0.9") diff --git a/server/core/src/main/java/dev/slimevr/firmware/serial.kt b/server/core/src/main/java/dev/slimevr/firmware/serial.kt index f6a6294957..682062b701 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/serial.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/serial.kt @@ -44,9 +44,10 @@ suspend fun doSerialFlash( parts.map { part -> val url = part.url ?: error("missing url") val digest = part.digest ?: error("missing digest") + val offset = part.offset?.toInt() ?: error("missing offset") DownloadedFirmwarePart( data = downloadFirmware(url, digest), - offset = part.offset.toInt(), + offset = offset, ) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/bvh.kt b/server/core/src/main/java/dev/slimevr/solarxr/bvh.kt index 164bc8d6ea..747497ab02 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/bvh.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/bvh.kt @@ -12,7 +12,7 @@ import java.nio.file.Path class BvhBehaviour(private val bvhManager: BVHManager) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { req -> - if (req.stop) { + if (req.stop == true) { bvhManager.stopRecording() } else { req.path?.let { path -> bvhManager.startRecording(Path.of(path)) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 0b867e79c1..8eb43889c0 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -18,12 +18,15 @@ import solarxr_protocol.data_feed.device_data.DeviceData import solarxr_protocol.data_feed.tracker.TrackerData import solarxr_protocol.data_feed.tracker.TrackerDataMask import solarxr_protocol.data_feed.tracker.TrackerInfo +import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.Ipv4Address import solarxr_protocol.datatypes.Temperature import solarxr_protocol.datatypes.TrackerId +import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.HardwareInfo import solarxr_protocol.datatypes.hardware_info.HardwareStatus +import solarxr_protocol.datatypes.hardware_info.ImuType import solarxr_protocol.datatypes.math.Quat import solarxr_protocol.datatypes.math.Vec3f import java.nio.ByteBuffer @@ -33,10 +36,10 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas trackerNum = tracker.id.toUByte(), deviceId = DeviceId(device.id.toUByte()), ), - status = if (trackerMask.status) tracker.status else null, - rotation = if (trackerMask.rotation) tracker.rawRotation.let { Quat(it.x, it.y, it.z, it.w) } else null, - position = if (trackerMask.position && tracker.position != null) tracker.position.let { Vec3f(it.x, it.y, it.z) } else null, - info = if (trackerMask.info) { + status = if (trackerMask.status == true) tracker.status else null, + rotation = if (trackerMask.rotation == true) tracker.rawRotation.let { Quat(it.x, it.y, it.z, it.w) } else null, + position = if (trackerMask.position == true && tracker.position != null) tracker.position.let { Vec3f(it.x, it.y, it.z) } else null, + info = if (trackerMask.info == true) { TrackerInfo( imuType = tracker.sensorType, bodyPart = tracker.bodyPart, @@ -49,10 +52,10 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas } else { null }, - tps = if (trackerMask.tps) tracker.tps else null, - temp = if (trackerMask.temp && tracker.imuTemp != null) Temperature(temp = tracker.imuTemp) else null, - rawAcceleration = if (trackerMask.rawAcceleration) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null, - linearAcceleration = if (trackerMask.linearAcceleration) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null, // FIXME: temp value + tps = if (trackerMask.tps == true) tracker.tps else null, + temp = if (trackerMask.temp == true && tracker.imuTemp != null) Temperature(temp = tracker.imuTemp) else null, + rawAcceleration = if (trackerMask.rawAcceleration == true) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null, + linearAcceleration = if (trackerMask.linearAcceleration == true) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null, // FIXME: temp value ) private fun createDevice( @@ -108,7 +111,7 @@ fun createDatafeedFrame( val trackers = serverState.trackers.values.map { it.context.state.value } val devices = serverState.devices.values.map { it.context.state.value } .map { device -> createDevice(device, trackers, datafeedConfig) } - val bones = if (datafeedConfig.boneMask) { + val bones = if (datafeedConfig.boneMask == true) { skeleton.computed.value.values.map { createBone(it) } } else { null diff --git a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt index 12b1c4a2c9..55c9428ed5 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt @@ -47,7 +47,7 @@ class FirmwareBehaviour(private val server: VRServer, private val firmwareManage firmwareManager.flash( portLocation, parts, - method.needmanualreboot, + method.needmanualreboot == true, method.ssid, method.password, server, diff --git a/server/core/src/main/java/dev/slimevr/solarxr/ipc.kt b/server/core/src/main/java/dev/slimevr/solarxr/ipc.kt index fb5454c702..81743d882c 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/ipc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/ipc.kt @@ -3,6 +3,7 @@ package dev.slimevr.solarxr import com.google.flatbuffers.FlatBufferBuilder import dev.slimevr.AppContextProvider import dev.slimevr.VRServerActions +import dev.slimevr.fbscodegen.runtime.JvmFlatBufferWriter import io.ktor.util.moveToByteArray import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow @@ -24,7 +25,7 @@ suspend fun handleSolarXRBridge( bridge.outbound.on { bundle -> val fbb = FlatBufferBuilder(256) - fbb.finish(bundle.encode(fbb)) + fbb.finish(bundle.encode(JvmFlatBufferWriter(fbb))) send(fbb.dataBuffer().moveToByteArray()) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt index e5dae7200c..68c7141ba6 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt @@ -13,6 +13,8 @@ import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.rpc.ChangeMagToggleRequest import solarxr_protocol.rpc.MagToggleRequest import solarxr_protocol.rpc.MagToggleResponse +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds class MagBehaviour( private val appContext: AppContextProvider, @@ -32,35 +34,37 @@ class MagBehaviour( override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { req -> val trackerId = req.trackerId + val enable = req.enable == true; if (trackerId == null) { - receiver.appContext.config.settings.context.dispatch(SettingsActions.Update { copy(globalMagEnabled = req.enable) }) + receiver.appContext.config.settings.context.dispatch(SettingsActions.Update { copy(globalMagEnabled = enable) }) appContext.server.context.state.value.trackers.values.forEach { tracker -> val state = tracker.context.state.value if (state.magStatus == MagnetometerStatus.NOT_SUPPORTED) return@forEach when (state.origin) { - DeviceOrigin.UDP -> setUDPTrackerMag(state.id, state.deviceId, req.enable) + DeviceOrigin.UDP -> setUDPTrackerMag(state.id, state.deviceId, enable) DeviceOrigin.HID -> { /* TODO: implement HID mag toggle */ } else -> Unit } } - receiver.sendRpc(MagToggleResponse(trackerId = null, enable = req.enable)) + receiver.sendRpc(MagToggleResponse(trackerId = null, enable = enable)) return@on } - val tracker = appContext.server.getTracker(trackerId.trackerNum.toInt()) ?: return@on + val trackerNum = trackerId.trackerNum ?: return@on + val tracker = appContext.server.getTracker(trackerNum.toInt()) ?: return@on val trackerState = tracker.context.state.value if (trackerState.magStatus == MagnetometerStatus.NOT_SUPPORTED) return@on when (trackerState.origin) { DeviceOrigin.UDP -> { - if (!setUDPTrackerMag(trackerState.id, trackerState.deviceId, req.enable)) return@on + if (!setUDPTrackerMag(trackerState.id, trackerState.deviceId, enable)) return@on tracker.context.scope.launch { - withTimeout(10_000) { + withTimeout(10.seconds) { tracker.context.state .distinctUntilChangedBy { it.magStatus } - .first { it.magStatus == if (req.enable) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED } - receiver.sendRpc(MagToggleResponse(trackerId = trackerId, enable = req.enable)) + .first { it.magStatus == if (enable) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED } + receiver.sendRpc(MagToggleResponse(trackerId = trackerId, enable = enable)) } } } @@ -86,7 +90,8 @@ class MagBehaviour( return@on } - val trackerState = appContext.server.getTracker(trackerId.trackerNum.toInt())?.context?.state?.value ?: return@on + val trackerNum = trackerId.trackerNum ?: return@on + val trackerState = appContext.server.getTracker(trackerNum.toInt())?.context?.state?.value ?: return@on receiver.sendRpc( MagToggleResponse( trackerId = trackerId, diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 66e086a885..25bd7c4d3a 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -6,6 +6,7 @@ import dev.slimevr.VRServerActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.context.ManagedContext +import dev.slimevr.fbscodegen.runtime.JvmFlatBufferReader import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import solarxr_protocol.MessageBundle @@ -29,7 +30,7 @@ typealias SolarXRBridgeContext = Context suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRBridge) { - val messageBundle = MessageBundle.fromByteBuffer(message) + val messageBundle = MessageBundle.fromByteBuffer(JvmFlatBufferReader(message)) messageBundle.dataFeedMsgs?.forEach { val msg = it.message ?: return diff --git a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt index 68812a19c9..13fe9bc32b 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt @@ -55,7 +55,7 @@ class SerialBehaviour(private val serialServer: SerialServer) : SolarXRBridgeBeh } receiver.rpcDispatcher.on { req -> - val portLocation = if (req.auto) { + val portLocation = if (req.auto == true) { serialServer.context.state.value.availablePorts.keys.firstOrNull() } else { req.port diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt index 5def9ba281..2afd8e5e38 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt @@ -71,8 +71,10 @@ class SkeletonBehaviour( receiver.rpcDispatcher.on { req -> val bone = req.bone ?: return@on + val value = req.value ?: return@on + userConfig.context.dispatch( - UserConfigActions.Update { copy(proportions = proportions + (bone.name to req.value)) }, + UserConfigActions.Update { copy(proportions = proportions + (bone.name to value)) }, ) receiver.sendRpc(buildConfigResponse()) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt b/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt index 52f525ffa2..7b78e396de 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt @@ -11,8 +11,8 @@ class AssignTrackerBehaviour( ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { req -> - val trackerId = req.trackerId ?: return@on - val tracker = server.getTracker(trackerId.trackerNum.toInt()) + val trackerNum = req.trackerId?.trackerNum ?: return@on + val tracker = server.getTracker(trackerNum.toInt()) ?: return@on val bodyPart = req.bodyPosition?.takeIf { it != BodyPart.NONE } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt b/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt index 83f697849b..66e0b98848 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt @@ -46,7 +46,7 @@ class TrackingChecklistBehaviour( val name = stepId.name settings.context.dispatch( SettingsActions.Update { - copy(mutedChecklistSteps = if (req.ignore) mutedChecklistSteps + name else mutedChecklistSteps - name) + copy(mutedChecklistSteps = if (req.ignore == true) mutedChecklistSteps + name else mutedChecklistSteps - name) }, ) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt index 1ac0fad7d1..a5345d8410 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt @@ -14,15 +14,17 @@ class VmcBehaviour( receiver.rpcDispatcher.on { req -> val vmc = req.vmcOsc ?: return@on val osc = vmc.oscSettings ?: return@on + val portIn = osc.portIn?.toInt() ?: return@on + val portOut = osc.portOut?.toInt() ?: return@on vmcManager.context.dispatch( VMCActions.UpdateConfig( VMCConfig( - enabled = osc.enabled, - portIn = osc.portIn.toInt(), - portOut = osc.portOut.toInt(), + enabled = osc.enabled == true, + portIn = portIn, + portOut = portOut, address = osc.address.orEmpty(), - mirrorTracking = vmc.mirrorTracking, - anchorAtHips = vmc.anchorHip, + mirrorTracking = vmc.mirrorTracking == true, + anchorAtHips = vmc.anchorHip == true, vrmJson = vmc.vrmJson?.ifEmpty { null }, ), ), diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt index f2a258f897..68412ca7d3 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt @@ -55,12 +55,12 @@ internal class VrcOscBehaviour( vrcOscManager.context.dispatch( VRCOSCActions.UpdateConfig( VRCOSCConfig( - enabled = vrcOsc.enabled, - manualNetwork = vrcOsc.manualNetwork?.let { network -> + enabled = vrcOsc.enabled == true, + manualNetwork = vrcOsc.manualNetwork?.takeIf { it.portIn !== null && it.portOut !== null && it.address !== null }?.let { network -> VRCOSCManualNetworkConfig( - portIn = network.portIn.toInt(), - portOut = network.portOut.toInt(), - address = network.address.orEmpty(), + portIn = network.portIn?.toInt() ?: error("portIn should be set"), + portOut = network.portOut?.toInt() ?: error("portOut should be set"), + address = network.address ?: error("address should be set"), ) }, trackers = VRCOSCTrackers( diff --git a/server/core/src/main/java/dev/slimevr/vrchat/module.kt b/server/core/src/main/java/dev/slimevr/vrchat/module.kt index 4c223e7a05..f4f0d4d8a5 100644 --- a/server/core/src/main/java/dev/slimevr/vrchat/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrchat/module.kt @@ -107,23 +107,23 @@ fun computeRecommendedValues(server: VRServer, userHeight: Float): VRCConfigReco ) } -fun isVRCConfigValid(validity: VRCConfigValidity, mutedWarnings: List): Boolean = (validity.legacyModeOk || "legacyModeOk" in mutedWarnings) && - (validity.shoulderTrackingOk || "shoulderTrackingOk" in mutedWarnings) && - (validity.shoulderWidthCompensationOk || "shoulderWidthCompensationOk" in mutedWarnings) && - (validity.userHeightOk || "userHeightOk" in mutedWarnings) && - (validity.calibrationRangeOk || "calibrationRangeOk" in mutedWarnings) && - (validity.calibrationVisualsOk || "calibrationVisualsOk" in mutedWarnings) && - (validity.trackerModelOk || "trackerModelOk" in mutedWarnings) && - (validity.spineModeOk || "spineModeOk" in mutedWarnings) && - (validity.avatarMeasurementTypeOk || "avatarMeasurementTypeOk" in mutedWarnings) +fun isVRCConfigValid(validity: VRCConfigValidity, mutedWarnings: List): Boolean = (validity.legacyModeOk == true || "legacyModeOk" in mutedWarnings) && + (validity.shoulderTrackingOk == true || "shoulderTrackingOk" in mutedWarnings) && + (validity.shoulderWidthCompensationOk == true || "shoulderWidthCompensationOk" in mutedWarnings) && + (validity.userHeightOk == true || "userHeightOk" in mutedWarnings) && + (validity.calibrationRangeOk == true || "calibrationRangeOk" in mutedWarnings) && + (validity.calibrationVisualsOk == true || "calibrationVisualsOk" in mutedWarnings) && + (validity.trackerModelOk == true|| "trackerModelOk" in mutedWarnings) && + (validity.spineModeOk == true || "spineModeOk" in mutedWarnings) && + (validity.avatarMeasurementTypeOk == true || "avatarMeasurementTypeOk" in mutedWarnings) 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, + calibrationRangeOk = abs((values.calibrationRange ?: 0f) - (recommended.calibrationRange ?: 0f)) < 0.1f, + userHeightOk = abs((recommended.userHeight ?: 0f) - (values.userHeight?: 0f)) < 0.1f, calibrationVisualsOk = values.calibrationVisuals == recommended.calibrationVisuals, avatarMeasurementTypeOk = values.avatarMeasurementType == recommended.avatarMeasurementType, shoulderWidthCompensationOk = values.shoulderWidthCompensation == recommended.shoulderWidthCompensation, diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index 968abedbdb..d3b75138fc 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -74,7 +74,7 @@ wire { dependencies { implementation(project(":server:core")) - implementation(project(":solarxr-protocol")) + implementation(project(":solarxr-protocol:generated")) implementation("net.java.dev.jna:jna:5.+") implementation("net.java.dev.jna:jna-platform:5.+") @@ -97,7 +97,7 @@ tasks.shadowJar { exclude(dependency("net.java.dev.jna:.*:.*")) exclude(dependency("com.google.flatbuffers:flatbuffers-java:.*")) - exclude(project(":solarxr-protocol")) + exclude(project(":solarxr-protocol:generated")) } archiveBaseName.set("slimevr") archiveClassifier.set("") 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 d5bc1e5808..a5280044fe 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -3,7 +3,9 @@ package dev.slimevr.desktop import dev.slimevr.AppContext +import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Phase1Context +import dev.slimevr.Platform import dev.slimevr.VRServer import dev.slimevr.bvh.BVHManager import dev.slimevr.config.AppConfig @@ -36,7 +38,7 @@ fun main(args: Array) = runBlocking { val firmwareManager = FirmwareManager.create(ctx = phase1, scope = this) val vrcConfigManager = createDesktopVRCConfigManager(ctx = phase1, scope = this) - val networkProfileManager = NetworkProfileManager.create(scope = this, isSupported = true) + val networkProfileManager = NetworkProfileManager.create(scope = this, isSupported = CURRENT_PLATFORM == Platform.WINDOWS) val skeleton = Skeleton.create(scope = this, ctx = phase1) val provisioningManager = ProvisioningManager.create(ctx = phase1, scope = this) val heightCalibrationManager = HeightCalibrationManager.create(ctx = phase1, scope = this) diff --git a/settings.gradle.kts b/settings.gradle.kts index 7c7f9abf25..2674078111 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,6 +26,7 @@ pluginManagement { kotlin("plugin.serialization") version kotlinVersion kotlin("jvm") version kotlinVersion kotlin("android") version kotlinVersion + kotlin("multiplatform") version kotlinVersion id("com.diffplug.spotless") version spotlessVersion id("com.gradleup.shadow") version shadowJarVersion id("com.github.gmazzo.buildconfig") version buildconfigVersion @@ -35,7 +36,11 @@ pluginManagement { } include(":solarxr-protocol") -project(":solarxr-protocol").projectDir = File("solarxr-protocol/protocol/kotlin") +project(":solarxr-protocol").projectDir = File("solarxr-protocol") +include(":solarxr-protocol:codegen") +project(":solarxr-protocol:codegen").projectDir = File("solarxr-protocol/kotlin-codegen") +include(":solarxr-protocol:generated") +project(":solarxr-protocol:generated").projectDir = File("solarxr-protocol/protocol/kotlin") include(":server") project(":server").projectDir = File("server") diff --git a/solarxr-protocol b/solarxr-protocol index c3dfef44ae..311db6ba7c 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit c3dfef44ae6269a4e90efc946ae04092f07fbb86 +Subproject commit 311db6ba7cb081252ac1d64a0aeffbb783bd072e From f3de32a7cb8592361cb806fe9078c1cece340e21 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 11 May 2026 02:21:57 +0200 Subject: [PATCH 100/350] Moved as much as possible to KMP --- server/core/build.gradle.kts | 106 ++++++--------- .../main/java/com/jme3/system/NanoTimer.java | 105 --------------- .../src/main/java/com/jme3/system/Timer.java | 93 ------------- .../main/java/dev/slimevr/bvh/behaviours.kt | 41 +++--- .../src/main/java/dev/slimevr/bvh/module.kt | 12 +- .../src/main/java/dev/slimevr/bvh/stream.kt | 83 ++++++------ .../src/main/java/dev/slimevr/config/io.kt | 71 +++++----- .../main/java/dev/slimevr/config/module.kt | 12 +- .../main/java/dev/slimevr/config/settings.kt | 75 ++--------- .../src/main/java/dev/slimevr/config/user.kt | 17 +-- .../main/java/dev/slimevr/context/context.kt | 62 +++++---- .../java/dev/slimevr/firmware/download.kt | 21 ++- .../main/java/dev/slimevr/firmware/module.kt | 12 +- .../src/main/java/dev/slimevr/firmware/ota.kt | 36 +++-- .../main/java/dev/slimevr/firmware/serial.kt | 43 +++--- .../src/main/java/dev/slimevr/hid/packets.kt | 20 ++- .../java/dev/slimevr/oscquery/discovery.kt | 123 ++++-------------- .../main/java/dev/slimevr/oscquery/http.kt | 6 +- .../main/java/dev/slimevr/oscquery/model.kt | 2 +- .../main/java/dev/slimevr/serial/module.kt | 15 ++- .../src/main/java/dev/slimevr/solarxr/bvh.kt | 3 +- .../main/java/dev/slimevr/solarxr/datafeed.kt | 12 +- .../main/java/dev/slimevr/solarxr/module.kt | 10 +- .../main/java/dev/slimevr/udp/connection.kt | 30 ++--- .../src/main/java/dev/slimevr/udp/packets.kt | 52 ++++---- .../src/main/java/dev/slimevr/udp/server.kt | 42 +++--- .../main/java/dev/slimevr/vrcosc/module.kt | 9 +- .../main/java/dev/slimevr/vrcosc/oscquery.kt | 21 +-- .../src/test/java/dev/slimevr/TestServer.kt | 24 ++-- .../dev/slimevr/firmware/DoSerialFlashTest.kt | 15 ++- .../dev/slimevr/serial/SerialServerTest.kt | 3 +- server/desktop/build.gradle.kts | 2 + .../src/main/java/dev/slimevr/desktop/Main.kt | 17 ++- .../dev/slimevr/desktop/config/storage.kt | 84 ++++++++++++ .../java/dev/slimevr/desktop/serial/flash.kt | 42 ++++++ .../dev/slimevr/desktop/vrchat/oscquery.kt | 20 +++ .../src/main/java/dev/slimevr/platform.kt | 0 .../src/main/java/dev/slimevr/solarxr/ipc.kt | 3 +- 38 files changed, 593 insertions(+), 751 deletions(-) delete mode 100644 server/core/src/main/java/com/jme3/system/NanoTimer.java delete mode 100644 server/core/src/main/java/com/jme3/system/Timer.java create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/config/storage.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/vrchat/oscquery.kt rename server/{core => desktop}/src/main/java/dev/slimevr/platform.kt (100%) rename server/{core => desktop}/src/main/java/dev/slimevr/solarxr/ipc.kt (86%) diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index 8eefbc78ae..cc47e4b928 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -5,41 +5,9 @@ * For more details take a look at the Java Libraries chapter in the Gradle * User Manual available at https://docs.gradle.org/6.3/userguide/java_library_plugin.html */ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - plugins { - kotlin("jvm") + kotlin("multiplatform") kotlin("plugin.serialization") - `java-library` -} - -kotlin { - jvmToolchain { - languageVersion.set(JavaLanguageVersion.of(24)) - } -} -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(24)) - } -} -tasks.withType { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_24) - freeCompilerArgs.set(listOf("-Xvalue-classes")) - } -} - -// Set compiler to use UTF-8 -tasks.withType { - options.encoding = "UTF-8" -} -tasks.withType { - systemProperty("file.encoding", "UTF-8") -} -tasks.withType { - options.encoding = "UTF-8" } tasks.withType { @@ -56,42 +24,52 @@ allprojects { } } -dependencies { - implementation(project(":solarxr-protocol:generated")) - implementation("com.google.flatbuffers:flatbuffers-java:22.10.26") - - implementation("com.illposed.osc:javaosc-core:0.9") - implementation("com.melloware:jintellitype:1.+") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") - implementation("com.mayakapps.kache:kache:2.1.1") - implementation("io.klogging:klogging:0.11.7") - implementation("io.klogging:slf4j-klogging:0.11.7") - - val ktor_version = "3.4.1" - implementation("io.ktor:ktor-server-core-jvm:$ktor_version") - implementation("io.ktor:ktor-server-netty-jvm:$ktor_version") - implementation("io.ktor:ktor-server-websockets-jvm:$ktor_version") - implementation("io.ktor:ktor-server-content-negotiation-jvm:$ktor_version") - implementation("io.ktor:ktor-serialization-kotlinx-json-jvm:$ktor_version") - implementation("io.ktor:ktor-network:$ktor_version") - implementation("io.ktor:ktor-utils:$ktor_version") +kotlin { + jvmToolchain(24) + jvm() - api("com.github.loucass003:EspflashKotlin:v0.11.0") + sourceSets { + val commonMain by getting { + kotlin.srcDir("src/main/java") + dependencies { + implementation(project(":solarxr-protocol:generated")) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") + implementation("com.mayakapps.kache:kache:2.1.1") + implementation("io.klogging:klogging:0.11.7") + implementation("com.appstractive:dns-sd-kt:1.1.0") - // Allow the use of reflection - implementation(kotlin("reflect")) + val ktorVersion = "3.4.1" + implementation("io.ktor:ktor-client-core:$ktorVersion") + implementation("io.ktor:ktor-client-cio:$ktorVersion") + implementation("io.ktor:ktor-server-core:$ktorVersion") + implementation("io.ktor:ktor-server-cio:$ktorVersion") + implementation("io.ktor:ktor-network:$ktorVersion") + implementation("io.ktor:ktor-utils:$ktorVersion") - implementation("com.appstractive:dns-sd-kt:1.1.0") + // Allow the use of reflection + implementation(kotlin("reflect")) - testImplementation(kotlin("test")) - // Use JUnit test framework - testImplementation(platform("org.junit:junit-bom:6.0.2")) - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.junit.platform:junit-platform-launcher") - testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") + } + } + val commonTest by getting { + kotlin.srcDir("src/test/java") + dependencies { + implementation(kotlin("test")) + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + } + } + } } -tasks.test { +tasks.withType { + options.encoding = "UTF-8" +} +tasks.withType { + systemProperty("file.encoding", "UTF-8") useJUnitPlatform() } +tasks.withType { + options.encoding = "UTF-8" +} diff --git a/server/core/src/main/java/com/jme3/system/NanoTimer.java b/server/core/src/main/java/com/jme3/system/NanoTimer.java deleted file mode 100644 index b99dcf2dd3..0000000000 --- a/server/core/src/main/java/com/jme3/system/NanoTimer.java +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright (c) 2009-2012 jMonkeyEngine - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are - * met: - * - * * Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * - * * Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * - * * Neither the name of 'jMonkeyEngine' nor the names of its contributors - * may be used to endorse or promote products derived from this software - * without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR - * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR - * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, - * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, - * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR - * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -package com.jme3.system; - -/** - * NanoTimer is a System.nanoTime implementation of - * Timer. This is primarily useful for headless applications - * running on a server. - * - * @author Matthew D. Hicks - */ -public class NanoTimer extends Timer { - - private static final long TIMER_RESOLUTION = 1000000000L; - private static final float INVERSE_TIMER_RESOLUTION = 1f / TIMER_RESOLUTION; - - private long startTime; - private long previousTime; - private float tpf; - private float fps; - private long currentTime; - - public NanoTimer() { - startTime = System.nanoTime(); - } - - /** - * Returns the time in seconds. The timer starts at 0.0 seconds. - * - * @return the current time in seconds - */ - - protected long getTimeInternal() { - return System.nanoTime() - startTime; - } - - @Override - public float getTimeInSeconds() { - return getTime() * INVERSE_TIMER_RESOLUTION; - } - - @Override - public long getTime() { - return currentTime; - } - - @Override - public long getResolution() { - return TIMER_RESOLUTION; - } - - @Override - public float getFrameRate() { - return fps; - } - - @Override - public float getTimePerFrame() { - return tpf; - } - - @Override - public void update() { - currentTime = getTimeInternal(); - tpf = (currentTime - previousTime) * (1.0f / TIMER_RESOLUTION); - fps = 1.0f / tpf; - previousTime = getTime(); - } - - @Override - public void reset() { - startTime = System.nanoTime(); - currentTime = getTimeInternal(); - previousTime = getTime(); - } -} diff --git a/server/core/src/main/java/com/jme3/system/Timer.java b/server/core/src/main/java/com/jme3/system/Timer.java deleted file mode 100644 index ea0a61dfc8..0000000000 --- a/server/core/src/main/java/com/jme3/system/Timer.java +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright (c) 2009-2012 jMonkeyEngine - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are - * met: - * - * * Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * - * * Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * - * * Neither the name of 'jMonkeyEngine' nor the names of its contributors - * may be used to endorse or promote products derived from this software - * without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR - * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR - * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, - * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, - * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR - * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -package com.jme3.system; - -/** - * Timer is the base class for a high resolution timer. It is - * created from getTimer("display system") - * - * @author Mark Powell - * @version $Id: Timer.java,v 1.18 2007/03/09 10:19:34 rherlitz Exp $ - */ -public abstract class Timer { - - /** - * Returns the current time in ticks. A tick is an arbitrary measure of time - * defined by the timer implementation. The number of ticks per second is - * given by getResolution(). The timer starts at 0 ticks. - * - * @return a long value representing the current time - */ - public abstract long getTime(); - - /** - * Returns the time in seconds. The timer starts at 0.0 seconds. - * - * @return the current time in seconds - */ - public float getTimeInSeconds() { - return getTime() / (float) getResolution(); - } - - /** - * Returns the resolution of the timer. - * - * @return the number of timer ticks per second - */ - public abstract long getResolution(); - - /** - * Returns the "calls per second". If this is called every frame, then it - * will return the "frames per second". - * - * @return The "calls per second". - */ - public abstract float getFrameRate(); - - /** - * Returns the time, in seconds, between the last call and the current one. - * - * @return Time between this call and the last one. - */ - public abstract float getTimePerFrame(); - - /** - * update recalculates the frame rate based on the previous - * call to update. It is assumed that update is called each frame. - */ - public abstract void update(); - - /** - * Reset the timer to 0. Clear any tpf history. - */ - public abstract void reset(); -} diff --git a/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt b/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt index ca508250bf..1302e5cdc2 100644 --- a/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt @@ -1,40 +1,31 @@ package dev.slimevr.bvh import dev.slimevr.AppLogger +import dev.slimevr.config.ConfigStorage import dev.slimevr.skeleton.Skeleton -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.withContext -import java.io.File -import java.nio.file.Path -private suspend fun resolveBvhFile(path: Path): File? = withContext(Dispatchers.IO) { - val file = path.toFile() - if (file.isDirectory) { - getBvhFileInDir(file) - } else { - if (file.name.lowercase().endsWith(".bvh")) file else null +private suspend fun resolveBvhPath(storage: ConfigStorage, path: String): String? { + if (path.lowercase().endsWith(".bvh")) return path + if (!storage.ensureDirectory(path)) { + AppLogger.bvh.error("Failed to create recording directory \"${storage.displayPath(path)}\"") + return null } -} -private suspend fun getBvhFileInDir(dir: File): File? = withContext(Dispatchers.IO) { - if (dir.isDirectory || dir.mkdirs()) { - var file: File - var index = 1 - do { - file = File(dir, "BVH-Recording${index++}.bvh") - } while (file.exists()) - file - } else { - AppLogger.bvh.error("Failed to create recording directory \"${dir.path}\"") - null + var index = 1 + while (true) { + val candidate = "$path/BVH-Recording${index++}.bvh" + if (!storage.exists(candidate)) return candidate } } -class BVHRecordingBehaviour(private val skeleton: Skeleton) : BVHBehaviourType { +class BVHRecordingBehaviour( + private val skeleton: Skeleton, + private val storage: ConfigStorage, +) : BVHBehaviourType { override fun reduce(state: BVHState, action: BVHActions) = when (action) { is BVHActions.StartRecording -> state.copy(recording = true, recordingPath = action.path) is BVHActions.StopRecording -> state.copy(recording = false, recordingPath = null) @@ -48,9 +39,9 @@ class BVHRecordingBehaviour(private val skeleton: Skeleton) : BVHBehaviourType { .distinctUntilChanged() .onEach { (recording, path) -> if (recording && stream == null) { - val file = resolveBvhFile(path ?: return@onEach) ?: return@onEach + val resolvedPath = resolveBvhPath(storage, path ?: return@onEach) ?: return@onEach try { - stream = BvhStream(file).also { + stream = BvhStream(storage.openTextFile(resolvedPath)).also { it.writeHeader(skeleton.computed.value) // TODO: we could write the initial T-Pose or whatever here } diff --git a/server/core/src/main/java/dev/slimevr/bvh/module.kt b/server/core/src/main/java/dev/slimevr/bvh/module.kt index 834b698f5f..c4e914e4b5 100644 --- a/server/core/src/main/java/dev/slimevr/bvh/module.kt +++ b/server/core/src/main/java/dev/slimevr/bvh/module.kt @@ -1,18 +1,18 @@ package dev.slimevr.bvh +import dev.slimevr.config.ConfigStorage import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.skeleton.Skeleton import kotlinx.coroutines.CoroutineScope -import java.nio.file.Path data class BVHState( val recording: Boolean = false, - val recordingPath: Path? = null, + val recordingPath: String? = null, ) sealed interface BVHActions { - data class StartRecording(val path: Path) : BVHActions + data class StartRecording(val path: String) : BVHActions data object StopRecording : BVHActions } @@ -22,16 +22,16 @@ typealias BVHBehaviourType = Behaviour class BVHManager(val context: BVHContext) { val isRecording get() = context.state.value.recording - fun startRecording(path: Path) = context.dispatch(BVHActions.StartRecording(path)) + fun startRecording(path: String) = context.dispatch(BVHActions.StartRecording(path)) fun stopRecording() = context.dispatch(BVHActions.StopRecording) fun startObserving() = context.observeAll(this) companion object { - fun create(skeleton: Skeleton, scope: CoroutineScope): BVHManager { + fun create(skeleton: Skeleton, storage: ConfigStorage, scope: CoroutineScope): BVHManager { val context = Context.create( initialState = BVHState(), scope = scope, - behaviours = listOf(BVHRecordingBehaviour(skeleton)), + behaviours = listOf(BVHRecordingBehaviour(skeleton, storage)), name = "BVH", ) return BVHManager(context).also { manager -> manager.startObserving() } diff --git a/server/core/src/main/java/dev/slimevr/bvh/stream.kt b/server/core/src/main/java/dev/slimevr/bvh/stream.kt index 6a6300a85b..e8957e8514 100644 --- a/server/core/src/main/java/dev/slimevr/bvh/stream.kt +++ b/server/core/src/main/java/dev/slimevr/bvh/stream.kt @@ -1,50 +1,45 @@ package dev.slimevr.bvh import com.jme3.math.FastMath +import dev.slimevr.config.TextFileHandle import dev.slimevr.skeleton.BODY_PART_HIERARCHY_MAP import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.DEFAULT_BONE_OFFSETS import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import solarxr_protocol.datatypes.BodyPart -import java.io.BufferedWriter -import java.io.File -import java.io.FileOutputStream -import java.io.OutputStreamWriter private const val FRAME_COUNT_DIGITS = Long.MAX_VALUE.toString().length private const val OFFSET_SCALE = 1f private const val POSITION_SCALE = 1f -class BvhStream(file: File) { - private val outputStream = FileOutputStream(file) - private val writer = BufferedWriter(OutputStreamWriter(outputStream), 4096) +class BvhStream( + private val file: TextFileHandle, +) { private var frameCount = 0L private var frameCountOffset = 0L private var frameTimeOffset = 0L private var lastFrameTime = System.currentTimeMillis() private val frameIntervals = mutableListOf() - suspend fun writeHeader(bones: Map) = withContext(Dispatchers.IO) { - writer.write("HIERARCHY\n") + suspend fun writeHeader(bones: Map) { + file.write("HIERARCHY\n") writeBone(BodyPart.HEAD, null, bones, 0) - writer.write("MOTION\n") - writer.write("Frames: ") - writer.flush() - frameCountOffset = outputStream.channel.position() + file.write("MOTION\n") + file.write("Frames: ") + file.flush() + frameCountOffset = file.position() frameCount = 0L - writer.write(getBufferedFrameCount(0L) + "\n") - writer.write("Frame Time: ") - writer.flush() - frameTimeOffset = outputStream.channel.position() - writer.write(getBufferedFrameInterval(0.01f) + "\n") + file.write(getBufferedFrameCount(0L) + "\n") + file.write("Frame Time: ") + file.flush() + frameTimeOffset = file.position() + file.write(getBufferedFrameInterval(0.01f) + "\n") lastFrameTime = System.currentTimeMillis() } - suspend fun writeFrame(bones: Map) = withContext(Dispatchers.IO) { + suspend fun writeFrame(bones: Map) { val now = System.currentTimeMillis() if (frameCount > 0) { val interval = (now - lastFrameTime) / 1000f @@ -54,23 +49,23 @@ class BvhStream(file: File) { val head = bones[BodyPart.HEAD] val pos = head?.headPosition ?: Vector3.NULL - writer.write("${pos.x * POSITION_SCALE} ${pos.y * POSITION_SCALE} ${pos.z * POSITION_SCALE}") + file.write("${pos.x * POSITION_SCALE} ${pos.y * POSITION_SCALE} ${pos.z * POSITION_SCALE}") writeRotations(BodyPart.HEAD, bones) - writer.newLine() + file.write("\n") frameCount++ } - suspend fun close() = withContext(Dispatchers.IO) { - writer.flush() + suspend fun close() { + file.flush() val avgInterval = if (frameIntervals.isNotEmpty()) frameIntervals.average().toFloat() else 0.01f - outputStream.channel.position(frameCountOffset) - writer.write(getBufferedFrameCount(frameCount)) + file.seek(frameCountOffset) + file.write(getBufferedFrameCount(frameCount)) - outputStream.channel.position(frameTimeOffset) - writer.write(getBufferedFrameInterval(avgInterval)) + file.seek(frameTimeOffset) + file.write(getBufferedFrameInterval(avgInterval)) - writer.close() + file.close() } private fun getBufferedFrameCount(frameCount: Long): String { @@ -90,38 +85,38 @@ class BvhStream(file: File) { return tailOffsetLocal.unit() * (parentLength * OFFSET_SCALE) } - private fun writeRotations(part: BodyPart, bones: Map) { + private suspend fun writeRotations(part: BodyPart, bones: Map) { val bone = bones[part] val rot = bone?.localRotation ?: Quaternion.IDENTITY val angles = rot.toEulerAngles(EulerOrder.ZXY) - writer.write(" ${angles.z * FastMath.RAD_TO_DEG} ${angles.x * FastMath.RAD_TO_DEG} ${angles.y * FastMath.RAD_TO_DEG}") + file.write(" ${angles.z * FastMath.RAD_TO_DEG} ${angles.x * FastMath.RAD_TO_DEG} ${angles.y * FastMath.RAD_TO_DEG}") BODY_PART_HIERARCHY_MAP[part]?.forEach { child -> writeRotations(child, bones) } } - private fun writeBone(part: BodyPart, parent: BodyPart?, bones: Map, depth: Int) { + private suspend fun writeBone(part: BodyPart, parent: BodyPart?, bones: Map, depth: Int) { val indent = "\t".repeat(depth) val childIndent = "\t".repeat(depth + 1) - writer.write("$indent${if (parent == null) "ROOT" else "JOINT"} $part\n") - writer.write("$indent{\n") + file.write("$indent${if (parent == null) "ROOT" else "JOINT"} $part\n") + file.write("$indent{\n") if (parent == null) { - writer.write("${childIndent}OFFSET 0.0 0.0 0.0\n") - writer.write("${childIndent}CHANNELS 6 Xposition Yposition Zposition Zrotation Xrotation Yrotation\n") + file.write("${childIndent}OFFSET 0.0 0.0 0.0\n") + file.write("${childIndent}CHANNELS 6 Xposition Yposition Zposition Zrotation Xrotation Yrotation\n") } else { val offset = getBvhOffset(parent, bones) - writer.write("${childIndent}OFFSET ${offset.x} ${offset.y} ${offset.z}\n") - writer.write("${childIndent}CHANNELS 3 Zrotation Xrotation Yrotation\n") + file.write("${childIndent}OFFSET ${offset.x} ${offset.y} ${offset.z}\n") + file.write("${childIndent}CHANNELS 3 Zrotation Xrotation Yrotation\n") } val children = BODY_PART_HIERARCHY_MAP[part] if (children.isNullOrEmpty()) { - writer.write("${childIndent}End Site\n") - writer.write("$childIndent{\n") - writer.write("${" ".repeat(depth + 2)}OFFSET 0.0 0.0 0.0\n") - writer.write("$childIndent}\n") + file.write("${childIndent}End Site\n") + file.write("$childIndent{\n") + file.write("${"\t".repeat(depth + 2)}OFFSET 0.0 0.0 0.0\n") + file.write("$childIndent}\n") } else { children.forEach { child -> writeBone(child, part, bones, depth + 1) } } - writer.write("$indent}\n") + file.write("$indent}\n") } } diff --git a/server/core/src/main/java/dev/slimevr/config/io.kt b/server/core/src/main/java/dev/slimevr/config/io.kt index d32f41769c..510183d9b2 100644 --- a/server/core/src/main/java/dev/slimevr/config/io.kt +++ b/server/core/src/main/java/dev/slimevr/config/io.kt @@ -14,9 +14,24 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.sample import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json -import java.io.File -import java.nio.file.Files -import java.nio.file.StandardCopyOption + +interface ConfigStorage { + suspend fun read(path: String): String? + suspend fun write(path: String, content: String) + suspend fun backup(path: String) + suspend fun exists(path: String): Boolean + suspend fun ensureDirectory(path: String): Boolean + suspend fun openTextFile(path: String): TextFileHandle + fun displayPath(path: String): String = path +} + +interface TextFileHandle { + suspend fun write(text: String) + suspend fun flush() + suspend fun position(): Long + suspend fun seek(position: Long) + suspend fun close() +} val jsonConfig = Json { prettyPrint = true @@ -24,38 +39,29 @@ val jsonConfig = Json { encodeDefaults = true } -suspend fun atomicWriteFile(file: File, content: String) = withContext(Dispatchers.IO) { - file.parentFile?.mkdirs() - val tmp = File(file.parent, "${file.name}.tmp") - tmp.writeText(content) - Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) - Unit -} +fun configPath(vararg parts: String): String = parts.filter { it.isNotEmpty() }.joinToString("/") -suspend inline fun loadFileWithBackup(file: File, default: T, crossinline deserialize: (String) -> T): T = withContext(Dispatchers.IO) { - if (!file.exists()) { - atomicWriteFile(file, jsonConfig.encodeToString(default)) +suspend inline fun loadFileWithBackup( + storage: ConfigStorage, + path: String, + default: T, + crossinline deserialize: (String) -> T, +): T = withContext(Dispatchers.IO) { + val raw = storage.read(path) + if (raw == null) { + storage.write(path, jsonConfig.encodeToString(default)) return@withContext default } try { - deserialize(file.readText()) + deserialize(raw) } catch (e: Exception) { e.printStackTrace() - System.err.println("Failed to load ${file.absolutePath}: ${e.message}") - if (file.exists()) { - try { - val bakTmp = File(file.parent, "${file.name}.bak.tmp") - file.copyTo(bakTmp, overwrite = true) - Files.move( - bakTmp.toPath(), - File(file.parent, "${file.name}.bak").toPath(), - StandardCopyOption.ATOMIC_MOVE, - StandardCopyOption.REPLACE_EXISTING, - ) - } catch (e2: Exception) { - System.err.println("Failed to back up corrupted file: ${e2.message}") - } + System.err.println("Failed to load ${storage.displayPath(path)}: ${e.message}") + try { + storage.backup(path) + } catch (e2: Exception) { + System.err.println("Failed to back up corrupted file: ${e2.message}") } default } @@ -71,7 +77,8 @@ suspend inline fun loadFileWithBackup(file: File, default: T, crossi fun launchAutosave( scope: CoroutineScope, state: StateFlow, - toFile: (S) -> File, + storage: ConfigStorage, + toPath: (S) -> String, serialize: (S) -> String, ): Job { var lastSaved = state.value @@ -80,10 +87,10 @@ fun launchAutosave( .filter { it != lastSaved } .onEach { s -> try { - val file = toFile(s) - atomicWriteFile(file, serialize(s)) + val path = toPath(s) + storage.write(path, serialize(s)) lastSaved = s - println("Saved ${file.absolutePath}") + println("Saved ${storage.displayPath(path)}") } catch (e: Exception) { System.err.println("Failed to save: ${e.message}") } diff --git a/server/core/src/main/java/dev/slimevr/config/module.kt b/server/core/src/main/java/dev/slimevr/config/module.kt index 0c09bb9479..9ca987ec9a 100644 --- a/server/core/src/main/java/dev/slimevr/config/module.kt +++ b/server/core/src/main/java/dev/slimevr/config/module.kt @@ -9,7 +9,6 @@ import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import java.io.File private const val GLOBAL_CONFIG_VERSION = 1 @@ -57,8 +56,8 @@ class AppConfig( } companion object { - suspend fun create(scope: CoroutineScope, configFolder: File): AppConfig { - val initialGlobal = loadFileWithBackup(File(configFolder, "global.json"), GlobalConfigState()) { + suspend fun create(scope: CoroutineScope, storage: ConfigStorage): AppConfig { + val initialGlobal = loadFileWithBackup(storage, "global.json", GlobalConfigState()) { parseAndMigrateGlobalConfig(it) } @@ -74,12 +73,13 @@ class AppConfig( launchAutosave( scope = scope, state = globalContext.state, - toFile = { File(configFolder, "global.json") }, + storage = storage, + toPath = { "global.json" }, serialize = { jsonConfig.encodeToString(it) }, ) - val userConfig = UserConfig.create(scope, configFolder, initialGlobal.selectedUserProfile) - val settings = Settings.create(scope, configFolder, initialGlobal.selectedSettingsProfile) + val userConfig = UserConfig.create(scope, storage, initialGlobal.selectedUserProfile) + val settings = Settings.create(scope, storage, initialGlobal.selectedSettingsProfile) return AppConfig( globalContext = globalContext, diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index f805bb85c9..8122e1f62e 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -16,7 +16,6 @@ import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import solarxr_protocol.datatypes.BodyPart -import java.io.File private const val SETTINGS_CONFIG_VERSION = 2 @@ -48,68 +47,10 @@ private fun defaultVrcOscConfig() = VRCOSCConfig() private fun migrateSettingsConfig(json: JsonObject): JsonObject { val version = json["version"]?.jsonPrimitive?.intOrNull ?: 0 return when { - version < 2 -> migrateSettingsConfig(migrateSettingsConfigV2(json)) else -> json } } -private fun migrateSettingsConfigV2(json: JsonObject): JsonObject { - val vrcOscConfig = json["vrcOscConfig"]?.jsonObject - val migratedVrcOscConfig = if (vrcOscConfig == null || "manualNetwork" in vrcOscConfig) { - vrcOscConfig?.let(::normalizeVrcOscConfig) - } else { - val portIn = vrcOscConfig["portIn"]?.jsonPrimitive?.intOrNull ?: 9001 - val portOut = vrcOscConfig["portOut"]?.jsonPrimitive?.intOrNull ?: 9000 - val address = vrcOscConfig["address"]?.jsonPrimitive?.contentOrNull.orEmpty() - val oscqueryEnabled = vrcOscConfig["oscqueryEnabled"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: true - val useAutomaticNetwork = - oscqueryEnabled && - portIn == 9001 && - portOut == 9000 && - (address.isBlank() || address == "127.0.0.1" || address == "localhost") - - buildMap { - vrcOscConfig["enabled"]?.let { put("enabled", it) } - vrcOscConfig["trackers"]?.let { put("trackers", it) } - if (!useAutomaticNetwork) { - put( - "manualNetwork", - JsonObject( - mapOf( - "portIn" to JsonPrimitive(portIn), - "portOut" to JsonPrimitive(portOut), - "address" to JsonPrimitive(address), - ), - ), - ) - } - }.let(::JsonObject) - } - - return JsonObject( - json.toMutableMap().apply { - put("version", JsonPrimitive(SETTINGS_CONFIG_VERSION)) - if (migratedVrcOscConfig != null) put("vrcOscConfig", migratedVrcOscConfig) - }, - ) -} - -private fun normalizeVrcOscConfig(vrcOscConfig: JsonObject): JsonObject { - val manualNetwork = vrcOscConfig["manualNetwork"]?.jsonObject ?: return vrcOscConfig - val normalizedManualNetwork = JsonObject( - mapOf( - "portIn" to (manualNetwork["portIn"] ?: JsonPrimitive(DEFAULT_VRC_OSC_PORT_IN)), - "portOut" to (manualNetwork["portOut"] ?: JsonPrimitive(DEFAULT_VRC_OSC_PORT_OUT)), - "address" to (manualNetwork["address"] ?: JsonPrimitive(DEFAULT_VRC_OSC_ADDRESS)), - ), - ) - return JsonObject( - vrcOscConfig.toMutableMap().apply { - put("manualNetwork", normalizedManualNetwork) - }, - ) -} - private fun parseAndMigrateSettingsConfig(raw: String): SettingsConfigState { val json = jsonConfig.parseToJsonElement(raw).jsonObject return jsonConfig.decodeFromJsonElement(migrateSettingsConfig(json)) @@ -134,21 +75,23 @@ typealias SettingsBehaviour = Behaviour File(settingsDir, "${state.name}.json") }, + storage = storage, + toPath = { state -> configPath(settingsDir, "${state.name}.json") }, serialize = { state -> jsonConfig.encodeToString(state.data) }, ) suspend fun swap(newName: String) { autosaveJob.cancelAndJoin() - val newData = loadFileWithBackup(File(settingsDir, "$newName.json"), SettingsConfigState()) { + val newData = loadFileWithBackup(storage, configPath(settingsDir, "$newName.json"), SettingsConfigState()) { parseAndMigrateSettingsConfig(it) } val newState = SettingsState(name = newName, data = newData) @@ -158,10 +101,10 @@ class Settings( } companion object { - suspend fun create(scope: CoroutineScope, configDir: File, name: String): Settings { - val settingsDir = File(configDir, "settings") + suspend fun create(scope: CoroutineScope, storage: ConfigStorage, name: String): Settings { + val settingsDir = "settings" - val initialData = loadFileWithBackup(File(settingsDir, "$name.json"), SettingsConfigState()) { + val initialData = loadFileWithBackup(storage, configPath(settingsDir, "$name.json"), SettingsConfigState()) { parseAndMigrateSettingsConfig(it) } val initialState = SettingsState(name = name, data = initialData) @@ -173,7 +116,7 @@ class Settings( behaviours = behaviours, name = "Settings[$name]", ) - val settings = Settings(context, scope = scope, settingsDir = settingsDir) + val settings = Settings(context, scope = scope, storage = storage, settingsDir = settingsDir) behaviours.forEach { it.observe(settings) } return settings } diff --git a/server/core/src/main/java/dev/slimevr/config/user.kt b/server/core/src/main/java/dev/slimevr/config/user.kt index 95e0eb1bb0..ab8acff7b8 100644 --- a/server/core/src/main/java/dev/slimevr/config/user.kt +++ b/server/core/src/main/java/dev/slimevr/config/user.kt @@ -11,7 +11,6 @@ import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import java.io.File private const val USER_CONFIG_VERSION = 1 @@ -51,21 +50,23 @@ typealias UserConfigBehaviour = Behaviour File(userConfigDir, "${state.name}.json") }, + storage = storage, + toPath = { state -> configPath(userConfigDir, "${state.name}.json") }, serialize = { state -> jsonConfig.encodeToString(state.data) }, ) suspend fun swap(newName: String) { autosaveJob.cancelAndJoin() - val newData = loadFileWithBackup(File(userConfigDir, "$newName.json"), UserConfigData()) { + val newData = loadFileWithBackup(storage, configPath(userConfigDir, "$newName.json"), UserConfigData()) { parseAndMigrateUserConfig(it) } val newState = UserConfigState(name = newName, data = newData) @@ -75,10 +76,10 @@ class UserConfig( } companion object { - suspend fun create(scope: CoroutineScope, configDir: File, name: String): UserConfig { - val userConfigDir = File(configDir, "user") + suspend fun create(scope: CoroutineScope, storage: ConfigStorage, name: String): UserConfig { + val userConfigDir = "user" - val initialData = loadFileWithBackup(File(userConfigDir, "$name.json"), UserConfigData()) { + val initialData = loadFileWithBackup(storage, configPath(userConfigDir, "$name.json"), UserConfigData()) { parseAndMigrateUserConfig(it) } val initialState = UserConfigState(name = name, data = initialData) @@ -89,7 +90,7 @@ class UserConfig( behaviours = listOf(DefaultUserBehaviour), name = "UserConfig[$name]", ) - val userConfig = UserConfig(context, scope = scope, userConfigDir = userConfigDir) + val userConfig = UserConfig(context, scope = scope, storage = storage, userConfigDir = userConfigDir) context.observeAll(userConfig) return userConfig } diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index a725d6d446..69b3e06234 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -5,17 +5,38 @@ import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import java.util.concurrent.CopyOnWriteArrayList interface Behaviour { fun reduce(state: S, action: A): S = state fun observe(receiver: C) {} } +class BehaviourList( + initial: List>, +) { + private val mutex = Mutex() + private var items: List> = initial.toList() + + fun addAll(newItems: List>) = runBlocking { + mutex.withLock { + items = items + newItems + } + } + + fun snapshot(): List> = runBlocking { + mutex.withLock { + items.toList() + } + } +} + class ManagedContext( val context: Context, private val supervisorJob: Job, @@ -40,55 +61,48 @@ class ManagedContext( class Context( private val mutableStateFlow: MutableStateFlow, - val reducer: (S, A) -> S, val scope: CoroutineScope, - val behaviours: CopyOnWriteArrayList>, + val behaviours: BehaviourList, private val debugMiddleware: DebugMiddleware? = null, ) { val state: StateFlow = mutableStateFlow.asStateFlow() fun dispatch(action: A) { if (debugMiddleware == null) { - mutableStateFlow.update { currentState -> reducer(currentState, action) } + mutableStateFlow.update { currentState -> reduce(currentState, action) } return } - val caller = captureCallerBehaviour() val before = mutableStateFlow.value - mutableStateFlow.update { currentState -> reducer(currentState, action) } - debugMiddleware.onDispatch(caller, before, action, mutableStateFlow.value) + mutableStateFlow.update { currentState -> reduce(currentState, action) } + debugMiddleware.onDispatch(null, before, action, mutableStateFlow.value) } fun dispatchAll(actions: List
) { if (debugMiddleware == null) { mutableStateFlow.update { currentState -> - actions.fold(currentState) { s, action -> reducer(s, action) } + actions.fold(currentState) { s, action -> reduce(s, action) } } return } - val caller = captureCallerBehaviour() val before = mutableStateFlow.value mutableStateFlow.update { currentState -> - actions.fold(currentState) { s, action -> reducer(s, action) } + actions.fold(currentState) { s, action -> reduce(s, action) } } - debugMiddleware.onDispatchAll(caller, before, actions, mutableStateFlow.value) + debugMiddleware.onDispatchAll(null, before, actions, mutableStateFlow.value) } - fun observeAll(receiver: C) = behaviours.forEach { behaviour -> + fun observeAll(receiver: C) = behaviours.snapshot().forEach { behaviour -> @Suppress("UNCHECKED_CAST") (behaviour as Behaviour).observe(receiver) } - private fun captureCallerBehaviour(): String? { - val knownBehaviourClasses = behaviours.mapTo(HashSet()) { b -> b::class.java.name } - return Thread.currentThread().stackTrace - .firstOrNull { frame -> frame.className in knownBehaviourClasses } - ?.className?.substringAfterLast('.') + private fun reduce(currentState: S, action: A): S { + return behaviours.snapshot().fold(currentState) { state, behaviour -> + behaviour.reduce(state, action) + } } companion object { - val debugEnabled: Boolean = System.getProperty("slimevr.debug.context") == "true" || - System.getenv("SLIMEVR_DEBUG_CONTEXT") == "true" - fun create( initialState: S, scope: CoroutineScope, @@ -97,13 +111,9 @@ class Context( name: String, ): Context { val mutableStateFlow = MutableStateFlow(initialState) - val reducer: (S, A) -> S = { currentState, action -> - behaviours.fold(currentState) { s, b -> b.reduce(s, action) } - } val scopeWithName = CoroutineScope(scope.coroutineContext + CoroutineName(name)) - val middlewareToUse = if (debugEnabled) debugMiddleware else null - val context = Context(mutableStateFlow, reducer, scopeWithName, CopyOnWriteArrayList(behaviours), middlewareToUse) - middlewareToUse?.init(context) + val context = Context(mutableStateFlow, scopeWithName, BehaviourList(behaviours), debugMiddleware) + debugMiddleware?.init(context) return context } } diff --git a/server/core/src/main/java/dev/slimevr/firmware/download.kt b/server/core/src/main/java/dev/slimevr/firmware/download.kt index ebc27e5ce8..96847af9ed 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/download.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/download.kt @@ -1,7 +1,9 @@ package dev.slimevr.firmware -import java.io.ByteArrayOutputStream -import java.net.URL +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.request.get import java.security.MessageDigest data class DownloadedFirmwarePart( @@ -23,17 +25,10 @@ data class DownloadedFirmwarePart( } } -fun downloadFirmware(url: String, digest: String): ByteArray { - val output = ByteArrayOutputStream() - val chunk = ByteArray(4096) - URL(url).openStream().use { stream -> - while (true) { - val read = stream.read(chunk) - if (read <= 0) break - output.write(chunk, 0, read) - } - } - val data = output.toByteArray() +private val firmwareHttpClient = HttpClient(CIO) + +suspend fun downloadFirmware(url: String, digest: String): ByteArray { + val data: ByteArray = firmwareHttpClient.get(url).body() check(verifyChecksum(data, digest)) { "Checksum verification failed for $url" } return data } diff --git a/server/core/src/main/java/dev/slimevr/firmware/module.kt b/server/core/src/main/java/dev/slimevr/firmware/module.kt index 4422ab2361..4165233357 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/module.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/module.kt @@ -44,6 +44,7 @@ class FirmwareManager( val context: FirmwareManagerContext, private val serialServer: SerialServer, private val settings: Settings, + private val flasher: FirmwareFlasher, private val scope: CoroutineScope, ) { fun startObserving() = context.observeAll(this) @@ -69,6 +70,7 @@ class FirmwareManager( serialServer = serialServer, settings = settings, server = server, + flasher = flasher, onStatus = { status, progress -> context.dispatch( FirmwareManagerActions.UpdateJob( @@ -124,7 +126,7 @@ class FirmwareManager( } companion object { - fun create(ctx: Phase1ContextProvider, scope: CoroutineScope): FirmwareManager { + fun create(ctx: Phase1ContextProvider, scope: CoroutineScope, flasher: FirmwareFlasher): FirmwareManager { val behaviours = listOf(FirmwareManagerBaseBehaviour) val context = Context.create( initialState = FirmwareManagerState(jobs = mapOf()), @@ -132,7 +134,13 @@ class FirmwareManager( behaviours = behaviours, name = "FirmwareManager", ) - return FirmwareManager(context = context, serialServer = ctx.serialServer, settings = ctx.config.settings, scope = scope) + return FirmwareManager( + context = context, + serialServer = ctx.serialServer, + settings = ctx.config.settings, + flasher = flasher, + scope = scope, + ) } } } diff --git a/server/core/src/main/java/dev/slimevr/firmware/ota.kt b/server/core/src/main/java/dev/slimevr/firmware/ota.kt index 90498f796b..2df90d20c8 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/ota.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/ota.kt @@ -5,7 +5,14 @@ import io.ktor.network.selector.SelectorManager import io.ktor.network.sockets.BoundDatagramSocket import io.ktor.network.sockets.Datagram import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.ServerSocket import io.ktor.network.sockets.aSocket +import io.ktor.network.sockets.openReadChannel +import io.ktor.network.sockets.openWriteChannel +import io.ktor.network.sockets.port +import io.ktor.utils.io.discardExact +import io.ktor.utils.io.readRemaining +import io.ktor.utils.io.writeFully import io.ktor.utils.io.core.buildPacket import io.ktor.utils.io.core.readText import io.ktor.utils.io.core.writeFully @@ -22,13 +29,10 @@ import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.FirmwarePart import solarxr_protocol.rpc.FirmwareUpdateStatus -import java.io.DataInputStream -import java.io.DataOutputStream -import java.io.IOException -import java.net.ServerSocket import java.security.MessageDigest import java.util.UUID import kotlin.math.min +import kotlinx.io.readByteArray private const val OTA_PORT = 8266 private const val OTA_PASSWORD = "SlimeVR-OTA" @@ -81,29 +85,24 @@ private suspend fun otaUpload( firmware: ByteArray, onProgress: suspend (Int) -> Unit, ): Boolean { - val socket = withContext(Dispatchers.IO) { tcpServer.accept() } + val socket = withTimeout(30_000) { tcpServer.accept() } return socket.use { - socket.soTimeout = 1_000 - val dos = DataOutputStream(socket.getOutputStream()) - val dis = DataInputStream(socket.getInputStream()) + val output = socket.openWriteChannel(autoFlush = true) + val input = socket.openReadChannel() var offset = 0 while (offset < firmware.size) { onProgress(((offset.toDouble() / firmware.size) * 100).toInt()) val chunkLen = min(OTA_CHUNK_SIZE, firmware.size - offset) - withContext(Dispatchers.IO) { - dos.write(firmware, offset, chunkLen) - dos.flush() - } + output.writeFully(firmware, offset, offset + chunkLen) offset += chunkLen - val bytesSkipped = withContext(Dispatchers.IO) { dis.skipBytes(4) } - if (bytesSkipped != 4) throw IOException("Unexpected bytes skipped: $bytesSkipped") + withTimeout(1_000) { input.discardExact(4) } } - socket.soTimeout = 10_000 - val response = withContext(Dispatchers.IO) { dis.readBytes().decodeToString() } + output.flush() + val response = withTimeout(10_000) { input.readRemaining().readByteArray().decodeToString() } response.contains("OK") } } @@ -132,9 +131,8 @@ suspend fun doOtaFlash( SelectorManager(Dispatchers.IO).use { selectorManager -> // Bind TCP server first so we know which port to advertise in the invitation - ServerSocket(0).use { tcpServer -> - tcpServer.soTimeout = 30_000 - val localPort = tcpServer.localPort + aSocket(selectorManager).tcp().bind(port = 0).use { tcpServer -> + val localPort = tcpServer.port if (!otaAuthenticate(selectorManager, deviceIp, localPort, firmware)) { onStatus(FirmwareUpdateStatus.ERROR_AUTHENTICATION_FAILED, 0) diff --git a/server/core/src/main/java/dev/slimevr/firmware/serial.kt b/server/core/src/main/java/dev/slimevr/firmware/serial.kt index 682062b701..571208a54c 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/serial.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/serial.kt @@ -1,10 +1,9 @@ package dev.slimevr.firmware -import dev.llelievr.espflashkotlin.Flasher -import dev.llelievr.espflashkotlin.FlashingProgressListener import dev.slimevr.VRServer import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions +import dev.slimevr.serial.FlashingHandler import dev.slimevr.serial.MAC_REGEX import dev.slimevr.serial.SerialConnection import dev.slimevr.serial.SerialServer @@ -24,6 +23,24 @@ import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.FirmwarePart import solarxr_protocol.rpc.FirmwareUpdateStatus +fun interface FirmwareFlasher { + suspend fun flash( + portLocation: String, + handler: FlashingHandler, + parts: List, + onProgress: (Int) -> Unit, + ) +} + +object NoopFirmwareFlasher : FirmwareFlasher { + override suspend fun flash( + portLocation: String, + handler: FlashingHandler, + parts: List, + onProgress: (Int) -> Unit, + ) = Unit +} + @OptIn(ExperimentalCoroutinesApi::class) suspend fun doSerialFlash( portLocation: String, @@ -34,6 +51,7 @@ suspend fun doSerialFlash( serialServer: SerialServer, settings: Settings, server: VRServer, + flasher: FirmwareFlasher, onStatus: suspend (FirmwareUpdateStatus, Int) -> Unit, scope: CoroutineScope, ) { @@ -63,20 +81,15 @@ suspend fun doSerialFlash( return } - val flasher = Flasher(handler) - for (part in downloadedParts) { - flasher.addBin(part.data, part.offset) - } - flasher.addProgressListener( - object : FlashingProgressListener { - override fun progress(progress: Float) { - scope.launch { onStatus(FirmwareUpdateStatus.UPLOADING, (progress * 100).toInt()) } - } - }, - ) - val runFlasher = runCatching { - withContext(Dispatchers.IO) { flasher.flash(portLocation) } + flasher.flash( + portLocation = portLocation, + handler = handler, + parts = downloadedParts, + onProgress = { progress -> + scope.launch { onStatus(FirmwareUpdateStatus.UPLOADING, progress) } + }, + ) } if (runFlasher.isFailure) { diff --git a/server/core/src/main/java/dev/slimevr/hid/packets.kt b/server/core/src/main/java/dev/slimevr/hid/packets.kt index 5faa90f387..f32d8497e8 100644 --- a/server/core/src/main/java/dev/slimevr/hid/packets.kt +++ b/server/core/src/main/java/dev/slimevr/hid/packets.kt @@ -6,8 +6,6 @@ import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.BoardType import solarxr_protocol.datatypes.hardware_info.ImuType import solarxr_protocol.datatypes.hardware_info.McuType -import java.nio.ByteBuffer -import java.nio.ByteOrder import kotlin.math.PI import kotlin.math.cos import kotlin.math.sin @@ -80,6 +78,20 @@ private val AXES_OFFSET = Quaternion.fromRotationVector(-PI.toFloat() / 2f, 0f, private fun readLE16Signed(data: ByteArray, offset: Int): Int = data[offset + 1].toInt() shl 8 or data[offset].toUByte().toInt() +private fun readLE32Unsigned(data: ByteArray, offset: Int): UInt = + (data[offset].toUInt() and 0xFFu) or + ((data[offset + 1].toUInt() and 0xFFu) shl 8) or + ((data[offset + 2].toUInt() and 0xFFu) shl 16) or + ((data[offset + 3].toUInt() and 0xFFu) shl 24) + +private fun readLE48Unsigned(data: ByteArray, offset: Int): ULong = + (data[offset].toULong() and 0xFFu) or + ((data[offset + 1].toULong() and 0xFFu) shl 8) or + ((data[offset + 2].toULong() and 0xFFu) shl 16) or + ((data[offset + 3].toULong() and 0xFFu) shl 24) or + ((data[offset + 4].toULong() and 0xFFu) shl 32) or + ((data[offset + 5].toULong() and 0xFFu) shl 40) + private fun decodeQ15Quat(data: ByteArray, offset: Int): Quaternion { val scale = 1f / 32768f val x = readLE16Signed(data, offset).toShort().toFloat() * scale @@ -90,7 +102,7 @@ private fun decodeQ15Quat(data: ByteArray, offset: Int): Quaternion { } private fun decodeExpMapQuat(data: ByteArray, offset: Int): Quaternion { - val buf = ByteBuffer.wrap(data, offset, 4).order(ByteOrder.LITTLE_ENDIAN).int.toUInt() + val buf = readLE32Unsigned(data, offset) val vx = ((buf and 1023u).toFloat() / 1024f) * 2f - 1f val vy = ((buf shr 10 and 2047u).toFloat() / 2048f) * 2f - 1f val vz = ((buf shr 21 and 2047u).toFloat() / 2048f) * 2f - 1f @@ -121,7 +133,7 @@ private fun parseSingleHIDPacket(data: ByteArray, i: Int): HIDPacket? { return when (packetType) { 255 -> { - val addr = ByteBuffer.wrap(data, i + 2, 8).order(ByteOrder.LITTLE_ENDIAN).long and 0x0000_FFFF_FFFF_FFFFL + val addr = readLE48Unsigned(data, i + 2) HIDDeviceRegister(hidId, "%012X".format(addr)) } diff --git a/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt b/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt index 067bc28935..58c1b021aa 100644 --- a/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt +++ b/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt @@ -1,35 +1,19 @@ package dev.slimevr.oscquery +import com.appstractive.dnssd.DiscoveryEvent +import com.appstractive.dnssd.discoverServices import dev.slimevr.AppLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.net.Inet4Address -import java.net.InetAddress -import java.net.NetworkInterface -import java.util.Collections -import javax.jmdns.JmDNS -import javax.jmdns.ServiceEvent -import javax.jmdns.ServiceListener -private const val OSC_JSON_SERVICE_TYPE = "_oscjson._tcp.local." +private const val OSC_JSON_SERVICE_TYPE = "_oscjson._tcp" -/** - * Listens for OSCQuery (`_oscjson._tcp` by default) services on the local - * network and exposes the resolved set as a [StateFlow]. - * - * Binds one JmDNS instance per IPv4 site-local interface — `JmDNS.create()` - * with no arguments only listens on a single auto-picked NIC, which on - * multi-NIC hosts (Wi-Fi + Ethernet, virtual adapters) frequently misses the - * LAN the headset is actually on. - */ class OscQueryDiscovery( private val serviceType: String = OSC_JSON_SERVICE_TYPE, private val serviceFilter: (String) -> Boolean = { true }, @@ -41,36 +25,16 @@ class OscQueryDiscovery( fun start(scope: CoroutineScope) { if (job != null) return - job = scope.launch(Dispatchers.IO) { - val instances = mutableListOf() - try { - val addresses = enumerateBindAddresses() - if (addresses.isEmpty()) { - AppLogger.oscQuery.warn("OSCQuery discovery: no usable interface addresses found, falling back to default JmDNS bind") - } - - for (address in addresses.ifEmpty { listOf(null) }) { - val instance = try { - JmDNS.create(address, null) - } catch (e: Exception) { - AppLogger.oscQuery.error("OSCQuery discovery: failed to create JmDNS on $address", e) - continue + job = scope.launch(Dispatchers.Default) { + discoverServices(serviceType).collectLatest { event -> + when (event) { + is DiscoveryEvent.Discovered -> { + if (!serviceFilter(event.service.name)) return@collectLatest + event.resolve() } - instance.addServiceListener(serviceType, listener(this, instance)) - instances.add(instance) - } - awaitCancellation() - } finally { - withContext(NonCancellable) { - for (instance in instances) { - try { - instance.close() - } catch (e: Exception) { - AppLogger.oscQuery.error("OSCQuery discovery: failed to close JmDNS instance", e) - } - } - mutableServices.value = emptyList() + is DiscoveryEvent.Resolved -> onResolved(event) + is DiscoveryEvent.Removed -> onRemoved(event.service.name) } } } @@ -79,23 +43,7 @@ class OscQueryDiscovery( fun close() { job?.cancel() job = null - } - - // JmDNS callbacks fire on its own threads and can't be suspend. The shims - // hand work back to the coroutine scope so log ordering and state mutations - // stay sequential. - private fun listener(scope: CoroutineScope, instance: JmDNS) = object : ServiceListener { - override fun serviceAdded(event: ServiceEvent) { - instance.requestServiceInfo(event.type, event.name) - } - - override fun serviceRemoved(event: ServiceEvent) { - scope.launch { onRemoved(event.name) } - } - - override fun serviceResolved(event: ServiceEvent) { - scope.launch { onResolved(event) } - } + mutableServices.value = emptyList() } private fun onRemoved(name: String) { @@ -103,48 +51,25 @@ class OscQueryDiscovery( mutableServices.value = mutableServices.value.filterNot { entry -> entry.name == name } } - private suspend fun onResolved(event: ServiceEvent) { - val name = event.name + private suspend fun onResolved(event: DiscoveryEvent.Resolved) { + val service = event.service + val name = service.name if (!serviceFilter(name)) return - val info = event.info ?: return - - val addresses = info.inet4Addresses?.map { addr -> addr.hostAddress }?.toList().orEmpty() - if (addresses.isEmpty()) { - // This JmDNS instance only resolved IPv6 entries — another instance on - // a different interface will have the IPv4 view. - return - } val resolved = OscQueryService( name = name, - addresses = addresses, - host = info.inetAddresses?.firstOrNull()?.canonicalHostName.orEmpty(), - type = event.type, - port = info.port, - txt = buildMap { - info.propertyNames?.iterator()?.forEach { key -> - val value = info.getPropertyString(key) - put(key.toString(), value.orEmpty()) - } - }, + addresses = service.addresses, + host = service.host, + type = service.type, + port = service.port, + txt = service.txt.mapValues { (_, value) -> value?.decodeToString().orEmpty() }, + ) + AppLogger.oscQuery.info( + "OSCQuery discovered service: name=$name address=${service.addresses.firstOrNull().orEmpty()} port=${service.port}", ) - AppLogger.oscQuery.info("OSCQuery discovered service: name=$name address=${addresses.first()} port=${info.port}") mutableServices.value = mutableServices.value .filterNot { existing -> existing.name == name } .plus(resolved) .sortedBy { entry -> entry.name } } } - -private fun enumerateBindAddresses(): List = try { - Collections.list(NetworkInterface.getNetworkInterfaces()) - .asSequence() - .filter { iface -> iface.isUp && !iface.isLoopback && !iface.isVirtual } - .flatMap { iface -> Collections.list(iface.inetAddresses).asSequence() } - .filterIsInstance() - .filter { address -> !address.isLoopbackAddress && address.isSiteLocalAddress } - .distinct() - .toList() -} catch (_: Exception) { - emptyList() -} diff --git a/server/core/src/main/java/dev/slimevr/oscquery/http.kt b/server/core/src/main/java/dev/slimevr/oscquery/http.kt index c2da71574c..4ada7724dd 100644 --- a/server/core/src/main/java/dev/slimevr/oscquery/http.kt +++ b/server/core/src/main/java/dev/slimevr/oscquery/http.kt @@ -3,9 +3,9 @@ package dev.slimevr.oscquery import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall +import io.ktor.server.cio.CIO import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer -import io.ktor.server.netty.Netty import io.ktor.server.response.respondText import io.ktor.server.routing.get import io.ktor.server.routing.param @@ -28,10 +28,8 @@ internal class OscQueryHttpServer( suspend fun start(scope: CoroutineScope, address: String = "0.0.0.0", port: UShort = 0u): UShort = mutex.withLock { if (engine != null) return boundPort - val started = embeddedServer(Netty, host = address, port = port.toInt()) { + val started = embeddedServer(CIO, host = address, port = port.toInt()) { routing { - // `GET /?HOST_INFO` returns server host info. Must come before the - // plain root route so ktor matches the parameterised route first. param("HOST_INFO") { get("/") { respondHostInfo(call) } } get("/") { respondNode(call, "/") } get("/{path...}") { diff --git a/server/core/src/main/java/dev/slimevr/oscquery/model.kt b/server/core/src/main/java/dev/slimevr/oscquery/model.kt index f8daacc5e6..473f5e1a96 100644 --- a/server/core/src/main/java/dev/slimevr/oscquery/model.kt +++ b/server/core/src/main/java/dev/slimevr/oscquery/model.kt @@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement -internal val oscQueryJson = Json { +val oscQueryJson = Json { encodeDefaults = true ignoreUnknownKeys = true // OSCQuery clients (notably VRChat) treat omitted fields as "absent" rather diff --git a/server/core/src/main/java/dev/slimevr/serial/module.kt b/server/core/src/main/java/dev/slimevr/serial/module.kt index ed19e04413..edf7e4192f 100644 --- a/server/core/src/main/java/dev/slimevr/serial/module.kt +++ b/server/core/src/main/java/dev/slimevr/serial/module.kt @@ -1,13 +1,23 @@ package dev.slimevr.serial -import dev.llelievr.espflashkotlin.FlasherSerialInterface import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import solarxr_protocol.rpc.SerialDevice -typealias FlashingHandler = FlasherSerialInterface +interface FlashingHandler { + fun openSerial(port: Any) + fun closeSerial() + fun write(data: ByteArray) + fun read(length: Int): ByteArray + fun setDTR(value: Boolean) + fun setRTS(value: Boolean) + fun changeBaud(baud: Int) + fun setReadTimeout(timeout: Long) + fun availableBytes(): Int + fun flushIOBuffers() +} data class SerialPortInfo( val portLocation: String, @@ -45,6 +55,7 @@ class SerialServer( ) -> SerialPortHandle?, private val openFlashingPortFactory: () -> FlashingHandler, ) { + fun onPortDetected(info: SerialPortInfo) { context.dispatch(SerialServerActions.PortDetected(info)) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/bvh.kt b/server/core/src/main/java/dev/slimevr/solarxr/bvh.kt index 747497ab02..cad9543ea5 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/bvh.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/bvh.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.onEach import solarxr_protocol.rpc.RecordBVHRequest import solarxr_protocol.rpc.RecordBVHStatus import solarxr_protocol.rpc.RecordBVHStatusRequest -import java.nio.file.Path class BvhBehaviour(private val bvhManager: BVHManager) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { @@ -15,7 +14,7 @@ class BvhBehaviour(private val bvhManager: BVHManager) : SolarXRBridgeBehaviour if (req.stop == true) { bvhManager.stopRecording() } else { - req.path?.let { path -> bvhManager.startRecording(Path.of(path)) } + req.path?.let { path -> bvhManager.startRecording(path) } } receiver.sendRpc(RecordBVHStatus(recording = bvhManager.isRecording)) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 8eb43889c0..7b79738876 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -29,7 +29,15 @@ import solarxr_protocol.datatypes.hardware_info.HardwareStatus import solarxr_protocol.datatypes.hardware_info.ImuType import solarxr_protocol.datatypes.math.Quat import solarxr_protocol.datatypes.math.Vec3f -import java.nio.ByteBuffer + +private fun ipv4AddressFromString(address: String): UInt { + val parts = address.split('.') + if (parts.size != 4) return 0u + return parts.fold(0u) { acc, part -> + val value = part.toUIntOrNull()?.takeIf { it <= 255u } ?: return 0u + (acc shl 8) or value + } +} private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMask: TrackerDataMask, datafeedConfig: DataFeedConfig): TrackerData = TrackerData( trackerId = TrackerId( @@ -83,7 +91,7 @@ private fun createDevice( officialBoardType = device.boardType, model = device.mcuType.toString(), firmwareVersion = device.firmware, - ipAddress = Ipv4Address(ByteBuffer.wrap(device.address.toByteArray()).getLong().toUInt()), + ipAddress = Ipv4Address(ipv4AddressFromString(device.address)), ), trackers = if (trackerMask != null) { trackers.filter { it.deviceId == device.id } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 25bd7c4d3a..f7e7012614 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -6,7 +6,6 @@ import dev.slimevr.VRServerActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.context.ManagedContext -import dev.slimevr.fbscodegen.runtime.JvmFlatBufferReader import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import solarxr_protocol.MessageBundle @@ -15,7 +14,6 @@ import solarxr_protocol.data_feed.DataFeedMessage import solarxr_protocol.data_feed.DataFeedMessageHeader import solarxr_protocol.rpc.RpcMessage import solarxr_protocol.rpc.RpcMessageHeader -import java.nio.ByteBuffer data class SolarXRBridgeState( val dataFeedConfigs: List, @@ -29,15 +27,13 @@ sealed interface SolarXRBridgeActions { typealias SolarXRBridgeContext = Context typealias SolarXRBridgeBehaviour = Behaviour -suspend fun onSolarXRMessage(message: ByteBuffer, context: SolarXRBridge) { - val messageBundle = MessageBundle.fromByteBuffer(JvmFlatBufferReader(message)) - - messageBundle.dataFeedMsgs?.forEach { +suspend fun onSolarXRMessage(message: MessageBundle, context: SolarXRBridge) { + message.dataFeedMsgs?.forEach { val msg = it.message ?: return context.dataFeedDispatcher.emit(msg) } - messageBundle.rpcMsgs?.forEach { + message.rpcMsgs?.forEach { val msg = it.message ?: return context.rpcDispatcher.emit(msg) } diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index e08212eac1..8e01f4df34 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -8,15 +8,14 @@ import dev.slimevr.device.Device import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerIdNum import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch +import io.ktor.network.sockets.BoundDatagramSocket +import io.ktor.network.sockets.Datagram +import io.ktor.network.sockets.InetSocketAddress import kotlinx.io.Buffer import kotlinx.io.readByteArray import solarxr_protocol.datatypes.MagnetometerStatus -import java.net.DatagramPacket -import java.net.DatagramSocket -import java.net.InetAddress data class LastPing( val id: Int, @@ -59,17 +58,15 @@ class UDPConnection( val appContext: AppContextProvider, val packetEvents: UDPPacketDispatcher, val packetChannel: Channel>, - private val socket: DatagramSocket, - private val remoteInetAddress: InetAddress, - private val remotePort: Int, + private val socket: BoundDatagramSocket, + private val remoteAddress: InetSocketAddress, private val scope: CoroutineScope, ) { fun send(packet: UDPPacket) { - scope.launch(Dispatchers.IO) { + scope.launch { val buf = Buffer() writePacket(buf, packet) - val bytes = buf.readByteArray() - socket.send(DatagramPacket(bytes, bytes.size, remoteInetAddress, remotePort)) + socket.send(Datagram(buf, remoteAddress)) } } @@ -88,9 +85,8 @@ class UDPConnection( companion object { fun create( id: String, - socket: DatagramSocket, - remoteIp: String, - remotePort: Int, + socket: BoundDatagramSocket, + remoteAddress: InetSocketAddress, appContext: AppContextProvider, scope: CoroutineScope, ): UDPConnection { @@ -118,8 +114,8 @@ class UDPConnection( lastPacketNum = 0, lastPing = LastPing(id = 0, startTime = 0), didHandshake = false, - address = remoteIp, - port = remotePort, + address = remoteAddress.hostname, + port = remoteAddress.port, deviceId = null, trackerIds = listOf(), features = null, @@ -132,7 +128,6 @@ class UDPConnection( val dispatcher = EventDispatcher> { it.data::class } val packetChannel = Channel>(capacity = 256) - val remoteInetAddress = InetAddress.getByName(remoteIp) val conn = UDPConnection( context = context, @@ -140,8 +135,7 @@ class UDPConnection( packetEvents = dispatcher, packetChannel = packetChannel, socket = socket, - remoteInetAddress = remoteInetAddress, - remotePort = remotePort, + remoteAddress = remoteAddress, scope = scope, ) conn.startObserving() diff --git a/server/core/src/main/java/dev/slimevr/udp/packets.kt b/server/core/src/main/java/dev/slimevr/udp/packets.kt index a94608d08b..231e233e44 100644 --- a/server/core/src/main/java/dev/slimevr/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/udp/packets.kt @@ -3,8 +3,8 @@ package dev.slimevr.udp import dev.slimevr.EventDispatcher import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 -import io.ktor.utils.io.core.ByteReadPacket import io.ktor.utils.io.core.remaining +import kotlinx.io.Buffer import kotlinx.io.Sink import kotlinx.io.Source import kotlinx.io.readByteArray @@ -18,7 +18,6 @@ import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.BoardType import solarxr_protocol.datatypes.hardware_info.ImuType import solarxr_protocol.datatypes.hardware_info.McuType -import java.nio.ByteBuffer enum class ServerFeatureFlags { /** Server can parse bundle packets: `PACKET_BUNDLE` = 100 (0x64). */ @@ -77,11 +76,10 @@ class FirmwareFeatures { } companion object { - - fun from(received: ByteBuffer, length: Int): FirmwareFeatures { + fun from(received: ByteArray, length: Int): FirmwareFeatures { val res = FirmwareFeatures() val bytesToRead = res.flags.size.coerceAtMost(length) - received.get(res.flags, 0, bytesToRead) + received.copyInto(res.flags, endIndex = bytesToRead) return res } } @@ -198,18 +196,15 @@ data class PacketBundle( if (bundlePacketLen <= 0) continue val rawBytes = readByteArray(bundlePacketLen) - val subSrc = ByteReadPacket(rawBytes) - - subSrc.use { subSrc -> - if (subSrc.remaining >= 4) { - val packetId = subSrc.readInt() - val type = PacketType.fromId(packetId) - - if (type != null) { - // 4. Pass the isolated sub-source to your existing parser - val packetData = readPacket(type, subSrc) - readPackets.add(packetData) - } + val subSrc = Buffer().apply { write(rawBytes) } + + if (subSrc.remaining >= 4) { + val packetId = subSrc.readInt() + val type = PacketType.fromId(packetId) + + if (type != null) { + val packetData = readPacket(type, subSrc) + readPackets.add(packetData) } } } @@ -229,18 +224,15 @@ data class PacketBundleCompact( if (bundlePacketLen <= 0) continue val rawBytes = readByteArray(bundlePacketLen) - val subSrc = ByteReadPacket(rawBytes) - - subSrc.use { subSrc -> - if (subSrc.remaining >= 4) { - val packetId = subSrc.readUByte().toInt() - val type = PacketType.fromId(packetId) - - if (type != null) { - // 4. Pass the isolated sub-source to your existing parser - val packetData = readPacket(type, subSrc) - readPackets.add(packetData) - } + val subSrc = Buffer().apply { write(rawBytes) } + + if (subSrc.remaining >= 1) { + val packetId = subSrc.readUByte().toInt() + val type = PacketType.fromId(packetId) + + if (type != null) { + val packetData = readPacket(type, subSrc) + readPackets.add(packetData) } } } @@ -418,7 +410,7 @@ data class FeatureFlags(val firmwareFeatures: FirmwareFeatures = FirmwareFeature fun read(src: Source) = FeatureFlags( FirmwareFeatures.from( length = src.remaining.toInt(), - received = ByteBuffer.wrap(src.readByteArray()), + received = src.readByteArray(), ), ) } diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index 354eb63ada..2419db925b 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -4,14 +4,13 @@ import dev.slimevr.AppContextProvider import dev.slimevr.AppLogger import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.aSocket import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.io.Buffer -import java.net.DatagramPacket -import java.net.DatagramSocket import kotlin.time.measureTime data class UdpServerState( @@ -43,25 +42,22 @@ class UdpServer(val context: UdpServerContext) { fun startReceiving(appContext: AppContextProvider, scope: CoroutineScope) { scope.launch { val port = appContext.config.settings.context.state.value.data.trackerPort - val socket = withContext(Dispatchers.IO) { DatagramSocket(port) } - val recvBuffer = ByteArray(2048) - val recvPacket = DatagramPacket(recvBuffer, recvBuffer.size) + val selectorManager = SelectorManager(Dispatchers.IO) + val socket = aSocket(selectorManager).udp().bind(port = port) - launch(Dispatchers.IO) { + launch { while (isActive) { - socket.receive(recvPacket) + val recvPacket = socket.receive() val took = measureTime { - val src = Buffer() - src.write(recvBuffer, 0, recvPacket.length) - + val src = recvPacket.packet val packetId = src.readInt() val packetNumber = src.readLong() val type = PacketType.fromId(packetId) ?: return@measureTime val packetData = readPacket(type, src) - val ip = recvPacket.address.hostAddress - val port = recvPacket.port - val conn = context.state.value.connections[ip] + val remoteAddress = recvPacket.address as? InetSocketAddress ?: return@measureTime + val id = addressKey(remoteAddress) + val conn = context.state.value.connections[id] val event = PacketEvent(data = packetData, packetNumber = packetNumber) @@ -69,14 +65,13 @@ class UdpServer(val context: UdpServerContext) { conn.packetChannel.trySend(event) } else { val newConn = UDPConnection.create( - id = ip, - remoteIp = ip, - remotePort = port, + id = id, + remoteAddress = remoteAddress, socket = socket, appContext = appContext, scope = scope, ) - context.dispatch(UdpServerActions.ConnectionAdded(ip, newConn)) + context.dispatch(UdpServerActions.ConnectionAdded(id, newConn)) newConn.packetChannel.trySend(event) } } @@ -91,6 +86,15 @@ class UdpServer(val context: UdpServerContext) { companion object { val INITIAL_STATE = UdpServerState(connections = emptyMap()) + private fun addressKey(address: InetSocketAddress): String { + val raw = address.resolveAddress() + return if (raw != null && raw.size == 4) { + raw.joinToString(".") { byte -> (byte.toInt() and 0xFF).toString() } + } else { + address.hostname + } + } + fun create(scope: CoroutineScope): UdpServer { val context = Context.create( initialState = INITIAL_STATE, diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt index e301658aad..4b35c628ba 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt @@ -107,7 +107,12 @@ class VRCOSCManager( } companion object { - fun create(skeleton: Skeleton, ctx: Phase1ContextProvider, scope: CoroutineScope): VRCOSCManager { + fun create( + skeleton: Skeleton, + ctx: Phase1ContextProvider, + scope: CoroutineScope, + oscQueryAddress: String, + ): VRCOSCManager { val settings = ctx.config.settings val initialConfig = settings.context.state.value.data.vrcOscConfig val context = Context.create( @@ -120,7 +125,7 @@ class VRCOSCManager( VRCOSCSettingsBehaviour(settings), VRCOSCOutputBehaviour(skeleton), VRCOSCInputBehaviour(ctx), - VRCOSCOscQueryBehaviour(), + VRCOSCOscQueryBehaviour(localIp = oscQueryAddress), ), name = "VRCOSC", ) diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt b/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt index ab86b74b33..2bd272b2ce 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt @@ -17,7 +17,9 @@ import solarxr_protocol.rpc.VRCOSCOscQueryState private const val VRCHAT_SERVICE_PREFIX = "VRChat-Client" -class VRCOSCOscQueryBehaviour : VRCOSCBehaviour { +class VRCOSCOscQueryBehaviour( + private val localIp: String, +) : VRCOSCBehaviour { private class OscQueryRuntime( val localIp: String, ) { @@ -43,7 +45,7 @@ class VRCOSCOscQueryBehaviour : VRCOSCBehaviour { } override fun observe(receiver: VRCOSCManager) { - val runtime = OscQueryRuntime(resolveLocalIp()) + val runtime = OscQueryRuntime(localIp) receiver.context.state .map { state -> Triple(state.config.enabled, state.config.manualNetwork == null, vrcOscPortIn(state.config)) } @@ -59,21 +61,6 @@ class VRCOSCOscQueryBehaviour : VRCOSCBehaviour { }.launchIn(receiver.context.scope) } - private fun resolveLocalIp(): String = try { - val candidates = java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces()) - .asSequence() - .filter { iface -> iface.isUp && !iface.isLoopback && !iface.isVirtual } - .flatMap { iface -> java.util.Collections.list(iface.inetAddresses).asSequence() } - .filter { address -> !address.isLoopbackAddress && !address.hostAddress.contains(':') } - .toList() - - val siteLocal = candidates.firstOrNull { address -> address.isSiteLocalAddress } - (siteLocal ?: candidates.firstOrNull())?.hostAddress - ?: java.net.InetAddress.getLoopbackAddress().hostAddress - } catch (_: Exception) { - java.net.InetAddress.getLoopbackAddress().hostAddress - } - private suspend fun stopOscQuery(receiver: VRCOSCManager, runtime: OscQueryRuntime) { runtime.discoveryJob?.cancel() runtime.discoveryJob = null diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 464da5d82b..8f6abbacf3 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -1,12 +1,13 @@ package dev.slimevr -import dev.llelievr.espflashkotlin.FlasherSerialInterface import dev.slimevr.bvh.BVHManager import dev.slimevr.config.AppConfig +import dev.slimevr.config.ConfigStorage import dev.slimevr.config.DefaultUserBehaviour import dev.slimevr.config.Settings import dev.slimevr.config.SettingsConfigState import dev.slimevr.config.SettingsState +import dev.slimevr.config.TextFileHandle import dev.slimevr.config.UserConfig import dev.slimevr.config.UserConfigData import dev.slimevr.config.UserConfigState @@ -15,6 +16,7 @@ import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.provisioning.ProvisioningManager +import dev.slimevr.serial.FlashingHandler import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialServer import dev.slimevr.skeleton.DEFAULT_SKELETON_STATE @@ -28,12 +30,11 @@ import dev.slimevr.vrchat.VRCConfigManager import dev.slimevr.vrcosc.VRCOSCManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow -import java.nio.file.Files fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create( openPort = { loc, _, _ -> SerialPortHandle(loc, "Fake $loc", {}, {}) }, openFlashingPort = { - object : FlasherSerialInterface { + object : FlashingHandler { override fun openSerial(port: Any) = Unit override fun closeSerial() = Unit override fun write(data: ByteArray) = Unit @@ -58,15 +59,13 @@ fun buildTestVrServerStub(scope: CoroutineScope): VRServer { } fun buildTestUserConfig(scope: CoroutineScope): UserConfig { - val tempDir = Files.createTempDirectory("slimevr-test").toFile() - tempDir.deleteOnExit() val context = Context.create( initialState = UserConfigState(data = UserConfigData(), name = "test"), scope = scope, behaviours = listOf(DefaultUserBehaviour), name = "TestUserConfig", ) - val userConfig = UserConfig(context, scope = scope, userConfigDir = tempDir) + val userConfig = UserConfig(context, scope = scope, storage = NoopConfigStorage, userConfigDir = "user") context.observeAll(userConfig) return userConfig } @@ -91,9 +90,16 @@ fun buildTestSettings(scope: CoroutineScope): Settings { behaviours = emptyList(), name = "Settings[test]", ) - // Use a path in /tmp that won't actually be written to in tests - val fakeDir = java.io.File("/tmp/slimevr-test-${System.nanoTime()}") - return Settings(context, scope, fakeDir) + return Settings(context, scope, NoopConfigStorage, "settings") +} + +private object NoopConfigStorage : ConfigStorage { + override suspend fun read(path: String): String? = null + override suspend fun write(path: String, content: String) = Unit + override suspend fun backup(path: String) = Unit + override suspend fun exists(path: String): Boolean = false + override suspend fun ensureDirectory(path: String): Boolean = true + override suspend fun openTextFile(path: String): TextFileHandle = error("Not used in tests") } abstract class TestAppContext : AppContextProvider { diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt index 2e6b1b3220..84291421d5 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -1,6 +1,5 @@ package dev.slimevr.firmware -import dev.llelievr.espflashkotlin.FlasherSerialInterface import dev.slimevr.VRServer import dev.slimevr.VRServerActions import dev.slimevr.buildTestSettings @@ -8,6 +7,7 @@ import dev.slimevr.buildTestVrServerStub import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin +import dev.slimevr.serial.FlashingHandler import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialPortInfo import dev.slimevr.serial.SerialServer @@ -32,7 +32,7 @@ private fun fakePortHandle(loc: String) = SerialPortHandle( private fun fakePort(loc: String = "COM1") = SerialPortInfo(loc, "Fake $loc", 0x1A86, 0x7523) /** Fails immediately at openSerial so the Flasher throws with no IO delays */ -private fun failingFlashHandler() = object : FlasherSerialInterface { +private fun fakeFlashHandler() = object : FlashingHandler { override fun openSerial(port: Any) = error("simulated flash failure") override fun closeSerial() {} override fun write(data: ByteArray) {} @@ -45,9 +45,13 @@ private fun failingFlashHandler() = object : FlasherSerialInterface { override fun flushIOBuffers() {} } +private val failingFirmwareFlasher = FirmwareFlasher { portLocation, handler, _, _ -> + handler.openSerial(portLocation) +} + private fun buildSerialServer( scope: kotlinx.coroutines.CoroutineScope, - flashHandler: () -> FlasherSerialInterface = ::failingFlashHandler, + flashHandler: () -> FlashingHandler = ::fakeFlashHandler, ) = SerialServer.create( openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = flashHandler, @@ -78,6 +82,7 @@ class DoSerialFlashTest { serialServer = server, settings = buildTestSettings(backgroundScope), server = vrServer, + flasher = failingFirmwareFlasher, onStatus = { s, _ -> statuses += s }, scope = this, ) @@ -101,6 +106,7 @@ class DoSerialFlashTest { serialServer = server, settings = buildTestSettings(backgroundScope), server = buildTestVrServerStub(backgroundScope), + flasher = failingFirmwareFlasher, onStatus = { s, _ -> statuses += s }, scope = this, ) @@ -110,7 +116,7 @@ class DoSerialFlashTest { @Test fun `emits ERROR_UPLOAD_FAILED when flash throws`() = runTest { - val server = buildSerialServer(this, ::failingFlashHandler) + val server = buildSerialServer(this, ::fakeFlashHandler) server.onPortDetected(fakePort()) val statuses = mutableListOf() @@ -123,6 +129,7 @@ class DoSerialFlashTest { serialServer = server, settings = buildTestSettings(backgroundScope), server = buildTestVrServerStub(backgroundScope), + flasher = failingFirmwareFlasher, onStatus = { s, _ -> statuses += s }, scope = this, ) diff --git a/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt b/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt index 1197d9f9ff..11dc0da356 100644 --- a/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt +++ b/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt @@ -1,6 +1,5 @@ package dev.slimevr.serial -import dev.llelievr.espflashkotlin.FlasherSerialInterface import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -15,7 +14,7 @@ private fun fakePortHandle(portLocation: String) = SerialPortHandle( close = {}, ) -private fun fakeFlashingHandler() = object : FlasherSerialInterface { +private fun fakeFlashingHandler() = object : FlashingHandler { override fun openSerial(port: Any) {} override fun closeSerial() {} override fun write(data: ByteArray) {} diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index d3b75138fc..df77763bb7 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -75,6 +75,8 @@ wire { dependencies { implementation(project(":server:core")) implementation(project(":solarxr-protocol:generated")) + implementation("com.google.flatbuffers:flatbuffers-java:22.10.26") + implementation("com.github.loucass003:EspflashKotlin:v0.11.0") implementation("net.java.dev.jna:jna:5.+") implementation("net.java.dev.jna:jna-platform:5.+") 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 a5280044fe..02625de176 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -9,12 +9,15 @@ import dev.slimevr.Platform import dev.slimevr.VRServer import dev.slimevr.bvh.BVHManager import dev.slimevr.config.AppConfig +import dev.slimevr.desktop.config.DesktopConfigStorage import dev.slimevr.desktop.hid.createDesktopHIDManager import dev.slimevr.desktop.ipc.createIpcServers import dev.slimevr.desktop.ipc.createSolarXRWebsocketServer import dev.slimevr.desktop.networkprofile.setupDesktopNetworkProfileChecker +import dev.slimevr.desktop.serial.DesktopFirmwareFlasher import dev.slimevr.desktop.serial.createDesktopSerialServer import dev.slimevr.desktop.vrchat.createDesktopVRCConfigManager +import dev.slimevr.desktop.vrchat.resolveDesktopOscQueryAddress import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.networkprofile.NetworkProfileManager @@ -30,13 +33,14 @@ import kotlinx.coroutines.runBlocking fun main(args: Array) = runBlocking { val configFolder = resolveConfigDirectory() ?: error("Unable to resolve config folder") - val config = AppConfig.create(this, configFolder = configFolder.toFile()) + val storage = DesktopConfigStorage(configFolder.toFile()) + val config = AppConfig.create(this, storage = storage) val server = VRServer.create(this) val serialServer = createDesktopSerialServer(this) val phase1 = Phase1Context(server = server, config = config, serialServer = serialServer) - val firmwareManager = FirmwareManager.create(ctx = phase1, scope = this) + val firmwareManager = FirmwareManager.create(ctx = phase1, scope = this, flasher = DesktopFirmwareFlasher) val vrcConfigManager = createDesktopVRCConfigManager(ctx = phase1, scope = this) val networkProfileManager = NetworkProfileManager.create(scope = this, isSupported = CURRENT_PLATFORM == Platform.WINDOWS) val skeleton = Skeleton.create(scope = this, ctx = phase1) @@ -44,9 +48,14 @@ fun main(args: Array) = runBlocking { val heightCalibrationManager = HeightCalibrationManager.create(ctx = phase1, scope = this) val trackingChecklist = TrackingChecklist.create(scope = this) val udpServer = UdpServer.create(scope = this) - val bvhManager = BVHManager.create(skeleton = skeleton, scope = this) + val bvhManager = BVHManager.create(skeleton = skeleton, storage = storage, scope = this) val vmcManager = VMCManager.create(skeleton = skeleton, ctx = phase1, scope = this) - val vrcOscManager = VRCOSCManager.create(skeleton = skeleton, ctx = phase1, scope = this) + val vrcOscManager = VRCOSCManager.create( + skeleton = skeleton, + ctx = phase1, + scope = this, + oscQueryAddress = resolveDesktopOscQueryAddress(), + ) val appContext = AppContext( server = server, diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/config/storage.kt b/server/desktop/src/main/java/dev/slimevr/desktop/config/storage.kt new file mode 100644 index 0000000000..ae903ae1f0 --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/config/storage.kt @@ -0,0 +1,84 @@ +package dev.slimevr.desktop.config + +import dev.slimevr.config.ConfigStorage +import dev.slimevr.config.TextFileHandle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.RandomAccessFile +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +class DesktopConfigStorage( + private val root: File, +) : ConfigStorage { + override suspend fun read(path: String): String? = withContext(Dispatchers.IO) { + val file = resolve(path) + if (file.exists()) file.readText() else null + } + + override suspend fun write(path: String, content: String) = withContext(Dispatchers.IO) { + val file = resolve(path) + file.parentFile?.mkdirs() + val tmp = File(file.parent, "${file.name}.tmp") + tmp.writeText(content) + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + Unit + } + + override suspend fun backup(path: String) = withContext(Dispatchers.IO) { + val file = resolve(path) + if (!file.exists()) return@withContext + val bakTmp = File(file.parent, "${file.name}.bak.tmp") + file.copyTo(bakTmp, overwrite = true) + Files.move( + bakTmp.toPath(), + File(file.parent, "${file.name}.bak").toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } + + override suspend fun exists(path: String): Boolean = withContext(Dispatchers.IO) { + resolve(path).exists() + } + + override suspend fun ensureDirectory(path: String): Boolean = withContext(Dispatchers.IO) { + val directory = resolve(path) + directory.isDirectory || directory.mkdirs() + } + + override suspend fun openTextFile(path: String): TextFileHandle = withContext(Dispatchers.IO) { + val file = resolve(path) + file.parentFile?.mkdirs() + RandomAccessTextFileHandle(RandomAccessFile(file, "rw").also { handle -> handle.setLength(0L) }) + } + + override fun displayPath(path: String): String = resolve(path).absolutePath + + private fun resolve(path: String): File = File(root, path) +} + +private class RandomAccessTextFileHandle( + private val file: RandomAccessFile, +) : TextFileHandle { + override suspend fun write(text: String) = withContext(Dispatchers.IO) { + file.write(text.encodeToByteArray()) + } + + override suspend fun flush() = withContext(Dispatchers.IO) { + file.fd.sync() + } + + override suspend fun position(): Long = withContext(Dispatchers.IO) { + file.filePointer + } + + override suspend fun seek(position: Long) = withContext(Dispatchers.IO) { + file.seek(position) + } + + override suspend fun close() = withContext(Dispatchers.IO) { + file.close() + } +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/serial/flash.kt b/server/desktop/src/main/java/dev/slimevr/desktop/serial/flash.kt index 5fd74dd2ab..68b7ebcda5 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/serial/flash.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/serial/flash.kt @@ -1,6 +1,13 @@ package dev.slimevr.desktop.serial +import dev.llelievr.espflashkotlin.Flasher +import dev.llelievr.espflashkotlin.FlasherSerialInterface +import dev.llelievr.espflashkotlin.FlashingProgressListener +import dev.slimevr.firmware.DownloadedFirmwarePart +import dev.slimevr.firmware.FirmwareFlasher import dev.slimevr.serial.FlashingHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import com.fazecast.jSerialComm.SerialPort as JSerialPort class DesktopFlashingHandler : FlashingHandler { @@ -66,3 +73,38 @@ class DesktopFlashingHandler : FlashingHandler { p.flushIOBuffers() } } + +object DesktopFirmwareFlasher : FirmwareFlasher { + override suspend fun flash( + portLocation: String, + handler: FlashingHandler, + parts: List, + onProgress: (Int) -> Unit, + ) { + val flasher = Flasher( + object : FlasherSerialInterface { + override fun openSerial(port: Any) = handler.openSerial(port) + override fun closeSerial() = handler.closeSerial() + override fun write(data: ByteArray) = handler.write(data) + override fun read(length: Int) = handler.read(length) + override fun setDTR(value: Boolean) = handler.setDTR(value) + override fun setRTS(value: Boolean) = handler.setRTS(value) + override fun changeBaud(baud: Int) = handler.changeBaud(baud) + override fun setReadTimeout(timeout: Long) = handler.setReadTimeout(timeout) + override fun availableBytes() = handler.availableBytes() + override fun flushIOBuffers() = handler.flushIOBuffers() + }, + ) + for (part in parts) { + flasher.addBin(part.data, part.offset) + } + flasher.addProgressListener( + object : FlashingProgressListener { + override fun progress(progress: Float) { + onProgress((progress * 100).toInt()) + } + }, + ) + withContext(Dispatchers.IO) { flasher.flash(portLocation) } + } +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/oscquery.kt b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/oscquery.kt new file mode 100644 index 0000000000..19095a3ee9 --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/oscquery.kt @@ -0,0 +1,20 @@ +package dev.slimevr.desktop.vrchat + +import java.net.InetAddress +import java.net.NetworkInterface +import java.util.Collections + +fun resolveDesktopOscQueryAddress(): String = try { + val candidates = Collections.list(NetworkInterface.getNetworkInterfaces()) + .asSequence() + .filter { iface -> iface.isUp && !iface.isLoopback && !iface.isVirtual } + .flatMap { iface -> Collections.list(iface.inetAddresses).asSequence() } + .filter { address -> !address.isLoopbackAddress && !address.hostAddress.contains(':') } + .toList() + + val siteLocal = candidates.firstOrNull { address -> address.isSiteLocalAddress } + (siteLocal ?: candidates.firstOrNull())?.hostAddress + ?: InetAddress.getLoopbackAddress().hostAddress +} catch (_: Exception) { + InetAddress.getLoopbackAddress().hostAddress +} diff --git a/server/core/src/main/java/dev/slimevr/platform.kt b/server/desktop/src/main/java/dev/slimevr/platform.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/platform.kt rename to server/desktop/src/main/java/dev/slimevr/platform.kt diff --git a/server/core/src/main/java/dev/slimevr/solarxr/ipc.kt b/server/desktop/src/main/java/dev/slimevr/solarxr/ipc.kt similarity index 86% rename from server/core/src/main/java/dev/slimevr/solarxr/ipc.kt rename to server/desktop/src/main/java/dev/slimevr/solarxr/ipc.kt index 81743d882c..e0545bf8e7 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/ipc.kt +++ b/server/desktop/src/main/java/dev/slimevr/solarxr/ipc.kt @@ -3,6 +3,7 @@ package dev.slimevr.solarxr import com.google.flatbuffers.FlatBufferBuilder import dev.slimevr.AppContextProvider import dev.slimevr.VRServerActions +import dev.slimevr.fbscodegen.runtime.JvmFlatBufferReader import dev.slimevr.fbscodegen.runtime.JvmFlatBufferWriter import io.ktor.util.moveToByteArray import kotlinx.coroutines.coroutineScope @@ -31,7 +32,7 @@ suspend fun handleSolarXRBridge( try { messages.collect { bytes -> - onSolarXRMessage(ByteBuffer.wrap(bytes), bridge) + onSolarXRMessage(MessageBundle.fromByteBuffer(JvmFlatBufferReader(ByteBuffer.wrap(bytes))), bridge) } } finally { bridge.disconnect() From b3e61a548220166b54c69f7a62d2c7a216a42f46 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 11 May 2026 02:39:02 +0200 Subject: [PATCH 101/350] Bring back debug env var --- .../core/src/main/java/dev/slimevr/context/context.kt | 11 +++++++++-- .../java/dev/slimevr/context/debug/ContextDebug.kt | 5 +++++ .../desktop/src/main/java/dev/slimevr/desktop/Main.kt | 4 ++++ 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/context/debug/ContextDebug.kt diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index 69b3e06234..297d05d13c 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -1,6 +1,7 @@ package dev.slimevr.context import dev.slimevr.context.debug.DebugMiddleware +import dev.slimevr.context.debug.ContextDebug import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -112,8 +113,14 @@ class Context( ): Context { val mutableStateFlow = MutableStateFlow(initialState) val scopeWithName = CoroutineScope(scope.coroutineContext + CoroutineName(name)) - val context = Context(mutableStateFlow, scopeWithName, BehaviourList(behaviours), debugMiddleware) - debugMiddleware?.init(context) + val effectiveDebugMiddleware = if (ContextDebug.enabled) debugMiddleware else null + val context = Context( + mutableStateFlow, + scopeWithName, + BehaviourList(behaviours), + effectiveDebugMiddleware, + ) + effectiveDebugMiddleware?.init(context) return context } } diff --git a/server/core/src/main/java/dev/slimevr/context/debug/ContextDebug.kt b/server/core/src/main/java/dev/slimevr/context/debug/ContextDebug.kt new file mode 100644 index 0000000000..4b7d3d360b --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/context/debug/ContextDebug.kt @@ -0,0 +1,5 @@ +package dev.slimevr.context.debug + +object ContextDebug { + var enabled: Boolean = false +} 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 02625de176..153f35593d 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -9,6 +9,7 @@ import dev.slimevr.Platform import dev.slimevr.VRServer import dev.slimevr.bvh.BVHManager import dev.slimevr.config.AppConfig +import dev.slimevr.context.debug.ContextDebug import dev.slimevr.desktop.config.DesktopConfigStorage import dev.slimevr.desktop.hid.createDesktopHIDManager import dev.slimevr.desktop.ipc.createIpcServers @@ -32,6 +33,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking fun main(args: Array) = runBlocking { + ContextDebug.enabled = System.getProperty("slimevr.debug.context") == "true" || + System.getenv("SLIMEVR_DEBUG_CONTEXT") == "true" + val configFolder = resolveConfigDirectory() ?: error("Unable to resolve config folder") val storage = DesktopConfigStorage(configFolder.toFile()) val config = AppConfig.create(this, storage = storage) From d8041833f15e1950546fbb55c1179f376dbfe7f9 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 11 May 2026 02:48:17 +0200 Subject: [PATCH 102/350] Add a note about behaviour list for later --- server/core/src/main/java/dev/slimevr/context/context.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index 297d05d13c..9edbc26131 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -19,6 +19,8 @@ interface Behaviour { fun observe(receiver: C) {} } +//TODO: missing remove + prob a unobserve or something to clear the stuff launched +// in a observe -> each behaviour is prob gonna need its own scope class BehaviourList( initial: List>, ) { From f2b540d0bdb1e35697de5b9b81ca2c9b7036eb27 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 11 May 2026 13:13:57 +0200 Subject: [PATCH 103/350] Solve regressions with KMP --- .../src/main/java/dev/slimevr/app-context.kt | 5 + .../main/java/dev/slimevr/bvh/behaviours.kt | 15 ++- .../src/main/java/dev/slimevr/bvh/stream.kt | 81 +++++++----- .../main/java/dev/slimevr/context/context.kt | 19 ++- .../src/main/java/dev/slimevr/firmware/ota.kt | 64 ++++----- .../java/dev/slimevr/firmware/reconnect.kt | 61 +++++++++ .../main/java/dev/slimevr/firmware/serial.kt | 28 ---- .../java/dev/slimevr/oscquery/discovery.kt | 16 ++- .../main/java/dev/slimevr/udp/behaviours.kt | 1 + .../src/main/java/dev/slimevr/udp/server.kt | 28 +++- .../src/test/java/dev/slimevr/TestServer.kt | 1 + .../java/dev/slimevr/bvh/BvhStreamTest.kt | 88 +++++++++++++ .../context/ContextCallerCaptureTest.kt | 55 ++++++++ .../context/ContextScopeIsolationTest.kt | 41 ++++++ .../dev/slimevr/firmware/OtaUploadTest.kt | 123 ++++++++++++++++++ server/desktop/build.gradle.kts | 2 + .../src/main/java/dev/slimevr/desktop/Main.kt | 19 ++- .../dev/slimevr/desktop/config/storage.kt | 6 +- 18 files changed, 534 insertions(+), 119 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/firmware/reconnect.kt create mode 100644 server/core/src/test/java/dev/slimevr/bvh/BvhStreamTest.kt create mode 100644 server/core/src/test/java/dev/slimevr/context/ContextCallerCaptureTest.kt create mode 100644 server/core/src/test/java/dev/slimevr/context/ContextScopeIsolationTest.kt create mode 100644 server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index 4a2977288d..f325502ee3 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -39,6 +39,7 @@ interface AppContextProvider : Phase1ContextProvider { val vmcManager: VMCManager val vrcOscManager: VRCOSCManager fun startObserving() + suspend fun dispose() } class AppContext( @@ -69,4 +70,8 @@ class AppContext( vmcManager.startObserving() vrcOscManager.startObserving() } + + override suspend fun dispose() { + udpServer.dispose() + } } diff --git a/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt b/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt index 1302e5cdc2..17e4fdd34f 100644 --- a/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt @@ -39,8 +39,13 @@ class BVHRecordingBehaviour( .distinctUntilChanged() .onEach { (recording, path) -> if (recording && stream == null) { - val resolvedPath = resolveBvhPath(storage, path ?: return@onEach) ?: return@onEach + val resolvedPath = resolveBvhPath(storage, path ?: return@onEach) + if (resolvedPath == null) { + receiver.context.dispatch(BVHActions.StopRecording) + return@onEach + } try { + AppLogger.bvh.info("Opening BVH recording at ${storage.displayPath(resolvedPath)}") stream = BvhStream(storage.openTextFile(resolvedPath)).also { it.writeHeader(skeleton.computed.value) // TODO: we could write the initial T-Pose or whatever here @@ -51,6 +56,7 @@ class BVHRecordingBehaviour( } } else if (!recording && stream != null) { try { + AppLogger.bvh.info("Finalizing BVH recording") stream?.close() } catch (e: Exception) { AppLogger.bvh.error("Failed to finalize BVH recording", e) @@ -61,7 +67,12 @@ class BVHRecordingBehaviour( }.launchIn(receiver.context.scope) skeleton.computed.onEach { bones -> - stream?.writeFrame(bones) + try { + stream?.writeFrame(bones) + } catch (e: Exception) { + AppLogger.bvh.error("Failed to write BVH frame", e) + receiver.context.dispatch(BVHActions.StopRecording) + } }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/bvh/stream.kt b/server/core/src/main/java/dev/slimevr/bvh/stream.kt index e8957e8514..24959b32b6 100644 --- a/server/core/src/main/java/dev/slimevr/bvh/stream.kt +++ b/server/core/src/main/java/dev/slimevr/bvh/stream.kt @@ -8,6 +8,8 @@ import dev.slimevr.skeleton.DEFAULT_BONE_OFFSETS import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import solarxr_protocol.datatypes.BodyPart private const val FRAME_COUNT_DIGITS = Long.MAX_VALUE.toString().length @@ -17,55 +19,70 @@ private const val POSITION_SCALE = 1f class BvhStream( private val file: TextFileHandle, ) { + private val mutex = Mutex() private var frameCount = 0L private var frameCountOffset = 0L private var frameTimeOffset = 0L private var lastFrameTime = System.currentTimeMillis() private val frameIntervals = mutableListOf() + private var closed = false suspend fun writeHeader(bones: Map) { - file.write("HIERARCHY\n") - writeBone(BodyPart.HEAD, null, bones, 0) - file.write("MOTION\n") - file.write("Frames: ") - file.flush() - frameCountOffset = file.position() - frameCount = 0L - file.write(getBufferedFrameCount(0L) + "\n") - file.write("Frame Time: ") - file.flush() - frameTimeOffset = file.position() - file.write(getBufferedFrameInterval(0.01f) + "\n") - lastFrameTime = System.currentTimeMillis() + mutex.withLock { + check(!closed) { "BVH stream is closed" } + file.write("HIERARCHY\n") + writeBone(BodyPart.HEAD, null, bones, 0) + file.write("MOTION\n") + file.write("Frames: ") + file.flush() + frameCountOffset = file.position() + frameCount = 0L + file.write(getBufferedFrameCount(0L) + "\n") + file.write("Frame Time: ") + file.flush() + frameTimeOffset = file.position() + file.write(getBufferedFrameInterval(0.01f) + "\n") + lastFrameTime = System.currentTimeMillis() + } } suspend fun writeFrame(bones: Map) { - val now = System.currentTimeMillis() - if (frameCount > 0) { - val interval = (now - lastFrameTime) / 1000f - frameIntervals.add(interval) + mutex.withLock { + if (closed) return + + val now = System.currentTimeMillis() + if (frameCount > 0) { + val interval = (now - lastFrameTime) / 1000f + frameIntervals.add(interval) + } + lastFrameTime = now + + val head = bones[BodyPart.HEAD] + val pos = head?.headPosition ?: Vector3.NULL + file.write("${pos.x * POSITION_SCALE} ${pos.y * POSITION_SCALE} ${pos.z * POSITION_SCALE}") + writeRotations(BodyPart.HEAD, bones) + file.write("\n") + frameCount++ } - lastFrameTime = now - - val head = bones[BodyPart.HEAD] - val pos = head?.headPosition ?: Vector3.NULL - file.write("${pos.x * POSITION_SCALE} ${pos.y * POSITION_SCALE} ${pos.z * POSITION_SCALE}") - writeRotations(BodyPart.HEAD, bones) - file.write("\n") - frameCount++ } suspend fun close() { - file.flush() - val avgInterval = if (frameIntervals.isNotEmpty()) frameIntervals.average().toFloat() else 0.01f + mutex.withLock { + if (closed) return + closed = true + + file.flush() + val avgInterval = if (frameIntervals.isNotEmpty()) frameIntervals.average().toFloat() else 0.01f - file.seek(frameCountOffset) - file.write(getBufferedFrameCount(frameCount)) + file.seek(frameCountOffset) + file.write(getBufferedFrameCount(frameCount)) - file.seek(frameTimeOffset) - file.write(getBufferedFrameInterval(avgInterval)) + file.seek(frameTimeOffset) + file.write(getBufferedFrameInterval(avgInterval)) + file.flush() - file.close() + file.close() + } } private fun getBufferedFrameCount(frameCount: Long): String { diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index 9edbc26131..cdfb7e4300 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -75,9 +75,10 @@ class Context( mutableStateFlow.update { currentState -> reduce(currentState, action) } return } + val caller = captureCallerBehaviour() val before = mutableStateFlow.value mutableStateFlow.update { currentState -> reduce(currentState, action) } - debugMiddleware.onDispatch(null, before, action, mutableStateFlow.value) + debugMiddleware.onDispatch(caller, before, action, mutableStateFlow.value) } fun dispatchAll(actions: List) { @@ -87,11 +88,12 @@ class Context( } return } + val caller = captureCallerBehaviour() val before = mutableStateFlow.value mutableStateFlow.update { currentState -> actions.fold(currentState) { s, action -> reduce(s, action) } } - debugMiddleware.onDispatchAll(null, before, actions, mutableStateFlow.value) + debugMiddleware.onDispatchAll(caller, before, actions, mutableStateFlow.value) } fun observeAll(receiver: C) = behaviours.snapshot().forEach { behaviour -> @@ -105,6 +107,16 @@ class Context( } } + private fun captureCallerBehaviour(): String? { + val knownBehaviourClasses = behaviours.snapshot().mapTo(HashSet()) { behaviour -> + behaviour::class.qualifiedName ?: behaviour::class.simpleName.orEmpty() + } + return Throwable().stackTrace + .firstOrNull { frame -> frame.className in knownBehaviourClasses } + ?.className + ?.substringAfterLast('.') + } + companion object { fun create( initialState: S, @@ -114,7 +126,8 @@ class Context( name: String, ): Context { val mutableStateFlow = MutableStateFlow(initialState) - val scopeWithName = CoroutineScope(scope.coroutineContext + CoroutineName(name)) + val contextJob = SupervisorJob(scope.coroutineContext[Job]) + val scopeWithName = CoroutineScope(scope.coroutineContext + contextJob + CoroutineName(name)) val effectiveDebugMiddleware = if (ContextDebug.enabled) debugMiddleware else null val context = Context( mutableStateFlow, diff --git a/server/core/src/main/java/dev/slimevr/firmware/ota.kt b/server/core/src/main/java/dev/slimevr/firmware/ota.kt index 2df90d20c8..5acf083ca2 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/ota.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/ota.kt @@ -10,6 +10,8 @@ import io.ktor.network.sockets.aSocket import io.ktor.network.sockets.openReadChannel import io.ktor.network.sockets.openWriteChannel import io.ktor.network.sockets.port +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.ByteWriteChannel import io.ktor.utils.io.discardExact import io.ktor.utils.io.readRemaining import io.ktor.utils.io.writeFully @@ -17,16 +19,9 @@ import io.ktor.utils.io.core.buildPacket import io.ktor.utils.io.core.readText import io.ktor.utils.io.core.writeFully import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout -import kotlinx.coroutines.withTimeoutOrNull import solarxr_protocol.datatypes.DeviceId -import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.FirmwarePart import solarxr_protocol.rpc.FirmwareUpdateStatus import java.security.MessageDigest @@ -87,24 +82,35 @@ private suspend fun otaUpload( ): Boolean { val socket = withTimeout(30_000) { tcpServer.accept() } return socket.use { - val output = socket.openWriteChannel(autoFlush = true) - val input = socket.openReadChannel() + uploadFirmware( + output = socket.openWriteChannel(autoFlush = true), + input = socket.openReadChannel(), + firmware = firmware, + onProgress = onProgress, + ) + } +} - var offset = 0 - while (offset < firmware.size) { - onProgress(((offset.toDouble() / firmware.size) * 100).toInt()) +internal suspend fun uploadFirmware( + output: ByteWriteChannel, + input: ByteReadChannel, + firmware: ByteArray, + onProgress: suspend (Int) -> Unit, +): Boolean { + var offset = 0 + while (offset < firmware.size) { + onProgress(((offset.toDouble() / firmware.size) * 100).toInt()) - val chunkLen = min(OTA_CHUNK_SIZE, firmware.size - offset) - output.writeFully(firmware, offset, offset + chunkLen) - offset += chunkLen + val chunkLen = min(OTA_CHUNK_SIZE, firmware.size - offset) + output.writeFully(firmware, offset, offset + chunkLen) + offset += chunkLen - withTimeout(1_000) { input.discardExact(4) } - } - - output.flush() - val response = withTimeout(10_000) { input.readRemaining().readByteArray().decodeToString() } - response.contains("OK") + withTimeout(1_000) { input.discardExact(4) } } + + output.flush() + val response = withTimeout(10_000) { input.readRemaining().readByteArray().decodeToString() } + return response.contains("OK") } suspend fun doOtaFlash( @@ -154,21 +160,7 @@ suspend fun doOtaFlash( onStatus(FirmwareUpdateStatus.REBOOTING, 0) - // Wait for the device to come back online after reboot. - // flatMapLatest switches to the matched device's own state flow so that status changes, - // which don't emit a new VRServerState, are also observed. - @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) - val connected = withTimeoutOrNull(60_000) { - server.context.state - .flatMapLatest { state -> - val device = state.devices.values.find { it.context.state.value.id.toUByte() == deviceId.id } - device?.context?.state?.map { it.status != TrackerStatus.DISCONNECTED } ?: flowOf(false) - } - .filter { it } - .first() - } - - if (connected == null) { + if (waitForReconnected(server, deviceId) == null) { onStatus(FirmwareUpdateStatus.ERROR_TIMEOUT, 0) return } diff --git a/server/core/src/main/java/dev/slimevr/firmware/reconnect.kt b/server/core/src/main/java/dev/slimevr/firmware/reconnect.kt new file mode 100644 index 0000000000..281be86765 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/firmware/reconnect.kt @@ -0,0 +1,61 @@ +package dev.slimevr.firmware + +import dev.slimevr.VRServer +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withTimeoutOrNull +import solarxr_protocol.datatypes.DeviceId +import solarxr_protocol.datatypes.TrackerStatus + +private fun isOnlineStatus(status: TrackerStatus): Boolean = when (status) { + TrackerStatus.NONE, + TrackerStatus.DISCONNECTED, + TrackerStatus.TIMED_OUT, + -> false + + else -> true +} + +@OptIn(ExperimentalCoroutinesApi::class) +private fun deviceStatusFlow( + server: VRServer, + matches: (Int, String?) -> Boolean, +) = server.context.state.flatMapLatest { state -> + val device = state.devices.values.find { device -> + val deviceState = device.context.state.value + matches(deviceState.id, deviceState.macAddress) + } + device?.context?.state?.map { it.status } ?: flowOf(TrackerStatus.DISCONNECTED) +} + +suspend fun waitForConnected( + server: VRServer, + macAddress: String, + timeoutMs: Long = 30_000, +): Boolean? = withTimeoutOrNull(timeoutMs) { + deviceStatusFlow(server) { _, deviceMac -> deviceMac?.uppercase() == macAddress } + .filter(::isOnlineStatus) + .first() + true +} + +suspend fun waitForReconnected( + server: VRServer, + deviceId: DeviceId, + timeoutMs: Long = 60_000, +): Boolean? = withTimeoutOrNull(timeoutMs) { + val statuses = deviceStatusFlow(server) { id, _ -> id.toUByte() == deviceId.id } + .distinctUntilChanged() + + if (isOnlineStatus(statuses.first())) { + statuses.first { status -> !isOnlineStatus(status) } + } + + statuses.first(::isOnlineStatus) + true +} diff --git a/server/core/src/main/java/dev/slimevr/firmware/serial.kt b/server/core/src/main/java/dev/slimevr/firmware/serial.kt index 571208a54c..5528b88780 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/serial.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/serial.kt @@ -9,17 +9,13 @@ import dev.slimevr.serial.SerialConnection import dev.slimevr.serial.SerialServer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.FirmwarePart import solarxr_protocol.rpc.FirmwareUpdateStatus @@ -32,16 +28,6 @@ fun interface FirmwareFlasher { ) } -object NoopFirmwareFlasher : FirmwareFlasher { - override suspend fun flash( - portLocation: String, - handler: FlashingHandler, - parts: List, - onProgress: (Int) -> Unit, - ) = Unit -} - -@OptIn(ExperimentalCoroutinesApi::class) suspend fun doSerialFlash( portLocation: String, parts: List, @@ -212,17 +198,3 @@ internal suspend fun doSerialFlashPostFlash( onStatus(FirmwareUpdateStatus.DONE, 0) } - -suspend fun waitForConnected(server: VRServer, macAddress: String): Boolean? = - @OptIn(ExperimentalCoroutinesApi::class) - withTimeoutOrNull(30_000) { - server.context.state - .flatMapLatest { state -> - val device = - state.devices.values.find { it.context.state.value.macAddress?.uppercase() == macAddress } - device?.context?.state?.map { it.status != TrackerStatus.DISCONNECTED } - ?: flowOf(false) - } - .filter { it } - .first() - } diff --git a/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt b/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt index 58c1b021aa..a986976bdb 100644 --- a/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt +++ b/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt @@ -4,12 +4,13 @@ import com.appstractive.dnssd.DiscoveryEvent import com.appstractive.dnssd.discoverServices import dev.slimevr.AppLogger import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch private const val OSC_JSON_SERVICE_TYPE = "_oscjson._tcp" @@ -17,6 +18,7 @@ private const val OSC_JSON_SERVICE_TYPE = "_oscjson._tcp" class OscQueryDiscovery( private val serviceType: String = OSC_JSON_SERVICE_TYPE, private val serviceFilter: (String) -> Boolean = { true }, + private val discoverServicesFlow: (String) -> Flow = ::discoverServices, ) { private val mutableServices = MutableStateFlow>(emptyList()) val services: StateFlow> = mutableServices.asStateFlow() @@ -25,11 +27,11 @@ class OscQueryDiscovery( fun start(scope: CoroutineScope) { if (job != null) return - job = scope.launch(Dispatchers.Default) { - discoverServices(serviceType).collectLatest { event -> + job = scope.launch { + discoverServicesFlow(serviceType).collect { event -> when (event) { is DiscoveryEvent.Discovered -> { - if (!serviceFilter(event.service.name)) return@collectLatest + if (!serviceFilter(event.service.name)) return@collect event.resolve() } @@ -40,8 +42,8 @@ class OscQueryDiscovery( } } - fun close() { - job?.cancel() + suspend fun close() { + job?.cancelAndJoin() job = null mutableServices.value = emptyList() } diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 0e78eeaa51..ad30a6bc03 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -192,6 +192,7 @@ object HandshakeBehaviour : UDPConnectionBehaviour { mcuType = packet.data.mcuType, firmware = packet.data.firmware ?: firmware, protocolVersion = packet.data.protocolVersion, + status = TrackerStatus.OK, ) }, ) diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index 2419db925b..e352edc19e 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -5,10 +5,13 @@ import dev.slimevr.AppLogger import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.BoundDatagramSocket import io.ktor.network.sockets.InetSocketAddress import io.ktor.network.sockets.aSocket import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.time.measureTime @@ -33,6 +36,10 @@ object UdpServerBaseBehaviour : UdpServerBehaviour { } class UdpServer(val context: UdpServerContext) { + private var receiveJob: Job? = null + private var socket: BoundDatagramSocket? = null + private var selectorManager: SelectorManager? = null + fun startObserving() = context.observeAll(this) fun findConnectionForDevice(deviceId: Int): UDPConnection? = context.state.value.connections.values.find { conn -> @@ -40,12 +47,14 @@ class UdpServer(val context: UdpServerContext) { } fun startReceiving(appContext: AppContextProvider, scope: CoroutineScope) { - scope.launch { + if (receiveJob != null) return + receiveJob = scope.launch { val port = appContext.config.settings.context.state.value.data.trackerPort val selectorManager = SelectorManager(Dispatchers.IO) val socket = aSocket(selectorManager).udp().bind(port = port) - - launch { + this@UdpServer.selectorManager = selectorManager + this@UdpServer.socket = socket + try { while (isActive) { val recvPacket = socket.receive() val took = measureTime { @@ -79,10 +88,23 @@ class UdpServer(val context: UdpServerContext) { AppLogger.udp.warn("Packet processing took too long ${took.inWholeMilliseconds}") } } + } finally { + this@UdpServer.receiveJob = null + this@UdpServer.socket = null + this@UdpServer.selectorManager = null + socket.close() + selectorManager.close() } } } + suspend fun dispose() { + socket?.close() + selectorManager?.close() + receiveJob?.cancelAndJoin() + receiveJob = null + } + companion object { val INITIAL_STATE = UdpServerState(connections = emptyMap()) diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 8f6abbacf3..767eb44916 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -116,4 +116,5 @@ abstract class TestAppContext : AppContextProvider { override val vmcManager: VMCManager get() = error("not used in test") override val vrcOscManager: VRCOSCManager get() = error("not used in test") override fun startObserving() {} + override suspend fun dispose() = Unit } diff --git a/server/core/src/test/java/dev/slimevr/bvh/BvhStreamTest.kt b/server/core/src/test/java/dev/slimevr/bvh/BvhStreamTest.kt new file mode 100644 index 0000000000..2d89dc5f42 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/bvh/BvhStreamTest.kt @@ -0,0 +1,88 @@ +package dev.slimevr.bvh + +import dev.slimevr.config.TextFileHandle +import dev.slimevr.skeleton.DEFAULT_SKELETON_STATE +import dev.slimevr.skeleton.buildBones +import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BvhStreamTest { + @Test + fun `close persists header and frame data`() = runTest { + val file = InMemoryBvhFile() + val stream = BvhStream(file) + val initialBones = buildBones(DEFAULT_SKELETON_STATE) + val firstFrame = buildBones(DEFAULT_SKELETON_STATE, rootHead = Vector3(1f, 2f, 3f)) + val secondFrame = buildBones(DEFAULT_SKELETON_STATE, rootHead = Vector3(4f, 5f, 6f)) + + stream.writeHeader(initialBones) + stream.writeFrame(firstFrame) + stream.writeFrame(secondFrame) + stream.close() + + val content = file.content() + assertTrue(content.contains("HIERARCHY")) + assertTrue(content.contains("MOTION")) + assertTrue(content.contains("Frames: 2")) + assertTrue(content.contains("1.0 2.0 3.0")) + assertTrue(content.contains("4.0 5.0 6.0")) + } + + @Test + fun `writeFrame after close is ignored`() = runTest { + val file = InMemoryBvhFile() + val stream = BvhStream(file) + val bones = buildBones(DEFAULT_SKELETON_STATE) + + stream.writeHeader(bones) + stream.close() + val before = file.content() + + stream.writeFrame(buildBones(DEFAULT_SKELETON_STATE, rootHead = Vector3(7f, 8f, 9f))) + + assertEquals(before, file.content()) + } +} + +private class InMemoryBvhFile : TextFileHandle { + private val chars = mutableListOf() + private var pointer = 0 + private var closed = false + + override suspend fun write(text: String) { + check(!closed) { "file is closed" } + ensureCapacity(pointer) + text.forEachIndexed { index, char -> + val position = pointer + index + if (position < chars.size) { + chars[position] = char + } else { + chars.add(char) + } + } + pointer += text.length + } + + override suspend fun flush() = Unit + + override suspend fun position(): Long = pointer.toLong() + + override suspend fun seek(position: Long) { + pointer = position.toInt() + } + + override suspend fun close() { + closed = true + } + + fun content(): String = chars.joinToString("") + + private fun ensureCapacity(target: Int) { + while (chars.size < target) { + chars.add(' ') + } + } +} diff --git a/server/core/src/test/java/dev/slimevr/context/ContextCallerCaptureTest.kt b/server/core/src/test/java/dev/slimevr/context/ContextCallerCaptureTest.kt new file mode 100644 index 0000000000..92baca2e0c --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/context/ContextCallerCaptureTest.kt @@ -0,0 +1,55 @@ +package dev.slimevr.context + +import dev.slimevr.context.debug.ContextDebug +import dev.slimevr.context.debug.DebugMiddleware +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +private sealed interface TestAction { + object Increment : TestAction +} + +private object DispatchingBehaviour : Behaviour> { + override fun reduce(state: Int, action: TestAction): Int = when (action) { + TestAction.Increment -> state + 1 + } + + fun trigger(context: Context) { + context.dispatch(TestAction.Increment) + } + + override fun observe(receiver: Context) { + trigger(receiver) + } +} + +private class RecordingDebugMiddleware : DebugMiddleware { + var caller: String? = null + + override fun onDispatch(caller: String?, before: Int, action: TestAction, after: Int) { + this.caller = caller + } + + override fun onDispatchAll(caller: String?, before: Int, actions: List, after: Int) = Unit +} + +class ContextCallerCaptureTest { + + @Test + fun `dispatch reports calling behaviour`() { + ContextDebug.enabled = true + + val middleware = RecordingDebugMiddleware() + val context = Context.create( + initialState = 0, + scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined), + behaviours = listOf(DispatchingBehaviour), + debugMiddleware = middleware, + name = "TestContext", + ) + + context.observeAll(context) + assertEquals("DispatchingBehaviour", middleware.caller) + } +} diff --git a/server/core/src/test/java/dev/slimevr/context/ContextScopeIsolationTest.kt b/server/core/src/test/java/dev/slimevr/context/ContextScopeIsolationTest.kt new file mode 100644 index 0000000000..64c3087ae6 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/context/ContextScopeIsolationTest.kt @@ -0,0 +1,41 @@ +package dev.slimevr.context + +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private object NoopBehaviour : Behaviour + +class ContextScopeIsolationTest { + @Test + @OptIn(ExperimentalCoroutinesApi::class) + fun `direct context child failure does not cancel parent scope`() = runTest { + val uncaught = mutableListOf() + val handler = CoroutineExceptionHandler { _, throwable -> uncaught += throwable } + val parentJob = Job() + val parentScope = CoroutineScope(StandardTestDispatcher(testScheduler) + parentJob + handler) + val context = Context.create( + initialState = 0, + scope = parentScope, + behaviours = listOf(NoopBehaviour), + name = "IsolatedContext", + ) + + context.scope.launch { + error("boom") + } + + advanceUntilIdle() + + assertTrue(parentJob.isActive) + assertEquals(1, uncaught.size) + } +} diff --git a/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt b/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt new file mode 100644 index 0000000000..f966d43b0c --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt @@ -0,0 +1,123 @@ +package dev.slimevr.firmware + +import dev.slimevr.VRServer +import dev.slimevr.VRServerActions +import dev.slimevr.buildTestVrServerStub +import dev.slimevr.device.Device +import dev.slimevr.device.DeviceActions +import dev.slimevr.device.DeviceOrigin +import io.ktor.utils.io.ByteChannel +import io.ktor.utils.io.readRemaining +import io.ktor.utils.io.writeFully +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.io.readByteArray +import solarxr_protocol.datatypes.DeviceId +import solarxr_protocol.datatypes.TrackerStatus +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private fun addUdpDevice( + server: VRServer, + scope: kotlinx.coroutines.CoroutineScope, + id: Int = server.nextHandle(), + macAddress: String? = null, +): Device { + val device = Device.create( + scope = scope, + id = id, + address = "192.168.1.100", + macAddress = macAddress, + origin = DeviceOrigin.UDP, + protocolVersion = 0, + ) + server.context.dispatch(VRServerActions.NewDevice(device.context.state.value.id, device)) + return device +} + +class OtaUploadTest { + @Test + fun `uploadFirmware writes complete payload across chunk boundaries`() = runTest { + val firmware = ByteArray(2048 + 17) { index -> index.toByte() } + val output = ByteChannel(autoFlush = true) + val input = ByteChannel(autoFlush = true) + val progress = mutableListOf() + + repeat(2) { + input.writeFully(ByteArray(4)) + } + input.writeFully("OK".encodeToByteArray()) + input.close() + + assertTrue( + uploadFirmware( + output = output, + input = input, + firmware = firmware, + onProgress = { value -> progress += value }, + ), + ) + + output.close() + assertContentEquals(firmware, output.readRemaining().readByteArray()) + assertEquals(listOf(0, 99), progress) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `waitForConnected ignores timed out status`() = runTest { + val vrServer = buildTestVrServerStub(backgroundScope) + val device = addUdpDevice(vrServer, backgroundScope, macAddress = "AA:BB:CC:DD:EE:FF") + var result: Boolean? = null + + val job = backgroundScope.launch { + result = waitForConnected(vrServer, "AA:BB:CC:DD:EE:FF", timeoutMs = 1_000) + } + + runCurrent() + assertFalse(job.isCompleted) + + device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.TIMED_OUT) }) + runCurrent() + assertFalse(job.isCompleted) + + device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) }) + runCurrent() + + assertEquals(true, result) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `waitForReconnected requires offline then online transition`() = runTest { + val vrServer = buildTestVrServerStub(backgroundScope) + val device = addUdpDevice(vrServer, backgroundScope) + device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) }) + var result: Boolean? = null + + val job = backgroundScope.launch { + result = waitForReconnected( + vrServer, + DeviceId(device.context.state.value.id.toUByte()), + timeoutMs = 1_000, + ) + } + + runCurrent() + assertFalse(job.isCompleted) + + device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.TIMED_OUT) }) + runCurrent() + assertFalse(job.isCompleted) + + device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) }) + runCurrent() + + assertEquals(true, result) + } +} diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index df77763bb7..85ec7ba1dc 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -91,6 +91,8 @@ dependencies { implementation("io.ktor:ktor-server-core-jvm:$ktor_version") implementation("io.ktor:ktor-server-netty-jvm:$ktor_version") implementation("io.ktor:ktor-server-websockets-jvm:$ktor_version") + + testImplementation(kotlin("test")) } tasks.shadowJar { 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 153f35593d..f9d5057fd7 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -29,10 +29,11 @@ import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer import dev.slimevr.vmc.VMCManager import dev.slimevr.vrcosc.VRCOSCManager +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -fun main(args: Array) = runBlocking { +fun main(args: Array) = runBlocking { ContextDebug.enabled = System.getProperty("slimevr.debug.context") == "true" || System.getenv("SLIMEVR_DEBUG_CONTEXT") == "true" @@ -78,12 +79,16 @@ fun main(args: Array) = runBlocking { vrcOscManager = vrcOscManager, ) - appContext.startObserving() + try { + appContext.startObserving() - launch { createDesktopHIDManager(appContext, this) } - launch { createSolarXRWebsocketServer(appContext) } - launch { createIpcServers(appContext) } - launch { setupDesktopNetworkProfileChecker(this, networkProfileManager) } + launch { createDesktopHIDManager(appContext, this) } + launch { createSolarXRWebsocketServer(appContext) } + launch { createIpcServers(appContext) } + launch { setupDesktopNetworkProfileChecker(this, networkProfileManager) } - Unit + awaitCancellation() + } finally { + appContext.dispose() + } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/config/storage.kt b/server/desktop/src/main/java/dev/slimevr/desktop/config/storage.kt index ae903ae1f0..9318136a46 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/config/storage.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/config/storage.kt @@ -56,7 +56,10 @@ class DesktopConfigStorage( override fun displayPath(path: String): String = resolve(path).absolutePath - private fun resolve(path: String): File = File(root, path) + private fun resolve(path: String): File { + val requested = File(path) + return if (requested.isAbsolute) requested else File(root, path) + } } private class RandomAccessTextFileHandle( @@ -79,6 +82,7 @@ private class RandomAccessTextFileHandle( } override suspend fun close() = withContext(Dispatchers.IO) { + file.fd.sync() file.close() } } From 96998a644f4936c4c0addae0de3871cd487e81bb Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 11 May 2026 12:55:22 -0500 Subject: [PATCH 104/350] Merge driver and feeder bridge --- .../src/main/java/dev/slimevr/behaviours.kt | 2 - .../main/java/dev/slimevr/device/module.kt | 1 - .../java/dev/slimevr/driver/behaviours.kt | 65 ++++++++++++++++- .../main/java/dev/slimevr/driver/module.kt | 14 +++- .../java/dev/slimevr/feeder/behaviours.kt | 72 ------------------- .../main/java/dev/slimevr/feeder/module.kt | 65 ----------------- .../src/main/java/dev/slimevr/vrserver.kt | 5 -- .../java/dev/slimevr/desktop/ipc/linux.kt | 2 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 33 +-------- .../java/dev/slimevr/desktop/ipc/windows.kt | 2 +- 10 files changed, 79 insertions(+), 182 deletions(-) delete mode 100644 server/core/src/main/java/dev/slimevr/feeder/behaviours.kt delete mode 100644 server/core/src/main/java/dev/slimevr/feeder/module.kt diff --git a/server/core/src/main/java/dev/slimevr/behaviours.kt b/server/core/src/main/java/dev/slimevr/behaviours.kt index 98d6bc2c2f..5d27aff418 100644 --- a/server/core/src/main/java/dev/slimevr/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/behaviours.kt @@ -10,8 +10,6 @@ object BaseBehaviour : VRServerBehaviour { is VRServerActions.NewDevice -> state.copy(devices = state.devices + (action.deviceId to action.context)) is VRServerActions.DriverConnected -> state.copy(drivers = state.drivers + (action.bridge.id to action.bridge)) is VRServerActions.DriverDisconnected -> state.copy(drivers = state.drivers - action.bridgeId) - is VRServerActions.FeederConnected -> state.copy(feeders = state.feeders + (action.bridge.id to action.bridge)) - is VRServerActions.FeederDisconnected -> state.copy(feeders = state.feeders - action.bridgeId) is VRServerActions.SolarXRConnected -> state.copy(solarxr = state.solarxr + (action.connection.id to action.connection)) is VRServerActions.SolarXRDisconnected -> state.copy(solarxr = state.solarxr - action.connectionId) } diff --git a/server/core/src/main/java/dev/slimevr/device/module.kt b/server/core/src/main/java/dev/slimevr/device/module.kt index 48f54f877c..47b6c570d6 100644 --- a/server/core/src/main/java/dev/slimevr/device/module.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -11,7 +11,6 @@ import solarxr_protocol.datatypes.hardware_info.McuType enum class DeviceOrigin { DRIVER, - FEEDER, UDP, HID, OSC, diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index e0c2dab276..9a1fbecfa2 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -1,12 +1,20 @@ package dev.slimevr.driver +import dev.slimevr.AppLogger +import dev.slimevr.VRServerActions +import dev.slimevr.device.Device +import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin +import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.datatypes.hardware_info.ImuType object DriverBaseBehaviour : DriverBridgeBehaviour { override fun reduce(state: DriverBridgeState, action: DriverBridgeActions): DriverBridgeState = when (action) { + is DriverBridgeActions.AddTracker -> state.copy(trackers = state.trackers + (action.id to action.tracker)) is DriverBridgeActions.UpdateProtocolVersion -> state.copy(protocolVersion = action.version) } @@ -15,10 +23,17 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { receiver.context.dispatch(DriverBridgeActions.UpdateProtocolVersion(event.protocolVersion)) } + receiver.inbound.on { event -> + handleTrackerAdded(receiver, event.id, event.serial) + } + receiver.inbound.on { event -> - receiver.appContext.server.getTracker(event.trackerId)?.context?.dispatch( - TrackerActions.SetRotation(rotation = event.rotation), - ) + val tracker = receiver.context.state.value.trackers[event.trackerId] + if (tracker != null) { + tracker.context.dispatch(TrackerActions.SetRotation(rotation = event.rotation)) + } else { + AppLogger.steamvr.warn("Failed to find tracker ${event.trackerId}") + } } // Should be safe: StateFlow never delivers two emissions concurrently to the same collector. @@ -53,4 +68,48 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { } .launchIn(receiver.context.scope) } + + private fun handleTrackerAdded(receiver: DriverBridge, id: Int, serial: String) { + val server = receiver.appContext.server + val settings = receiver.appContext.config.settings + val scope = server.context.scope + val existingTracker = server.context.state.value.trackers.values + .find { tracker -> tracker.context.state.value.hardwareId == serial } + + val device = if (existingTracker != null) { + server.getDevice(existingTracker.context.state.value.deviceId) + ?: error("could not find existing device for serial $serial") + } else { + val deviceId = server.nextHandle() + val newDevice = Device.create( + scope = scope, + id = deviceId, + address = serial, + macAddress = serial, + origin = DeviceOrigin.DRIVER, + protocolVersion = 0, + ) + server.context.dispatch(VRServerActions.NewDevice(deviceId, newDevice)) + + val trackerId = server.nextHandle() + val tracker = Tracker.create( + scope = scope, + id = trackerId, + deviceId = deviceId, + sensorType = ImuType.Other, + hardwareId = serial, + origin = DeviceOrigin.DRIVER, + server = server, + settings = settings, + ) + server.context.dispatch(VRServerActions.NewTracker(trackerId, tracker)) + + receiver.context.dispatch(DriverBridgeActions.AddTracker(id, tracker)) + tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) + + newDevice + } + + device.context.dispatch(DeviceActions.Update { copy(protocolVersion = 0) }) + } } diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt index 943ec31285..c659391414 100644 --- a/server/core/src/main/java/dev/slimevr/driver/module.kt +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -6,20 +6,26 @@ import dev.slimevr.VRServerActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.context.ManagedContext +import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerActions import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope +import solarxr_protocol.datatypes.TrackerStatus data class DriverBridgeState( val protocolVersion: Int, + val trackers: Map, ) sealed interface DriverBridgeActions { + data class AddTracker(val id: Int, val tracker: Tracker) : DriverBridgeActions data class UpdateProtocolVersion(val version: Int) : DriverBridgeActions } sealed interface DriverBridgeInbound { data class Version(val protocolVersion: Int) : DriverBridgeInbound + data class TrackerAdded(val id: Int, val serial: String) : DriverBridgeInbound data class TrackerPosition(val trackerId: Int, val rotation: Quaternion, val position: Vector3?) : DriverBridgeInbound } @@ -46,6 +52,9 @@ class DriverBridge( fun disconnect() { dispose() appContext.server.context.dispatch(VRServerActions.DriverDisconnected(id)) + context.state.value.trackers.forEach { (_, tracker) -> + tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.DISCONNECTED)) + } } companion object { @@ -53,7 +62,10 @@ class DriverBridge( val behaviours = listOf(DriverBaseBehaviour) val managedContext = ManagedContext.create( - initialState = DriverBridgeState(protocolVersion = 0), + initialState = DriverBridgeState( + protocolVersion = 0, + trackers = emptyMap(), + ), scope = scope, behaviours = behaviours, name = "Driver[$id]", diff --git a/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt b/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt deleted file mode 100644 index 76b679caa0..0000000000 --- a/server/core/src/main/java/dev/slimevr/feeder/behaviours.kt +++ /dev/null @@ -1,72 +0,0 @@ -package dev.slimevr.feeder - -import dev.slimevr.VRServerActions -import dev.slimevr.device.Device -import dev.slimevr.device.DeviceActions -import dev.slimevr.device.DeviceOrigin -import dev.slimevr.tracker.Tracker -import dev.slimevr.tracker.TrackerActions -import solarxr_protocol.datatypes.hardware_info.ImuType - -object FeederBaseBehaviour : FeederBridgeBehaviour { - override fun reduce(state: FeederBridgeState, action: FeederBridgeActions): FeederBridgeState = when (action) { - is FeederBridgeActions.UpdateProtocolVersion -> state.copy(protocolVersion = action.version, firmware = action.firmware) - } - - override fun observe(receiver: FeederBridge) { - receiver.inbound.on { event -> - receiver.context.dispatch(FeederBridgeActions.UpdateProtocolVersion(event.protocolVersion, event.firmware)) - } - - receiver.inbound.on { event -> - handleTrackerAdded(receiver, event.serial) - } - - receiver.inbound.on { event -> - receiver.appContext.server.getTracker(event.trackerId)?.context?.dispatch( - TrackerActions.SetRotation(rotation = event.rotation), - ) - } - } - - private fun handleTrackerAdded(receiver: FeederBridge, serial: String) { - val server = receiver.appContext.server - val settings = receiver.appContext.config.settings - val scope = server.context.scope - val existingTracker = server.context.state.value.trackers.values - .find { tracker -> tracker.context.state.value.hardwareId == serial } - - val device = if (existingTracker != null) { - server.getDevice(existingTracker.context.state.value.deviceId) - ?: error("could not find existing device for serial $serial") - } else { - val deviceId = server.nextHandle() - val newDevice = Device.create( - scope = scope, - id = deviceId, - address = serial, - macAddress = serial, - origin = DeviceOrigin.FEEDER, - protocolVersion = 0, - ) - server.context.dispatch(VRServerActions.NewDevice(deviceId, newDevice)) - - val trackerId = server.nextHandle() - val tracker = Tracker.create( - scope = scope, - id = trackerId, - deviceId = deviceId, - sensorType = ImuType.MPU9250, - hardwareId = serial, - origin = DeviceOrigin.FEEDER, - server = server, - settings = settings, - ) - server.context.dispatch(VRServerActions.NewTracker(trackerId, tracker)) - - newDevice - } - - device.context.dispatch(DeviceActions.Update { copy(protocolVersion = 0) }) - } -} diff --git a/server/core/src/main/java/dev/slimevr/feeder/module.kt b/server/core/src/main/java/dev/slimevr/feeder/module.kt deleted file mode 100644 index 3551fee387..0000000000 --- a/server/core/src/main/java/dev/slimevr/feeder/module.kt +++ /dev/null @@ -1,65 +0,0 @@ -package dev.slimevr.feeder - -import dev.slimevr.AppContextProvider -import dev.slimevr.EventDispatcher -import dev.slimevr.VRServerActions -import dev.slimevr.context.Behaviour -import dev.slimevr.context.Context -import dev.slimevr.context.ManagedContext -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import kotlinx.coroutines.CoroutineScope - -data class FeederBridgeState( - val protocolVersion: Int, - val firmware: String?, -) - -sealed interface FeederBridgeActions { - data class UpdateProtocolVersion(val version: Int, val firmware: String?) : FeederBridgeActions -} - -sealed interface FeederBridgeInbound { - data class Version(val protocolVersion: Int, val firmware: String?) : FeederBridgeInbound - data class TrackerAdded(val serial: String) : FeederBridgeInbound - data class TrackerPosition(val trackerId: Int, val rotation: Quaternion, val position: Vector3?) : FeederBridgeInbound -} - -typealias FeederBridgeContext = Context -typealias FeederBridgeBehaviour = Behaviour - -class FeederBridge( - val id: Int, - val context: FeederBridgeContext, - val appContext: AppContextProvider, - val inbound: EventDispatcher = EventDispatcher(), - private val managedContext: ManagedContext? = null, -) { - fun dispose() = managedContext?.dispose() - - fun startObserving() = context.observeAll(this) - - fun disconnect() { - dispose() - appContext.server.context.dispatch(VRServerActions.FeederDisconnected(id)) - } - - companion object { - fun create(id: Int, appContext: AppContextProvider, scope: CoroutineScope): FeederBridge { - val behaviours = listOf(FeederBaseBehaviour) - - val managedContext = ManagedContext.create( - initialState = FeederBridgeState(protocolVersion = 0, firmware = null), - scope = scope, - behaviours = behaviours, - name = "Feeder[$id]", - ) - - val bridge = FeederBridge(id = id, context = managedContext.context, appContext = appContext, managedContext = managedContext) - bridge.startObserving() - appContext.server.context.dispatch(VRServerActions.FeederConnected(bridge)) - - return bridge - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index 1f05c880b5..95ebcb26bd 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -4,7 +4,6 @@ import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.device.Device import dev.slimevr.driver.DriverBridge -import dev.slimevr.feeder.FeederBridge import dev.slimevr.solarxr.SolarXRBridge import dev.slimevr.tracker.Tracker import kotlinx.coroutines.CoroutineScope @@ -16,7 +15,6 @@ data class VRServerState( val trackers: Map, val devices: Map, val drivers: Map, - val feeders: Map, val solarxr: Map, ) @@ -25,8 +23,6 @@ sealed interface VRServerActions { data class NewDevice(val deviceId: Int, val context: Device) : VRServerActions data class DriverConnected(val bridge: DriverBridge) : VRServerActions data class DriverDisconnected(val bridgeId: Int) : VRServerActions - data class FeederConnected(val bridge: FeederBridge) : VRServerActions - data class FeederDisconnected(val bridgeId: Int) : VRServerActions data class SolarXRConnected(val connection: SolarXRBridge) : VRServerActions data class SolarXRDisconnected(val connectionId: Int) : VRServerActions } @@ -52,7 +48,6 @@ class VRServer( trackers = emptyMap(), devices = emptyMap(), drivers = emptyMap(), - feeders = emptyMap(), solarxr = emptyMap(), ), scope = scope, diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index fc99b6cd15..2fbe3f736a 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -26,7 +26,7 @@ suspend fun createUnixDriverSocket(appContext: AppContextProvider) = acceptUnixC } suspend fun createUnixFeederSocket(appContext: AppContextProvider) = acceptUnixClients(FEEDER_SOCKET_NAME) { channel -> - handleFeederConnection( + handleDriverConnection( appContext = appContext, messages = readFramedMessages(channel), send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index b807e7ff5e..58e9c0317f 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -11,12 +11,9 @@ import dev.slimevr.desktop.platform.Version import dev.slimevr.driver.DriverBridge import dev.slimevr.driver.DriverBridgeInbound import dev.slimevr.driver.DriverBridgeOutbound -import dev.slimevr.feeder.FeederBridge -import dev.slimevr.feeder.FeederBridgeInbound import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -25,8 +22,6 @@ import kotlinx.coroutines.withContext import java.nio.file.Path import kotlin.io.path.Path import kotlin.io.path.exists -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds const val PROTOCOL_VERSION = 2 @@ -108,35 +103,11 @@ suspend fun handleDriverConnection( } } } - msg.position?.let { pos -> - bridge.inbound.emit(DriverBridgeInbound.TrackerPosition(trackerId = pos.tracker_id, rotation = Quaternion(w = pos.qw, x = pos.qx, y = pos.qy, z = pos.qz), position = null)) - } - } - } finally { - bridge.disconnect() - } -} - -suspend fun handleFeederConnection( - appContext: AppContextProvider, - messages: Flow, - send: suspend (ByteArray) -> Unit, -) = coroutineScope { - val bridge = FeederBridge.create(id = appContext.server.nextHandle(), appContext = appContext, scope = this) - - send(ProtobufMessage.ADAPTER.encode(ProtobufMessage(version = Version(protocol_version = PROTOCOL_VERSION)))) - - try { - messages.collect { bytes -> - val msg = ProtobufMessage.ADAPTER.decode(bytes) - msg.version?.let { ver -> - bridge.inbound.emit(FeederBridgeInbound.Version(protocolVersion = ver.protocol_version, firmware = ver.toString())) - } msg.tracker_added?.let { ta -> - bridge.inbound.emit(FeederBridgeInbound.TrackerAdded(serial = ta.tracker_serial)) + bridge.inbound.emit(DriverBridgeInbound.TrackerAdded(id = ta.tracker_id, serial = ta.tracker_serial)) } msg.position?.let { pos -> - bridge.inbound.emit(FeederBridgeInbound.TrackerPosition(trackerId = pos.tracker_id, rotation = Quaternion(w = pos.qw, x = pos.qx, y = pos.qy, z = pos.qz), position = null)) + bridge.inbound.emit(DriverBridgeInbound.TrackerPosition(trackerId = pos.tracker_id, rotation = Quaternion(w = pos.qw, x = pos.qx, y = pos.qy, z = pos.qz), position = null)) } } } finally { diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt index 8a17499d7c..c640bf9466 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt @@ -29,7 +29,7 @@ suspend fun createWindowsDriverPipe(appContext: AppContextProvider) = acceptWind } suspend fun createWindowsFeederPipe(appContext: AppContextProvider) = acceptWindowsClients(FEEDER_PIPE) { handle -> - handleFeederConnection( + handleDriverConnection( appContext = appContext, messages = readFramedMessages(handle), send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, From f59e5aa869221bba5065d14c442f4851fb23e63b Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 11 May 2026 12:56:47 -0500 Subject: [PATCH 105/350] Catch exceptions when reading from Unix sockets --- .../core/src/main/java/dev/slimevr/logger.kt | 1 + .../java/dev/slimevr/desktop/ipc/linux.kt | 22 ++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index e73ed36fe1..1720c8f3c3 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -10,6 +10,7 @@ object AppLogger { val tracker = logger("Tracker") val device = logger("Device") val udp = logger("UDPConnection") + val ipc = logger("IPC") val solarxr = logger("SolarXR") val steamvr = logger("SteamVR") val hid = logger("HID") diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index 2fbe3f736a..7c899b1d98 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -1,6 +1,7 @@ package dev.slimevr.desktop.ipc import dev.slimevr.AppContextProvider +import dev.slimevr.AppLogger import dev.slimevr.getSocketDirectory import dev.slimevr.solarxr.handleSolarXRBridge import kotlinx.coroutines.Dispatchers @@ -9,6 +10,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.net.SocketException import java.net.StandardProtocolFamily import java.net.UnixDomainSocketAddress import java.nio.ByteBuffer @@ -53,16 +55,20 @@ private fun isSocketInUse(socketPath: String): Boolean = try { // Length field is LE u32 and includes the 4-byte header itself private fun readFramedMessages(channel: SocketChannel) = flow { val lenBuf = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN) - while (true) { - lenBuf.clear() - if (channel.read(lenBuf) == -1) break - lenBuf.flip() + try { + while (true) { + lenBuf.clear() + if (channel.read(lenBuf) == -1) break + lenBuf.flip() - val dataBuf = ByteBuffer.allocate(lenBuf.int - 4) - while (dataBuf.hasRemaining()) { - if (channel.read(dataBuf) == -1) break + val dataBuf = ByteBuffer.allocate(lenBuf.int - 4) + while (dataBuf.hasRemaining()) { + if (channel.read(dataBuf) == -1) break + } + emit(dataBuf.array()) } - emit(dataBuf.array()) + } catch (e: SocketException) { + AppLogger.ipc.warn("Exception on socket: ${e.message}") } }.flowOn(Dispatchers.IO) From 945796dade87082ecde68b923f45324cdd4e9af8 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 11 May 2026 13:05:24 -0500 Subject: [PATCH 106/350] Use battery info from SteamVR driver --- .../src/main/java/dev/slimevr/driver/behaviours.kt | 14 ++++++++++++++ .../src/main/java/dev/slimevr/driver/module.kt | 1 + .../main/java/dev/slimevr/desktop/ipc/protocol.kt | 3 +++ 3 files changed, 18 insertions(+) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 9a1fbecfa2..240a0c8c16 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -36,6 +36,20 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { } } + receiver.inbound.on { event -> + receiver.context.state.value.trackers[event.id]?.let { tracker -> + val device = tracker.server.getDevice(tracker.context.state.value.deviceId) ?: error("could not find device") + device.context.dispatch( + DeviceActions.Update { + copy( + batteryLevel = event.batteryLevel / 100f, + batteryVoltage = if (event.charging) 4.3f else 3.7f, + ) + }, + ) + } + } + // Should be safe: StateFlow never delivers two emissions concurrently to the same collector. val subscribedTrackers = mutableSetOf() diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt index c659391414..70d51a1d2f 100644 --- a/server/core/src/main/java/dev/slimevr/driver/module.kt +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -27,6 +27,7 @@ sealed interface DriverBridgeInbound { data class Version(val protocolVersion: Int) : DriverBridgeInbound data class TrackerAdded(val id: Int, val serial: String) : DriverBridgeInbound data class TrackerPosition(val trackerId: Int, val rotation: Quaternion, val position: Vector3?) : DriverBridgeInbound + data class TrackerBattery(val id: Int, val batteryLevel: Float, val charging: Boolean) : DriverBridgeInbound } sealed interface DriverBridgeOutbound { diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 58e9c0317f..ed1870d088 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -106,6 +106,9 @@ suspend fun handleDriverConnection( msg.tracker_added?.let { ta -> bridge.inbound.emit(DriverBridgeInbound.TrackerAdded(id = ta.tracker_id, serial = ta.tracker_serial)) } + msg.battery?.let { bat -> + bridge.inbound.emit(DriverBridgeInbound.TrackerBattery(id = bat.tracker_id, batteryLevel = bat.battery_level, charging = bat.is_charging)) + } msg.position?.let { pos -> bridge.inbound.emit(DriverBridgeInbound.TrackerPosition(trackerId = pos.tracker_id, rotation = Quaternion(w = pos.qw, x = pos.qx, y = pos.qy, z = pos.qz), position = null)) } From a86a6fb2f0234c3b0f1804e284098b35d4e9ba5d Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 11 May 2026 13:31:07 -0500 Subject: [PATCH 107/350] Make device battery info null by default --- server/core/src/main/java/dev/slimevr/device/module.kt | 8 ++++---- server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/device/module.kt b/server/core/src/main/java/dev/slimevr/device/module.kt index 47b6c570d6..55b8a7b132 100644 --- a/server/core/src/main/java/dev/slimevr/device/module.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -22,8 +22,8 @@ data class DeviceState( val name: String, val address: String, val macAddress: String?, - val batteryLevel: Float, - val batteryVoltage: Float, + val batteryLevel: Float?, + val batteryVoltage: Float?, val ping: Long?, val signalStrength: Int?, val firmware: String?, @@ -59,8 +59,8 @@ class Device( val deviceState = DeviceState( id = id, name = "Device $id", - batteryLevel = 0f, - batteryVoltage = 0f, + batteryLevel = null, + batteryVoltage = null, origin = origin, address = address, macAddress = macAddress, diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 7b79738876..48f62d21e6 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -77,7 +77,7 @@ private fun createDevice( id = DeviceId(device.id.toUByte()), hardwareStatus = HardwareStatus( batteryVoltage = device.batteryVoltage, - batteryPctEstimate = (device.batteryLevel * 100).toUInt().toUByte(), + batteryPctEstimate = device.batteryLevel?.let { (it * 100).toUInt().toUByte() }, ping = device.ping?.toUShort(), rssi = device.signalStrength?.toShort(), packetsReceived = device.packetsReceived.toInt(), From 7645ff67521bca540a4b6f6bc01757d3521ac3ca Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 11 May 2026 13:33:00 -0500 Subject: [PATCH 108/350] Don't set sensorType for devices from SteamVR --- server/core/src/main/java/dev/slimevr/driver/behaviours.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 240a0c8c16..4c146042b2 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -110,7 +110,7 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { scope = scope, id = trackerId, deviceId = deviceId, - sensorType = ImuType.Other, + sensorType = null, hardwareId = serial, origin = DeviceOrigin.DRIVER, server = server, From 47a706792baafe9356087131d8fce7c9b4859bf5 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 11 May 2026 13:48:05 -0500 Subject: [PATCH 109/350] Use device name and manufacturer from SteamVR --- server/core/src/main/java/dev/slimevr/device/module.kt | 6 +++++- server/core/src/main/java/dev/slimevr/driver/behaviours.kt | 7 +++++-- server/core/src/main/java/dev/slimevr/driver/module.kt | 2 +- server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt | 2 +- server/core/src/main/java/dev/slimevr/tracker/module.kt | 3 ++- .../src/main/java/dev/slimevr/desktop/ipc/protocol.kt | 2 +- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/device/module.kt b/server/core/src/main/java/dev/slimevr/device/module.kt index 55b8a7b132..bb6b8db6eb 100644 --- a/server/core/src/main/java/dev/slimevr/device/module.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -20,6 +20,7 @@ enum class DeviceOrigin { data class DeviceState( val id: Int, val name: String, + val manufacturer: String, val address: String, val macAddress: String?, val batteryLevel: Float?, @@ -51,6 +52,8 @@ class Device( fun create( scope: CoroutineScope, id: Int, + name: String = "Device $id", + manufacturer: String = "SlimeVR", address: String, macAddress: String? = null, origin: DeviceOrigin, @@ -58,7 +61,8 @@ class Device( ): Device { val deviceState = DeviceState( id = id, - name = "Device $id", + name = name, + manufacturer = manufacturer, batteryLevel = null, batteryVoltage = null, origin = origin, diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 4c146042b2..f75eea1035 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -24,7 +24,7 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { } receiver.inbound.on { event -> - handleTrackerAdded(receiver, event.id, event.serial) + handleTrackerAdded(receiver, event.id, event.name, event.manufacturer, event.serial) } receiver.inbound.on { event -> @@ -83,7 +83,7 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { .launchIn(receiver.context.scope) } - private fun handleTrackerAdded(receiver: DriverBridge, id: Int, serial: String) { + private fun handleTrackerAdded(receiver: DriverBridge, id: Int, name: String, manufacturer: String, serial: String) { val server = receiver.appContext.server val settings = receiver.appContext.config.settings val scope = server.context.scope @@ -98,6 +98,8 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { val newDevice = Device.create( scope = scope, id = deviceId, + name = name, + manufacturer = manufacturer, address = serial, macAddress = serial, origin = DeviceOrigin.DRIVER, @@ -109,6 +111,7 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { val tracker = Tracker.create( scope = scope, id = trackerId, + name = name, deviceId = deviceId, sensorType = null, hardwareId = serial, diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt index 70d51a1d2f..1c3c681570 100644 --- a/server/core/src/main/java/dev/slimevr/driver/module.kt +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -25,7 +25,7 @@ sealed interface DriverBridgeActions { sealed interface DriverBridgeInbound { data class Version(val protocolVersion: Int) : DriverBridgeInbound - data class TrackerAdded(val id: Int, val serial: String) : DriverBridgeInbound + data class TrackerAdded(val id: Int, val name: String, val manufacturer: String, val serial: String) : DriverBridgeInbound data class TrackerPosition(val trackerId: Int, val rotation: Quaternion, val position: Vector3?) : DriverBridgeInbound data class TrackerBattery(val id: Int, val batteryLevel: Float, val charging: Boolean) : DriverBridgeInbound } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 48f62d21e6..5ac5c30b2e 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -86,7 +86,7 @@ private fun createDevice( ), hardwareInfo = HardwareInfo( mcuId = device.mcuType, - manufacturer = "SlimeVR", + manufacturer = device.manufacturer, boardType = device.boardType.toString(), officialBoardType = device.boardType, model = device.mcuType.toString(), diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 85cc852032..323c623cf8 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -55,6 +55,7 @@ class Tracker( fun create( scope: CoroutineScope, id: Int, + name: String = "Tracker #$id", deviceId: Int, sensorType: ImuType?, hardwareId: String, @@ -67,7 +68,7 @@ class Tracker( val baseState = TrackerState( id = id, hardwareId = hardwareId, - name = "Tracker #$id", + name = name, rawRotation = Quaternion.IDENTITY, acceleration = Vector3.NULL, bodyPart = null, diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index ed1870d088..2da678ef03 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -104,7 +104,7 @@ suspend fun handleDriverConnection( } } msg.tracker_added?.let { ta -> - bridge.inbound.emit(DriverBridgeInbound.TrackerAdded(id = ta.tracker_id, serial = ta.tracker_serial)) + bridge.inbound.emit(DriverBridgeInbound.TrackerAdded(id = ta.tracker_id, name = ta.tracker_name, manufacturer = ta.manufacturer.ifEmpty { "SlimeVR" }, serial = ta.tracker_serial)) } msg.battery?.let { bat -> bridge.inbound.emit(DriverBridgeInbound.TrackerBattery(id = bat.tracker_id, batteryLevel = bat.battery_level, charging = bat.is_charging)) From 2f6cb0ab0df98fcfcb0e2848a85a1aa1c08a9242 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 11 May 2026 13:50:22 -0500 Subject: [PATCH 110/350] Make VRChat config warnings checklist step ignorable --- .../src/main/java/dev/slimevr/trackingchecklist/behaviours.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index 5e9fde2f72..708b64c19f 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -164,7 +164,7 @@ class VRChatSettingsCheckBehaviour( if (!vrc.isSupported || values == null) return TrackingChecklistStep(valid = true, enabled = false) val recommended = computeRecommendedValues(server, userHeight) val validity = computeValidity(values, recommended) - return TrackingChecklistStep(valid = isVRCConfigValid(validity, vrc.mutedWarnings), enabled = true) + return TrackingChecklistStep(valid = isVRCConfigValid(validity, vrc.mutedWarnings), ignorable = true, enabled = true) } override fun observe(receiver: TrackingChecklist) { From c71bb3e9c40f41a3aec41218fac34595a94a8deb Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 11 May 2026 14:06:08 -0500 Subject: [PATCH 111/350] Use position from SteamVR --- .../src/main/java/dev/slimevr/driver/behaviours.kt | 4 +++- .../main/java/dev/slimevr/desktop/ipc/protocol.kt | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index f75eea1035..b0e61a3819 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -30,7 +30,9 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { receiver.inbound.on { event -> val tracker = receiver.context.state.value.trackers[event.trackerId] if (tracker != null) { - tracker.context.dispatch(TrackerActions.SetRotation(rotation = event.rotation)) + tracker.context.dispatch(TrackerActions.Update { + copy(rawRotation = event.rotation, position = event.position) + }) } else { AppLogger.steamvr.warn("Failed to find tracker ${event.trackerId}") } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 2da678ef03..5707771e6c 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -12,6 +12,7 @@ import dev.slimevr.driver.DriverBridge import dev.slimevr.driver.DriverBridgeInbound import dev.slimevr.driver.DriverBridgeOutbound import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow @@ -104,13 +105,22 @@ suspend fun handleDriverConnection( } } msg.tracker_added?.let { ta -> - bridge.inbound.emit(DriverBridgeInbound.TrackerAdded(id = ta.tracker_id, name = ta.tracker_name, manufacturer = ta.manufacturer.ifEmpty { "SlimeVR" }, serial = ta.tracker_serial)) + bridge.inbound.emit(DriverBridgeInbound.TrackerAdded( + id = ta.tracker_id, + name = ta.tracker_name, + manufacturer = ta.manufacturer.ifEmpty { "SlimeVR" }, + serial = ta.tracker_serial + )) } msg.battery?.let { bat -> bridge.inbound.emit(DriverBridgeInbound.TrackerBattery(id = bat.tracker_id, batteryLevel = bat.battery_level, charging = bat.is_charging)) } msg.position?.let { pos -> - bridge.inbound.emit(DriverBridgeInbound.TrackerPosition(trackerId = pos.tracker_id, rotation = Quaternion(w = pos.qw, x = pos.qx, y = pos.qy, z = pos.qz), position = null)) + bridge.inbound.emit(DriverBridgeInbound.TrackerPosition( + trackerId = pos.tracker_id, + rotation = Quaternion(w = pos.qw, x = pos.qx, y = pos.qy, z = pos.qz), + position = if (pos.x != null && pos.y != null && pos.z != null) Vector3(pos.x, pos.y, pos.z) else null, + )) } } } finally { From 57c39c78b0d2504c033d1d84f12db429cba841ac Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 11 May 2026 22:29:58 +0200 Subject: [PATCH 112/350] No more silent launch failures --- .github/workflows/build.yml | 8 ++- .gitignore | 6 +- .idea/inspectionProfiles/Project_Default.xml | 29 ++++++++ .../inspectionProfiles/profiles_settings.xml | 7 ++ server/build.gradle.kts | 7 ++ .../main/java/dev/slimevr/context/context.kt | 16 ++--- .../main/java/dev/slimevr/firmware/module.kt | 3 +- .../src/main/java/dev/slimevr/firmware/ota.kt | 8 +-- .../main/java/dev/slimevr/firmware/serial.kt | 4 +- .../dev/slimevr/heightcalibration/module.kt | 4 +- .../src/main/java/dev/slimevr/hid/packets.kt | 24 ++++--- .../core/src/main/java/dev/slimevr/logger.kt | 2 + .../java/dev/slimevr/oscquery/discovery.kt | 7 +- .../java/dev/slimevr/provisioning/module.kt | 4 +- .../main/java/dev/slimevr/serial/module.kt | 4 +- .../java/dev/slimevr/skeleton/behaviours.kt | 26 ++++---- .../main/java/dev/slimevr/solarxr/datafeed.kt | 10 ++- .../src/main/java/dev/slimevr/solarxr/mag.kt | 20 +++--- .../main/java/dev/slimevr/solarxr/serial.kt | 32 +++++---- .../java/dev/slimevr/tracker/behaviours.kt | 18 +++-- .../main/java/dev/slimevr/udp/behaviours.kt | 10 +-- .../main/java/dev/slimevr/udp/connection.kt | 10 +-- .../src/main/java/dev/slimevr/udp/server.kt | 66 ++++++++++--------- .../main/java/dev/slimevr/util/SafeLaunch.kt | 34 ++++++++++ .../main/java/dev/slimevr/vmc/behaviours.kt | 8 +-- .../main/java/dev/slimevr/vrchat/module.kt | 8 +-- .../src/main/java/dev/slimevr/vrcosc/input.kt | 7 +- .../main/java/dev/slimevr/vrcosc/module.kt | 4 +- .../context/ContextScopeIsolationTest.kt | 8 +-- .../dev/slimevr/firmware/DoSerialFlashTest.kt | 20 +++--- .../dev/slimevr/firmware/OtaUploadTest.kt | 6 +- .../HeightCalibrationTest.kt | 6 +- .../provisioning/ProvisioningManagerTest.kt | 20 +++--- .../src/main/java/dev/slimevr/desktop/Main.kt | 10 +-- .../main/java/dev/slimevr/desktop/hid/hid.kt | 8 +-- .../main/java/dev/slimevr/desktop/ipc/ipc.kt | 14 ++-- .../java/dev/slimevr/desktop/ipc/linux.kt | 4 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 3 +- .../java/dev/slimevr/desktop/ipc/windows.kt | 4 +- .../desktop/networkprofile/network-profile.kt | 4 +- .../java/dev/slimevr/desktop/serial/serial.kt | 48 +++++++------- server/detekt.yml | 11 ++++ 42 files changed, 334 insertions(+), 218 deletions(-) create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 server/core/src/main/java/dev/slimevr/util/SafeLaunch.kt create mode 100644 server/detekt.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d6cf53439..d731430967 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,8 +44,8 @@ jobs: - name: GUI Lint run: pnpm i && cd gui && pnpm run lint - java-checks: - name: Java Checks + server-checks: + name: Server Checks runs-on: ubuntu-latest steps: - name: Checkout @@ -59,8 +59,10 @@ jobs: distribution: 'adopt' - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 - - name: Java Spotless Check + - name: Server Spotless Check run: ./gradlew spotlessCheck --build-cache + - name: Server Detekt Lint + run: ./gradlew :server:detekt --build-cache build-server-jar: diff --git a/.gitignore b/.gitignore index fc9250e505..30eaf86c05 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,10 @@ # Syncthing ignore file .stignore -# Ignore .idea -.idea +# Ignore .idea, except shared project inspection profiles +.idea/* +!.idea/inspectionProfiles/ +!.idea/inspectionProfiles/*.xml # Ignore .fleet .fleet diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000000..7fe922d352 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,29 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000000..d819ece1a2 --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,7 @@ + + + + diff --git a/server/build.gradle.kts b/server/build.gradle.kts index da4159f54a..c8997f4298 100644 --- a/server/build.gradle.kts +++ b/server/build.gradle.kts @@ -1,5 +1,12 @@ plugins { id("com.diffplug.spotless") + id("io.gitlab.arturbosch.detekt") version "1.23.6" +} + +detekt { + buildUponDefaultConfig = false + config.setFrom(files("${rootProject.projectDir}/server/detekt.yml")) + source.setFrom(files("core/src", "desktop/src")) } allprojects { diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index cdfb7e4300..a65c9f722b 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -1,25 +1,25 @@ package dev.slimevr.context -import dev.slimevr.context.debug.DebugMiddleware import dev.slimevr.context.debug.ContextDebug +import dev.slimevr.context.debug.DebugMiddleware import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock interface Behaviour { fun reduce(state: S, action: A): S = state fun observe(receiver: C) {} } -//TODO: missing remove + prob a unobserve or something to clear the stuff launched +// TODO: missing remove + prob a unobserve or something to clear the stuff launched // in a observe -> each behaviour is prob gonna need its own scope class BehaviourList( initial: List>, @@ -101,10 +101,8 @@ class Context( (behaviour as Behaviour).observe(receiver) } - private fun reduce(currentState: S, action: A): S { - return behaviours.snapshot().fold(currentState) { state, behaviour -> - behaviour.reduce(state, action) - } + private fun reduce(currentState: S, action: A): S = behaviours.snapshot().fold(currentState) { state, behaviour -> + behaviour.reduce(state, action) } private fun captureCallerBehaviour(): String? { diff --git a/server/core/src/main/java/dev/slimevr/firmware/module.kt b/server/core/src/main/java/dev/slimevr/firmware/module.kt index 4165233357..0d84fbf9a0 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/module.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/module.kt @@ -6,6 +6,7 @@ import dev.slimevr.config.Settings import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.serial.SerialServer +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -96,7 +97,7 @@ class FirmwareManager( server: VRServer, ) { runningJobs[deviceIp]?.cancelAndJoin() - runningJobs[deviceIp] = scope.launch { + runningJobs[deviceIp] = scope.safeLaunch { doOtaFlash( deviceIp = deviceIp, deviceId = (firmwareDeviceId as? DeviceIdTable)?.id ?: error("device id should exist"), diff --git a/server/core/src/main/java/dev/slimevr/firmware/ota.kt b/server/core/src/main/java/dev/slimevr/firmware/ota.kt index 5acf083ca2..9c44a3217d 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/ota.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/ota.kt @@ -12,22 +12,22 @@ import io.ktor.network.sockets.openWriteChannel import io.ktor.network.sockets.port import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.ByteWriteChannel -import io.ktor.utils.io.discardExact -import io.ktor.utils.io.readRemaining -import io.ktor.utils.io.writeFully import io.ktor.utils.io.core.buildPacket import io.ktor.utils.io.core.readText import io.ktor.utils.io.core.writeFully +import io.ktor.utils.io.discardExact +import io.ktor.utils.io.readRemaining +import io.ktor.utils.io.writeFully import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import kotlinx.io.readByteArray import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.rpc.FirmwarePart import solarxr_protocol.rpc.FirmwareUpdateStatus import java.security.MessageDigest import java.util.UUID import kotlin.math.min -import kotlinx.io.readByteArray private const val OTA_PORT = 8266 private const val OTA_PASSWORD = "SlimeVR-OTA" diff --git a/server/core/src/main/java/dev/slimevr/firmware/serial.kt b/server/core/src/main/java/dev/slimevr/firmware/serial.kt index 5528b88780..74593f8e66 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/serial.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/serial.kt @@ -7,13 +7,13 @@ import dev.slimevr.serial.FlashingHandler import dev.slimevr.serial.MAC_REGEX import dev.slimevr.serial.SerialConnection import dev.slimevr.serial.SerialServer +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import solarxr_protocol.rpc.FirmwarePart @@ -73,7 +73,7 @@ suspend fun doSerialFlash( handler = handler, parts = downloadedParts, onProgress = { progress -> - scope.launch { onStatus(FirmwareUpdateStatus.UPLOADING, progress) } + scope.safeLaunch { onStatus(FirmwareUpdateStatus.UPLOADING, progress) } }, ) } diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt index dfa2a0de7e..5a60c7ec57 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt @@ -7,6 +7,7 @@ import dev.slimevr.VRServer import dev.slimevr.config.UserConfig import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope @@ -16,7 +17,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.UserHeightCalibrationStatus @@ -81,7 +81,7 @@ class HeightCalibrationManager( fun start() { sessionJob?.cancel() - sessionJob = context.scope.launch { runCalibrationSession(context, userConfig, hmdUpdates, controllerUpdates) } + sessionJob = context.scope.safeLaunch { runCalibrationSession(context, userConfig, hmdUpdates, controllerUpdates) } } fun cancel() { diff --git a/server/core/src/main/java/dev/slimevr/hid/packets.kt b/server/core/src/main/java/dev/slimevr/hid/packets.kt index f32d8497e8..b47270df20 100644 --- a/server/core/src/main/java/dev/slimevr/hid/packets.kt +++ b/server/core/src/main/java/dev/slimevr/hid/packets.kt @@ -78,19 +78,17 @@ private val AXES_OFFSET = Quaternion.fromRotationVector(-PI.toFloat() / 2f, 0f, private fun readLE16Signed(data: ByteArray, offset: Int): Int = data[offset + 1].toInt() shl 8 or data[offset].toUByte().toInt() -private fun readLE32Unsigned(data: ByteArray, offset: Int): UInt = - (data[offset].toUInt() and 0xFFu) or - ((data[offset + 1].toUInt() and 0xFFu) shl 8) or - ((data[offset + 2].toUInt() and 0xFFu) shl 16) or - ((data[offset + 3].toUInt() and 0xFFu) shl 24) - -private fun readLE48Unsigned(data: ByteArray, offset: Int): ULong = - (data[offset].toULong() and 0xFFu) or - ((data[offset + 1].toULong() and 0xFFu) shl 8) or - ((data[offset + 2].toULong() and 0xFFu) shl 16) or - ((data[offset + 3].toULong() and 0xFFu) shl 24) or - ((data[offset + 4].toULong() and 0xFFu) shl 32) or - ((data[offset + 5].toULong() and 0xFFu) shl 40) +private fun readLE32Unsigned(data: ByteArray, offset: Int): UInt = (data[offset].toUInt() and 0xFFu) or + ((data[offset + 1].toUInt() and 0xFFu) shl 8) or + ((data[offset + 2].toUInt() and 0xFFu) shl 16) or + ((data[offset + 3].toUInt() and 0xFFu) shl 24) + +private fun readLE48Unsigned(data: ByteArray, offset: Int): ULong = (data[offset].toULong() and 0xFFu) or + ((data[offset + 1].toULong() and 0xFFu) shl 8) or + ((data[offset + 2].toULong() and 0xFFu) shl 16) or + ((data[offset + 3].toULong() and 0xFFu) shl 24) or + ((data[offset + 4].toULong() and 0xFFu) shl 32) or + ((data[offset + 5].toULong() and 0xFFu) shl 40) private fun decodeQ15Quat(data: ByteArray, offset: Int): Quaternion { val scale = 1f / 32768f diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index 1720c8f3c3..351a475bd0 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -3,6 +3,7 @@ package dev.slimevr import io.klogging.Level import io.klogging.config.loggingConfiguration import io.klogging.logger +import io.klogging.noCoLogger import io.klogging.rendering.RENDER_SIMPLE import io.klogging.sending.STDOUT @@ -20,6 +21,7 @@ object AppLogger { val bvh = logger("BVH") val vmc = logger("VMC") val oscQuery = logger("OSCQuery") + val coroutines = noCoLogger("Coroutines") init { loggingConfiguration { diff --git a/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt b/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt index a986976bdb..f96ac9c974 100644 --- a/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt +++ b/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt @@ -3,15 +3,15 @@ package dev.slimevr.oscquery import com.appstractive.dnssd.DiscoveryEvent import com.appstractive.dnssd.discoverServices import dev.slimevr.AppLogger +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.launch private const val OSC_JSON_SERVICE_TYPE = "_oscjson._tcp" @@ -27,7 +27,7 @@ class OscQueryDiscovery( fun start(scope: CoroutineScope) { if (job != null) return - job = scope.launch { + job = scope.safeLaunch { discoverServicesFlow(serviceType).collect { event -> when (event) { is DiscoveryEvent.Discovered -> { @@ -36,6 +36,7 @@ class OscQueryDiscovery( } is DiscoveryEvent.Resolved -> onResolved(event) + is DiscoveryEvent.Removed -> onRemoved(event.service.name) } } diff --git a/server/core/src/main/java/dev/slimevr/provisioning/module.kt b/server/core/src/main/java/dev/slimevr/provisioning/module.kt index 360665bef1..109e776da3 100644 --- a/server/core/src/main/java/dev/slimevr/provisioning/module.kt +++ b/server/core/src/main/java/dev/slimevr/provisioning/module.kt @@ -7,13 +7,13 @@ import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.serial.SerialConnection import dev.slimevr.serial.SerialServer +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import solarxr_protocol.rpc.WifiProvisioningStatus data class ProvisioningManagerState( @@ -60,7 +60,7 @@ data class ProvisioningManager( currentJob.cancelAndJoin() context.dispatch(ProvisioningActions.Clear) } - provisioningJob = scope.launch { + provisioningJob = scope.safeLaunch { // TODO handle port field, currently ignored while (isActive) { if (!selectAndOpenPort(context, serialServer)) continue diff --git a/server/core/src/main/java/dev/slimevr/serial/module.kt b/server/core/src/main/java/dev/slimevr/serial/module.kt index edf7e4192f..8bd033f817 100644 --- a/server/core/src/main/java/dev/slimevr/serial/module.kt +++ b/server/core/src/main/java/dev/slimevr/serial/module.kt @@ -2,8 +2,8 @@ package dev.slimevr.serial import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import solarxr_protocol.rpc.SerialDevice interface FlashingHandler { @@ -110,7 +110,7 @@ class SerialServer( return object : FlashingHandler by handler { override fun closeSerial() { handler.closeSerial() - context.scope.launch { context.dispatch(SerialServerActions.RemoveConnection(portLocation)) } + context.scope.safeLaunch { context.dispatch(SerialServerActions.RemoveConnection(portLocation)) } } } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index ad8f435d34..f766e4e84b 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -1,6 +1,7 @@ package dev.slimevr.skeleton import dev.slimevr.config.UserConfig +import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.delay @@ -8,7 +9,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart import kotlin.math.cos import kotlin.math.sin @@ -63,7 +63,7 @@ class ScaledProportionsBehaviour(private val userConfig: UserConfig) : SkeletonB class HeightLogBehaviour : SkeletonBehaviour { override fun observe(receiver: Skeleton) { - receiver.context.scope.launch { + receiver.context.scope.safeLaunch { receiver.context.state .map { state -> state.userHeight } .distinctUntilChanged() @@ -74,7 +74,7 @@ class HeightLogBehaviour : SkeletonBehaviour { class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour { override fun observe(receiver: Skeleton) { - receiver.context.scope.launch { + receiver.context.scope.safeLaunch { val intervalMs = (1000f / inputHz).toLong() val startTime = System.currentTimeMillis() while (true) { @@ -116,15 +116,19 @@ class ComputedSkeletonBehaviour( ) : SkeletonBehaviour { override fun observe(receiver: Skeleton) { val intervalMs = (1000f / hz).toLong() - receiver.context.scope.launch { + receiver.context.scope.safeLaunch { while (true) { - delay(intervalMs) - val targetState = receiver.context.state.value - val processed = processors - .filter { processor -> processor.enabled } - .fold(targetState) { state, processor -> processor.process(state) } - val rootHead = Vector3(0f, targetState.userHeight, 0f) // FIXME WRONG - receiver.computed.value = buildBones(processed, rootHead = rootHead) + try { + delay(intervalMs) + val targetState = receiver.context.state.value + val processed = processors + .filter { processor -> processor.enabled } + .fold(targetState) { state, processor -> processor.process(state) } + val rootHead = Vector3(0f, targetState.userHeight, 0f) // FIXME WRONG + receiver.computed.value = buildBones(processed, rootHead = rootHead) + } catch (e: Exception) { + dev.slimevr.AppLogger.coroutines.error(e, "Error in ComputedSkeletonBehaviour") + } } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 5ac5c30b2e..944870a9f1 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -5,10 +5,10 @@ import dev.slimevr.device.DeviceState import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.Skeleton import dev.slimevr.tracker.TrackerState +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import solarxr_protocol.data_feed.DataFeedConfig import solarxr_protocol.data_feed.DataFeedMessageHeader import solarxr_protocol.data_feed.DataFeedUpdate @@ -148,10 +148,14 @@ class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : Sola receiver.context.state.value.datafeedTimers.forEach { it.cancelAndJoin() } val timers = datafeeds.mapIndexed { index, config -> - receiver.context.scope.launch { + receiver.context.scope.safeLaunch { val minTime = config.minimumTimeSinceLast.toLong() while (isActive) { - receiver.sendDataFeed(createDatafeedFrame(server = server, skeleton = skeleton, datafeedConfig = config, index = index)) + try { + receiver.sendDataFeed(createDatafeedFrame(server = server, skeleton = skeleton, datafeedConfig = config, index = index)) + } catch (e: Exception) { + dev.slimevr.AppLogger.solarxr.error(e, "Error sending data feed") + } delay(minTime) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt index 68c7141ba6..a9ccc267e8 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt @@ -5,9 +5,9 @@ import dev.slimevr.config.SettingsActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.udp.SensorConfigFlags import dev.slimevr.udp.UDPConnectionActions +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.rpc.ChangeMagToggleRequest @@ -34,7 +34,7 @@ class MagBehaviour( override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { req -> val trackerId = req.trackerId - val enable = req.enable == true; + val enable = req.enable == true if (trackerId == null) { receiver.appContext.config.settings.context.dispatch(SettingsActions.Update { copy(globalMagEnabled = enable) }) @@ -59,12 +59,16 @@ class MagBehaviour( when (trackerState.origin) { DeviceOrigin.UDP -> { if (!setUDPTrackerMag(trackerState.id, trackerState.deviceId, enable)) return@on - tracker.context.scope.launch { - withTimeout(10.seconds) { - tracker.context.state - .distinctUntilChangedBy { it.magStatus } - .first { it.magStatus == if (enable) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED } - receiver.sendRpc(MagToggleResponse(trackerId = trackerId, enable = enable)) + tracker.context.scope.safeLaunch { + try { + withTimeout(10.seconds) { + tracker.context.state + .distinctUntilChangedBy { it.magStatus } + .first { it.magStatus == if (enable) MagnetometerStatus.ENABLED else MagnetometerStatus.DISABLED } + receiver.sendRpc(MagToggleResponse(trackerId = trackerId, enable = enable)) + } + } catch (e: kotlinx.coroutines.TimeoutCancellationException) { + dev.slimevr.AppLogger.solarxr.warn("Timeout waiting for mag toggle response from tracker") } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt index 13fe9bc32b..62c9f9e5ad 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt @@ -3,12 +3,12 @@ package dev.slimevr.solarxr import dev.slimevr.serial.SerialConnection import dev.slimevr.serial.SerialPortInfo import dev.slimevr.serial.SerialServer +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.Job import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import solarxr_protocol.rpc.CloseSerialRequest import solarxr_protocol.rpc.NewSerialDeviceResponse import solarxr_protocol.rpc.OpenSerialRequest @@ -73,21 +73,25 @@ class SerialBehaviour(private val serialServer: SerialServer) : SolarXRBridgeBeh activePortLocation = portLocation var lastSentCount = 0 - logSubscription = scope.launch { + logSubscription = scope.safeLaunch { var disconnected = false - connection.context.state.collect { connState -> - if (disconnected) return@collect - - connState.logLines.drop(lastSentCount).forEach { line -> - receiver.sendRpc(SerialUpdateResponse(log = line + "\n")) - } - lastSentCount = connState.logLines.size - - if (!connState.connected) { - disconnected = true - activePortLocation = null - receiver.sendRpc(SerialUpdateResponse(closed = true)) + try { + connection.context.state.collect { connState -> + if (disconnected) return@collect + + connState.logLines.drop(lastSentCount).forEach { line -> + receiver.sendRpc(SerialUpdateResponse(log = line + "\n")) + } + lastSentCount = connState.logLines.size + + if (!connState.connected) { + disconnected = true + activePortLocation = null + receiver.sendRpc(SerialUpdateResponse(closed = true)) + } } + } catch (e: Exception) { + dev.slimevr.AppLogger.solarxr.error(e, "Error streaming serial log") } } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index ef251dd950..de22f16887 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -1,11 +1,11 @@ package dev.slimevr.tracker +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch @@ -100,14 +100,18 @@ object TrackerTPSBehaviour : TrackerBehaviour { count.incrementAndFetch() }.launchIn(receiver.context.scope) - receiver.context.scope.launch { + receiver.context.scope.safeLaunch { var mark = TimeSource.Monotonic.markNow() while (isActive) { - delay(1000) - val elapsed = mark.elapsedNow() - val tps = count.exchange(0) * 1000L / elapsed.inWholeMilliseconds - receiver.context.dispatch(TrackerActions.Update { copy(tps = tps.toUShort()) }) - mark = TimeSource.Monotonic.markNow() + try { + delay(1000) + val elapsed = mark.elapsedNow() + val tps = count.exchange(0) * 1000L / elapsed.inWholeMilliseconds + receiver.context.dispatch(TrackerActions.Update { copy(tps = tps.toUShort()) }) + mark = TimeSource.Monotonic.markNow() + } catch (e: Exception) { + dev.slimevr.AppLogger.coroutines.error(e, "Error in TrackerTPSBehaviour") + } } } } diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index ad30a6bc03..08e0a6d249 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -8,13 +8,13 @@ import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import dev.slimevr.tracker.TrackerIdNum +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.UnknownDeviceHandshakeNotification @@ -92,7 +92,7 @@ object PingBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { // Send the ping every 1s - receiver.context.scope.launch { + receiver.context.scope.safeLaunch { while (isActive) { val state = receiver.context.state.value if (state.didHandshake) { @@ -163,7 +163,7 @@ object HandshakeBehaviour : UDPConnectionBehaviour { val settings = receiver.appContext.config.settings.context.state.value.data if (mac !in settings.allowedUdpDevices) { AppLogger.udp.info("[${state.address}] Unknown MAC $mac, notifying solarxr") - receiver.appContext.server.context.scope.launch { + receiver.appContext.server.context.scope.safeLaunch { receiver.appContext.server.context.state.value.solarxr.values.forEach { bridge -> bridge.sendRpc(UnknownDeviceHandshakeNotification(macAddress = mac)) } @@ -204,7 +204,7 @@ object HandshakeBehaviour : UDPConnectionBehaviour { object TimeoutBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { - receiver.context.scope.launch { + receiver.context.scope.safeLaunch { while (isActive) { val state = receiver.context.state.value if (!state.didHandshake) { @@ -236,7 +236,7 @@ object DisconnectBehaviour : UDPConnectionBehaviour { .distinctUntilChangedBy { it.didHandshake } .onEach { state -> if (!state.didHandshake) { - removalJob = receiver.context.scope.launch { + removalJob = receiver.context.scope.safeLaunch { delay(CONNECTION_REMOVAL_MS) val currentState = receiver.context.state.value AppLogger.udp.info("[${currentState.address}] Connection removed after extended timeout") diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 8e01f4df34..04ca8ae13f 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -7,12 +7,12 @@ import dev.slimevr.context.Context import dev.slimevr.device.Device import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerIdNum -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.launch +import dev.slimevr.util.safeLaunch import io.ktor.network.sockets.BoundDatagramSocket import io.ktor.network.sockets.Datagram import io.ktor.network.sockets.InetSocketAddress +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel import kotlinx.io.Buffer import kotlinx.io.readByteArray import solarxr_protocol.datatypes.MagnetometerStatus @@ -63,7 +63,7 @@ class UDPConnection( private val scope: CoroutineScope, ) { fun send(packet: UDPPacket) { - scope.launch { + scope.safeLaunch { val buf = Buffer() writePacket(buf, packet) socket.send(Datagram(buf, remoteAddress)) @@ -141,7 +141,7 @@ class UDPConnection( conn.startObserving() // Dedicated coroutine per connection so the receive loop is never blocked by packet processing - scope.launch { + scope.safeLaunch { for (event in packetChannel) { // We skip any packet from the tracker that are not handshake packets // if we didn't do a handshake with the server diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index e352edc19e..5cb06e4df2 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -4,6 +4,7 @@ import dev.slimevr.AppContextProvider import dev.slimevr.AppLogger import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.util.safeLaunch import io.ktor.network.selector.SelectorManager import io.ktor.network.sockets.BoundDatagramSocket import io.ktor.network.sockets.InetSocketAddress @@ -13,7 +14,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import kotlin.time.measureTime data class UdpServerState( @@ -48,7 +48,7 @@ class UdpServer(val context: UdpServerContext) { fun startReceiving(appContext: AppContextProvider, scope: CoroutineScope) { if (receiveJob != null) return - receiveJob = scope.launch { + receiveJob = scope.safeLaunch { val port = appContext.config.settings.context.state.value.data.trackerPort val selectorManager = SelectorManager(Dispatchers.IO) val socket = aSocket(selectorManager).udp().bind(port = port) @@ -56,36 +56,40 @@ class UdpServer(val context: UdpServerContext) { this@UdpServer.socket = socket try { while (isActive) { - val recvPacket = socket.receive() - val took = measureTime { - val src = recvPacket.packet - val packetId = src.readInt() - val packetNumber = src.readLong() - val type = PacketType.fromId(packetId) ?: return@measureTime - val packetData = readPacket(type, src) - - val remoteAddress = recvPacket.address as? InetSocketAddress ?: return@measureTime - val id = addressKey(remoteAddress) - val conn = context.state.value.connections[id] - - val event = PacketEvent(data = packetData, packetNumber = packetNumber) - - if (conn != null) { - conn.packetChannel.trySend(event) - } else { - val newConn = UDPConnection.create( - id = id, - remoteAddress = remoteAddress, - socket = socket, - appContext = appContext, - scope = scope, - ) - context.dispatch(UdpServerActions.ConnectionAdded(id, newConn)) - newConn.packetChannel.trySend(event) + try { + val recvPacket = socket.receive() + val took = measureTime { + val src = recvPacket.packet + val packetId = src.readInt() + val packetNumber = src.readLong() + val type = PacketType.fromId(packetId) ?: return@measureTime + val packetData = readPacket(type, src) + + val remoteAddress = recvPacket.address as? InetSocketAddress ?: return@measureTime + val id = addressKey(remoteAddress) + val conn = context.state.value.connections[id] + + val event = PacketEvent(data = packetData, packetNumber = packetNumber) + + if (conn != null) { + conn.packetChannel.trySend(event) + } else { + val newConn = UDPConnection.create( + id = id, + remoteAddress = remoteAddress, + socket = socket, + appContext = appContext, + scope = scope, + ) + context.dispatch(UdpServerActions.ConnectionAdded(id, newConn)) + newConn.packetChannel.trySend(event) + } } - } - if (took.inWholeMilliseconds > 2) { - AppLogger.udp.warn("Packet processing took too long ${took.inWholeMilliseconds}") + if (took.inWholeMilliseconds > 2) { + AppLogger.udp.warn("Packet processing took too long ${took.inWholeMilliseconds}") + } + } catch (e: Exception) { + AppLogger.udp.error(e, "Error processing UDP packet") } } } finally { diff --git a/server/core/src/main/java/dev/slimevr/util/SafeLaunch.kt b/server/core/src/main/java/dev/slimevr/util/SafeLaunch.kt new file mode 100644 index 0000000000..9675e9536a --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/util/SafeLaunch.kt @@ -0,0 +1,34 @@ +@file:Suppress("ForbiddenImport") + +package dev.slimevr.util + +import dev.slimevr.AppLogger +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * A safe wrapper around [launch] that ensures unhandled exceptions + * are explicitly caught and routed to the AppLogger, preventing silent failures. + * + * If you need specific error recovery, use a try/catch block inside the coroutine, + * or provide your own [CoroutineExceptionHandler] in the [context]. + */ +@Suppress("ForbiddenImport") +fun CoroutineScope.safeLaunch( + context: CoroutineContext = EmptyCoroutineContext, + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit, +): Job { + val handler = context[CoroutineExceptionHandler] ?: CoroutineExceptionHandler { ctx, throwable -> + val name = ctx[CoroutineName]?.name ?: "UnknownScope" + AppLogger.coroutines.error(throwable, "Unhandled exception in safeLaunch (scope: $name)") + } + //noinspection RAW_LAUNCH_USAGE + return this.launch(context + handler, start = start, block = block) +} diff --git a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt index 60058d9928..b57797b09a 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt @@ -12,13 +12,13 @@ import dev.slimevr.osc.OscReceiver import dev.slimevr.osc.OscSender import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.Skeleton +import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart class VMCOutputBehaviour( @@ -65,7 +65,7 @@ class VMCOutputBehaviour( if (enabled) { sender = OscSender(addr, port) AppLogger.vmc.info("VMC output started: $addr:$port") - receiver.context.scope.launch { + receiver.context.scope.safeLaunch { try { sender?.send(OscMessage("/VMC/Ext/Req", emptyList())) } catch (e: Exception) { @@ -81,7 +81,7 @@ class VMCOutputBehaviour( val config = receiver.context.state.value.config val currentTime = System.currentTimeMillis() val vrm = vrmGeometry - receiver.context.scope.launch { + receiver.context.scope.safeLaunch { try { s.send(buildBundle(bones, config, currentTime, vrm)) } catch (e: Exception) { @@ -168,7 +168,7 @@ class VMCInputBehaviour : Behaviour { if (receiver.context.state.value.config.enabled) { oscReceiver = OscReceiver(portIn) AppLogger.vmc.info("VMC input listening on port $portIn") - receiver.context.scope.launch { + receiver.context.scope.safeLaunch { try { oscReceiver?.listenBundles { bundle -> for (content in bundle.contents) { diff --git a/server/core/src/main/java/dev/slimevr/vrchat/module.kt b/server/core/src/main/java/dev/slimevr/vrchat/module.kt index f4f0d4d8a5..c2f8033321 100644 --- a/server/core/src/main/java/dev/slimevr/vrchat/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrchat/module.kt @@ -4,9 +4,9 @@ import dev.slimevr.VRServer import dev.slimevr.config.AppConfig import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.util.safeLaunch 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 @@ -68,7 +68,7 @@ class VRCConfigManager( name = "VRCConfig", ) - scope.launch { + scope.safeLaunch { values.collect { context.dispatch(VRCConfigActions.UpdateValues(it)) } } @@ -113,7 +113,7 @@ fun isVRCConfigValid(validity: VRCConfigValidity, mutedWarnings: List): (validity.userHeightOk == true || "userHeightOk" in mutedWarnings) && (validity.calibrationRangeOk == true || "calibrationRangeOk" in mutedWarnings) && (validity.calibrationVisualsOk == true || "calibrationVisualsOk" in mutedWarnings) && - (validity.trackerModelOk == true|| "trackerModelOk" in mutedWarnings) && + (validity.trackerModelOk == true || "trackerModelOk" in mutedWarnings) && (validity.spineModeOk == true || "spineModeOk" in mutedWarnings) && (validity.avatarMeasurementTypeOk == true || "avatarMeasurementTypeOk" in mutedWarnings) @@ -123,7 +123,7 @@ fun computeValidity(values: VRCConfigValues, recommended: VRCConfigRecommendedVa spineModeOk = recommended.spineMode?.contains(values.spineMode) == true, trackerModelOk = values.trackerModel == recommended.trackerModel, calibrationRangeOk = abs((values.calibrationRange ?: 0f) - (recommended.calibrationRange ?: 0f)) < 0.1f, - userHeightOk = abs((recommended.userHeight ?: 0f) - (values.userHeight?: 0f)) < 0.1f, + userHeightOk = abs((recommended.userHeight ?: 0f) - (values.userHeight ?: 0f)) < 0.1f, calibrationVisualsOk = values.calibrationVisuals == recommended.calibrationVisuals, avatarMeasurementTypeOk = values.avatarMeasurementType == recommended.avatarMeasurementType, shoulderWidthCompensationOk = values.shoulderWidthCompensation == recommended.shoulderWidthCompensation, diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt index 37e8a39603..ded323296f 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt @@ -6,20 +6,17 @@ import dev.slimevr.VRServerActions import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin -import dev.slimevr.osc.OscArg import dev.slimevr.osc.OscBundle import dev.slimevr.osc.OscContent import dev.slimevr.osc.OscMessage import dev.slimevr.osc.OscReceiver import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.VRCOSCInputState @@ -164,7 +161,7 @@ class VRCOSCInputBehaviour( ) AppLogger.vrc.info("VRChat OSC input listening on port $portIn") - receiver.context.scope.launch { + receiver.context.scope.safeLaunch { try { newReceiver.listenBundles { bundle -> handleBundle(bundle, registry, receiver, portIn) } } catch (e: Exception) { diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt index 4b35c628ba..c333231f6c 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt @@ -6,9 +6,9 @@ import dev.slimevr.config.VRCOSCConfig import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.skeleton.Skeleton +import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import solarxr_protocol.rpc.VRCOSCInputState import solarxr_protocol.rpc.VRCOSCOscQueryState import solarxr_protocol.rpc.VRCOSCOutputState @@ -101,7 +101,7 @@ class VRCOSCManager( fun startObserving() = context.observeAll(this) fun yawAlign(headRotation: Quaternion) { - context.scope.launch { + context.scope.safeLaunch { events.emit(VRCOSCEvent.YawAlign(headRotation)) } } diff --git a/server/core/src/test/java/dev/slimevr/context/ContextScopeIsolationTest.kt b/server/core/src/test/java/dev/slimevr/context/ContextScopeIsolationTest.kt index 64c3087ae6..0b293e2b0c 100644 --- a/server/core/src/test/java/dev/slimevr/context/ContextScopeIsolationTest.kt +++ b/server/core/src/test/java/dev/slimevr/context/ContextScopeIsolationTest.kt @@ -1,12 +1,12 @@ package dev.slimevr.context +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.Job import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals @@ -29,7 +29,7 @@ class ContextScopeIsolationTest { name = "IsolatedContext", ) - context.scope.launch { + context.scope.safeLaunch { error("boom") } diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt index 84291421d5..792cae10b7 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -11,10 +11,10 @@ import dev.slimevr.serial.FlashingHandler import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialPortInfo import dev.slimevr.serial.SerialServer +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import solarxr_protocol.datatypes.TrackerStatus @@ -166,7 +166,7 @@ class DoSerialFlashTest { server.openConnection("COM1") val statuses = mutableListOf() - val job = launch { + val job = safeLaunch { doSerialFlashPostFlash( portLocation = "COM1", needManualReboot = false, @@ -193,7 +193,7 @@ class DoSerialFlashTest { server.openConnection("COM1") val statuses = mutableListOf() - backgroundScope.launch { + backgroundScope.safeLaunch { doSerialFlashPostFlash( portLocation = "COM1", needManualReboot = false, @@ -206,7 +206,7 @@ class DoSerialFlashTest { ) } - backgroundScope.launch { + backgroundScope.safeLaunch { delay(100) server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") } @@ -224,7 +224,7 @@ class DoSerialFlashTest { server.openConnection("COM1") val statuses = mutableListOf() - val job = launch { + val job = safeLaunch { doSerialFlashPostFlash( portLocation = "COM1", needManualReboot = false, @@ -237,7 +237,7 @@ class DoSerialFlashTest { ) } - backgroundScope.launch { + backgroundScope.safeLaunch { delay(100) server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") } @@ -257,7 +257,7 @@ class DoSerialFlashTest { server.openConnection("COM1") val statuses = mutableListOf() - val job = launch { + val job = safeLaunch { doSerialFlashPostFlash( portLocation = "COM1", needManualReboot = false, @@ -270,7 +270,7 @@ class DoSerialFlashTest { ) } - backgroundScope.launch { + backgroundScope.safeLaunch { delay(100) server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") delay(200) @@ -293,7 +293,7 @@ class DoSerialFlashTest { val vrServer = buildTestVrServerStub(backgroundScope) val statuses = mutableListOf() - backgroundScope.launch { + backgroundScope.safeLaunch { doSerialFlashPostFlash( portLocation = "COM1", needManualReboot = false, @@ -306,7 +306,7 @@ class DoSerialFlashTest { ) } - backgroundScope.launch { + backgroundScope.safeLaunch { delay(100) server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") delay(200) diff --git a/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt b/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt index f966d43b0c..35e55c3557 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt @@ -6,11 +6,11 @@ import dev.slimevr.buildTestVrServerStub import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin +import dev.slimevr.util.safeLaunch import io.ktor.utils.io.ByteChannel import io.ktor.utils.io.readRemaining import io.ktor.utils.io.writeFully import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.io.readByteArray @@ -75,7 +75,7 @@ class OtaUploadTest { val device = addUdpDevice(vrServer, backgroundScope, macAddress = "AA:BB:CC:DD:EE:FF") var result: Boolean? = null - val job = backgroundScope.launch { + val job = backgroundScope.safeLaunch { result = waitForConnected(vrServer, "AA:BB:CC:DD:EE:FF", timeoutMs = 1_000) } @@ -100,7 +100,7 @@ class OtaUploadTest { device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) }) var result: Boolean? = null - val job = backgroundScope.launch { + val job = backgroundScope.safeLaunch { result = waitForReconnected( vrServer, DeviceId(device.context.state.value.id.toUByte()), diff --git a/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt index e009ca5edc..9b577880fc 100644 --- a/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt @@ -3,11 +3,11 @@ package dev.slimevr.heightcalibration import dev.slimevr.buildTestUserConfig +import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.currentTime @@ -54,7 +54,7 @@ private fun TestScope.launchSession( ): Job { val scope = this val userConfig = buildTestUserConfig(backgroundScope) - return launch { + return safeLaunch { runCalibrationSession(context, userConfig, hmdFlow, controllerFlow, clock = { scope.currentTime * 1_000_000L }) } } @@ -293,7 +293,7 @@ class HeightCalibrationSessionTest { val context = makeContext(this) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) - val job = launch { runCalibrationSession(context, buildTestUserConfig(backgroundScope), hmdFlow, controllerFlow) } + val job = safeLaunch { runCalibrationSession(context, buildTestUserConfig(backgroundScope), hmdFlow, controllerFlow) } advanceTimeBy(TIMEOUT_MS + 1) diff --git a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt index c890e70e83..a825719ab1 100644 --- a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt +++ b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt @@ -10,10 +10,10 @@ import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.serial.SerialPortInfo +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import solarxr_protocol.datatypes.TrackerStatus @@ -101,7 +101,7 @@ class ProvisioningManagerTest { advanceTimeBy(7_001) assertEquals(WifiProvisioningStatus.NO_SERIAL_LOGS_ERROR, manager.context.state.value.status) - launch { + safeLaunch { delay(100) serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") } @@ -119,7 +119,7 @@ class ProvisioningManagerTest { manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) - launch { + safeLaunch { delay(500) serialServer.onDataReceived("COM1", "some log line without a mac address") } @@ -138,7 +138,7 @@ class ProvisioningManagerTest { manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) - launch { + safeLaunch { delay(2_100) serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") // No credential acknowledgement sent @@ -158,7 +158,7 @@ class ProvisioningManagerTest { manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) - launch { + safeLaunch { delay(2_100) serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") delay(100) @@ -180,7 +180,7 @@ class ProvisioningManagerTest { manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) - launch { + safeLaunch { delay(2_100) serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") delay(100) @@ -207,7 +207,7 @@ class ProvisioningManagerTest { manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) - launch { + safeLaunch { delay(2_100) serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") delay(100) @@ -232,7 +232,7 @@ class ProvisioningManagerTest { manager.startProvisioning(vrServer, "wifi", "pass", null) serialServer.onPortDetected(fakePort()) - launch { + safeLaunch { delay(2_100) serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") delay(100) @@ -258,7 +258,7 @@ class ProvisioningManagerTest { manager.startProvisioning(vrServer, "wifi", "pass", null) serialServer.onPortDetected(fakePort()) - launch { + safeLaunch { delay(2_100) serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") delay(100) @@ -288,7 +288,7 @@ class ProvisioningManagerTest { manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) - launch { + safeLaunch { delay(500) serialServer.onDataReceived("COM1", "some log without mac") } 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 f9d5057fd7..9f41debe82 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -27,10 +27,10 @@ import dev.slimevr.resolveConfigDirectory import dev.slimevr.skeleton.Skeleton import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer +import dev.slimevr.util.safeLaunch import dev.slimevr.vmc.VMCManager import dev.slimevr.vrcosc.VRCOSCManager import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking fun main(args: Array) = runBlocking { @@ -82,10 +82,10 @@ fun main(args: Array) = runBlocking { try { appContext.startObserving() - launch { createDesktopHIDManager(appContext, this) } - launch { createSolarXRWebsocketServer(appContext) } - launch { createIpcServers(appContext) } - launch { setupDesktopNetworkProfileChecker(this, networkProfileManager) } + safeLaunch { createDesktopHIDManager(appContext, this) } + safeLaunch { createSolarXRWebsocketServer(appContext) } + safeLaunch { createIpcServers(appContext) } + safeLaunch { setupDesktopNetworkProfileChecker(this, networkProfileManager) } awaitCancellation() } finally { diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index 45021a4957..30ee0aa035 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -5,13 +5,13 @@ import dev.slimevr.AppLogger import dev.slimevr.device.DeviceActions import dev.slimevr.hid.HIDReceiver import dev.slimevr.hid.parseHIDPackets +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.hid4java.HidDevice import org.hid4java.HidManager @@ -55,7 +55,7 @@ private data class ActiveReceiver(val job: Job, val receiver: HIDReceiver) fun createDesktopHIDManager(appContext: AppContextProvider, scope: CoroutineScope) { val active = mutableMapOf() - scope.launch { + scope.safeLaunch { while (isActive) { val found = withContext(Dispatchers.IO) { try { @@ -96,7 +96,7 @@ fun createDesktopHIDManager(appContext: AppContextProvider, scope: CoroutineScop scope = deviceScope, ) - deviceScope.launch { + deviceScope.safeLaunch { try { while (isActive) { val data = withContext(Dispatchers.IO) { @@ -107,7 +107,7 @@ fun createDesktopHIDManager(appContext: AppContextProvider, scope: CoroutineScop } } when { - data == null -> return@launch + data == null -> return@safeLaunch // read error, device gone data.isNotEmpty() -> parseHIDPackets(data).forEach { receiver.packetEvents.emit(it) } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt index 4bd23d7eec..a712ae5857 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt @@ -3,8 +3,8 @@ package dev.slimevr.desktop.ipc import dev.slimevr.AppContextProvider import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch const val DRIVER_SOCKET_NAME = "SlimeVRDriver" const val FEEDER_SOCKET_NAME = "SlimeVRInput" @@ -17,15 +17,15 @@ const val SOLARXR_PIPE = "\\\\.\\pipe\\SlimeVRRpc" suspend fun createIpcServers(appContext: AppContextProvider) = coroutineScope { when (CURRENT_PLATFORM) { Platform.LINUX, Platform.OSX -> { - launch { createUnixDriverSocket(appContext) } - launch { createUnixFeederSocket(appContext) } - launch { createUnixSolarXRSocket(appContext) } + safeLaunch { createUnixDriverSocket(appContext) } + safeLaunch { createUnixFeederSocket(appContext) } + safeLaunch { createUnixSolarXRSocket(appContext) } } Platform.WINDOWS -> { - launch { createWindowsDriverPipe(appContext) } - launch { createWindowsFeederPipe(appContext) } - launch { createWindowsSolarXRPipe(appContext) } + safeLaunch { createWindowsDriverPipe(appContext) } + safeLaunch { createWindowsFeederPipe(appContext) } + safeLaunch { createWindowsSolarXRPipe(appContext) } } else -> Unit diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index 7c899b1d98..44a56b4814 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -4,11 +4,11 @@ import dev.slimevr.AppContextProvider import dev.slimevr.AppLogger import dev.slimevr.getSocketDirectory import dev.slimevr.solarxr.handleSolarXRBridge +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.net.SocketException import java.net.StandardProtocolFamily @@ -95,7 +95,7 @@ private suspend fun acceptUnixClients( server.bind(UnixDomainSocketAddress.of(path)) while (isActive) { val client = server.accept() - launch { handle(client) } + safeLaunch { handle(client) } } } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 5707771e6c..9cddb4d208 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -11,6 +11,7 @@ import dev.slimevr.desktop.platform.Version import dev.slimevr.driver.DriverBridge import dev.slimevr.driver.DriverBridgeInbound import dev.slimevr.driver.DriverBridgeOutbound +import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.Dispatchers @@ -99,7 +100,7 @@ suspend fun handleDriverConnection( msg.version?.let { ver -> bridge.inbound.emit(DriverBridgeInbound.Version(ver.protocol_version)) if (ver.protocol_version >= 2) { - launch { + safeLaunch { startBindingProvider() } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt index c640bf9466..e38e3bbcd6 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt @@ -8,11 +8,11 @@ import com.sun.jna.platform.win32.WinNT import com.sun.jna.ptr.IntByReference import dev.slimevr.AppContextProvider import dev.slimevr.solarxr.handleSolarXRBridge +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.nio.ByteBuffer import java.nio.ByteOrder @@ -115,7 +115,7 @@ private suspend fun acceptWindowsClients( continue } - launch { + safeLaunch { try { handle(pipe) } finally { diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt b/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt index b4b0b58bba..7613a4367e 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt @@ -22,9 +22,9 @@ import dev.slimevr.networkprofile.NetworkCategory import dev.slimevr.networkprofile.NetworkInfo import dev.slimevr.networkprofile.NetworkProfileActions import dev.slimevr.networkprofile.NetworkProfileManager +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds @Suppress("ktlint:standard:function-naming") @@ -178,7 +178,7 @@ suspend fun setupDesktopNetworkProfileChecker(scope: CoroutineScope, manager: Ne val callback = object : Callback { @Suppress("UNUSED") fun callback(context: Pointer, row: Pointer, notificationType: Int) { - scope.launch { + scope.safeLaunch { val networks = enumerateNetworks() .filter { it.connected == true && it.category == NetworkCategory.PUBLIC } manager.context.dispatch(NetworkProfileActions.UpdateNetworks(networks)) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt b/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt index 2af92a6d86..81ae952510 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt @@ -3,15 +3,13 @@ package dev.slimevr.desktop.serial import com.fazecast.jSerialComm.SerialPortEvent import com.fazecast.jSerialComm.SerialPortMessageListener import dev.slimevr.AppLogger -import dev.slimevr.serial.FlashingHandler import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialPortInfo import dev.slimevr.serial.SerialServer +import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.OutputStreamWriter import com.fazecast.jSerialComm.SerialPort as JSerialPort @@ -53,11 +51,11 @@ private fun openPort( when (event.eventType) { JSerialPort.LISTENING_EVENT_DATA_RECEIVED -> { val line = event.receivedData.toString(Charsets.UTF_8).trimEnd() - scope.launch { onDataReceived(portLocation, line) } + scope.safeLaunch { onDataReceived(portLocation, line) } } JSerialPort.LISTENING_EVENT_PORT_DISCONNECTED -> - scope.launch { onPortDisconnected(portLocation) } + scope.safeLaunch { onPortDisconnected(portLocation) } } } }) @@ -79,26 +77,30 @@ private suspend fun runSerialPoller(server: SerialServer) { var lastKnown: Set = emptySet() while (true) { - val current = withContext(Dispatchers.IO) { - JSerialPort.getCommPorts() - .filter { isKnownBoard(it.vendorID, it.productID) } - .associate { port -> - port.portLocation to SerialPortInfo( - portLocation = port.portLocation, - descriptivePortName = port.descriptivePortName, - vendorId = port.vendorID, - productId = port.productID, - ) - } - } + try { + val current = withContext(Dispatchers.IO) { + JSerialPort.getCommPorts() + .filter { isKnownBoard(it.vendorID, it.productID) } + .associate { port -> + port.portLocation to SerialPortInfo( + portLocation = port.portLocation, + descriptivePortName = port.descriptivePortName, + vendorId = port.vendorID, + productId = port.productID, + ) + } + } - val added = current.keys - lastKnown - val removed = lastKnown - current.keys + val added = current.keys - lastKnown + val removed = lastKnown - current.keys - added.forEach { loc -> server.onPortDetected(current.getValue(loc)) } - removed.forEach { loc -> server.onPortLost(loc) } + added.forEach { loc -> server.onPortDetected(current.getValue(loc)) } + removed.forEach { loc -> server.onPortLost(loc) } - lastKnown = current.keys + lastKnown = current.keys + } catch (e: Exception) { + AppLogger.serial.error(e, "Error polling serial ports") + } delay(3000) } } @@ -109,6 +111,6 @@ fun createDesktopSerialServer(scope: CoroutineScope): SerialServer { openFlashingPort = { DesktopFlashingHandler() }, scope = scope, ) - scope.launch { runSerialPoller(server) } + scope.safeLaunch { runSerialPoller(server) } return server } diff --git a/server/detekt.yml b/server/detekt.yml new file mode 100644 index 0000000000..7d1e151c2e --- /dev/null +++ b/server/detekt.yml @@ -0,0 +1,11 @@ +style: + ForbiddenImport: + active: true + imports: + - reason: 'Use dev.slimevr.util.safeLaunch instead of raw launch to ensure coroutine exceptions are handled properly.' + value: 'kotlinx.coroutines.launch' + +coroutines: + active: true + GlobalCoroutineUsage: + active: true From ddae53ab426a5fec98fa9d8847d2c010ae0cd86c Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 11 May 2026 15:53:21 -0500 Subject: [PATCH 113/350] Only store tracker index in driver bridge --- .../src/main/java/dev/slimevr/driver/behaviours.kt | 11 +++++------ .../core/src/main/java/dev/slimevr/driver/module.kt | 9 ++++++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index b0e61a3819..c7a6486f2d 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -14,7 +14,7 @@ import solarxr_protocol.datatypes.hardware_info.ImuType object DriverBaseBehaviour : DriverBridgeBehaviour { override fun reduce(state: DriverBridgeState, action: DriverBridgeActions): DriverBridgeState = when (action) { - is DriverBridgeActions.AddTracker -> state.copy(trackers = state.trackers + (action.id to action.tracker)) + is DriverBridgeActions.AddTracker -> state.copy(trackers = state.trackers + (action.id to action.tracker.context.state.value.id)) is DriverBridgeActions.UpdateProtocolVersion -> state.copy(protocolVersion = action.version) } @@ -28,18 +28,17 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { } receiver.inbound.on { event -> - val tracker = receiver.context.state.value.trackers[event.trackerId] - if (tracker != null) { + val trackerId = receiver.context.state.value.trackers[event.trackerId] ?: return@on + receiver.appContext.server.getTracker(trackerId)?.let { tracker -> tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = event.rotation, position = event.position) }) - } else { - AppLogger.steamvr.warn("Failed to find tracker ${event.trackerId}") } } receiver.inbound.on { event -> - receiver.context.state.value.trackers[event.id]?.let { tracker -> + val trackerId = receiver.context.state.value.trackers[event.id] ?: return@on + receiver.appContext.server.getTracker(trackerId)?.let { tracker -> val device = tracker.server.getDevice(tracker.context.state.value.deviceId) ?: error("could not find device") device.context.dispatch( DeviceActions.Update { diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt index 1c3c681570..2d573e6652 100644 --- a/server/core/src/main/java/dev/slimevr/driver/module.kt +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -15,7 +15,8 @@ import solarxr_protocol.datatypes.TrackerStatus data class DriverBridgeState( val protocolVersion: Int, - val trackers: Map, + // SteamVR device index -> Tracker ID + val trackers: Map, ) sealed interface DriverBridgeActions { @@ -53,8 +54,10 @@ class DriverBridge( fun disconnect() { dispose() appContext.server.context.dispatch(VRServerActions.DriverDisconnected(id)) - context.state.value.trackers.forEach { (_, tracker) -> - tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.DISCONNECTED)) + context.state.value.trackers.forEach { (_, id) -> + appContext.server.getTracker(id)?.let { + it.context.dispatch(TrackerActions.SetStatus(TrackerStatus.DISCONNECTED)) + } } } From 36f23981906aea73813fa146d75d76c3dbbb2357 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 11 May 2026 17:42:15 -0500 Subject: [PATCH 114/350] Fix tracker state on SteamVR restart, only hold onto ID in AddTracker --- .../main/java/dev/slimevr/driver/behaviours.kt | 18 ++++++++---------- .../src/main/java/dev/slimevr/driver/module.kt | 5 ++--- .../java/dev/slimevr/desktop/ipc/protocol.kt | 3 +-- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index c7a6486f2d..587bd5a902 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -1,6 +1,5 @@ package dev.slimevr.driver -import dev.slimevr.AppLogger import dev.slimevr.VRServerActions import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions @@ -10,11 +9,10 @@ import dev.slimevr.tracker.TrackerActions import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.TrackerStatus -import solarxr_protocol.datatypes.hardware_info.ImuType object DriverBaseBehaviour : DriverBridgeBehaviour { override fun reduce(state: DriverBridgeState, action: DriverBridgeActions): DriverBridgeState = when (action) { - is DriverBridgeActions.AddTracker -> state.copy(trackers = state.trackers + (action.id to action.tracker.context.state.value.id)) + is DriverBridgeActions.AddTracker -> state.copy(trackers = state.trackers + (action.id to action.trackerId)) is DriverBridgeActions.UpdateProtocolVersion -> state.copy(protocolVersion = action.version) } @@ -28,7 +26,7 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { } receiver.inbound.on { event -> - val trackerId = receiver.context.state.value.trackers[event.trackerId] ?: return@on + val trackerId = receiver.context.state.value.trackers[event.id] ?: return@on receiver.appContext.server.getTracker(trackerId)?.let { tracker -> tracker.context.dispatch(TrackerActions.Update { copy(rawRotation = event.rotation, position = event.position) @@ -91,9 +89,10 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { val existingTracker = server.context.state.value.trackers.values .find { tracker -> tracker.context.state.value.hardwareId == serial } - val device = if (existingTracker != null) { - server.getDevice(existingTracker.context.state.value.deviceId) + val (device, tracker) = if (existingTracker != null) { + val device = server.getDevice(existingTracker.context.state.value.deviceId) ?: error("could not find existing device for serial $serial") + Pair(device, existingTracker) } else { val deviceId = server.nextHandle() val newDevice = Device.create( @@ -122,12 +121,11 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { ) server.context.dispatch(VRServerActions.NewTracker(trackerId, tracker)) - receiver.context.dispatch(DriverBridgeActions.AddTracker(id, tracker)) - tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) - - newDevice + Pair(newDevice, tracker) } + receiver.context.dispatch(DriverBridgeActions.AddTracker(id, tracker.context.state.value.id)) + tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) device.context.dispatch(DeviceActions.Update { copy(protocolVersion = 0) }) } } diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt index 2d573e6652..d6b12a58f0 100644 --- a/server/core/src/main/java/dev/slimevr/driver/module.kt +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -6,7 +6,6 @@ import dev.slimevr.VRServerActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.context.ManagedContext -import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 @@ -20,14 +19,14 @@ data class DriverBridgeState( ) sealed interface DriverBridgeActions { - data class AddTracker(val id: Int, val tracker: Tracker) : DriverBridgeActions + data class AddTracker(val id: Int, val trackerId: Int) : DriverBridgeActions data class UpdateProtocolVersion(val version: Int) : DriverBridgeActions } sealed interface DriverBridgeInbound { data class Version(val protocolVersion: Int) : DriverBridgeInbound data class TrackerAdded(val id: Int, val name: String, val manufacturer: String, val serial: String) : DriverBridgeInbound - data class TrackerPosition(val trackerId: Int, val rotation: Quaternion, val position: Vector3?) : DriverBridgeInbound + data class TrackerPosition(val id: Int, val rotation: Quaternion, val position: Vector3?) : DriverBridgeInbound data class TrackerBattery(val id: Int, val batteryLevel: Float, val charging: Boolean) : DriverBridgeInbound } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 9cddb4d208..0d97539e89 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -17,7 +17,6 @@ import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -118,7 +117,7 @@ suspend fun handleDriverConnection( } msg.position?.let { pos -> bridge.inbound.emit(DriverBridgeInbound.TrackerPosition( - trackerId = pos.tracker_id, + id = pos.tracker_id, rotation = Quaternion(w = pos.qw, x = pos.qx, y = pos.qy, z = pos.qz), position = if (pos.x != null && pos.y != null && pos.z != null) Vector3(pos.x, pos.y, pos.z) else null, )) From cc68e0e750510c3b8a543ee2aea9364f80c70581 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 11 May 2026 17:42:43 -0500 Subject: [PATCH 115/350] Fix typo in default manufacturer name for SteamVR devices --- .../desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 0d97539e89..25be57d1e0 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -108,7 +108,7 @@ suspend fun handleDriverConnection( bridge.inbound.emit(DriverBridgeInbound.TrackerAdded( id = ta.tracker_id, name = ta.tracker_name, - manufacturer = ta.manufacturer.ifEmpty { "SlimeVR" }, + manufacturer = ta.manufacturer.ifEmpty { "OpenVR" }, serial = ta.tracker_serial )) } From c5eda4b8182b2a7c717c7e09bf65cf3e3a43601a Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 12 May 2026 04:53:46 +0200 Subject: [PATCH 116/350] Rework trackers init to have access to appContext --- .../src/main/java/dev/slimevr/app-context.kt | 2 +- .../java/dev/slimevr/driver/behaviours.kt | 5 ++-- .../main/java/dev/slimevr/hid/behaviours.kt | 15 +++++----- .../java/dev/slimevr/tracker/behaviours.kt | 24 +++++++++++++++- .../main/java/dev/slimevr/tracker/module.kt | 14 +++++----- .../main/java/dev/slimevr/udp/behaviours.kt | 3 +- .../src/main/java/dev/slimevr/vrcosc/input.kt | 28 +++++++++---------- .../main/java/dev/slimevr/vrcosc/module.kt | 28 +++++++++++-------- .../src/main/java/dev/slimevr/desktop/Main.kt | 1 - 9 files changed, 72 insertions(+), 48 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index f325502ee3..910712a807 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -68,7 +68,7 @@ class AppContext( trackingChecklist.startObserving(this) udpServer.startReceiving(this, server.context.scope) vmcManager.startObserving() - vrcOscManager.startObserving() + vrcOscManager.startObserving(this) } override suspend fun dispose() { diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 587bd5a902..91832bf30c 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -37,7 +37,7 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { receiver.inbound.on { event -> val trackerId = receiver.context.state.value.trackers[event.id] ?: return@on receiver.appContext.server.getTracker(trackerId)?.let { tracker -> - val device = tracker.server.getDevice(tracker.context.state.value.deviceId) ?: error("could not find device") + val device = tracker.appContext.server.getDevice(tracker.context.state.value.deviceId) ?: error("could not find device") device.context.dispatch( DeviceActions.Update { copy( @@ -116,8 +116,7 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { sensorType = null, hardwareId = serial, origin = DeviceOrigin.DRIVER, - server = server, - settings = settings, + appContext = receiver.appContext ) server.context.dispatch(VRServerActions.NewTracker(trackerId, tracker)) diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 48ed591979..56bfd5295d 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -98,14 +98,13 @@ object HIDDeviceInfoBehaviour : HIDReceiverBehaviour { } else { val trackerId = receiver.appContext.server.nextHandle() val newTracker = Tracker.create( - scope = receiver.appContext.server.context.scope, - id = trackerId, - deviceId = deviceState.id, - sensorType = packet.imuType, - hardwareId = deviceState.address, - origin = DeviceOrigin.HID, - server = receiver.appContext.server, - settings = receiver.appContext.config.settings, + scope = receiver.appContext.server.context.scope, + id = trackerId, + deviceId = deviceState.id, + sensorType = packet.imuType, + hardwareId = deviceState.address, + origin = DeviceOrigin.HID, + appContext = receiver.appContext ) receiver.appContext.server.context.dispatch(VRServerActions.NewTracker(trackerId, newTracker)) receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, trackerId)) diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index de22f16887..d2dd01e8bf 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -1,9 +1,13 @@ package dev.slimevr.tracker +import dev.slimevr.skeleton.SkeletonActions import dev.slimevr.util.safeLaunch import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlin.concurrent.atomics.AtomicInt @@ -41,7 +45,7 @@ object TrackerTapDetectionBehaviour : TrackerBehaviour { val accelDelta = max - min if (accelDelta > NEEDED_ACCEL_DELTA && !waitForLowAccel) { - val othersOverThreshold = receiver.server.context.state.value.trackers.values + val othersOverThreshold = receiver.appContext.server.context.state.value.trackers.values .count { it.context.state.value.id != current.id && it.context.state.value.acceleration.lenSq() > ALLOWED_BODY_ACCEL_SQUARED } if (othersOverThreshold <= 1) { tapTimestamps.add(now) @@ -116,3 +120,21 @@ object TrackerTPSBehaviour : TrackerBehaviour { } } } + +object TrackerToSkeletonBehaviour : TrackerBehaviour { + + override fun observe(receiver: Tracker) { + receiver.context.state + .filter { it.bodyPart != null } + .map { Pair(it.bodyPart, it.rawRotation) } + .distinctUntilChanged() + .onEach { (bodyPart, rotation) -> + bodyPart?.let { + receiver.appContext.skeleton.context.dispatch( + SkeletonActions.SetBoneRotation(it, rotation) + ) + } + }.launchIn(receiver.context.scope) + } + +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 323c623cf8..b85aac1c5b 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -1,7 +1,6 @@ package dev.slimevr.tracker -import dev.slimevr.VRServer -import dev.slimevr.config.Settings +import dev.slimevr.AppContextProvider import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.context.debug.DiffStyle @@ -49,7 +48,7 @@ typealias TrackerBehaviour = Behaviour class Tracker( val context: TrackerContext, - val server: VRServer, + val appContext: AppContextProvider, ) { companion object { fun create( @@ -60,10 +59,10 @@ class Tracker( sensorType: ImuType?, hardwareId: String, origin: DeviceOrigin, - server: VRServer, - settings: Settings, + appContext: AppContextProvider, ): Tracker { - val trackerConfigs = settings.context.state.value.data.trackers + val settings = appContext.config.settings + val trackerConfigs = appContext.config.settings.context.state.value.data.trackers val savedConfig = trackerConfigs[hardwareId] val baseState = TrackerState( id = id, @@ -95,6 +94,7 @@ class Tracker( TrackerConfigBehaviour(settings, hardwareId), TrackerTPSBehaviour, TrackerTapDetectionBehaviour, + TrackerToSkeletonBehaviour, ) val context = Context.create( initialState = trackerState, @@ -106,7 +106,7 @@ class Tracker( ), name = "Tracker[$hardwareId]", ) - val tracker = Tracker(context = context, server) + val tracker = Tracker(context = context, appContext) behaviours.forEach { it.observe(tracker) } return tracker } diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 08e0a6d249..a7f2a0508b 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -304,8 +304,7 @@ object SensorInfoBehaviour : UDPConnectionBehaviour { deviceId = deviceState.id, origin = DeviceOrigin.UDP, scope = receiver.appContext.server.context.scope, - server = receiver.appContext.server, - settings = receiver.appContext.config.settings, + appContext = receiver.appContext, ) receiver.appContext.server.context.dispatch(VRServerActions.NewTracker(trackerId = trackerId, context = newTracker)) receiver.context.dispatch( diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt index ded323296f..26ecc4e128 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt @@ -1,5 +1,6 @@ package dev.slimevr.vrcosc +import dev.slimevr.AppContextProvider import dev.slimevr.AppLogger import dev.slimevr.Phase1ContextProvider import dev.slimevr.VRServerActions @@ -29,17 +30,17 @@ import solarxr_protocol.rpc.VRCOSCInputState * disable we only mark them DISCONNECTED — they are reused on the next enable. */ private class VRSystemTrackerRegistry( - private val ctx: Phase1ContextProvider, + private val appContext: AppContextProvider, private val manager: VRCOSCManager, ) { private var deviceId: Int? = null private val trackerIds = mutableMapOf() fun trackerFor(tracker: VRSystemTracker): Tracker { - trackerIds[tracker]?.let { id -> ctx.server.getTracker(id)?.let { existing -> return existing } } + trackerIds[tracker]?.let { id -> appContext.server.getTracker(id)?.let { existing -> return existing } } val device = findOrCreateDevice() - val trackerId = ctx.server.nextHandle() + val trackerId = appContext.server.nextHandle() val bodyPart = when (tracker) { VRSystemTracker.HEAD -> BodyPart.HEAD VRSystemTracker.LEFT_WRIST -> BodyPart.LEFT_HAND @@ -51,16 +52,15 @@ private class VRSystemTrackerRegistry( VRSystemTracker.RIGHT_WRIST -> "VRChat right hand" } val runtimeTracker = Tracker.create( + scope = manager.context.scope, id = trackerId, deviceId = device.context.state.value.id, sensorType = null, hardwareId = "vrcosc:vrsystem:${tracker.name.lowercase()}", origin = DeviceOrigin.VRC, - scope = manager.context.scope, - server = ctx.server, - settings = ctx.config.settings, + appContext = appContext, ) - ctx.server.context.dispatch(VRServerActions.NewTracker(trackerId, runtimeTracker)) + appContext.server.context.dispatch(VRServerActions.NewTracker(trackerId, runtimeTracker)) runtimeTracker.context.dispatch( TrackerActions.Update { copy( @@ -76,17 +76,17 @@ private class VRSystemTrackerRegistry( } fun setStatus(status: TrackerStatus) { - deviceId?.let { id -> ctx.server.getDevice(id) } + deviceId?.let { id -> appContext.server.getDevice(id) } ?.context?.dispatch(DeviceActions.Update { copy(status = status) }) for ((_, trackerId) in trackerIds) { - ctx.server.getTracker(trackerId)?.context?.dispatch(TrackerActions.Update { copy(status = status) }) + appContext.server.getTracker(trackerId)?.context?.dispatch(TrackerActions.Update { copy(status = status) }) } } private fun findOrCreateDevice(): Device { - deviceId?.let { id -> ctx.server.getDevice(id)?.let { return it } } + deviceId?.let { id -> appContext.server.getDevice(id)?.let { return it } } - val id = ctx.server.nextHandle() + val id = appContext.server.nextHandle() val device = Device.create( scope = manager.context.scope, id = id, @@ -102,14 +102,14 @@ private class VRSystemTrackerRegistry( ) }, ) - ctx.server.context.dispatch(VRServerActions.NewDevice(id, device)) + appContext.server.context.dispatch(VRServerActions.NewDevice(id, device)) deviceId = id return device } } class VRCOSCInputBehaviour( - private val ctx: Phase1ContextProvider, + private val appContext: AppContextProvider, ) : VRCOSCBehaviour { override fun reduce(state: VRCOSCState, action: VRCOSCActions) = when (action) { is VRCOSCActions.SetInput -> state.copy( @@ -128,7 +128,7 @@ class VRCOSCInputBehaviour( } override fun observe(receiver: VRCOSCManager) { - val registry = VRSystemTrackerRegistry(ctx, receiver) + val registry = VRSystemTrackerRegistry(appContext, receiver) var oscReceiver: OscReceiver? = null receiver.context.state diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt index c333231f6c..06678986ca 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt @@ -1,11 +1,11 @@ package dev.slimevr.vrcosc +import dev.slimevr.AppContextProvider import dev.slimevr.EventDispatcher import dev.slimevr.Phase1ContextProvider import dev.slimevr.config.VRCOSCConfig import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.skeleton.Skeleton import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.CoroutineScope @@ -96,9 +96,21 @@ typealias VRCOSCBehaviour = Behaviour class VRCOSCManager( val context: VRCOSCContext, - val events: EventDispatcher = EventDispatcher(), + val oscQueryAddress: String ) { - fun startObserving() = context.observeAll(this) + val events: EventDispatcher = EventDispatcher() + + fun startObserving(appContext: AppContextProvider) { + val behaviours = listOf( + VRCOSCSettingsBehaviour(appContext.config.settings), + VRCOSCOutputBehaviour(appContext.skeleton), + VRCOSCInputBehaviour(appContext), + VRCOSCOscQueryBehaviour(localIp = oscQueryAddress), + ) + + context.behaviours.addAll(behaviours) + context.observeAll(this) + } fun yawAlign(headRotation: Quaternion) { context.scope.safeLaunch { @@ -108,7 +120,6 @@ class VRCOSCManager( companion object { fun create( - skeleton: Skeleton, ctx: Phase1ContextProvider, scope: CoroutineScope, oscQueryAddress: String, @@ -120,16 +131,11 @@ class VRCOSCManager( config = initialConfig, status = VRCOSCStatus(), ), + behaviours = emptyList(), scope = scope, - behaviours = listOf( - VRCOSCSettingsBehaviour(settings), - VRCOSCOutputBehaviour(skeleton), - VRCOSCInputBehaviour(ctx), - VRCOSCOscQueryBehaviour(localIp = oscQueryAddress), - ), name = "VRCOSC", ) - return VRCOSCManager(context) + return VRCOSCManager(context, oscQueryAddress) } } } 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 9f41debe82..47d02bb7b1 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -56,7 +56,6 @@ fun main(args: Array) = runBlocking { val bvhManager = BVHManager.create(skeleton = skeleton, storage = storage, scope = this) val vmcManager = VMCManager.create(skeleton = skeleton, ctx = phase1, scope = this) val vrcOscManager = VRCOSCManager.create( - skeleton = skeleton, ctx = phase1, scope = this, oscQueryAddress = resolveDesktopOscQueryAddress(), From 35741302072c0e8369f3bf0b73ae484c59fcf778 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Mon, 11 May 2026 22:31:10 -0400 Subject: [PATCH 117/350] Use offsets for bones --- .../src/main/java/dev/slimevr/bvh/stream.kt | 5 ++-- .../java/dev/slimevr/skeleton/behaviours.kt | 2 +- .../java/dev/slimevr/skeleton/bonegeometry.kt | 2 -- .../main/java/dev/slimevr/skeleton/module.kt | 14 +++++------ .../java/dev/slimevr/skeleton/processors.kt | 25 ++++++++++--------- .../main/java/dev/slimevr/solarxr/datafeed.kt | 2 +- .../dev/slimevr/solarxr/skeletonconfig.kt | 8 ++---- 7 files changed, 25 insertions(+), 33 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/bvh/stream.kt b/server/core/src/main/java/dev/slimevr/bvh/stream.kt index 24959b32b6..95d46a58b1 100644 --- a/server/core/src/main/java/dev/slimevr/bvh/stream.kt +++ b/server/core/src/main/java/dev/slimevr/bvh/stream.kt @@ -97,9 +97,8 @@ class BvhStream( } private fun getBvhOffset(parentPart: BodyPart, bones: Map): Vector3 { - val tailOffsetLocal = DEFAULT_BONE_OFFSETS[parentPart] ?: return Vector3.NULL - val parentLength = bones[parentPart]?.length ?: 0f - return tailOffsetLocal.unit() * (parentLength * OFFSET_SCALE) + val offset = bones[parentPart]?.offset ?: return Vector3.NULL + return offset * OFFSET_SCALE } private suspend fun writeRotations(part: BodyPart, bones: Map) { diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index f766e4e84b..03124025ce 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -38,7 +38,7 @@ class ProportionsBehaviour : SkeletonBehaviour { is SkeletonActions.SetProportions -> { val bones = action.lengths.toBoneOffsets() val newBones = state.rawBones.mapValues { (bodyPart, bone) -> - bone.copy(length = bones[bodyPart]?.len() ?: bone.length) + bone.copy(offset = bones[bodyPart] ?: bone.offset) } state.copy(rawBones = newBones, userHeight = action.lengths.height()) } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt b/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt index aa31856806..c7e4ea1271 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt @@ -9,5 +9,3 @@ private val FINGER_OFFSETS = ( ).map { it.second }.associateWith { Vector3(0f, -0.025f, 0f) } val DEFAULT_BONE_OFFSETS: Map = DEFAULT_PROPORTIONS.toBoneOffsets() + FINGER_OFFSETS - -val DEFAULT_BONE_DIRECTIONS: Map = DEFAULT_BONE_OFFSETS.mapValues { (_, vec) -> vec.unit() } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index b91b6b81bc..befb6ebaff 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -13,14 +13,14 @@ import solarxr_protocol.rpc.SkeletonBone data class RawBone( val bodyPart: BodyPart, - val length: Float, + val offset: Vector3, val rawRotation: Quaternion, val rawPosition: Vector3, ) data class BoneState( val bodyPart: BodyPart, - val length: Float, + val offset: Vector3, val rotation: Quaternion = Quaternion.IDENTITY, val headPosition: Vector3 = Vector3.NULL, val tailPosition: Vector3 = Vector3.NULL, @@ -42,29 +42,27 @@ val DEFAULT_SKELETON_STATE: SkeletonState = run { BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> Quaternion.rotationAroundXAxis(FastMath.HALF_PI) else -> Quaternion.IDENTITY } - bodyPart to BoneState(bodyPart = bodyPart, length = tailOffset.len(), rotation = restRotation) + bodyPart to BoneState(bodyPart = bodyPart, offset = tailOffset, rotation = restRotation) } - SkeletonState(rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, length = bone.length, rawPosition = Vector3.NULL) }, userHeight = DEFAULT_HEIGHT) + SkeletonState(rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, offset = bone.offset, rawPosition = Vector3.NULL) }, userHeight = DEFAULT_HEIGHT) } fun buildBones( state: SkeletonState, rootHead: Vector3 = Vector3.NULL, hierarchy: Sequence> = iterateBodyPartHierarchy(), - tailDirections: Map = DEFAULT_BONE_DIRECTIONS, ): Map { val result = mutableMapOf() hierarchy.forEach { (parentPart, childPart) -> val rawBone = state.rawBones[childPart] ?: return@forEach - val tailDirection = tailDirections[childPart] ?: return@forEach val parentBone = parentPart?.let { result[it] } val head = parentBone?.tailPosition ?: rootHead result[childPart] = BoneState( bodyPart = rawBone.bodyPart, - length = rawBone.length, + offset = rawBone.offset, headPosition = head, rotation = rawBone.rawRotation, - tailPosition = head + rawBone.rawRotation.sandwich(tailDirection * rawBone.length), + tailPosition = head + rawBone.rawRotation.sandwich(rawBone.offset), parentBone = parentBone, ) } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt index 5f77cbf721..f14d47cb70 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt @@ -1,6 +1,7 @@ package dev.slimevr.skeleton import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.BodyPart interface SkeletonProcessor { @@ -11,7 +12,7 @@ interface SkeletonProcessor { class SmoothingProcessor(var smoothing: Float) : SkeletonProcessor { override var enabled: Boolean = true private var smoothedRotations: Map = emptyMap() - private var smoothedLengths: Map = emptyMap() + private var smoothedLengths: Map = emptyMap() override fun process(state: SkeletonState): SkeletonState { val alpha = 1f - smoothing.coerceAtMost(0.99f) @@ -19,14 +20,14 @@ class SmoothingProcessor(var smoothing: Float) : SkeletonProcessor { (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() } smoothedLengths = state.rawBones.mapValues { (bodyPart, bone) -> - val prev = smoothedLengths[bodyPart] ?: bone.length - prev + (bone.length - prev) * alpha + val prev = smoothedLengths[bodyPart] ?: bone.offset + prev + (bone.offset - prev) * alpha } return state.copy( rawBones = state.rawBones.mapValues { (bodyPart, bone) -> bone.copy( rawRotation = smoothedRotations[bodyPart] ?: bone.rawRotation, - length = smoothedLengths[bodyPart] ?: bone.length, + offset = smoothedLengths[bodyPart] ?: bone.offset, ) }, ) @@ -39,8 +40,8 @@ class PredictionProcessor(var predictionAmount: Float) : SkeletonProcessor { private data class BoneVelocity( val lastRotation: Quaternion, val rotationDelta: Quaternion, - val lastLength: Float, - val lengthDelta: Float, + val lastOffset: Vector3, + val offsetDelta: Vector3, ) private var velocities: Map = emptyMap() @@ -50,7 +51,7 @@ class PredictionProcessor(var predictionAmount: Float) : SkeletonProcessor { val newBones = state.rawBones.mapValues { (bodyPart, bone) -> val prev = velocities[bodyPart] if (prev == null) { - newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, Quaternion.IDENTITY, bone.length, 0f) + newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, Quaternion.IDENTITY, bone.offset, Vector3.NULL) return@mapValues bone } val rotationDelta = if (bone.rawRotation !== prev.lastRotation) { @@ -58,16 +59,16 @@ class PredictionProcessor(var predictionAmount: Float) : SkeletonProcessor { } else { prev.rotationDelta } - val lengthDelta = if (bone.length != prev.lastLength) { - bone.length - prev.lastLength + val lengthDelta = if (bone.offset != prev.lastOffset) { + bone.offset - prev.lastOffset } else { - prev.lengthDelta + prev.offsetDelta } - newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, rotationDelta, bone.length, lengthDelta) + newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, rotationDelta, bone.offset, lengthDelta) val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, predictionAmount).unit() bone.copy( rawRotation = (scaledDelta * bone.rawRotation).unit(), - length = bone.length + lengthDelta * predictionAmount, + offset = bone.offset + lengthDelta * predictionAmount, ) } velocities = newVelocities diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 944870a9f1..0bd2221284 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -105,7 +105,7 @@ private fun createDevice( private fun createBone(bone: BoneState): solarxr_protocol.data_feed.Bone = solarxr_protocol.data_feed.Bone( bodyPart = bone.bodyPart, rotationG = bone.rotation.let { Quat(it.x, it.y, it.z, it.w) }, - boneLength = bone.length, + boneLength = bone.offset.len(), headPositionG = bone.headPosition.let { Vec3f(it.x, it.y, it.z) }, ) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt index 2afd8e5e38..9ea26575b2 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt @@ -8,6 +8,7 @@ import dev.slimevr.skeleton.computeAllDefaultProportionsByBone import dev.slimevr.skeleton.computeDefaultProportionsByBone import dev.slimevr.skeleton.configToBoneValues import dev.slimevr.skeleton.height +import dev.slimevr.skeleton.toBoneValues import solarxr_protocol.rpc.ChangeSettingsRequest import solarxr_protocol.rpc.ChangeSkeletonConfigRequest import solarxr_protocol.rpc.SkeletonConfigRequest @@ -25,12 +26,7 @@ class SkeletonBehaviour( private fun buildConfigResponse(): SkeletonConfigResponse { val proportions = userConfig.context.state.value.data.proportions val bones = skeleton.context.state.value.rawBones - val skeletonParts = SKELETON_BONE_TO_BODY_PARTS.mapNotNull { (skeletonBone, bodyParts) -> - val length = proportions[skeletonBone.name] - ?: bodyParts.mapNotNull { bones[it]?.length }.average().takeIf { !it.isNaN() }?.toFloat() - ?: return@mapNotNull null - SkeletonPart(bone = skeletonBone, value = length) - } + val skeletonParts = bones.mapValues { it.value.offset }.toBoneValues().map { (offset, bone) -> SkeletonPart(offset, bone) } val expanded = configToBoneValues(proportions) val userHeight = if (expanded.isNotEmpty()) { expanded.height() From 22d1126c6654825788fd1b5ec5448abd385ea29b Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Sat, 16 May 2026 12:30:10 -0400 Subject: [PATCH 118/350] Add quaternion test utility functions --- .../test/java/dev/slimevr/quaternionutils.kt | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 server/core/src/test/java/dev/slimevr/quaternionutils.kt diff --git a/server/core/src/test/java/dev/slimevr/quaternionutils.kt b/server/core/src/test/java/dev/slimevr/quaternionutils.kt new file mode 100644 index 0000000000..336e9e3c6d --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/quaternionutils.kt @@ -0,0 +1,234 @@ +package dev.slimevr + +import com.jme3.math.FastMath.DEG_TO_RAD +import com.jme3.math.FastMath.RAD_TO_DEG +import com.jme3.math.FastMath.TWO_PI +import com.jme3.math.FastMath.ZERO_TOLERANCE +import com.jme3.math.FastMath.isApproxEqual +import io.github.axisangles.ktmath.EulerOrder +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import org.junit.jupiter.api.AssertionFailureBuilder +import org.opentest4j.AssertionFailedError +import kotlin.math.abs + +/** + * Normalizes a radian angle to be positive and within the range [0, 2π). + * + * @param rot The angle in radians to normalize. + * @return The normalized positive angle in radians. + */ +fun normalizeRadian(rot: Float): Float { + // Reduce the rotation to the smallest form + val redRot = rot % TWO_PI + return abs(if (rot < 0f) TWO_PI + redRot else redRot) +} + +/** + * Extracts the yaw (rotation around the Y-axis) of a quaternion in radians. + * The result is normalized to be positive. + * + * @param rot The quaternion from which to extract the yaw. + * @return The yaw angle in radians. + */ +fun radianYaw(rot: Quaternion): Float = normalizeRadian(rot.toEulerAngles(EulerOrder.YZX).y) + +/** + * Converts an angle from radians to degrees. + * + * @param rot The angle in radians. + * @return The angle in degrees. + */ +fun radianToDegree(rot: Float): Float = rot * RAD_TO_DEG + +/** + * Converts an angle from degrees to radians. + * + * @param rot The angle in degrees. + * @return The angle in radians. + */ +fun degreeToRadian(rot: Float): Float = rot * DEG_TO_RAD + +/** + * Extracts the yaw of a rotation and returns it in degrees. + * + * @param rot The quaternion from which to extract the yaw. + * @return The yaw angle in degrees. + */ +fun degreeYaw(rot: Quaternion): Float = radianToDegree(radianYaw(rot)) + +/** + * Checks if two angles are approximately equal, accounting for the 2π wrap-around. + * + * @param a The first angle. + * @param b The second angle. + * @param tolerance The allowed difference between the angles. + * @return True if the angles are approximately equal. + */ +fun angularApproxEqual( + a: Float, + b: Float, + tolerance: Float = ZERO_TOLERANCE, +): Boolean = isApproxEqual(a, b, tolerance) || + isApproxEqual(a - TWO_PI, b, tolerance) || + isApproxEqual(a, b - TWO_PI, tolerance) + +/** + * Asserts that two angles are approximately equal. + * + * @param expected The expected angle. + * @param actual The actual angle. + * @param tolerance The allowed difference. + * @param message Optional failure message. + * @throws AssertionFailedError if the angles are not approximately equal. + */ +fun angularAssertEquals( + expected: Float, + actual: Float, + tolerance: Float = ZERO_TOLERANCE, + message: String? = null, +) { + if (!angularApproxEqual(expected, actual, tolerance)) { + AssertionFailureBuilder.assertionFailure().message(message) + .expected(expected).actual(actual).buildAndThrow() + } +} + +/** + * Asserts that two angles are not approximately equal. + * + * @param expected The value that the actual angle should not be equal to. + * @param actual The actual angle. + * @param tolerance The allowed difference. + * @param message Optional failure message. + * @throws AssertionFailedError if the angles are approximately equal. + */ +fun angularAssertNotEquals( + expected: Float, + actual: Float, + tolerance: Float = ZERO_TOLERANCE, + message: String? = null, +) { + if (angularApproxEqual(expected, actual, tolerance)) { + AssertionFailureBuilder.assertionFailure().message(message) + .expected(expected).actual(actual).buildAndThrow() + } +} + +/** + * True if the quaternions are approximately equal in quaternion space, not rotation + * space. + * + * @param q1 The first quaternion. + * @param q2 The second quaternion. + * @param tolerance The allowed difference for each component. + * @return True if all components (w, x, y, z) are within the tolerance. + */ +fun quaternionApproxEqual( + q1: Quaternion, + q2: Quaternion, + tolerance: Float = ZERO_TOLERANCE, +): Boolean = isApproxEqual(q1.w, q2.w, tolerance) && + isApproxEqual(q1.x, q2.x, tolerance) && + isApproxEqual(q1.y, q2.y, tolerance) && + isApproxEqual(q1.z, q2.z, tolerance) + +/** + * Asserts that two quaternions are approximately equal in quaternion space. + * + * @param expected The expected quaternion. + * @param actual The actual quaternion. + * @param tolerance The allowed difference for each component. + * @param message Optional failure message. + * @throws AssertionFailedError if the quaternions are not approximately equal. + */ +fun quaternionAssertEquals( + expected: Quaternion, + actual: Quaternion, + tolerance: Float = ZERO_TOLERANCE, + message: String? = null, +) { + if (!quaternionApproxEqual(expected, actual, tolerance)) { + AssertionFailureBuilder.assertionFailure().message(message) + .expected(expected).actual(actual).buildAndThrow() + } +} + +/** + * Asserts that two quaternions are not approximately equal in quaternion space. + * + * @param expected The quaternion that the actual value should not be equal to. + * @param actual The actual quaternion. + * @param tolerance The allowed difference for each component. + * @param message Optional failure message. + * @throws AssertionFailedError if the quaternions are approximately equal. + */ +fun quaternionAssertNotEquals( + expected: Quaternion, + actual: Quaternion, + tolerance: Float = ZERO_TOLERANCE, + message: String? = null, +) { + if (quaternionApproxEqual(expected, actual, tolerance)) { + AssertionFailureBuilder.assertionFailure().message(message) + .expected(expected).actual(actual).buildAndThrow() + } +} + +/** + * Checks if two vectors are approximately equal. + * + * @param v1 The first vector. + * @param v2 The second vector. + * @param tolerance The allowed difference for each component. + * @return True if all components (x, y, z) are within the tolerance. + */ +fun vectorApproxEqual( + v1: Vector3, + v2: Vector3, + tolerance: Float = ZERO_TOLERANCE, +): Boolean = isApproxEqual(v1.x, v2.x, tolerance) && + isApproxEqual(v1.y, v2.y, tolerance) && + isApproxEqual(v1.z, v2.z, tolerance) + +/** + * Asserts that two vectors are approximately equal. + * + * @param expected The expected vector. + * @param actual The actual vector. + * @param tolerance The allowed difference for each component. + * @param message Optional failure message. + * @throws AssertionFailedError if the vectors are not approximately equal. + */ +fun vectorAssertEquals( + expected: Vector3, + actual: Vector3, + tolerance: Float = ZERO_TOLERANCE, + message: String? = null, +) { + if (!vectorApproxEqual(expected, actual, tolerance)) { + AssertionFailureBuilder.assertionFailure().message(message) + .expected(expected).actual(actual).buildAndThrow() + } +} + +/** + * Asserts that two vectors are not approximately equal. + * + * @param expected The vector that the actual value should not be equal to. + * @param actual The actual vector. + * @param tolerance The allowed difference for each component. + * @param message Optional failure message. + * @throws AssertionFailedError if the vectors are approximately equal. + */ +fun vectorAssertNotEquals( + expected: Vector3, + actual: Vector3, + tolerance: Float = ZERO_TOLERANCE, + message: String? = null, +) { + if (vectorApproxEqual(expected, actual, tolerance)) { + AssertionFailureBuilder.assertionFailure().message(message) + .expected(expected).actual(actual).buildAndThrow() + } +} From 7b80cb926dd649a6c00c832a6c59bc40406b5117 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Sat, 16 May 2026 16:36:37 -0400 Subject: [PATCH 119/350] Add session calibration functions & tests --- .../dev/slimevr/tracker/sessioncalibration.kt | 21 ++ .../slimevr/tracker/SessionCalibrationTest.kt | 266 ++++++++++++++++++ 2 files changed, 287 insertions(+) create mode 100644 server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt create mode 100644 server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt diff --git a/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt new file mode 100644 index 0000000000..d0db3b0686 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt @@ -0,0 +1,21 @@ +package dev.slimevr.tracker + +import io.github.axisangles.ktmath.Quaternion + +fun applyCalibration( + q: Quaternion, + headingCorrect: Quaternion = Quaternion.IDENTITY, + attitudeAlign: Quaternion = Quaternion.IDENTITY, + headingAlign: Quaternion = Quaternion.IDENTITY, +): Quaternion = headingCorrect * headingAlign.inv() * q * attitudeAlign * headingAlign + +// We reverse the order of headingAlign and attitudeAlign here since our +// attitude alignment is within the raw heading frame of reference, so we must +// bring the orientation back into that frame of reference first. Whatever is +// applied last must be taken off first. +fun undoCalibration( + q: Quaternion, + headingCorrect: Quaternion = Quaternion.IDENTITY, + attitudeAlign: Quaternion = Quaternion.IDENTITY, + headingAlign: Quaternion = Quaternion.IDENTITY, +): Quaternion = headingAlign * headingCorrect.inv() * q * headingAlign.inv() * attitudeAlign.inv() diff --git a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt new file mode 100644 index 0000000000..b94655b76b --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt @@ -0,0 +1,266 @@ +package dev.slimevr.tracker + +import com.jme3.math.FastMath +import com.jme3.math.FastMath.ZERO_TOLERANCE +import com.jme3.math.FastMath.isApproxZero +import dev.slimevr.angularAssertEquals +import dev.slimevr.degreeToRadian +import dev.slimevr.quaternionApproxEqual +import dev.slimevr.quaternionAssertEquals +import dev.slimevr.quaternionAssertNotEquals +import io.github.axisangles.ktmath.EulerAngles +import io.github.axisangles.ktmath.EulerOrder +import io.github.axisangles.ktmath.Quaternion +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.TestFactory +import kotlin.math.abs +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class SessionCalibrationTest { + @TestFactory + fun makeRawOrientationTests(): List = roughHeading.flatMap { hC -> + roughAttitude.flatMap { aA -> + roughHeading.map { hA -> + DynamicTest.dynamicTest( + "testMakeOrientation( hC: $hC, aA: $aA, hA: $hA )", + ) { + // We can just use identity for the target orientation as only the + // calibration quaternions themselves matter. + testMakeRawOrientation(Quaternion.IDENTITY, hC, aA, hA) + } + } + } + } + + /** + * We're trying to prove stuff here using this, so let's at least prove that we can + * make a raw orientation and then bring it back to the bone frame of reference. + */ + fun testMakeRawOrientation( + boneOrientation: Quaternion, + headingCorrect: Quaternion, + attitudeAlign: Quaternion, + headingAlign: Quaternion, + ) { + val rawOrientation = undoCalibration( + boneOrientation, + headingCorrect, + attitudeAlign, + headingAlign, + ) + val newBoneOrientation = applyCalibration( + rawOrientation, + headingCorrect, + attitudeAlign, + headingAlign, + ) + // Now that we re-applied the calibrations, let's see if it matches! + quaternionAssertEquals(boneOrientation, newBoneOrientation) + } + + @TestFactory + fun headingCorrectTimingTests(): List = roughHeading.flatMap { hC -> + roughAttitude.flatMap { aA -> + roughHeading.map { hA -> + DynamicTest.dynamicTest( + "testHeadingCorrectTiming( hC: $hC, aA: $aA, hA: $hA )", + ) { + // We can just use identity for the target orientation as only the + // calibration quaternions themselves matter. + testHeadingCorrectTiming(Quaternion.IDENTITY, hC, aA, hA) + } + } + } + } + + /** + * It doesn't actually matter *when* you add heading correction, just as long as + * it's on the left side. + */ + fun testHeadingCorrectTiming( + rawOrientation: Quaternion, + headingCorrect: Quaternion, + attitudeAlign: Quaternion, + headingAlign: Quaternion, + ) { + val boneOrientationA = + headingCorrect * rawOrientation * attitudeAlign * headingAlign + val boneOrientationB = + headingCorrect * (rawOrientation * attitudeAlign * headingAlign) + val boneOrientationC = + headingCorrect * (rawOrientation * attitudeAlign) * headingAlign + quaternionAssertEquals(boneOrientationA, boneOrientationB) + quaternionAssertEquals(boneOrientationA, boneOrientationC) + } + + @TestFactory + fun headingCorrectAttitudeAlignTests(): List = roughHeading.flatMap { hC -> + roughAttitude.map { aA -> + DynamicTest.dynamicTest( + "testHeadingCorrectAttitudeAlign( hC: $hC, aA: $aA )", + ) { + // We can just use identity for the target orientation as only the + // calibration quaternions themselves matter. + testHeadingCorrectAttitudeAlign(Quaternion.IDENTITY, hC, aA) + } + } + } + + /** + * Heading correction also does not affect the calculation of the attitude alignment + * using Euler angles. + */ + fun testHeadingCorrectAttitudeAlign( + rawOrientation: Quaternion, + headingCorrect: Quaternion, + attitudeAlign: Quaternion, + ) { + val boneOrientationA = + (rawOrientation * attitudeAlign).toEulerAngles(EulerOrder.YZX) + val boneOrientationB = + (headingCorrect * rawOrientation * attitudeAlign).toEulerAngles(EulerOrder.YZX) + angularAssertEquals(boneOrientationA.x, boneOrientationB.x, ZERO_TOLERANCE) + angularAssertEquals(boneOrientationA.z, boneOrientationB.z, ZERO_TOLERANCE) + // We can also show that we're calculating the right attitude alignment. + val attitudeAlignEul = attitudeAlign.toEulerAngles(EulerOrder.YZX) + angularAssertEquals(attitudeAlignEul.x, boneOrientationA.x, ZERO_TOLERANCE) + angularAssertEquals(attitudeAlignEul.z, boneOrientationA.z, ZERO_TOLERANCE) + } + + @TestFactory + fun attitudeHeadingAlignDependenceTests(): List { + // Order doesn't matter if the attitude alignment has no attitude. + return roughAttitude.filterNot { isApproxZero(it.x) && isApproxZero(it.z) } + .flatMap { aA -> + // Same for if heading alignment is the quaternion identity. + roughHeading.filterNot { + quaternionApproxEqual( + it, + Quaternion.IDENTITY, + ) + }.map { hA -> + DynamicTest.dynamicTest( + "testAttitudeHeadingAlignDependence( aA: $aA, hA: $hA )", + ) { + // We can just use identity for the target orientation as only the + // calibration quaternions themselves matter. + testAttitudeHeadingAlignDependence(Quaternion.IDENTITY, aA, hA) + } + } + } + } + + /** + * It *does* matter what order you apply attitude and heading alignment. + */ + fun testAttitudeHeadingAlignDependence( + rawOrientation: Quaternion, + attitudeAlign: Quaternion, + headingAlign: Quaternion, + ) { + val boneOrientationA = rawOrientation * attitudeAlign * headingAlign + val boneOrientationB = rawOrientation * headingAlign * attitudeAlign + quaternionAssertNotEquals(boneOrientationA, boneOrientationB) + } + + @TestFactory + fun attitudeHeadingAlignOrderTests(): List { + // We're not proving anything if both attitude axes are of equal magnitude. + return roughAttitude.filterNot { FastMath.isApproxEqual(abs(it.x), abs(it.z)) } + .flatMap { aA -> + roughHeading.map { hA -> + DynamicTest.dynamicTest( + "testAttitudeHeadingAlignOrder( aA: $aA, hA: $hA )", + ) { + // We can just use identity for the target orientation as only the + // calibration quaternions themselves matter. + testAttitudeHeadingAlignOrder(Quaternion.IDENTITY, aA, hA) + } + } + } + } + + /** + * If we want to modify heading alignment but keep a constant attitude alignment, + * we need to apply heading alignment *after* attitude. + */ + fun testAttitudeHeadingAlignOrder( + rawOrientation: Quaternion, + attitudeAlign: Quaternion, + headingAlign: Quaternion, + ) { + // Perpendicular heading alignment (rotated by 90 deg), makes it easy to check + // our results. + val headingAlignB = + headingAlign * Quaternion.rotationAroundYAxis(FastMath.HALF_PI) + + // We must also apply an inverse of the heading alignment to make our + // quaternions comparable; we just want to affect the axes, not add to the + // orientation. + val boneOrientationA = applyCalibration( + rawOrientation, + Quaternion.IDENTITY, + attitudeAlign, + headingAlign, + ) + val boneOrientationB = applyCalibration( + rawOrientation, + Quaternion.IDENTITY, + attitudeAlign, + headingAlignB, + ) + assertEquals(abs(boneOrientationA.x), abs(boneOrientationB.z), ZERO_TOLERANCE) + assertEquals(abs(boneOrientationA.z), abs(boneOrientationB.x), ZERO_TOLERANCE) + + // Since it's required for this test, we can also show that by applying the + // inverse of heading alignment as a heading correction, we can retain the same + // heading orientation despite changing alignment. By doing this, we remove + // dependence between correction and alignment; they can resolve to definitive + // values. + assertEquals(boneOrientationA.y, boneOrientationB.y, ZERO_TOLERANCE) + + // We can also show that this does not work when heading alignment comes before + // attitude alignment. + val boneOrientationC = + headingAlign.inv() * (rawOrientation * headingAlign * attitudeAlign) + val boneOrientationD = + headingAlignB.inv() * (rawOrientation * headingAlignB * attitudeAlign) + assertNotEquals( + abs(boneOrientationC.x), + abs(boneOrientationD.z), + ZERO_TOLERANCE, + ) + assertNotEquals( + abs(boneOrientationC.z), + abs(boneOrientationD.x), + ZERO_TOLERANCE, + ) + } + + companion object { + // 5 steps + val roughStep = (-180..180 step 72).map { it.toFloat() } + + // 12 steps + val fineStep = (-180..180 step 30).map { it.toFloat() } + + // Will not work when we don't know the heading correction or heading alignment, + // we will need Euler angles to calculate those, and it needs to sacrifice one + // axis for Euler angles to work + val roughAttitude = roughStep.flatMap { x -> + roughStep.map { z -> + EulerAngles( + EulerOrder.YZX, + degreeToRadian(x), + 0f, + degreeToRadian(z), + ).toQuaternion() + } + } + + val roughHeading = roughStep.map { y -> + Quaternion.rotationAroundYAxis(degreeToRadian(y)) + } + } +} From 412e182079357299752ec6ac1a6b09ac4cfd1c5e Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Tue, 19 May 2026 03:30:11 -0400 Subject: [PATCH 120/350] Add calibration estimation functions --- .../java/dev/slimevr/tracker/sessioncalibration.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt index d0db3b0686..fdbb845206 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt @@ -1,5 +1,6 @@ package dev.slimevr.tracker +import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion fun applyCalibration( @@ -19,3 +20,12 @@ fun undoCalibration( attitudeAlign: Quaternion = Quaternion.IDENTITY, headingAlign: Quaternion = Quaternion.IDENTITY, ): Quaternion = headingAlign * headingCorrect.inv() * q * headingAlign.inv() * attitudeAlign.inv() + +private fun eulerHeading(q: Quaternion): Quaternion = + Quaternion.rotationAroundYAxis(q.toEulerAngles(EulerOrder.YZX).y) + +fun estimateHeadingCorrect(rawRotation: Quaternion, referenceRotation: Quaternion): Quaternion = + eulerHeading(eulerHeading(referenceRotation).inv() * rawRotation) + +fun estimateAttitudeAlign(rawRotation: Quaternion, headingCorrect: Quaternion): Quaternion = + headingCorrect.inv() * rawRotation From cc387a6c0299bea8e7694932102062af1ccd2616 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Tue, 19 May 2026 20:05:36 -0400 Subject: [PATCH 121/350] Rename "acceleration" to "rawAcceleration" --- .../main/java/dev/slimevr/solarxr/datafeed.kt | 7 +-- .../java/dev/slimevr/tracker/behaviours.kt | 8 +-- .../main/java/dev/slimevr/tracker/module.kt | 6 ++- .../dev/slimevr/tracker/sessioncalibration.kt | 51 ++++++++++++++++--- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 0bd2221284..844cab488b 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -18,15 +18,12 @@ import solarxr_protocol.data_feed.device_data.DeviceData import solarxr_protocol.data_feed.tracker.TrackerData import solarxr_protocol.data_feed.tracker.TrackerDataMask import solarxr_protocol.data_feed.tracker.TrackerInfo -import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.Ipv4Address import solarxr_protocol.datatypes.Temperature import solarxr_protocol.datatypes.TrackerId -import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.HardwareInfo import solarxr_protocol.datatypes.hardware_info.HardwareStatus -import solarxr_protocol.datatypes.hardware_info.ImuType import solarxr_protocol.datatypes.math.Quat import solarxr_protocol.datatypes.math.Vec3f @@ -62,8 +59,8 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas }, tps = if (trackerMask.tps == true) tracker.tps else null, temp = if (trackerMask.temp == true && tracker.imuTemp != null) Temperature(temp = tracker.imuTemp) else null, - rawAcceleration = if (trackerMask.rawAcceleration == true) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null, - linearAcceleration = if (trackerMask.linearAcceleration == true) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null, // FIXME: temp value + rawAcceleration = if (trackerMask.rawAcceleration == true) tracker.rawAcceleration.let { Vec3f(it.x, it.y, it.z) } else null, + linearAcceleration = if (trackerMask.linearAcceleration == true) tracker.rawAcceleration.let { Vec3f(it.x, it.y, it.z) } else null, // FIXME: temp value ) private fun createDevice( diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index d2dd01e8bf..194299be6a 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -30,10 +30,10 @@ object TrackerTapDetectionBehaviour : TrackerBehaviour { // TODO: only enable this on the trackers that have taps assigned to them receiver.context.state - .distinctUntilChangedBy { it.acceleration } + .distinctUntilChangedBy { it.rawAcceleration } .onEach { current -> val now = System.nanoTime() - val magnitude = current.acceleration.len() + val magnitude = current.rawAcceleration.len() accelList.add(magnitude to now) while (accelList.isNotEmpty() && now - accelList.first().second > CLUMP_TIME_NS) { @@ -46,7 +46,7 @@ object TrackerTapDetectionBehaviour : TrackerBehaviour { if (accelDelta > NEEDED_ACCEL_DELTA && !waitForLowAccel) { val othersOverThreshold = receiver.appContext.server.context.state.value.trackers.values - .count { it.context.state.value.id != current.id && it.context.state.value.acceleration.lenSq() > ALLOWED_BODY_ACCEL_SQUARED } + .count { it.context.state.value.id != current.id && it.context.state.value.rawAcceleration.lenSq() > ALLOWED_BODY_ACCEL_SQUARED } if (othersOverThreshold <= 1) { tapTimestamps.add(now) waitForLowAccel = true @@ -90,7 +90,7 @@ object TrackerBasicBehaviour : TrackerBehaviour { is TrackerActions.SetRotation -> state.copy( rawRotation = action.rotation ?: state.rawRotation, - acceleration = action.acceleration ?: state.acceleration, + rawAcceleration = action.acceleration ?: state.rawAcceleration, ) } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index b85aac1c5b..1e8755e63a 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -25,7 +25,7 @@ data class TrackerState( val customName: String?, val mountingOrientation: Quaternion?, val rawRotation: Quaternion, - val acceleration: Vector3, + val rawAcceleration: Vector3, val deviceId: Int, val origin: DeviceOrigin, val tps: UShort, @@ -34,6 +34,7 @@ data class TrackerState( val status: TrackerStatus, val completedRestCalibration: Boolean?, val magStatus: MagnetometerStatus, + val sessionCalibration: SessionCalibration?, ) sealed interface TrackerActions { @@ -69,7 +70,7 @@ class Tracker( hardwareId = hardwareId, name = name, rawRotation = Quaternion.IDENTITY, - acceleration = Vector3.NULL, + rawAcceleration = Vector3.NULL, bodyPart = null, mountingOrientation = null, origin = origin, @@ -82,6 +83,7 @@ class Tracker( status = TrackerStatus.DISCONNECTED, completedRestCalibration = false, magStatus = MagnetometerStatus.NOT_SUPPORTED, + sessionCalibration = null, ) val trackerState = if (savedConfig != null) { restoreFromConfig(baseState, savedConfig) diff --git a/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt index fdbb845206..2575418498 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt @@ -2,30 +2,65 @@ package dev.slimevr.tracker import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 + +data class SessionCalibration( + val headingCorrection: Quaternion, + val attitudeAlignment: Quaternion, + val headingAlignment: Quaternion, +) fun applyCalibration( - q: Quaternion, + rawRotation: Quaternion, headingCorrect: Quaternion = Quaternion.IDENTITY, attitudeAlign: Quaternion = Quaternion.IDENTITY, headingAlign: Quaternion = Quaternion.IDENTITY, -): Quaternion = headingCorrect * headingAlign.inv() * q * attitudeAlign * headingAlign +): Quaternion = + headingCorrect * headingAlign.inv() * rawRotation * attitudeAlign * headingAlign // We reverse the order of headingAlign and attitudeAlign here since our // attitude alignment is within the raw heading frame of reference, so we must // bring the orientation back into that frame of reference first. Whatever is // applied last must be taken off first. fun undoCalibration( - q: Quaternion, + calibratedRotation: Quaternion, headingCorrect: Quaternion = Quaternion.IDENTITY, attitudeAlign: Quaternion = Quaternion.IDENTITY, headingAlign: Quaternion = Quaternion.IDENTITY, -): Quaternion = headingAlign * headingCorrect.inv() * q * headingAlign.inv() * attitudeAlign.inv() +): Quaternion = + headingAlign * headingCorrect.inv() * calibratedRotation * headingAlign.inv() * attitudeAlign.inv() + +// Acceleration needs to be rotated by raw rotation with heading corrected +private fun accelerationRotation( + rawRotation: Quaternion, + headingCorrect: Quaternion = Quaternion.IDENTITY, + headingAlign: Quaternion = Quaternion.IDENTITY, +): Quaternion = headingAlign.inv() * headingCorrect * rawRotation + +fun applyCalibration( + rawAcceleration: Vector3, + rawRotation: Quaternion, + headingCorrect: Quaternion = Quaternion.IDENTITY, + headingAlign: Quaternion = Quaternion.IDENTITY, +): Vector3 = accelerationRotation(rawRotation, headingCorrect, headingAlign).sandwich( + rawAcceleration +) + +fun undoCalibration( + calibratedAcceleration: Vector3, + rawRotation: Quaternion, + headingCorrect: Quaternion = Quaternion.IDENTITY, + headingAlign: Quaternion = Quaternion.IDENTITY, +): Vector3 = accelerationRotation(rawRotation, headingCorrect, headingAlign).inv() + .sandwich(calibratedAcceleration) private fun eulerHeading(q: Quaternion): Quaternion = Quaternion.rotationAroundYAxis(q.toEulerAngles(EulerOrder.YZX).y) -fun estimateHeadingCorrect(rawRotation: Quaternion, referenceRotation: Quaternion): Quaternion = - eulerHeading(eulerHeading(referenceRotation).inv() * rawRotation) +fun estimateHeadingCorrect( + rawRotation: Quaternion, referenceRotation: Quaternion +): Quaternion = eulerHeading(eulerHeading(referenceRotation).inv() * rawRotation) -fun estimateAttitudeAlign(rawRotation: Quaternion, headingCorrect: Quaternion): Quaternion = - headingCorrect.inv() * rawRotation +fun estimateAttitudeAlign( + rawRotation: Quaternion, headingCorrect: Quaternion +): Quaternion = headingCorrect.inv() * rawRotation From 22cb14bb21e0c8d7f9deb21983778edbbded6b43 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Tue, 19 May 2026 21:39:47 -0400 Subject: [PATCH 122/350] SessionCalibration type aliases --- .../dev/slimevr/tracker/sessioncalibration.kt | 84 +++++++++++-------- 1 file changed, 49 insertions(+), 35 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt index 2575418498..8db3fe3147 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt @@ -4,63 +4,77 @@ import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 +typealias RawRotation = Quaternion +typealias RawAcceleration = Vector3 + +typealias HeadingCorrection = Quaternion +typealias AttitudeAlignment = Quaternion +typealias HeadingAlignment = Quaternion + +typealias AccelerationRotation = Quaternion + +typealias CalibratedRotation = Quaternion +typealias CalibratedAcceleration = Vector3 + data class SessionCalibration( - val headingCorrection: Quaternion, - val attitudeAlignment: Quaternion, - val headingAlignment: Quaternion, + val headingCorrection: HeadingCorrection, + val attitudeAlignment: AttitudeAlignment, + val headingAlignment: HeadingAlignment, ) fun applyCalibration( - rawRotation: Quaternion, - headingCorrect: Quaternion = Quaternion.IDENTITY, - attitudeAlign: Quaternion = Quaternion.IDENTITY, - headingAlign: Quaternion = Quaternion.IDENTITY, -): Quaternion = - headingCorrect * headingAlign.inv() * rawRotation * attitudeAlign * headingAlign + rawRotation: RawRotation, + headingCorrect: HeadingCorrection = Quaternion.IDENTITY, + attitudeAlign: AttitudeAlignment = Quaternion.IDENTITY, + headingAlign: HeadingAlignment = Quaternion.IDENTITY, +): CalibratedRotation = + headingAlign.inv() * headingCorrect * rawRotation * attitudeAlign * headingAlign // We reverse the order of headingAlign and attitudeAlign here since our // attitude alignment is within the raw heading frame of reference, so we must // bring the orientation back into that frame of reference first. Whatever is // applied last must be taken off first. fun undoCalibration( - calibratedRotation: Quaternion, - headingCorrect: Quaternion = Quaternion.IDENTITY, - attitudeAlign: Quaternion = Quaternion.IDENTITY, - headingAlign: Quaternion = Quaternion.IDENTITY, -): Quaternion = + calibratedRotation: CalibratedRotation, + headingCorrect: HeadingCorrection = Quaternion.IDENTITY, + attitudeAlign: AttitudeAlignment = Quaternion.IDENTITY, + headingAlign: HeadingAlignment = Quaternion.IDENTITY, +): RawRotation = headingAlign * headingCorrect.inv() * calibratedRotation * headingAlign.inv() * attitudeAlign.inv() // Acceleration needs to be rotated by raw rotation with heading corrected private fun accelerationRotation( - rawRotation: Quaternion, - headingCorrect: Quaternion = Quaternion.IDENTITY, - headingAlign: Quaternion = Quaternion.IDENTITY, -): Quaternion = headingAlign.inv() * headingCorrect * rawRotation + rawRotation: RawRotation, + headingCorrect: HeadingCorrection = Quaternion.IDENTITY, + headingAlign: HeadingAlignment = Quaternion.IDENTITY, +): AccelerationRotation = headingAlign.inv() * headingCorrect * rawRotation fun applyCalibration( - rawAcceleration: Vector3, - rawRotation: Quaternion, - headingCorrect: Quaternion = Quaternion.IDENTITY, - headingAlign: Quaternion = Quaternion.IDENTITY, -): Vector3 = accelerationRotation(rawRotation, headingCorrect, headingAlign).sandwich( - rawAcceleration -) + rawAcceleration: RawAcceleration, + rawRotation: RawRotation, + headingCorrect: HeadingCorrection = Quaternion.IDENTITY, + headingAlign: HeadingAlignment = Quaternion.IDENTITY, +): CalibratedAcceleration = + accelerationRotation(rawRotation, headingCorrect, headingAlign).sandwich( + rawAcceleration + ) fun undoCalibration( - calibratedAcceleration: Vector3, - rawRotation: Quaternion, - headingCorrect: Quaternion = Quaternion.IDENTITY, - headingAlign: Quaternion = Quaternion.IDENTITY, -): Vector3 = accelerationRotation(rawRotation, headingCorrect, headingAlign).inv() - .sandwich(calibratedAcceleration) + calibratedAcceleration: CalibratedAcceleration, + rawRotation: RawRotation, + headingCorrect: HeadingCorrection = Quaternion.IDENTITY, + headingAlign: HeadingAlignment = Quaternion.IDENTITY, +): RawAcceleration = + accelerationRotation(rawRotation, headingCorrect, headingAlign).inv() + .sandwich(calibratedAcceleration) private fun eulerHeading(q: Quaternion): Quaternion = Quaternion.rotationAroundYAxis(q.toEulerAngles(EulerOrder.YZX).y) fun estimateHeadingCorrect( - rawRotation: Quaternion, referenceRotation: Quaternion -): Quaternion = eulerHeading(eulerHeading(referenceRotation).inv() * rawRotation) + rawRotation: RawRotation, referenceRotation: Quaternion +): HeadingCorrection = eulerHeading(eulerHeading(referenceRotation).inv() * rawRotation) fun estimateAttitudeAlign( - rawRotation: Quaternion, headingCorrect: Quaternion -): Quaternion = headingCorrect.inv() * rawRotation + rawRotation: RawRotation, headingCorrect: HeadingCorrection +): AttitudeAlignment = headingCorrect.inv() * rawRotation From f390347b560d7ca805ef7858fc0114b0ef903bf8 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Tue, 19 May 2026 21:55:55 -0400 Subject: [PATCH 123/350] Shorten variable names for SessionCalibrationTest --- .../slimevr/tracker/SessionCalibrationTest.kt | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt index b94655b76b..602c095624 100644 --- a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt @@ -19,9 +19,9 @@ import kotlin.test.assertNotEquals class SessionCalibrationTest { @TestFactory - fun makeRawOrientationTests(): List = roughHeading.flatMap { hC -> - roughAttitude.flatMap { aA -> - roughHeading.map { hA -> + fun makeRawOrientationTests(): List = heading.flatMap { hC -> + attitude.flatMap { aA -> + heading.map { hA -> DynamicTest.dynamicTest( "testMakeOrientation( hC: $hC, aA: $aA, hA: $hA )", ) { @@ -60,9 +60,9 @@ class SessionCalibrationTest { } @TestFactory - fun headingCorrectTimingTests(): List = roughHeading.flatMap { hC -> - roughAttitude.flatMap { aA -> - roughHeading.map { hA -> + fun headingCorrectTimingTests(): List = heading.flatMap { hC -> + attitude.flatMap { aA -> + heading.map { hA -> DynamicTest.dynamicTest( "testHeadingCorrectTiming( hC: $hC, aA: $aA, hA: $hA )", ) { @@ -95,8 +95,8 @@ class SessionCalibrationTest { } @TestFactory - fun headingCorrectAttitudeAlignTests(): List = roughHeading.flatMap { hC -> - roughAttitude.map { aA -> + fun headingCorrectAttitudeAlignTests(): List = heading.flatMap { hC -> + attitude.map { aA -> DynamicTest.dynamicTest( "testHeadingCorrectAttitudeAlign( hC: $hC, aA: $aA )", ) { @@ -131,10 +131,10 @@ class SessionCalibrationTest { @TestFactory fun attitudeHeadingAlignDependenceTests(): List { // Order doesn't matter if the attitude alignment has no attitude. - return roughAttitude.filterNot { isApproxZero(it.x) && isApproxZero(it.z) } + return attitude.filterNot { isApproxZero(it.x) && isApproxZero(it.z) } .flatMap { aA -> // Same for if heading alignment is the quaternion identity. - roughHeading.filterNot { + heading.filterNot { quaternionApproxEqual( it, Quaternion.IDENTITY, @@ -167,9 +167,9 @@ class SessionCalibrationTest { @TestFactory fun attitudeHeadingAlignOrderTests(): List { // We're not proving anything if both attitude axes are of equal magnitude. - return roughAttitude.filterNot { FastMath.isApproxEqual(abs(it.x), abs(it.z)) } + return attitude.filterNot { FastMath.isApproxEqual(abs(it.x), abs(it.z)) } .flatMap { aA -> - roughHeading.map { hA -> + heading.map { hA -> DynamicTest.dynamicTest( "testAttitudeHeadingAlignOrder( aA: $aA, hA: $hA )", ) { @@ -240,7 +240,7 @@ class SessionCalibrationTest { companion object { // 5 steps - val roughStep = (-180..180 step 72).map { it.toFloat() } + val step = (-180..180 step 72).map { it.toFloat() } // 12 steps val fineStep = (-180..180 step 30).map { it.toFloat() } @@ -248,8 +248,8 @@ class SessionCalibrationTest { // Will not work when we don't know the heading correction or heading alignment, // we will need Euler angles to calculate those, and it needs to sacrifice one // axis for Euler angles to work - val roughAttitude = roughStep.flatMap { x -> - roughStep.map { z -> + val attitude = step.flatMap { x -> + step.map { z -> EulerAngles( EulerOrder.YZX, degreeToRadian(x), @@ -259,7 +259,7 @@ class SessionCalibrationTest { } } - val roughHeading = roughStep.map { y -> + val heading = step.map { y -> Quaternion.rotationAroundYAxis(degreeToRadian(y)) } } From 6dbe872958ae891fe3d69d9b86973a2d4cc9d120 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Wed, 20 May 2026 01:32:41 -0400 Subject: [PATCH 124/350] Fix session calibration math --- .../dev/slimevr/tracker/sessioncalibration.kt | 10 ++-- .../slimevr/tracker/SessionCalibrationTest.kt | 59 +++++++++++++++++-- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt index 8db3fe3147..3b53afb293 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt @@ -40,7 +40,7 @@ fun undoCalibration( attitudeAlign: AttitudeAlignment = Quaternion.IDENTITY, headingAlign: HeadingAlignment = Quaternion.IDENTITY, ): RawRotation = - headingAlign * headingCorrect.inv() * calibratedRotation * headingAlign.inv() * attitudeAlign.inv() + headingCorrect.inv() * headingAlign * calibratedRotation * headingAlign.inv() * attitudeAlign.inv() // Acceleration needs to be rotated by raw rotation with heading corrected private fun accelerationRotation( @@ -69,12 +69,14 @@ fun undoCalibration( .sandwich(calibratedAcceleration) private fun eulerHeading(q: Quaternion): Quaternion = - Quaternion.rotationAroundYAxis(q.toEulerAngles(EulerOrder.YZX).y) + Quaternion.rotationAroundYAxis(q.toEulerAngles(EulerOrder.YZX).y).twinNearest(q) fun estimateHeadingCorrect( rawRotation: RawRotation, referenceRotation: Quaternion -): HeadingCorrection = eulerHeading(eulerHeading(referenceRotation).inv() * rawRotation) +): HeadingCorrection = + eulerHeading(eulerHeading(referenceRotation).inv() * rawRotation).inv() + .twinNearest(referenceRotation) fun estimateAttitudeAlign( rawRotation: RawRotation, headingCorrect: HeadingCorrection -): AttitudeAlignment = headingCorrect.inv() * rawRotation +): AttitudeAlignment = (headingCorrect * rawRotation).inv() diff --git a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt index 602c095624..48879aa639 100644 --- a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt @@ -23,7 +23,7 @@ class SessionCalibrationTest { attitude.flatMap { aA -> heading.map { hA -> DynamicTest.dynamicTest( - "testMakeOrientation( hC: $hC, aA: $aA, hA: $hA )", + "( hC: $hC, aA: $aA, hA: $hA )", ) { // We can just use identity for the target orientation as only the // calibration quaternions themselves matter. @@ -64,7 +64,7 @@ class SessionCalibrationTest { attitude.flatMap { aA -> heading.map { hA -> DynamicTest.dynamicTest( - "testHeadingCorrectTiming( hC: $hC, aA: $aA, hA: $hA )", + "( hC: $hC, aA: $aA, hA: $hA )", ) { // We can just use identity for the target orientation as only the // calibration quaternions themselves matter. @@ -98,7 +98,7 @@ class SessionCalibrationTest { fun headingCorrectAttitudeAlignTests(): List = heading.flatMap { hC -> attitude.map { aA -> DynamicTest.dynamicTest( - "testHeadingCorrectAttitudeAlign( hC: $hC, aA: $aA )", + "( hC: $hC, aA: $aA )", ) { // We can just use identity for the target orientation as only the // calibration quaternions themselves matter. @@ -141,7 +141,7 @@ class SessionCalibrationTest { ) }.map { hA -> DynamicTest.dynamicTest( - "testAttitudeHeadingAlignDependence( aA: $aA, hA: $hA )", + "( aA: $aA, hA: $hA )", ) { // We can just use identity for the target orientation as only the // calibration quaternions themselves matter. @@ -171,7 +171,7 @@ class SessionCalibrationTest { .flatMap { aA -> heading.map { hA -> DynamicTest.dynamicTest( - "testAttitudeHeadingAlignOrder( aA: $aA, hA: $hA )", + "( aA: $aA, hA: $hA )", ) { // We can just use identity for the target orientation as only the // calibration quaternions themselves matter. @@ -238,6 +238,51 @@ class SessionCalibrationTest { ) } + @TestFactory + fun estimateSessionCalibrationTests(): List { + // We can only estimate session calibration with yaw and pitch, roll cannot be + // compensated for + return heading.flatMap { hC -> + pitch.map { aA -> + DynamicTest.dynamicTest( + "( hC: $hC, aA: $aA )", + ) { + // We can just use identity for the target orientation as only the + // calibration quaternions themselves matter. + testEstimateSessionCalibration(Quaternion.IDENTITY, hC, aA) + } + } + } + } + + fun testEstimateSessionCalibration( + calibratedRotation: CalibratedRotation, + headingCorrect: HeadingCorrection, + attitudeAlign: AttitudeAlignment, + ) { + val rawRotation = + undoCalibration(calibratedRotation, headingCorrect, attitudeAlign) + + // TODO: Can we avoid needing to use twinNearest? It would be best if it was + // just inherently in the right quaternion space (for both heading & attitude). + // This might also just not be a problem, I'm not sure. + val estimatedHeadingCorrect = + estimateHeadingCorrect(rawRotation, Quaternion.IDENTITY) + quaternionAssertEquals( + headingCorrect, + estimatedHeadingCorrect.twinNearest(headingCorrect), + message = "Estimated heading correction is wrong" + ) + + val estimatedAttitudeAlign = + estimateAttitudeAlign(rawRotation, estimatedHeadingCorrect) + quaternionAssertEquals( + attitudeAlign, + estimatedAttitudeAlign.twinNearest(attitudeAlign), + message = "Estimated attitude alignment is wrong" + ) + } + companion object { // 5 steps val step = (-180..180 step 72).map { it.toFloat() } @@ -262,5 +307,9 @@ class SessionCalibrationTest { val heading = step.map { y -> Quaternion.rotationAroundYAxis(degreeToRadian(y)) } + + val pitch = step.map { x -> + Quaternion.rotationAroundXAxis(degreeToRadian(x)) + } } } From cef72ee9ae1699b9fc76e749bbea317c5a17e3a3 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 22 May 2026 22:09:23 -0500 Subject: [PATCH 125/350] Fix network profile checker callback param types --- .../desktop/networkprofile/network-profile.kt | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt b/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt index 7613a4367e..9aa3dc5e15 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt @@ -24,8 +24,7 @@ import dev.slimevr.networkprofile.NetworkProfileActions import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.awaitCancellation @Suppress("ktlint:standard:function-naming") interface Iphlpapi : Library { @@ -177,7 +176,7 @@ suspend fun setupDesktopNetworkProfileChecker(scope: CoroutineScope, manager: Ne val notificationHandle = PointerByReference() val callback = object : Callback { @Suppress("UNUSED") - fun callback(context: Pointer, row: Pointer, notificationType: Int) { + fun callback(context: Pointer?, row: Pointer?, notificationType: Int) { scope.safeLaunch { val networks = enumerateNetworks() .filter { it.connected == true && it.category == NetworkCategory.PUBLIC } @@ -186,7 +185,7 @@ suspend fun setupDesktopNetworkProfileChecker(scope: CoroutineScope, manager: Ne } } - val result = Iphlpapi.INSTANCE.NotifyIpInterfaceChange( + Iphlpapi.INSTANCE.NotifyIpInterfaceChange( 0, callback, null, @@ -194,19 +193,13 @@ suspend fun setupDesktopNetworkProfileChecker(scope: CoroutineScope, manager: Ne notificationHandle, ) - if (result == 0) { - val initialNetworks = enumerateNetworks() - .filter { it.connected == true && it.category == NetworkCategory.PUBLIC } - manager.context.dispatch(NetworkProfileActions.UpdateNetworks(initialNetworks)) - try { - delay(Long.MAX_VALUE.milliseconds) - } catch (e: Exception) { - val handle = notificationHandle.value - if (handle != null) { - try { - Iphlpapi.INSTANCE.CancelMibChangeNotify2(handle) - } catch (ignore: Exception) {} - } + try { + awaitCancellation() + } finally { + notificationHandle.value?.let { + try { + Iphlpapi.INSTANCE.CancelMibChangeNotify2(it) + } catch (_: Exception) {} } } } From c10d536a6c73fda4c9a8d50a1c818ee8776b185b Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 22 May 2026 22:26:54 -0500 Subject: [PATCH 126/350] Prevent network profile checker callback from being GC'd --- .../java/dev/slimevr/networkprofile/module.kt | 6 +++++ .../desktop/networkprofile/network-profile.kt | 27 +++++++++++-------- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/networkprofile/module.kt b/server/core/src/main/java/dev/slimevr/networkprofile/module.kt index cc1231cc84..7d5d236693 100644 --- a/server/core/src/main/java/dev/slimevr/networkprofile/module.kt +++ b/server/core/src/main/java/dev/slimevr/networkprofile/module.kt @@ -2,6 +2,8 @@ package dev.slimevr.networkprofile import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.context.debug.DiffStyle +import dev.slimevr.context.debug.LoggingMiddleware import kotlinx.coroutines.CoroutineScope data class NetworkInfo( @@ -71,6 +73,10 @@ class NetworkProfileManager(val context: NetworkProfileContext) { initialState = NetworkProfileState(isSupported = isSupported), scope = scope, behaviours = listOf(DefaultNetworkProfileBehaviour), + debugMiddleware = LoggingMiddleware( + allow = setOf(NetworkProfileActions.UpdateNetworks::class), + diffStyle = DiffStyle.MULTILINE, + ), name = "NetworkProfile", ) return NetworkProfileManager(context) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt b/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt index 9aa3dc5e15..1ade92acf4 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt @@ -170,24 +170,28 @@ private fun enumerateNetworks(): List = try { emptyList() } +private class IpInterfaceChangeCallback(val scope: CoroutineScope, val manager: NetworkProfileManager) : Callback { + @Suppress("UNUSED") + fun callback(context: Pointer?, row: Pointer?, notificationType: Int) { + scope.safeLaunch { + val networks = enumerateNetworks().filter { it.connected == true && it.category == NetworkCategory.PUBLIC } + manager.context.dispatch(NetworkProfileActions.UpdateNetworks(networks)) + } + } +} + +// we need to keep a reference to our callback, lest it be garbage collected +private var IP_INTERFACE_CHANGE_CALLBACK: IpInterfaceChangeCallback? = null + suspend fun setupDesktopNetworkProfileChecker(scope: CoroutineScope, manager: NetworkProfileManager) { if (CURRENT_PLATFORM != Platform.WINDOWS) return val notificationHandle = PointerByReference() - val callback = object : Callback { - @Suppress("UNUSED") - fun callback(context: Pointer?, row: Pointer?, notificationType: Int) { - scope.safeLaunch { - val networks = enumerateNetworks() - .filter { it.connected == true && it.category == NetworkCategory.PUBLIC } - manager.context.dispatch(NetworkProfileActions.UpdateNetworks(networks)) - } - } - } + IP_INTERFACE_CHANGE_CALLBACK = IpInterfaceChangeCallback(scope, manager) Iphlpapi.INSTANCE.NotifyIpInterfaceChange( 0, - callback, + IP_INTERFACE_CHANGE_CALLBACK!!, null, 1, notificationHandle, @@ -201,5 +205,6 @@ suspend fun setupDesktopNetworkProfileChecker(scope: CoroutineScope, manager: Ne Iphlpapi.INSTANCE.CancelMibChangeNotify2(it) } catch (_: Exception) {} } + IP_INTERFACE_CHANGE_CALLBACK = null } } From c208b7cda21bf0c9155a288837fab11a5331b582 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 22 May 2026 22:27:07 -0500 Subject: [PATCH 127/350] Spotless --- .../java/dev/slimevr/driver/behaviours.kt | 10 +++--- .../main/java/dev/slimevr/hid/behaviours.kt | 14 ++++---- .../java/dev/slimevr/tracker/behaviours.kt | 5 ++- .../dev/slimevr/tracker/sessioncalibration.kt | 32 ++++++++----------- .../main/java/dev/slimevr/vrcosc/module.kt | 2 +- .../slimevr/tracker/SessionCalibrationTest.kt | 4 +-- .../src/main/java/dev/slimevr/desktop/Main.kt | 3 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 26 ++++++++------- 8 files changed, 48 insertions(+), 48 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 91832bf30c..b3217cbff6 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -28,9 +28,11 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { receiver.inbound.on { event -> val trackerId = receiver.context.state.value.trackers[event.id] ?: return@on receiver.appContext.server.getTracker(trackerId)?.let { tracker -> - tracker.context.dispatch(TrackerActions.Update { - copy(rawRotation = event.rotation, position = event.position) - }) + tracker.context.dispatch( + TrackerActions.Update { + copy(rawRotation = event.rotation, position = event.position) + }, + ) } } @@ -116,7 +118,7 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { sensorType = null, hardwareId = serial, origin = DeviceOrigin.DRIVER, - appContext = receiver.appContext + appContext = receiver.appContext, ) server.context.dispatch(VRServerActions.NewTracker(trackerId, tracker)) diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 56bfd5295d..8623c3b28f 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -98,13 +98,13 @@ object HIDDeviceInfoBehaviour : HIDReceiverBehaviour { } else { val trackerId = receiver.appContext.server.nextHandle() val newTracker = Tracker.create( - scope = receiver.appContext.server.context.scope, - id = trackerId, - deviceId = deviceState.id, - sensorType = packet.imuType, - hardwareId = deviceState.address, - origin = DeviceOrigin.HID, - appContext = receiver.appContext + scope = receiver.appContext.server.context.scope, + id = trackerId, + deviceId = deviceState.id, + sensorType = packet.imuType, + hardwareId = deviceState.address, + origin = DeviceOrigin.HID, + appContext = receiver.appContext, ) receiver.appContext.server.context.dispatch(VRServerActions.NewTracker(trackerId, newTracker)) receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, trackerId)) diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 194299be6a..34570a0a78 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -131,10 +131,9 @@ object TrackerToSkeletonBehaviour : TrackerBehaviour { .onEach { (bodyPart, rotation) -> bodyPart?.let { receiver.appContext.skeleton.context.dispatch( - SkeletonActions.SetBoneRotation(it, rotation) + SkeletonActions.SetBoneRotation(it, rotation), ) } - }.launchIn(receiver.context.scope) + }.launchIn(receiver.context.scope) } - } diff --git a/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt index 3b53afb293..26082afe38 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt @@ -27,8 +27,7 @@ fun applyCalibration( headingCorrect: HeadingCorrection = Quaternion.IDENTITY, attitudeAlign: AttitudeAlignment = Quaternion.IDENTITY, headingAlign: HeadingAlignment = Quaternion.IDENTITY, -): CalibratedRotation = - headingAlign.inv() * headingCorrect * rawRotation * attitudeAlign * headingAlign +): CalibratedRotation = headingAlign.inv() * headingCorrect * rawRotation * attitudeAlign * headingAlign // We reverse the order of headingAlign and attitudeAlign here since our // attitude alignment is within the raw heading frame of reference, so we must @@ -39,8 +38,7 @@ fun undoCalibration( headingCorrect: HeadingCorrection = Quaternion.IDENTITY, attitudeAlign: AttitudeAlignment = Quaternion.IDENTITY, headingAlign: HeadingAlignment = Quaternion.IDENTITY, -): RawRotation = - headingCorrect.inv() * headingAlign * calibratedRotation * headingAlign.inv() * attitudeAlign.inv() +): RawRotation = headingCorrect.inv() * headingAlign * calibratedRotation * headingAlign.inv() * attitudeAlign.inv() // Acceleration needs to be rotated by raw rotation with heading corrected private fun accelerationRotation( @@ -54,29 +52,27 @@ fun applyCalibration( rawRotation: RawRotation, headingCorrect: HeadingCorrection = Quaternion.IDENTITY, headingAlign: HeadingAlignment = Quaternion.IDENTITY, -): CalibratedAcceleration = - accelerationRotation(rawRotation, headingCorrect, headingAlign).sandwich( - rawAcceleration - ) +): CalibratedAcceleration = accelerationRotation(rawRotation, headingCorrect, headingAlign).sandwich( + rawAcceleration, +) fun undoCalibration( calibratedAcceleration: CalibratedAcceleration, rawRotation: RawRotation, headingCorrect: HeadingCorrection = Quaternion.IDENTITY, headingAlign: HeadingAlignment = Quaternion.IDENTITY, -): RawAcceleration = - accelerationRotation(rawRotation, headingCorrect, headingAlign).inv() - .sandwich(calibratedAcceleration) +): RawAcceleration = accelerationRotation(rawRotation, headingCorrect, headingAlign).inv() + .sandwich(calibratedAcceleration) -private fun eulerHeading(q: Quaternion): Quaternion = - Quaternion.rotationAroundYAxis(q.toEulerAngles(EulerOrder.YZX).y).twinNearest(q) +private fun eulerHeading(q: Quaternion): Quaternion = Quaternion.rotationAroundYAxis(q.toEulerAngles(EulerOrder.YZX).y).twinNearest(q) fun estimateHeadingCorrect( - rawRotation: RawRotation, referenceRotation: Quaternion -): HeadingCorrection = - eulerHeading(eulerHeading(referenceRotation).inv() * rawRotation).inv() - .twinNearest(referenceRotation) + rawRotation: RawRotation, + referenceRotation: Quaternion, +): HeadingCorrection = eulerHeading(eulerHeading(referenceRotation).inv() * rawRotation).inv() + .twinNearest(referenceRotation) fun estimateAttitudeAlign( - rawRotation: RawRotation, headingCorrect: HeadingCorrection + rawRotation: RawRotation, + headingCorrect: HeadingCorrection, ): AttitudeAlignment = (headingCorrect * rawRotation).inv() diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt index 06678986ca..d687ec8905 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt @@ -96,7 +96,7 @@ typealias VRCOSCBehaviour = Behaviour class VRCOSCManager( val context: VRCOSCContext, - val oscQueryAddress: String + val oscQueryAddress: String, ) { val events: EventDispatcher = EventDispatcher() diff --git a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt index 48879aa639..d073d5b8be 100644 --- a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt @@ -271,7 +271,7 @@ class SessionCalibrationTest { quaternionAssertEquals( headingCorrect, estimatedHeadingCorrect.twinNearest(headingCorrect), - message = "Estimated heading correction is wrong" + message = "Estimated heading correction is wrong", ) val estimatedAttitudeAlign = @@ -279,7 +279,7 @@ class SessionCalibrationTest { quaternionAssertEquals( attitudeAlign, estimatedAttitudeAlign.twinNearest(attitudeAlign), - message = "Estimated attitude alignment is wrong" + message = "Estimated attitude alignment is wrong", ) } 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 47d02bb7b1..f8ccfa1ff8 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -34,8 +34,7 @@ import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.runBlocking fun main(args: Array) = runBlocking { - ContextDebug.enabled = System.getProperty("slimevr.debug.context") == "true" || - System.getenv("SLIMEVR_DEBUG_CONTEXT") == "true" + ContextDebug.enabled = true val configFolder = resolveConfigDirectory() ?: error("Unable to resolve config folder") val storage = DesktopConfigStorage(configFolder.toFile()) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 25be57d1e0..496fa1e4d3 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -105,22 +105,26 @@ suspend fun handleDriverConnection( } } msg.tracker_added?.let { ta -> - bridge.inbound.emit(DriverBridgeInbound.TrackerAdded( - id = ta.tracker_id, - name = ta.tracker_name, - manufacturer = ta.manufacturer.ifEmpty { "OpenVR" }, - serial = ta.tracker_serial - )) + bridge.inbound.emit( + DriverBridgeInbound.TrackerAdded( + id = ta.tracker_id, + name = ta.tracker_name, + manufacturer = ta.manufacturer.ifEmpty { "OpenVR" }, + serial = ta.tracker_serial, + ), + ) } msg.battery?.let { bat -> bridge.inbound.emit(DriverBridgeInbound.TrackerBattery(id = bat.tracker_id, batteryLevel = bat.battery_level, charging = bat.is_charging)) } msg.position?.let { pos -> - bridge.inbound.emit(DriverBridgeInbound.TrackerPosition( - id = pos.tracker_id, - rotation = Quaternion(w = pos.qw, x = pos.qx, y = pos.qy, z = pos.qz), - position = if (pos.x != null && pos.y != null && pos.z != null) Vector3(pos.x, pos.y, pos.z) else null, - )) + bridge.inbound.emit( + DriverBridgeInbound.TrackerPosition( + id = pos.tracker_id, + rotation = Quaternion(w = pos.qw, x = pos.qx, y = pos.qy, z = pos.qz), + position = if (pos.x != null && pos.y != null && pos.z != null) Vector3(pos.x, pos.y, pos.z) else null, + ), + ) } } } finally { From bdde772c5c0e0cafc5441a32499c67e9ae94a36f Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 22 May 2026 22:41:41 -0500 Subject: [PATCH 128/350] Send names of the public network adapters in checklist step --- .../java/dev/slimevr/trackingchecklist/behaviours.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index 708b64c19f..7e2239743c 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -27,6 +27,7 @@ import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.TrackerId import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.TrackingChecklistNeedCalibration +import solarxr_protocol.rpc.TrackingChecklistPublicNetworks import solarxr_protocol.rpc.TrackingChecklistSteamVRDisconnected import solarxr_protocol.rpc.TrackingChecklistStep import solarxr_protocol.rpc.TrackingChecklistStepId @@ -187,6 +188,15 @@ class NetworkProfileCheckBehaviour( TrackingChecklistStep( valid = state.publicNetworks.isEmpty(), enabled = state.isSupported, + extraData = if (state.publicNetworks.isNotEmpty()) { + TrackingChecklistPublicNetworks( + adapters = state.publicNetworks.map { + it.name ?: "[no name]" + }, + ) + } else { + null + }, ) } .distinctUntilChanged() From feb3eae3d3f73b9aad2614128f11359f0e6d17ed Mon Sep 17 00:00:00 2001 From: Sapphire Date: Sat, 23 May 2026 17:21:59 -0500 Subject: [PATCH 129/350] oops --- server/desktop/src/main/java/dev/slimevr/desktop/Main.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 f8ccfa1ff8..47d02bb7b1 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -34,7 +34,8 @@ import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.runBlocking fun main(args: Array) = runBlocking { - ContextDebug.enabled = true + ContextDebug.enabled = System.getProperty("slimevr.debug.context") == "true" || + System.getenv("SLIMEVR_DEBUG_CONTEXT") == "true" val configFolder = resolveConfigDirectory() ?: error("Unable to resolve config folder") val storage = DesktopConfigStorage(configFolder.toFile()) From 5a4ba1f73b78285004584aa76136a5af8affb537 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 25 May 2026 03:56:56 +0200 Subject: [PATCH 130/350] Fix address resolvig / avoid dns lookup --- .../main/java/dev/slimevr/udp/behaviours.kt | 2 +- .../main/java/dev/slimevr/udp/connection.kt | 15 +++++---- .../src/main/java/dev/slimevr/udp/server.kt | 31 +++++++------------ .../src/main/java/dev/slimevr/desktop/Main.kt | 3 +- .../java/dev/slimevr/desktop/udp/address.kt | 8 +++++ 5 files changed, 29 insertions(+), 30 deletions(-) create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/udp/address.kt diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index a7f2a0508b..e835b559c6 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -213,7 +213,7 @@ object TimeoutBehaviour : UDPConnectionBehaviour { } val timeUntilTimeout = CONNECTION_TIMEOUT_MS - (System.currentTimeMillis() - state.lastPacket) if (timeUntilTimeout <= 0) { - AppLogger.udp.info("[${state.address}] Connection timed out for ${state.id}") + AppLogger.udp.info("[${state.address}] Connection timed out") receiver.context.dispatch(UDPConnectionActions.TimedOut) receiver.getDevice()?.context?.dispatch( DeviceActions.Update { copy(status = TrackerStatus.TIMED_OUT) }, diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 04ca8ae13f..126b41db29 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -27,13 +27,14 @@ data class SensorConfigFlags( ) data class UDPConnectionState( - val id: String, + // Pre-resolved IP string. InetSocketAddress.hostname triggers a reverse DNS lookup so we + // can't derive this on demand — it must be resolved once by the platform (see UdpServer.addressResolver) + // and cached here. remoteAddress is kept separately on UDPConnection only for sending datagrams. + val address: String, val lastPacket: Long, val lastPacketNum: Long, val lastPing: LastPing, val didHandshake: Boolean, - val address: String, - val port: Int, val deviceId: Int?, val trackerIds: List, val features: FirmwareFeatures?, @@ -84,7 +85,7 @@ class UDPConnection( companion object { fun create( - id: String, + address: String, socket: BoundDatagramSocket, remoteAddress: InetSocketAddress, appContext: AppContextProvider, @@ -109,13 +110,11 @@ class UDPConnection( val context = Context.create( initialState = UDPConnectionState( - id = id, + address = address, lastPacket = System.currentTimeMillis(), lastPacketNum = 0, lastPing = LastPing(id = 0, startTime = 0), didHandshake = false, - address = remoteAddress.hostname, - port = remoteAddress.port, deviceId = null, trackerIds = listOf(), features = null, @@ -123,7 +122,7 @@ class UDPConnection( ), scope = scope, behaviours = behaviours, - name = "UDPConnection[$id]", + name = "UDPConnection[$address]", ) val dispatcher = EventDispatcher> { it.data::class } diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index 5cb06e4df2..2496d1e2d3 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -21,8 +21,8 @@ data class UdpServerState( ) sealed interface UdpServerActions { - data class ConnectionAdded(val ip: String, val conn: UDPConnection) : UdpServerActions - data class ConnectionRemoved(val ip: String) : UdpServerActions + data class ConnectionAdded(val address: String, val conn: UDPConnection) : UdpServerActions + data class ConnectionRemoved(val address: String) : UdpServerActions } typealias UdpServerContext = Context @@ -30,12 +30,12 @@ typealias UdpServerBehaviour = Behaviour state.copy(connections = state.connections + (action.ip to action.conn)) - is UdpServerActions.ConnectionRemoved -> state.copy(connections = state.connections - action.ip) + is UdpServerActions.ConnectionAdded -> state.copy(connections = state.connections + (action.address to action.conn)) + is UdpServerActions.ConnectionRemoved -> state.copy(connections = state.connections - action.address) } } -class UdpServer(val context: UdpServerContext) { +class UdpServer(val context: UdpServerContext, private val addressResolver: (InetSocketAddress) -> String) { private var receiveJob: Job? = null private var socket: BoundDatagramSocket? = null private var selectorManager: SelectorManager? = null @@ -66,8 +66,8 @@ class UdpServer(val context: UdpServerContext) { val packetData = readPacket(type, src) val remoteAddress = recvPacket.address as? InetSocketAddress ?: return@measureTime - val id = addressKey(remoteAddress) - val conn = context.state.value.connections[id] + val address = addressResolver(remoteAddress) + val conn = context.state.value.connections[address] val event = PacketEvent(data = packetData, packetNumber = packetNumber) @@ -75,13 +75,13 @@ class UdpServer(val context: UdpServerContext) { conn.packetChannel.trySend(event) } else { val newConn = UDPConnection.create( - id = id, + address = address, remoteAddress = remoteAddress, socket = socket, appContext = appContext, scope = scope, ) - context.dispatch(UdpServerActions.ConnectionAdded(id, newConn)) + context.dispatch(UdpServerActions.ConnectionAdded(address, newConn)) newConn.packetChannel.trySend(event) } } @@ -112,23 +112,14 @@ class UdpServer(val context: UdpServerContext) { companion object { val INITIAL_STATE = UdpServerState(connections = emptyMap()) - private fun addressKey(address: InetSocketAddress): String { - val raw = address.resolveAddress() - return if (raw != null && raw.size == 4) { - raw.joinToString(".") { byte -> (byte.toInt() and 0xFF).toString() } - } else { - address.hostname - } - } - - fun create(scope: CoroutineScope): UdpServer { + fun create(scope: CoroutineScope, addressResolver: (InetSocketAddress) -> String): UdpServer { val context = Context.create( initialState = INITIAL_STATE, scope = scope, behaviours = listOf(UdpServerBaseBehaviour), name = "UdpServer", ) - val server = UdpServer(context) + val server = UdpServer(context, addressResolver) server.startObserving() return server } 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 47d02bb7b1..221c953373 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -26,6 +26,7 @@ import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resolveConfigDirectory import dev.slimevr.skeleton.Skeleton import dev.slimevr.trackingchecklist.TrackingChecklist +import dev.slimevr.desktop.udp.resolveDesktopUdpAddress import dev.slimevr.udp.UdpServer import dev.slimevr.util.safeLaunch import dev.slimevr.vmc.VMCManager @@ -52,7 +53,7 @@ fun main(args: Array) = runBlocking { val provisioningManager = ProvisioningManager.create(ctx = phase1, scope = this) val heightCalibrationManager = HeightCalibrationManager.create(ctx = phase1, scope = this) val trackingChecklist = TrackingChecklist.create(scope = this) - val udpServer = UdpServer.create(scope = this) + val udpServer = UdpServer.create(scope = this, addressResolver = ::resolveDesktopUdpAddress) val bvhManager = BVHManager.create(skeleton = skeleton, storage = storage, scope = this) val vmcManager = VMCManager.create(skeleton = skeleton, ctx = phase1, scope = this) val vrcOscManager = VRCOSCManager.create( diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/udp/address.kt b/server/desktop/src/main/java/dev/slimevr/desktop/udp/address.kt new file mode 100644 index 0000000000..fafb58049b --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/udp/address.kt @@ -0,0 +1,8 @@ +package dev.slimevr.desktop.udp + +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.toJavaAddress + +// Prevent DNS lookup when getting the address +fun resolveDesktopUdpAddress(addr: InetSocketAddress): String = + (addr.toJavaAddress() as java.net.InetSocketAddress).hostString From 256574dc2c318bc7fface2066e3e43c45096d84a Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 26 May 2026 13:02:38 +0200 Subject: [PATCH 131/350] Sync solarxr + apply back https://github.com/SlimeVR/SlimeVR-Server/pull/1792 --- .../src/main/java/dev/slimevr/driver/behaviours.kt | 1 - .../main/java/dev/slimevr/provisioning/module.kt | 2 +- .../src/main/java/dev/slimevr/desktop/hid/hid.kt | 14 ++++++++++---- solarxr-protocol | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index b3217cbff6..24a92b2fe2 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -86,7 +86,6 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { private fun handleTrackerAdded(receiver: DriverBridge, id: Int, name: String, manufacturer: String, serial: String) { val server = receiver.appContext.server - val settings = receiver.appContext.config.settings val scope = server.context.scope val existingTracker = server.context.state.value.trackers.values .find { tracker -> tracker.context.state.value.hardwareId == serial } diff --git a/server/core/src/main/java/dev/slimevr/provisioning/module.kt b/server/core/src/main/java/dev/slimevr/provisioning/module.kt index 109e776da3..5f5d99f435 100644 --- a/server/core/src/main/java/dev/slimevr/provisioning/module.kt +++ b/server/core/src/main/java/dev/slimevr/provisioning/module.kt @@ -77,7 +77,7 @@ data class ProvisioningManager( // Any outcome (success or failure) waits for the port to disconnect // before looking for the next tracker - serialServer.context.state.filter { portLocation !in it.availablePorts }.first() + serialServer.context.state.first { portLocation !in it.availablePorts } context.dispatch(ProvisioningActions.Clear) } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index 30ee0aa035..9e5aa55de5 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -22,11 +22,17 @@ import solarxr_protocol.datatypes.TrackerStatus private const val HID_POLL_INTERVAL_MS = 3000L -private const val HID_TRACKER_RECEIVER_VID = 0x1209 -private const val HID_TRACKER_RECEIVER_PID = 0x7690 -private const val HID_TRACKER_PID = 0x7692 +private data class HidProductRule(val vendorId: Int, val productId: Int, val productMask: Int = 0xFFFF) -private fun isCompatibleDevice(vid: Int, pid: Int) = vid == HID_TRACKER_RECEIVER_VID && (pid == HID_TRACKER_RECEIVER_PID || pid == HID_TRACKER_PID) +private val HID_PRODUCT_RULES = listOf( + HidProductRule(0x1209, 0x7690), // SlimeVR receiver + HidProductRule(0x1209, 0x7692), // SlimeVR tracker direct + HidProductRule(0x4E76, 0xD200, 0xFF00), // Gestures Inc. D2XX +) + +private fun isCompatibleDevice(vid: Int, pid: Int) = HID_PRODUCT_RULES.any { rule -> + vid == rule.vendorId && (pid and rule.productMask) == rule.productId +} private val hidSpec = HidServicesSpecification().apply { isAutoStart = false } diff --git a/solarxr-protocol b/solarxr-protocol index 311db6ba7c..44f8afe88e 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 311db6ba7cb081252ac1d64a0aeffbb783bd072e +Subproject commit 44f8afe88e335da779c0fe3092f752b356d65e37 From 11783de08b410ea15f5ae032e459c0952b63d199 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 26 May 2026 13:21:36 +0200 Subject: [PATCH 132/350] Port https://github.com/SlimeVR/SlimeVR-Server/pull/1760 --- server/core/src/main/java/dev/slimevr/hid/packets.kt | 6 +++--- .../core/src/main/java/dev/slimevr/udp/behaviours.kt | 12 +++++++++--- server/core/src/main/java/dev/slimevr/udp/packets.kt | 9 ++++----- .../desktop/src/main/java/dev/slimevr/solarxr/ipc.kt | 3 ++- solarxr-protocol | 2 +- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/hid/packets.kt b/server/core/src/main/java/dev/slimevr/hid/packets.kt index b47270df20..142becab96 100644 --- a/server/core/src/main/java/dev/slimevr/hid/packets.kt +++ b/server/core/src/main/java/dev/slimevr/hid/packets.kt @@ -25,7 +25,7 @@ data class HIDDeviceInfo( val boardType: BoardType, val mcuType: McuType, val firmware: String, - val batteryLevel: Float, + val batteryLevel: Float?, val batteryVoltage: Float, val rssi: Int, ) : HIDPacket @@ -42,7 +42,7 @@ data class HIDRotationBattery( override val hidId: Int, val rotation: Quaternion, val acceleration: Vector3, - val batteryLevel: Float, + val batteryLevel: Float?, val batteryVoltage: Float, val rssi: Int, ) : HIDPacket @@ -121,7 +121,7 @@ private fun decodeAccel(data: ByteArray, offset: Int): Vector3 { ) } -private fun decodeBattery(raw: Int): Float = if (raw == 128) 1f else (raw and 127).toFloat() / 100f +private fun decodeBattery(raw: Int): Float? = if (raw == 128) null else (raw and 127).toFloat() / 100f private fun decodeBatteryVoltage(raw: Int): Float = (raw.toFloat() + 245f) / 100f diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index e835b559c6..96ad98b96a 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -261,10 +261,16 @@ object DeviceStatsBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { receiver.packetEvents.onPacket { event -> val device = receiver.getDevice() ?: return@onPacket + val voltage = event.data.voltage + val level = event.data.level + val batteryLevel = if (voltage != null) { + // Gate on voltage validity: too low/high means no battery or measurement error + if (voltage > 2f && voltage < 6f) if (level < 0.01f) null else level else null + } else { + level + } device.context.dispatch( - DeviceActions.Update { - copy(batteryLevel = event.data.level, batteryVoltage = event.data.voltage) - }, + DeviceActions.Update { copy(batteryLevel = batteryLevel, batteryVoltage = voltage) }, ) } diff --git a/server/core/src/main/java/dev/slimevr/udp/packets.kt b/server/core/src/main/java/dev/slimevr/udp/packets.kt index 231e233e44..bf7b8b7650 100644 --- a/server/core/src/main/java/dev/slimevr/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/udp/packets.kt @@ -271,12 +271,11 @@ data class Serial(val serial: String = "") : UDPPacket { } } -data class BatteryLevel(val voltage: Float = 0f, val level: Float = 0f) : UDPPacket { +data class BatteryLevel(val voltage: Float?, val level: Float) : UDPPacket { companion object { - fun read(src: Source): BatteryLevel { - val f = src.readSafeFloat() - return if (src.remaining >= 4) BatteryLevel(f, src.readSafeFloat()) else BatteryLevel(0f, f) - } + fun read(src: Source): BatteryLevel = + if (src.remaining >= 8) BatteryLevel(src.readSafeFloat(), src.readSafeFloat()) + else BatteryLevel(null, src.readSafeFloat()) } } diff --git a/server/desktop/src/main/java/dev/slimevr/solarxr/ipc.kt b/server/desktop/src/main/java/dev/slimevr/solarxr/ipc.kt index e0545bf8e7..ec66d0024a 100644 --- a/server/desktop/src/main/java/dev/slimevr/solarxr/ipc.kt +++ b/server/desktop/src/main/java/dev/slimevr/solarxr/ipc.kt @@ -32,7 +32,8 @@ suspend fun handleSolarXRBridge( try { messages.collect { bytes -> - onSolarXRMessage(MessageBundle.fromByteBuffer(JvmFlatBufferReader(ByteBuffer.wrap(bytes))), bridge) + val reader = JvmFlatBufferReader(ByteBuffer.wrap(bytes)) + onSolarXRMessage(MessageBundle.decode(reader, reader.getInt(0)), bridge) } } finally { bridge.disconnect() diff --git a/solarxr-protocol b/solarxr-protocol index 44f8afe88e..561febbfb5 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 44f8afe88e335da779c0fe3092f752b356d65e37 +Subproject commit 561febbfb5d18bd0998e2c8edf9514224aba23e8 From 3f7580ac1a48baf93433688d5a17a05b9897af5b Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 26 May 2026 13:43:34 +0200 Subject: [PATCH 133/350] Port https://github.com/SlimeVR/SlimeVR-Server/pull/1761 --- .../main/java/dev/slimevr/hid/behaviours.kt | 65 +++++++++++++++++++ .../src/main/java/dev/slimevr/hid/module.kt | 1 + .../src/main/java/dev/slimevr/hid/packets.kt | 19 +++++- 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 8623c3b28f..9d015d3933 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -7,6 +7,10 @@ import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions +import dev.slimevr.util.safeLaunch +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import solarxr_protocol.datatypes.TrackerStatus object HIDRegistrationBehaviour : HIDReceiverBehaviour { @@ -157,6 +161,67 @@ object HIDBatteryBehaviour : HIDReceiverBehaviour { DeviceActions.Update { copy(signalStrength = packet.rssi) }, ) } + + receiver.packetEvents.onPacket { packet -> + receiver.getDevice(packet.hidId)?.context?.dispatch( + DeviceActions.Update { copy(signalStrength = packet.rssi) }, + ) + } + } +} + +private const val HID_TIMEOUT_MS = 2_000L + +object HIDSleepBehaviour : HIDReceiverBehaviour { + override fun observe(receiver: HIDReceiver) { + val sleepJobs = mutableMapOf() + val idleJobs = mutableMapOf() + + fun scheduleSleep(hidId: Int, timeoutMs: Int) { + if (timeoutMs == 0) return + sleepJobs[hidId]?.cancel() + if (timeoutMs == 65535) { + sleepJobs.remove(hidId) + return + } + sleepJobs[hidId] = receiver.context.scope.safeLaunch { + delay(timeoutMs.toLong()) + receiver.getTracker(hidId)?.context?.dispatch(TrackerActions.SetStatus(TrackerStatus.TIMED_OUT)) + } + } + + fun armIdleTimeout(hidId: Int) { + idleJobs[hidId]?.cancel() + idleJobs[hidId] = receiver.context.scope.safeLaunch { + delay(HID_TIMEOUT_MS) + receiver.getTracker(hidId)?.context?.dispatch(TrackerActions.SetStatus(TrackerStatus.TIMED_OUT)) + } + } + + fun onPacket(hidId: Int) { + val tracker = receiver.getTracker(hidId) ?: return + if (tracker.context.state.value.status == TrackerStatus.TIMED_OUT) { + sleepJobs[hidId]?.cancel() + sleepJobs.remove(hidId) + tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) + } + armIdleTimeout(hidId) + } + + receiver.packetEvents.onPacket { packet -> + onPacket(packet.hidId) + scheduleSleep(packet.hidId, packet.timeout) + } + + receiver.packetEvents.onPacket { packet -> + onPacket(packet.hidId) + scheduleSleep(packet.hidId, packet.timeout) + } + + receiver.packetEvents.onPacket { packet -> onPacket(packet.hidId) } + receiver.packetEvents.onPacket { packet -> onPacket(packet.hidId) } + receiver.packetEvents.onPacket { packet -> onPacket(packet.hidId) } + receiver.packetEvents.onPacket { packet -> onPacket(packet.hidId) } } } diff --git a/server/core/src/main/java/dev/slimevr/hid/module.kt b/server/core/src/main/java/dev/slimevr/hid/module.kt index e85e63e0b3..958a2fd4bd 100644 --- a/server/core/src/main/java/dev/slimevr/hid/module.kt +++ b/server/core/src/main/java/dev/slimevr/hid/module.kt @@ -65,6 +65,7 @@ class HIDReceiver( HIDBatteryBehaviour, HIDStatusBehaviour, HIDPacketLossBehaviour, + HIDSleepBehaviour, ) val context = Context.create( diff --git a/server/core/src/main/java/dev/slimevr/hid/packets.kt b/server/core/src/main/java/dev/slimevr/hid/packets.kt index 142becab96..47c0042d2a 100644 --- a/server/core/src/main/java/dev/slimevr/hid/packets.kt +++ b/server/core/src/main/java/dev/slimevr/hid/packets.kt @@ -63,15 +63,24 @@ data class HIDRotationMag( val magnetometer: Vector3, ) : HIDPacket -/** Button state + compact exp-map quaternion + Q7 acceleration + rssi (type 7). */ +/** Button state + timeout hint + compact exp-map quaternion + Q7 acceleration + rssi (type 7). */ data class HIDRotationButton( override val hidId: Int, val button: Int, + val timeout: Int, val rotation: Quaternion, val acceleration: Vector3, val rssi: Int, ) : HIDPacket +/** Button state + timeout hint + rssi, no rotation (type 6). */ +data class HIDData( + override val hidId: Int, + val button: Int, + val timeout: Int, + val rssi: Int, +) : HIDPacket + private const val HID_PACKET_SIZE = 16 private val AXES_OFFSET = Quaternion.fromRotationVector(-PI.toFloat() / 2f, 0f, 0f) @@ -197,9 +206,17 @@ private fun parseSingleHIDPacket(data: ByteArray, i: Int): HIDPacket? { ) } + 6 -> HIDData( + hidId = hidId, + button = data[i + 2].toUByte().toInt(), + timeout = data[i + 3].toUByte().toInt() shl 8 or data[i + 4].toUByte().toInt(), + rssi = -data[i + 15].toUByte().toInt(), + ) + 7 -> HIDRotationButton( hidId = hidId, button = data[i + 2].toUByte().toInt(), + timeout = data[i + 3].toUByte().toInt() shl 8 or data[i + 4].toUByte().toInt(), rotation = decodeExpMapQuat(data, i + 5), acceleration = decodeAccel(data, i + 9), rssi = -data[i + 15].toUByte().toInt(), From e1802987ca0c910a770e39d0ec8fbf60f2a81e72 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 26 May 2026 14:24:39 +0200 Subject: [PATCH 134/350] Port https://github.com/SlimeVR/SlimeVR-Server/pull/1763 --- .../src/main/java/dev/slimevr/app-context.kt | 2 + .../main/java/dev/slimevr/feature-flags.kt | 7 ++ .../core/src/main/java/dev/slimevr/logger.kt | 1 + .../dev/slimevr/solarxr/installed-info.kt | 13 ++++ .../main/java/dev/slimevr/solarxr/module.kt | 1 + .../src/test/java/dev/slimevr/TestServer.kt | 1 + .../src/main/java/dev/slimevr/desktop/Main.kt | 26 ++++++++ .../dev/slimevr/desktop/install/install.kt | 31 +++++++++ .../java/dev/slimevr/desktop/install/linux.kt | 52 +++++++++++++++ .../dev/slimevr/desktop/install/windows.kt | 65 +++++++++++++++++++ .../src/main/java/dev/slimevr/platform.kt | 6 ++ 11 files changed, 205 insertions(+) create mode 100644 server/core/src/main/java/dev/slimevr/feature-flags.kt create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/install/install.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/install/linux.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/install/windows.kt diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index 910712a807..ab110bf747 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -27,6 +27,7 @@ data class Phase1Context( ) : Phase1ContextProvider interface AppContextProvider : Phase1ContextProvider { + val featureFlags: FeatureFlags val skeleton: Skeleton val firmwareManager: FirmwareManager val vrcConfigManager: VRCConfigManager? @@ -46,6 +47,7 @@ class AppContext( override val server: VRServer, override val config: AppConfig, override val serialServer: SerialServer, + override val featureFlags: FeatureFlags, override val skeleton: Skeleton, override val firmwareManager: FirmwareManager, override val vrcConfigManager: VRCConfigManager?, diff --git a/server/core/src/main/java/dev/slimevr/feature-flags.kt b/server/core/src/main/java/dev/slimevr/feature-flags.kt new file mode 100644 index 0000000000..75ba7b43bc --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/feature-flags.kt @@ -0,0 +1,7 @@ +package dev.slimevr + +data class FeatureFlags( + var steam: Boolean = false, + var skipCheckUdev: Boolean = false, + var udevRulesInstalled: Boolean? = null, +) \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index 351a475bd0..f328526e87 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -21,6 +21,7 @@ object AppLogger { val bvh = logger("BVH") val vmc = logger("VMC") val oscQuery = logger("OSCQuery") + val install = logger("Install") val coroutines = noCoLogger("Coroutines") init { diff --git a/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt b/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt new file mode 100644 index 0000000000..d179e90f0b --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt @@ -0,0 +1,13 @@ +package dev.slimevr.solarxr + +import solarxr_protocol.rpc.InstalledInfoRequest +import solarxr_protocol.rpc.InstalledInfoResponse + +object InstalledInfoBehaviour : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { + receiver.rpcDispatcher.on { + val udevRulesInstalled = receiver.appContext.featureFlags.udevRulesInstalled ?: return@on + receiver.sendRpc(InstalledInfoResponse(isudevinstalled = udevRulesInstalled)) + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index f7e7012614..42c095be25 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -80,6 +80,7 @@ class SolarXRBridge( add(MagBehaviour(appContext)) add(KnownTrackersBehaviour(appContext.config.settings)) add(BvhBehaviour(appContext.bvhManager)) + add(InstalledInfoBehaviour) } fun create( diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 767eb44916..bfefe7abe4 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -103,6 +103,7 @@ private object NoopConfigStorage : ConfigStorage { } abstract class TestAppContext : AppContextProvider { + override val featureFlags: FeatureFlags = FeatureFlags() override val config: AppConfig get() = error("not used in test") override val serialServer: SerialServer get() = error("not used in test") override val firmwareManager: FirmwareManager get() = error("not used in test") 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 221c953373..dcfc67209d 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -4,6 +4,7 @@ package dev.slimevr.desktop import dev.slimevr.AppContext import dev.slimevr.CURRENT_PLATFORM +import dev.slimevr.FeatureFlags import dev.slimevr.Phase1Context import dev.slimevr.Platform import dev.slimevr.VRServer @@ -12,6 +13,8 @@ import dev.slimevr.config.AppConfig import dev.slimevr.context.debug.ContextDebug import dev.slimevr.desktop.config.DesktopConfigStorage import dev.slimevr.desktop.hid.createDesktopHIDManager +import dev.slimevr.desktop.install.executeShellCommand +import dev.slimevr.desktop.install.runInstaller import dev.slimevr.desktop.ipc.createIpcServers import dev.slimevr.desktop.ipc.createSolarXRWebsocketServer import dev.slimevr.desktop.networkprofile.setupDesktopNetworkProfileChecker @@ -38,6 +41,28 @@ fun main(args: Array) = runBlocking { ContextDebug.enabled = System.getProperty("slimevr.debug.context") == "true" || System.getenv("SLIMEVR_DEBUG_CONTEXT") == "true" + val featureFlags = FeatureFlags() + for (arg in args) { + when (arg) { + "--steam", "-s" -> featureFlags.steam = true + "--install", "-i" -> { runInstaller(featureFlags); return@runBlocking } + "--no-udev", "-u" -> featureFlags.skipCheckUdev = true + } + } + if (CURRENT_PLATFORM != Platform.LINUX) featureFlags.skipCheckUdev = true + + val isInstallDisabled = System.getenv("SLIME_SERVER_DISABLE_INSTALLER")?.toIntOrNull() + if (featureFlags.steam && isInstallDisabled != 1) runInstaller(featureFlags) + + if (!featureFlags.skipCheckUdev) { + val command = if (featureFlags.steam) { + arrayOf("steam-runtime-launch-client", "--alongside-steam", "--", "udevadm", "cat") + } else { + arrayOf("udevadm", "cat") + } + featureFlags.udevRulesInstalled = executeShellCommand(*command)?.contains("slime") + } + val configFolder = resolveConfigDirectory() ?: error("Unable to resolve config folder") val storage = DesktopConfigStorage(configFolder.toFile()) val config = AppConfig.create(this, storage = storage) @@ -66,6 +91,7 @@ fun main(args: Array) = runBlocking { server = server, config = config, serialServer = serialServer, + featureFlags = featureFlags, skeleton = skeleton, firmwareManager = firmwareManager, vrcConfigManager = vrcConfigManager, diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/install/install.kt b/server/desktop/src/main/java/dev/slimevr/desktop/install/install.kt new file mode 100644 index 0000000000..c355718784 --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/install/install.kt @@ -0,0 +1,31 @@ +package dev.slimevr.desktop.install + +import dev.slimevr.AppLogger +import dev.slimevr.CURRENT_PLATFORM +import dev.slimevr.FeatureFlags +import dev.slimevr.Platform +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.IOException + +suspend fun runInstaller(featureFlags: FeatureFlags) { + when (CURRENT_PLATFORM) { + Platform.LINUX -> installLinux(featureFlags) + Platform.WINDOWS -> installWindows() + else -> AppLogger.install.warn("Updater doesn't support operating system '$CURRENT_PLATFORM'") + } +} + +suspend fun executeShellCommand(vararg command: String): String? = try { + val process = withContext(Dispatchers.IO) { + ProcessBuilder(*command) + .redirectErrorStream(true) + .start() + } + process.inputStream.bufferedReader().readText().also { + process.waitFor() + } +} catch (e: IOException) { + AppLogger.install.warn("Error executing shell command: ${e.message}") + null +} \ No newline at end of file diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/install/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/install/linux.kt new file mode 100644 index 0000000000..fb94b2c45a --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/install/linux.kt @@ -0,0 +1,52 @@ +package dev.slimevr.desktop.install + +import dev.slimevr.AppLogger +import dev.slimevr.FeatureFlags + +private const val LINUX_STEAM_DRIVER_DIRECTORY = "slimevr-openvr-driver-x64-linux" +private const val LINUX_FEEDER_DIRECTORY = "SlimeVR-Feeder-App-Linux" + +suspend fun installLinux(featureFlags: FeatureFlags) { + installLinuxSteamVRDriver() + installLinuxFeeder(featureFlags) +} + +private suspend fun installLinuxSteamVRDriver() { + val path = System.getProperty("user.dir") + val vrPathReg = "${System.getProperty("user.home")}/.steam/steam/steamapps/common/SteamVR/bin/vrpathreg.sh" + val existing = executeShellCommand(vrPathReg) + if (existing == null) { + AppLogger.install.warn("SteamVR driver installation failed") + return + } + if (existing.contains("slimevr")) { + AppLogger.install.info("SteamVR driver is already installed") + return + } + executeShellCommand(vrPathReg, "adddriver", "$path/$LINUX_STEAM_DRIVER_DIRECTORY") + if (executeShellCommand(vrPathReg)?.contains("slimevr") != true) { + AppLogger.install.warn("Failed to install SteamVR driver") + return + } + AppLogger.install.info("SteamVR driver successfully installed") +} + +private suspend fun installLinuxFeeder(featureFlags: FeatureFlags) { + val path = System.getProperty("user.dir") + executeShellCommand("chmod", "+x", "$path/$LINUX_FEEDER_DIRECTORY/SlimeVR-Feeder-App") + val command = if (featureFlags.steam) { + arrayOf("steam-runtime-launch-client", "--alongside-steam", "--", "$path/$LINUX_FEEDER_DIRECTORY/SlimeVR-Feeder-App", "--install") + } else { + arrayOf("$path/$LINUX_FEEDER_DIRECTORY/SlimeVR-Feeder-App", "--install") + } + val output = executeShellCommand(*command) + if (output == null) { + AppLogger.install.warn("Error installing feeder") + return + } + if (output.lowercase().contains("manifest is not installed")) { + AppLogger.install.warn("Could not install feeder application") + } else { + AppLogger.install.info("Successfully installed feeder application") + } +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/install/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/install/windows.kt new file mode 100644 index 0000000000..1a34d882c6 --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/install/windows.kt @@ -0,0 +1,65 @@ +package dev.slimevr.desktop.install + +import com.sun.jna.platform.win32.Advapi32Util +import com.sun.jna.platform.win32.WinReg +import dev.slimevr.AppLogger + +private const val WINDOWS_STEAMVR_DRIVER_DIRECTORY = "slimevr-openvr-driver-win64" +private const val WINDOWS_FEEDER_DIRECTORY = "SlimeVR-Feeder-App-win64" + +suspend fun installWindows() { + installWindowsSteamVRDriver() + installWindowsFeeder() +} + +private suspend fun getKeyByPath(hkey: WinReg.HKEY, path: String): Map { + val keysMap = mutableMapOf() + try { + Advapi32Util.registryGetValues(hkey, path).forEach { + keysMap[it.key.replace("""_h\d+$""".toRegex(), "")] = it.value.toString() + } + } catch (e: Exception) { + AppLogger.install.error("[RegEdit] Error reading values from registry: ${e.message}") + } + return keysMap +} + +private suspend fun installWindowsSteamVRDriver() { + val path = System.getProperty("user.dir") + val regQuery = getKeyByPath(WinReg.HKEY_LOCAL_MACHINE, "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Steam App 250820") + val steamVRLocation = regQuery["InstallLocation"] + if (steamVRLocation == null || !steamVRLocation.contains("SteamVR")) { + AppLogger.install.warn("Can't find SteamVR, unable to install SteamVR driver") + return + } + val vrPathReg = "${steamVRLocation}\\bin\\win64\\vrpathreg.exe" + val existing = executeShellCommand(vrPathReg, "finddriver", "slimevr") + if (existing == null) { + AppLogger.install.warn("Error installing SteamVR driver") + return + } + if (existing.contains("slimevr")) { + AppLogger.install.info("SteamVR driver is already installed") + return + } + executeShellCommand(vrPathReg, "adddriver", "${path}\\${WINDOWS_STEAMVR_DRIVER_DIRECTORY}") + if (executeShellCommand(vrPathReg, "finddriver", "slimevr")?.contains("slimevr") != true) { + AppLogger.install.warn("Failed to install SlimeVR driver") + return + } + AppLogger.install.info("SteamVR driver successfully installed") +} + +private suspend fun installWindowsFeeder() { + val path = System.getProperty("user.dir") + val output = executeShellCommand("${path}\\${WINDOWS_FEEDER_DIRECTORY}\\SlimeVR-Feeder-App.exe", "--install") + if (output == null) { + AppLogger.install.warn("Error installing feeder") + return + } + if (output.lowercase().contains("manifest is not installed")) { + AppLogger.install.warn("Could not install feeder application") + } else { + AppLogger.install.info("Successfully installed feeder application") + } +} \ No newline at end of file diff --git a/server/desktop/src/main/java/dev/slimevr/platform.kt b/server/desktop/src/main/java/dev/slimevr/platform.kt index ffcd82f89a..b43cc52411 100644 --- a/server/desktop/src/main/java/dev/slimevr/platform.kt +++ b/server/desktop/src/main/java/dev/slimevr/platform.kt @@ -42,6 +42,12 @@ fun getSocketDirectory(): String { if (envDir != null) return envDir if (CURRENT_PLATFORM == Platform.LINUX) { + val isPressureVessel = System.getenv("PRESSURE_VESSEL_RUNTIME")?.isNotEmpty() == true + if (isPressureVessel) { + val dir = System.getenv("XDG_CONFIG_HOME")?.let { Path(it, SLIMEVR_IDENTIFIER).toString() } + ?: System.getenv("HOME")?.let { Path(it, ".local", "share", SLIMEVR_IDENTIFIER).toString() } + if (dir != null) return dir + } val xdg = System.getenv("XDG_RUNTIME_DIR") if (xdg != null) return xdg } From 67ae02a1b0ca38ae8b02e8e9f908dcdb01519711 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Tue, 26 May 2026 15:44:30 +0200 Subject: [PATCH 135/350] HID battery runtime --- .../src/main/java/dev/slimevr/device/module.kt | 2 ++ .../src/main/java/dev/slimevr/hid/behaviours.kt | 10 ++++++++++ .../src/main/java/dev/slimevr/hid/packets.kt | 17 +++++++++++++++++ .../main/java/dev/slimevr/solarxr/datafeed.kt | 1 + 4 files changed, 30 insertions(+) diff --git a/server/core/src/main/java/dev/slimevr/device/module.kt b/server/core/src/main/java/dev/slimevr/device/module.kt index bb6b8db6eb..7036dfa8d1 100644 --- a/server/core/src/main/java/dev/slimevr/device/module.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -25,6 +25,7 @@ data class DeviceState( val macAddress: String?, val batteryLevel: Float?, val batteryVoltage: Float?, + val batteryRemainingRuntime: Long?, val ping: Long?, val signalStrength: Int?, val firmware: String?, @@ -65,6 +66,7 @@ class Device( manufacturer = manufacturer, batteryLevel = null, batteryVoltage = null, + batteryRemainingRuntime = null, origin = origin, address = address, macAddress = macAddress, diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 9d015d3933..aa5e88000f 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -167,6 +167,15 @@ object HIDBatteryBehaviour : HIDReceiverBehaviour { DeviceActions.Update { copy(signalStrength = packet.rssi) }, ) } + + receiver.packetEvents.onPacket { packet -> + // -1: not yet known (keep existing value); 0: N/A (e.g. charging) + if (packet.runtime >= 0) { + receiver.getDevice(packet.hidId)?.context?.dispatch( + DeviceActions.Update { copy(batteryRemainingRuntime = packet.runtime) }, + ) + } + } } } @@ -222,6 +231,7 @@ object HIDSleepBehaviour : HIDReceiverBehaviour { receiver.packetEvents.onPacket { packet -> onPacket(packet.hidId) } receiver.packetEvents.onPacket { packet -> onPacket(packet.hidId) } receiver.packetEvents.onPacket { packet -> onPacket(packet.hidId) } + receiver.packetEvents.onPacket { packet -> onPacket(packet.hidId) } } } diff --git a/server/core/src/main/java/dev/slimevr/hid/packets.kt b/server/core/src/main/java/dev/slimevr/hid/packets.kt index 47c0042d2a..e562e964d9 100644 --- a/server/core/src/main/java/dev/slimevr/hid/packets.kt +++ b/server/core/src/main/java/dev/slimevr/hid/packets.kt @@ -73,6 +73,9 @@ data class HIDRotationButton( val rssi: Int, ) : HIDPacket +/** Battery remaining runtime in milliseconds (type 5). -1 = unknown, 0 = N/A (e.g. charging). */ +data class HIDRuntime(override val hidId: Int, val runtime: Long) : HIDPacket + /** Button state + timeout hint + rssi, no rotation (type 6). */ data class HIDData( override val hidId: Int, @@ -99,6 +102,15 @@ private fun readLE48Unsigned(data: ByteArray, offset: Int): ULong = (data[offset ((data[offset + 4].toULong() and 0xFFu) shl 32) or ((data[offset + 5].toULong() and 0xFFu) shl 40) +private fun readLE64Unsigned(data: ByteArray, offset: Int): Long = (data[offset].toUByte().toLong()) or + (data[offset + 1].toUByte().toLong() shl 8) or + (data[offset + 2].toUByte().toLong() shl 16) or + (data[offset + 3].toUByte().toLong() shl 24) or + (data[offset + 4].toUByte().toLong() shl 32) or + (data[offset + 5].toUByte().toLong() shl 40) or + (data[offset + 6].toUByte().toLong() shl 48) or + (data[offset + 7].toUByte().toLong() shl 56) + private fun decodeQ15Quat(data: ByteArray, offset: Int): Quaternion { val scale = 1f / 32768f val x = readLE16Signed(data, offset).toShort().toFloat() * scale @@ -206,6 +218,11 @@ private fun parseSingleHIDPacket(data: ByteArray, i: Int): HIDPacket? { ) } + 5 -> HIDRuntime( + hidId = hidId, + runtime = readLE64Unsigned(data, i + 2), + ) + 6 -> HIDData( hidId = hidId, button = data[i + 2].toUByte().toInt(), diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 844cab488b..0df486e42b 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -75,6 +75,7 @@ private fun createDevice( hardwareStatus = HardwareStatus( batteryVoltage = device.batteryVoltage, batteryPctEstimate = device.batteryLevel?.let { (it * 100).toUInt().toUByte() }, + batteryRuntimeEstimate = device.batteryRemainingRuntime, ping = device.ping?.toUShort(), rssi = device.signalStrength?.toShort(), packetsReceived = device.packetsReceived.toInt(), From 559c076b26b3e93989cbfe06eb874fe5025ef1a2 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Wed, 27 May 2026 23:40:48 -0400 Subject: [PATCH 136/350] Fix OSCQuery unit test --- .../src/test/java/dev/slimevr/oscquery/OscQueryServerTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/core/src/test/java/dev/slimevr/oscquery/OscQueryServerTest.kt b/server/core/src/test/java/dev/slimevr/oscquery/OscQueryServerTest.kt index f506732873..11ded211ee 100644 --- a/server/core/src/test/java/dev/slimevr/oscquery/OscQueryServerTest.kt +++ b/server/core/src/test/java/dev/slimevr/oscquery/OscQueryServerTest.kt @@ -38,7 +38,7 @@ class OscQueryServerTest { assertEquals("_osc._udp", createdServices[1].type) assertTrue(createdServices.all { it.isRegistered.value }) - val hostInfo = json.parseToJsonElement(readUrl("http://127.0.0.1:${server.oscQueryPort}/HOST_INFO")).jsonObject + val hostInfo = json.parseToJsonElement(readUrl("http://127.0.0.1:${server.oscQueryPort}/?HOST_INFO")).jsonObject assertEquals("SlimeVR", hostInfo["NAME"]?.jsonPrimitive?.content) assertEquals(9000, hostInfo["OSC_PORT"]?.jsonPrimitive?.content?.toInt()) From 24da10715fcbcdf0280d7142088313ff085a11d1 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Wed, 27 May 2026 23:41:43 -0400 Subject: [PATCH 137/350] Make safeLaunch inherit CoroutineScope's handler --- .../src/main/java/dev/slimevr/util/SafeLaunch.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/util/SafeLaunch.kt b/server/core/src/main/java/dev/slimevr/util/SafeLaunch.kt index 9675e9536a..e51913285e 100644 --- a/server/core/src/main/java/dev/slimevr/util/SafeLaunch.kt +++ b/server/core/src/main/java/dev/slimevr/util/SafeLaunch.kt @@ -25,10 +25,14 @@ fun CoroutineScope.safeLaunch( start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> Unit, ): Job { - val handler = context[CoroutineExceptionHandler] ?: CoroutineExceptionHandler { ctx, throwable -> - val name = ctx[CoroutineName]?.name ?: "UnknownScope" - AppLogger.coroutines.error(throwable, "Unhandled exception in safeLaunch (scope: $name)") - } + val handler = context[CoroutineExceptionHandler] + ?: this.coroutineContext[CoroutineExceptionHandler] + ?: CoroutineExceptionHandler { ctx, throwable -> + val name = ctx[CoroutineName]?.name ?: "UnknownScope" + AppLogger.coroutines.error( + throwable, "Unhandled exception in safeLaunch (scope: $name)" + ) + } //noinspection RAW_LAUNCH_USAGE return this.launch(context + handler, start = start, block = block) } From 83f48a7ebae1ab2b3e11bc760e8256af9d741c04 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 28 May 2026 15:28:15 +0200 Subject: [PATCH 138/350] safeLaunch forgotten --- server/core/src/main/java/dev/slimevr/firmware/module.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/firmware/module.kt b/server/core/src/main/java/dev/slimevr/firmware/module.kt index 0d84fbf9a0..a8e1d528ae 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/module.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/module.kt @@ -61,7 +61,7 @@ class FirmwareManager( server: VRServer, ) { runningJobs[portLocation]?.cancelAndJoin() - runningJobs[portLocation] = scope.launch { + runningJobs[portLocation] = scope.safeLaunch { doSerialFlash( portLocation = portLocation, parts = parts, From 16d2e0b46b17eb151745ab1e01a23d20d6814a89 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Thu, 28 May 2026 10:48:07 -0400 Subject: [PATCH 139/350] Apply session calibration to tracker --- .../java/dev/slimevr/tracker/behaviours.kt | 43 +++++++++++++++++-- .../main/java/dev/slimevr/tracker/module.kt | 10 +++-- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 34570a0a78..70e17d3604 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -2,6 +2,8 @@ package dev.slimevr.tracker import dev.slimevr.skeleton.SkeletonActions import dev.slimevr.util.safeLaunch +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy @@ -88,10 +90,43 @@ object TrackerBasicBehaviour : TrackerBehaviour { is TrackerActions.SetStatus -> state.copy(status = action.status) - is TrackerActions.SetRotation -> state.copy( - rawRotation = action.rotation ?: state.rawRotation, - rawAcceleration = action.acceleration ?: state.rawAcceleration, - ) + is TrackerActions.SetRotation -> { + val cal = state.sessionCalibration + + val rawRotation: RawRotation = action.rotation ?: state.rawRotation + val rotation: CalibratedRotation = when { + cal != null && action.rotation != null -> applyCalibration( + rawRotation, + cal.headingCorrection, + cal.attitudeAlignment, + cal.headingAlignment, + ) + + cal != null -> state.rotation + else -> rawRotation + } + + val rawAcceleration: RawAcceleration = + action.acceleration ?: state.rawAcceleration + val acceleration: CalibratedAcceleration = when { + cal != null && action.acceleration != null -> applyCalibration( + rawAcceleration, + rawRotation, + cal.headingCorrection, + cal.headingAlignment, + ) + + cal != null -> state.acceleration + else -> rawAcceleration + } + + state.copy( + rawRotation = rawRotation, + rotation = rotation, + rawAcceleration = rawAcceleration, + acceleration = acceleration, + ) + } } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 1e8755e63a..62dd7a9332 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -23,9 +23,11 @@ data class TrackerState( val sensorType: ImuType?, val bodyPart: BodyPart?, val customName: String?, - val mountingOrientation: Quaternion?, - val rawRotation: Quaternion, - val rawAcceleration: Vector3, + val mountingOrientation: HeadingAlignment?, + val rawRotation: RawRotation, + val rotation: CalibratedRotation, + val rawAcceleration: RawAcceleration, + val acceleration: CalibratedAcceleration, val deviceId: Int, val origin: DeviceOrigin, val tps: UShort, @@ -70,7 +72,9 @@ class Tracker( hardwareId = hardwareId, name = name, rawRotation = Quaternion.IDENTITY, + rotation = Quaternion.IDENTITY, rawAcceleration = Vector3.NULL, + acceleration = Vector3.NULL, bodyPart = null, mountingOrientation = null, origin = origin, From d6e6926d44711ba4b6f12089868306af4ef4df81 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Thu, 28 May 2026 11:18:11 -0400 Subject: [PATCH 140/350] Add "reset" tracker actions --- .../java/dev/slimevr/tracker/behaviours.kt | 41 +++++++++++++++++++ .../main/java/dev/slimevr/tracker/module.kt | 3 ++ .../dev/slimevr/tracker/sessioncalibration.kt | 6 +-- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 70e17d3604..8e3fabbc3d 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -127,6 +127,47 @@ object TrackerBasicBehaviour : TrackerBehaviour { acceleration = acceleration, ) } + + is TrackerActions.FullReset -> { + val headingCorrection = estimateHeadingCorrect( + state.rawRotation, + action.referenceRotation, + ) + val attitudeAlignment = estimateAttitudeAlign( + state.rawRotation, + headingCorrection, + ) + + val sessionCalibration = state.sessionCalibration?.copy( + headingCorrection = headingCorrection, + attitudeAlignment = attitudeAlignment, + ) ?: SessionCalibration( + headingCorrection = headingCorrection, + attitudeAlignment = attitudeAlignment, + ) + + state.copy(sessionCalibration = sessionCalibration) + } + + is TrackerActions.YawReset -> { + val headingCorrection = estimateHeadingCorrect( + state.rawRotation, + action.referenceRotation, + ) + + val sessionCalibration = state.sessionCalibration?.copy( + headingCorrection = headingCorrection, + ) ?: SessionCalibration( + headingCorrection = headingCorrection, + ) + + state.copy(sessionCalibration = sessionCalibration) + } + + is TrackerActions.MountingReset -> { + // TODO: Actually add mounting reset + state + } } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 62dd7a9332..088969802c 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -44,6 +44,9 @@ sealed interface TrackerActions { data class SetMagStatus(val status: MagnetometerStatus) : TrackerActions data class SetStatus(val status: TrackerStatus) : TrackerActions data class SetRotation(val rotation: Quaternion? = null, val acceleration: Vector3? = null) : TrackerActions + data class FullReset(val referenceRotation: Quaternion) : TrackerActions + data class YawReset(val referenceRotation: Quaternion) : TrackerActions + data class MountingReset(val referenceRotation: Quaternion) : TrackerActions } typealias TrackerContext = Context diff --git a/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt index 26082afe38..137355917f 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt @@ -17,9 +17,9 @@ typealias CalibratedRotation = Quaternion typealias CalibratedAcceleration = Vector3 data class SessionCalibration( - val headingCorrection: HeadingCorrection, - val attitudeAlignment: AttitudeAlignment, - val headingAlignment: HeadingAlignment, + val headingCorrection: HeadingCorrection = Quaternion.IDENTITY, + val attitudeAlignment: AttitudeAlignment = Quaternion.IDENTITY, + val headingAlignment: HeadingAlignment = Quaternion.IDENTITY, ) fun applyCalibration( From 861a9fbf1234c3660909d6574d0291dc35a746bf Mon Sep 17 00:00:00 2001 From: loucass003 Date: Fri, 29 May 2026 17:09:39 +0200 Subject: [PATCH 141/350] Android basics (not tested) --- server/android/build.gradle.kts | 19 +- server/android/src/main/AndroidManifest.xml | 3 + .../dev/slimevr/android/ForegroundService.kt | 180 +++++++++- .../src/main/java/dev/slimevr/android/Main.kt | 62 +--- .../java/dev/slimevr/android/MainActivity.kt | 71 ++-- .../dev/slimevr/android/config/storage.kt | 86 +++++ .../main/java/dev/slimevr/android/hid/hid.kt | 188 +++++++++++ .../java/dev/slimevr/android/ipc/ws-server.kt | 75 +++++ .../main/java/dev/slimevr/android/logging.kt | 16 + .../android/serial/AndroidSerialHandler.kt | 314 ------------------ .../java/dev/slimevr/android/serial/flash.kt | 137 ++++++++ .../java/dev/slimevr/android/serial/serial.kt | 197 +++++++++++ .../tracking/trackers/hid/AndroidHIDDevice.kt | 93 ------ .../trackers/hid/AndroidHIDManager.kt | 258 -------------- .../java/dev/slimevr/android/udp/address.kt | 7 + .../dev/slimevr/android/vrchat/oscquery.kt | 20 ++ .../android/src/main/res/values/strings.xml | 1 + server/core/build.gradle.kts | 3 +- .../main/java/dev/slimevr/feature-flags.kt | 1 + .../java/dev/slimevr/firmware/download.kt | 9 +- .../main/java/dev/slimevr/solarxr/datafeed.kt | 3 +- .../dev/slimevr/trackingchecklist/module.kt | 4 +- .../java/dev/slimevr/desktop/ipc/linux.kt | 1 - .../ipc.kt => desktop/ipc/solarxr.kt} | 6 +- .../java/dev/slimevr/desktop/ipc/windows.kt | 1 - .../java/dev/slimevr/desktop/ipc/ws-server.kt | 1 - 26 files changed, 969 insertions(+), 787 deletions(-) create mode 100644 server/android/src/main/java/dev/slimevr/android/config/storage.kt create mode 100644 server/android/src/main/java/dev/slimevr/android/hid/hid.kt create mode 100644 server/android/src/main/java/dev/slimevr/android/ipc/ws-server.kt create mode 100644 server/android/src/main/java/dev/slimevr/android/logging.kt delete mode 100644 server/android/src/main/java/dev/slimevr/android/serial/AndroidSerialHandler.kt create mode 100644 server/android/src/main/java/dev/slimevr/android/serial/flash.kt create mode 100644 server/android/src/main/java/dev/slimevr/android/serial/serial.kt delete mode 100644 server/android/src/main/java/dev/slimevr/android/tracking/trackers/hid/AndroidHIDDevice.kt delete mode 100644 server/android/src/main/java/dev/slimevr/android/tracking/trackers/hid/AndroidHIDManager.kt create mode 100644 server/android/src/main/java/dev/slimevr/android/udp/address.kt create mode 100644 server/android/src/main/java/dev/slimevr/android/vrchat/oscquery.kt rename server/desktop/src/main/java/dev/slimevr/{solarxr/ipc.kt => desktop/ipc/solarxr.kt} (90%) diff --git a/server/android/build.gradle.kts b/server/android/build.gradle.kts index 39180108e9..e2f1a1e4e4 100644 --- a/server/android/build.gradle.kts +++ b/server/android/build.gradle.kts @@ -83,7 +83,7 @@ val deleteTempKeyStore = tasks.register("deleteTempKeyStore") { tasks.withType { compilerOptions { - jvmTarget.set(JvmTarget.JVM_22) + jvmTarget.set(JvmTarget.JVM_24) freeCompilerArgs.set(listOf("-Xvalue-classes")) } } @@ -101,14 +101,13 @@ tasks.withType { repositories { google() + maven(url = "https://jitpack.io") + mavenCentral() } dependencies { implementation(project(":server:core")) - implementation("commons-cli:commons-cli:1.11.0") - implementation("org.apache.commons:commons-lang3:3.20.0") - // Android stuff implementation("androidx.appcompat:appcompat:1.7.1") implementation("androidx.core:core-ktx:1.17.0") @@ -118,8 +117,18 @@ dependencies { androidTestImplementation("androidx.test.ext:junit:1.3.0") androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") - // Serial + // Serial + firmware flashing implementation("com.github.mik3y:usb-serial-for-android:3.7.0") + implementation("com.github.loucass003:EspflashKotlin:v0.11.0") + + // Logging + implementation("io.klogging:klogging:0.11.7") + + // WebSocket server + coroutines + val ktorVersion = "3.4.1" + implementation("io.ktor:ktor-server-cio:$ktorVersion") + implementation("io.ktor:ktor-server-websockets:$ktorVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") } // The android block is where you configure all your Android-specific build options. diff --git a/server/android/src/main/AndroidManifest.xml b/server/android/src/main/AndroidManifest.xml index b6580ed5ac..bc013dcff7 100644 --- a/server/android/src/main/AndroidManifest.xml +++ b/server/android/src/main/AndroidManifest.xml @@ -5,8 +5,11 @@ + + + diff --git a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt index faa6998d32..d61cba3574 100644 --- a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt +++ b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt @@ -1,15 +1,52 @@ package dev.slimevr.android +import android.annotation.SuppressLint import android.app.* +import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo +import android.net.wifi.WifiManager import android.os.Build import android.os.IBinder +import android.os.PowerManager import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.ServiceCompat -import io.eiren.util.logging.LogManager +import dev.slimevr.AppContext +import dev.slimevr.FeatureFlags +import dev.slimevr.Phase1Context +import dev.slimevr.VRServer +import dev.slimevr.android.config.AndroidConfigStorage +import dev.slimevr.android.hid.createAndroidHIDManager +import dev.slimevr.android.ipc.createAndroidSolarXRWebsocketServer +import dev.slimevr.android.serial.AndroidFirmwareFlasher +import dev.slimevr.android.serial.createAndroidSerialServer +import dev.slimevr.android.udp.resolveAndroidUdpAddress +import dev.slimevr.android.vrchat.resolveAndroidOscQueryAddress +import dev.slimevr.bvh.BVHManager +import dev.slimevr.config.AppConfig +import dev.slimevr.firmware.FirmwareManager +import dev.slimevr.heightcalibration.HeightCalibrationManager +import dev.slimevr.networkprofile.NetworkProfileManager +import dev.slimevr.provisioning.ProvisioningManager +import dev.slimevr.skeleton.Skeleton +import dev.slimevr.trackingchecklist.TrackingChecklist +import dev.slimevr.udp.UdpServer +import dev.slimevr.util.safeLaunch +import dev.slimevr.vmc.VMCManager +import dev.slimevr.vrcosc.VRCOSCManager +import io.klogging.noCoLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel + +private val logger = noCoLogger("ForegroundService") + +private const val ACTION_STOP = "dev.slimevr.android.STOP_SERVER" +const val ACTION_FINISH_APP = "dev.slimevr.android.FINISH_APP" /** * ForegroundService helps to keep the SlimeVR Server on Android from being killed. @@ -22,27 +59,37 @@ import io.eiren.util.logging.LogManager * Tested with VRChat on Quest 3 in high population worlds with all avatars on * for over an hour with no shut down of the SlimeVR server. Without this service, * the server would often be shutdown unexpectedly. - * - * Notes: - * Luckily this does not show anything visual over the running app. - * The notification only shows in the alert panel and cannot be dismissed. - * On Quest there's no way to manually kill the Slime Server, so you have to restart the device. */ class ForegroundService : Service() { private val CHANNEL_ID = "SlimeVrForegroundServiceChannel" private val NOTIFICATION_ID = 100 + private var serviceScope: CoroutineScope? = null + private var wakeLock: PowerManager.WakeLock? = null + private var multicastLock: WifiManager.MulticastLock? = null + private var wifiLock: WifiManager.WifiLock? = null + override fun onCreate() { super.onCreate() + setupAndroidLogging() createNotificationChannel() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_STOP) { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + serviceScope?.cancel() + serviceScope = null + sendBroadcast(Intent(ACTION_FINISH_APP).setPackage(packageName)) + stopSelf() + return START_NOT_STICKY + } + try { val notification = createNotification() ServiceCompat.startForeground( this, - NOTIFICATION_ID, // Cannot be 0 + NOTIFICATION_ID, notification, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE @@ -54,22 +101,112 @@ class ForegroundService : Service() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && e is ForegroundServiceStartNotAllowedException ) { - LogManager.severe("Tried to start foreground service when not allowed:", e) + logger.error(e, "Tried to start foreground service when not allowed") } - // ... } - /* - * Currently being a foreground process should be enough to keep the server running. - * If it turns out to not be enough then the next option would be to return sticky here - * which would also require moving server initialization from the MainActivity to here - * so the server is restarted when the activity is. You can tell the process is restarted - * if the intent variable is null. - */ + + if (serviceScope == null) { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + serviceScope = scope + scope.safeLaunch { startServer(scope) } + } + return START_NOT_STICKY } + override fun onDestroy() { + serviceScope?.cancel() + serviceScope = null + wakeLock?.release() + wakeLock = null + multicastLock?.release() + multicastLock = null + wifiLock?.release() + wifiLock = null + super.onDestroy() + } + override fun onBind(intent: Intent?): IBinder? = null + private suspend fun startServer(scope: CoroutineScope) { + val storage = AndroidConfigStorage(filesDir) + val config = AppConfig.create(scope = scope, storage = storage) + val server = VRServer.create(scope = scope) + val serialServer = createAndroidSerialServer(context = this, scope = scope) + + val phase1 = Phase1Context(server = server, config = config, serialServer = serialServer) + + val firmwareManager = FirmwareManager.create(ctx = phase1, scope = scope, flasher = AndroidFirmwareFlasher) + val networkProfileManager = NetworkProfileManager.create(scope = scope, isSupported = false) + val skeleton = Skeleton.create(scope = scope, ctx = phase1) + val provisioningManager = ProvisioningManager.create(ctx = phase1, scope = scope) + val heightCalibrationManager = HeightCalibrationManager.create(ctx = phase1, scope = scope) + val trackingChecklist = TrackingChecklist.create(scope = scope) + val udpServer = UdpServer.create(scope = scope, addressResolver = ::resolveAndroidUdpAddress) + val bvhManager = BVHManager.create(skeleton = skeleton, storage = storage, scope = scope) + val vmcManager = VMCManager.create(skeleton = skeleton, ctx = phase1, scope = scope) + val vrcOscManager = VRCOSCManager.create( + ctx = phase1, + scope = scope, + oscQueryAddress = resolveAndroidOscQueryAddress(), + ) + + val appContext = AppContext( + server = server, + config = config, + serialServer = serialServer, + featureFlags = FeatureFlags(supportsSteamVR = false), + skeleton = skeleton, + firmwareManager = firmwareManager, + vrcConfigManager = null, + networkProfileManager = networkProfileManager, + provisioningManager = provisioningManager, + heightCalibrationManager = heightCalibrationManager, + trackingChecklist = trackingChecklist, + udpServer = udpServer, + bvhManager = bvhManager, + vmcManager = vmcManager, + vrcOscManager = vrcOscManager, + ) + + acquireLocks() + + try { + appContext.startObserving() + + scope.safeLaunch { createAndroidHIDManager(context = this@ForegroundService, appContext = appContext, scope = this) } + scope.safeLaunch { createAndroidSolarXRWebsocketServer(appContext) } + + awaitCancellation() + } finally { + appContext.dispose() + } + } + + @SuppressLint("WakelockTimeout") + private fun acquireLocks() { + val power = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "slimevr:server") + .apply { setReferenceCounted(false) } + .also { it.acquire() } + + val wifi = getSystemService(Context.WIFI_SERVICE) as WifiManager + multicastLock = wifi.createMulticastLock("slimevr-multicast-lock") + .apply { setReferenceCounted(true) } + .also { it.acquire() } + @Suppress("DEPRECATION") + wifiLock = wifi.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "slimevr-wifi-lock") + .apply { setReferenceCounted(false) } + .also { it.acquire() } + } + + private fun stopPendingIntent(): PendingIntent = PendingIntent.getService( + this, + 0, + Intent(this, ForegroundService::class.java).apply { action = ACTION_STOP }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + private fun createNotificationChannel() { val serviceChannel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManager.IMPORTANCE_LOW) .setName("SlimeVR Foreground Service Channel") @@ -79,9 +216,20 @@ class ForegroundService : Service() { .createNotificationChannel(serviceChannel) } + private fun openAppPendingIntent(): PendingIntent = PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_REORDER_TO_FRONT + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + private fun createNotification(): Notification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle("SlimeVR Server Running") .setContentText("This notification helps keep the server alive") .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentIntent(openAppPendingIntent()) + .addAction(0, getString(R.string.stop_server), stopPendingIntent()) .build() } diff --git a/server/android/src/main/java/dev/slimevr/android/Main.kt b/server/android/src/main/java/dev/slimevr/android/Main.kt index 6866445608..131380dd40 100644 --- a/server/android/src/main/java/dev/slimevr/android/Main.kt +++ b/server/android/src/main/java/dev/slimevr/android/Main.kt @@ -2,65 +2,9 @@ package dev.slimevr.android -import android.content.Context -import android.net.wifi.WifiManager +import android.content.Intent import androidx.appcompat.app.AppCompatActivity -import dev.slimevr.Keybinding -import dev.slimevr.VRServer -import dev.slimevr.android.serial.AndroidSerialHandler -import dev.slimevr.android.tracking.trackers.hid.AndroidHIDManager -import dev.slimevr.config.ConfigManager -import dev.slimevr.tracking.trackers.Tracker -import io.eiren.util.logging.LogManager -import java.io.File -import kotlin.concurrent.thread -import kotlin.system.exitProcess - -lateinit var vrServer: VRServer - private set -val vrServerInitialized: Boolean - get() = ::vrServer.isInitialized fun startVRServer(activity: AppCompatActivity) { - thread(start = true, name = "Main VRServer Thread") { - try { - LogManager.initialize(activity.filesDir) - } catch (e1: java.lang.Exception) { - e1.printStackTrace() - } - - try { - val configPath = File(activity.filesDir, "vrconfig.yml").absolutePath - val configManager = ConfigManager(configPath) - configManager.loadConfig() - - vrServer = VRServer( - configManager = configManager, - serialHandlerProvider = { _ -> AndroidSerialHandler(activity) }, - acquireMulticastLock = { - val wifi = activity.getSystemService(Context.WIFI_SERVICE) as WifiManager - val lock = wifi.createMulticastLock("slimevr-jmdns-multicast-lock") - lock.setReferenceCounted(true) - lock.acquire() - }, - ) - vrServer.start() - - // Start service for USB HID trackers - val androidHidManager = AndroidHIDManager( - "Sensors HID service", - { tracker: Tracker -> vrServer.registerTracker(tracker) }, - activity, - ) - androidHidManager.start() - - Keybinding(vrServer) - vrServer.join() - LogManager.closeLogger() - exitProcess(0) - } catch (e: Throwable) { - e.printStackTrace() - exitProcess(1) - } - } -} + activity.startForegroundService(Intent(activity, ForegroundService::class.java)) +} \ No newline at end of file diff --git a/server/android/src/main/java/dev/slimevr/android/MainActivity.kt b/server/android/src/main/java/dev/slimevr/android/MainActivity.kt index 07ec2658d9..9ace3366fe 100644 --- a/server/android/src/main/java/dev/slimevr/android/MainActivity.kt +++ b/server/android/src/main/java/dev/slimevr/android/MainActivity.kt @@ -1,18 +1,24 @@ package dev.slimevr.android +import android.Manifest +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle +import android.util.Log import android.webkit.JavascriptInterface import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity -import io.eiren.util.logging.LogManager +import androidx.core.content.ContextCompat import java.io.IOException import java.net.URLConnection -import java.util.concurrent.locks.ReentrantLock -import kotlin.concurrent.withLock class AndroidJsObject { @JavascriptInterface @@ -20,36 +26,38 @@ class AndroidJsObject { } class MainActivity : AppCompatActivity() { + private val requestNotificationPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { + startVRServer(this) + } + + private val finishReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) = finishAndRemoveTask() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) - // Initialize logger (doesn't re-initialize if already run) - try { - LogManager.initialize(filesDir) - } catch (e1: java.lang.Exception) { - e1.printStackTrace() + ContextCompat.registerReceiver( + this, + finishReceiver, + IntentFilter(ACTION_FINISH_APP), + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } else { + startVRServer(this) } - initLock.withLock { - // Start the server if it isn't already running - if (!vrServerInitialized) { - LogManager.info("[MainActivity] VRServer isn't running yet, starting it...") - startVRServer(this) - } else { - LogManager.info("[MainActivity] VRServer is already running, skipping initialization.") - } - } - - // Load the web GUI web page - LogManager.info("[MainActivity] Initializing GUI WebView...") + Log.i(TAG, "Initializing GUI WebView...") val guiWebView = findViewById(R.id.guiWebView) - // ## Configure for web GUI ## - // Enable debug mode WebView.setWebContentsDebuggingEnabled(true) - // Handle path resolution guiWebView.webViewClient = object : WebViewClient() { override fun shouldInterceptRequest( view: WebView, @@ -78,30 +86,27 @@ class MainActivity : AppCompatActivity() { } } - // Set required features guiWebView.settings.javaScriptEnabled = true guiWebView.settings.domStorageEnabled = true // TODO: Let code know it is in android, should be gone when we start using tauri guiWebView.addJavascriptInterface(AndroidJsObject(), "__ANDROID__") - // Try fixing zoom usability guiWebView.settings.setSupportZoom(true) guiWebView.settings.useWideViewPort = true guiWebView.settings.loadWithOverviewMode = true guiWebView.invokeZoomPicker() - // Load GUI page guiWebView.loadUrl("https://slimevr.gui/") - LogManager.info("[MainActivity] GUI WebView has been initialized and loaded.") + Log.i(TAG, "GUI WebView initialized.") + } - // Start a foreground service to notify the user the SlimeVR Server is running - // This also helps prevent Android from ejecting the process unexpectedly - val serviceIntent = Intent(this, ForegroundService::class.java) - startForegroundService(serviceIntent) + override fun onDestroy() { + unregisterReceiver(finishReceiver) + super.onDestroy() } companion object { - val initLock = ReentrantLock() + private const val TAG = "MainActivity" } -} +} \ No newline at end of file diff --git a/server/android/src/main/java/dev/slimevr/android/config/storage.kt b/server/android/src/main/java/dev/slimevr/android/config/storage.kt new file mode 100644 index 0000000000..c7c034f937 --- /dev/null +++ b/server/android/src/main/java/dev/slimevr/android/config/storage.kt @@ -0,0 +1,86 @@ +package dev.slimevr.android.config + +import dev.slimevr.config.ConfigStorage +import dev.slimevr.config.TextFileHandle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.RandomAccessFile +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +class AndroidConfigStorage(private val root: File) : ConfigStorage { + override suspend fun read(path: String): String? = withContext(Dispatchers.IO) { + val file = resolve(path) + if (file.exists()) file.readText() else null + } + + override suspend fun write(path: String, content: String) = withContext(Dispatchers.IO) { + val file = resolve(path) + file.parentFile?.mkdirs() + val tmp = File(file.parent, "${file.name}.tmp") + tmp.writeText(content) + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING) + Unit + } + + override suspend fun backup(path: String) = withContext(Dispatchers.IO) { + val file = resolve(path) + if (!file.exists()) return@withContext + val bakTmp = File(file.parent, "${file.name}.bak.tmp") + file.copyTo(bakTmp, overwrite = true) + Files.move( + bakTmp.toPath(), + File(file.parent, "${file.name}.bak").toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } + + override suspend fun exists(path: String): Boolean = withContext(Dispatchers.IO) { + resolve(path).exists() + } + + override suspend fun ensureDirectory(path: String): Boolean = withContext(Dispatchers.IO) { + val directory = resolve(path) + directory.isDirectory || directory.mkdirs() + } + + override suspend fun openTextFile(path: String): TextFileHandle = withContext(Dispatchers.IO) { + val file = resolve(path) + file.parentFile?.mkdirs() + AndroidRandomAccessTextFileHandle(RandomAccessFile(file, "rw").also { handle -> handle.setLength(0L) }) + } + + override fun displayPath(path: String): String = resolve(path).absolutePath + + private fun resolve(path: String): File { + val requested = File(path) + return if (requested.isAbsolute) requested else File(root, path) + } +} + +private class AndroidRandomAccessTextFileHandle( + private val file: RandomAccessFile, +) : TextFileHandle { + override suspend fun write(text: String) = withContext(Dispatchers.IO) { + file.write(text.encodeToByteArray()) + } + + override suspend fun flush() = withContext(Dispatchers.IO) { + file.fd.sync() + } + + override suspend fun position(): Long = withContext(Dispatchers.IO) { + file.filePointer + } + + override suspend fun seek(position: Long) = withContext(Dispatchers.IO) { + file.seek(position) + } + + override suspend fun close() = withContext(Dispatchers.IO) { + file.fd.sync() + file.close() + } +} \ No newline at end of file diff --git a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt new file mode 100644 index 0000000000..3c4d6e53f9 --- /dev/null +++ b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt @@ -0,0 +1,188 @@ +package dev.slimevr.android.hid + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbManager +import androidx.core.content.ContextCompat +import dev.slimevr.AppContextProvider +import dev.slimevr.AppLogger +import dev.slimevr.device.DeviceActions +import dev.slimevr.hid.HIDReceiver +import dev.slimevr.hid.parseHIDPackets +import dev.slimevr.util.safeLaunch +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import solarxr_protocol.datatypes.TrackerStatus + +private const val ACTION_USB_HID_PERMISSION = "dev.slimevr.android.USB_HID_PERMISSION" +private const val HID_POLL_INTERVAL_MS = 3000L + +private data class HidProductRule(val vendorId: Int, val productId: Int, val productMask: Int = 0xFFFF) + +private val HID_PRODUCT_RULES = listOf( + HidProductRule(0x1209, 0x7690), // SlimeVR receiver + HidProductRule(0x1209, 0x7692), // SlimeVR tracker direct + HidProductRule(0x4E76, 0xD200, 0xFF00), // Gestures Inc. D2XX +) + +private fun isCompatibleDevice(vid: Int, pid: Int) = + HID_PRODUCT_RULES.any { rule -> vid == rule.vendorId && (pid and rule.productMask) == rule.productId } + +private fun findHidInputEndpoint(device: UsbDevice): UsbEndpoint? { + for (ifaceIdx in 0 until device.interfaceCount) { + val iface = device.getInterface(ifaceIdx) + if (iface.interfaceClass != UsbConstants.USB_CLASS_HID) continue + for (epIdx in 0 until iface.endpointCount) { + val endpoint = iface.getEndpoint(epIdx) + if (endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT && + endpoint.direction == UsbConstants.USB_DIR_IN + ) return endpoint + } + } + return null +} + +private data class ActiveReceiver(val job: Job, val receiver: HIDReceiver) + +fun createAndroidHIDManager(context: Context, appContext: AppContextProvider, scope: CoroutineScope) { + val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager + val wakeSignal = Channel(Channel.CONFLATED) + val permissionRequested = mutableSetOf() + val active = mutableMapOf() + + val permissionIntent = PendingIntent.getBroadcast( + context, + 0, + Intent(ACTION_USB_HID_PERMISSION).apply { setPackage(context.packageName) }, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val usbReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + wakeSignal.trySend(Unit) + } + } + + val intentFilter = IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED).apply { + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + addAction(ACTION_USB_HID_PERMISSION) + } + ContextCompat.registerReceiver(context, usbReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED) + + scope.safeLaunch { + while (isActive) { + val found = withContext(Dispatchers.IO) { + try { + usbManager.deviceList.values + .filter { device -> isCompatibleDevice(device.vendorId, device.productId) } + .associate { device -> device.deviceName to device } + } catch (_: Exception) { + emptyMap() + } + } + + for ((deviceName, device) in found) { + if (!usbManager.hasPermission(device) && deviceName !in permissionRequested) { + AppLogger.hid.info("Requesting USB HID permission for $deviceName") + usbManager.requestPermission(device, permissionIntent) + permissionRequested.add(deviceName) + } + } + + val authorized = found.filter { (_, device) -> usbManager.hasPermission(device) } + + val toRemove = (active.keys - authorized.keys) + + active.entries.filter { (_, entry) -> !entry.job.isActive }.map { (key, _) -> key } + for (deviceName in toRemove) { + val entry = active.remove(deviceName) ?: continue + entry.job.cancel() + entry.job.join() + permissionRequested.remove(deviceName) + AppLogger.hid.info("HID device removed: $deviceName") + } + + for ((deviceName, device) in authorized) { + if (deviceName in active) continue + permissionRequested.remove(deviceName) + + val endpoint = findHidInputEndpoint(device) + if (endpoint == null) { + AppLogger.hid.warn("No HID input endpoint found for $deviceName") + continue + } + + val connection = withContext(Dispatchers.IO) { usbManager.openDevice(device) } + if (connection == null) { + AppLogger.hid.warn("Failed to open HID device $deviceName") + continue + } + + val iface = device.getInterface(0) + connection.claimInterface(iface, true) + + val serial = device.serialNumber ?: deviceName + AppLogger.hid.info("HID device detected: $serial") + + val deviceJob = Job(scope.coroutineContext[Job]) + val deviceScope = CoroutineScope(scope.coroutineContext + deviceJob) + + val receiver = HIDReceiver.create( + serialNumber = serial, + appContext = appContext, + scope = deviceScope, + ) + + deviceScope.safeLaunch { + try { + val buffer = ByteArray(64) + while (isActive) { + val read = withContext(Dispatchers.IO) { + try { + connection.bulkTransfer(endpoint, buffer, buffer.size, 0) + } catch (_: Exception) { + -1 + } + } + when { + read < 0 -> return@safeLaunch + read > 0 -> parseHIDPackets(buffer.copyOf(read)).forEach { packet -> receiver.packetEvents.emit(packet) } + else -> delay(1) + } + } + } finally { + withContext(NonCancellable + Dispatchers.IO) { + connection.releaseInterface(iface) + connection.close() + } + withContext(NonCancellable) { + for (record in receiver.context.state.value.trackers.values) { + appContext.server.getDevice(record.deviceId)?.context?.dispatch( + DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, + ) + } + } + } + } + deviceJob.complete() + + active[deviceName] = ActiveReceiver(deviceJob, receiver) + } + + permissionRequested.retainAll(found.keys) + withTimeoutOrNull(HID_POLL_INTERVAL_MS) { wakeSignal.receive() } + } + } +} \ No newline at end of file diff --git a/server/android/src/main/java/dev/slimevr/android/ipc/ws-server.kt b/server/android/src/main/java/dev/slimevr/android/ipc/ws-server.kt new file mode 100644 index 0000000000..1870d0c72f --- /dev/null +++ b/server/android/src/main/java/dev/slimevr/android/ipc/ws-server.kt @@ -0,0 +1,75 @@ +package dev.slimevr.android.ipc + +import com.google.flatbuffers.FlatBufferBuilder +import dev.slimevr.AppContextProvider +import dev.slimevr.AppLogger +import dev.slimevr.VRServerActions +import dev.slimevr.fbscodegen.runtime.JvmFlatBufferReader +import dev.slimevr.fbscodegen.runtime.JvmFlatBufferWriter +import dev.slimevr.solarxr.SolarXRBridge +import dev.slimevr.solarxr.onSolarXRMessage +import io.ktor.server.application.install +import io.ktor.server.cio.CIO +import io.ktor.server.engine.embeddedServer +import io.ktor.server.routing.routing +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.webSocket +import io.ktor.util.moveToByteArray +import io.ktor.websocket.Frame +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.flow +import solarxr_protocol.MessageBundle +import java.nio.ByteBuffer + +const val SOLARXR_PORT = 21110 + +suspend fun createAndroidSolarXRWebsocketServer(appContext: AppContextProvider) { + val engine = embeddedServer(CIO, port = SOLARXR_PORT) { + install(WebSockets) + + routing { + webSocket { + AppLogger.ipc.info("New connection") + coroutineScope { + val bridge = SolarXRBridge.create( + id = appContext.server.nextHandle(), + appContext = appContext, + scope = this, + ) + + appContext.server.context.dispatch(VRServerActions.SolarXRConnected(bridge)) + + bridge.outbound.on { bundle -> + val fbb = FlatBufferBuilder(256) + fbb.finish(bundle.encode(JvmFlatBufferWriter(fbb))) + send(Frame.Binary(fin = true, data = fbb.dataBuffer().moveToByteArray())) + } + + try { + flow { + for (frame in incoming) { + when (frame) { + is Frame.Binary -> emit(frame.data) + is Frame.Close -> AppLogger.ipc.info("Connection closed") + else -> {} + } + } + }.collect { bytes -> + val reader = JvmFlatBufferReader(ByteBuffer.wrap(bytes)) + onSolarXRMessage(MessageBundle.decode(reader, reader.getInt(0)), bridge) + } + } finally { + bridge.disconnect() + } + } + } + } + } + engine.start(wait = false) + try { + awaitCancellation() + } finally { + engine.stop() + } +} \ No newline at end of file diff --git a/server/android/src/main/java/dev/slimevr/android/logging.kt b/server/android/src/main/java/dev/slimevr/android/logging.kt new file mode 100644 index 0000000000..9bb9d1c04d --- /dev/null +++ b/server/android/src/main/java/dev/slimevr/android/logging.kt @@ -0,0 +1,16 @@ +package dev.slimevr.android + +import io.klogging.Level +import io.klogging.config.loggingConfiguration +import io.klogging.sending.ANDROID + +fun setupAndroidLogging() { + loggingConfiguration { + sink("android", ANDROID) + logging { + fromMinLevel(Level.DEBUG) { + toSink("android") + } + } + } +} diff --git a/server/android/src/main/java/dev/slimevr/android/serial/AndroidSerialHandler.kt b/server/android/src/main/java/dev/slimevr/android/serial/AndroidSerialHandler.kt deleted file mode 100644 index aa48ee178b..0000000000 --- a/server/android/src/main/java/dev/slimevr/android/serial/AndroidSerialHandler.kt +++ /dev/null @@ -1,314 +0,0 @@ -package dev.slimevr.android.serial - -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.hardware.usb.UsbManager -import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.ContextCompat -import com.hoho.android.usbserial.driver.UsbSerialDriver -import com.hoho.android.usbserial.driver.UsbSerialPort -import com.hoho.android.usbserial.driver.UsbSerialProber -import com.hoho.android.usbserial.util.SerialInputOutputManager -import dev.slimevr.serial.SerialHandler -import dev.slimevr.serial.SerialListener -import io.eiren.util.logging.LogManager -import java.io.IOException -import java.nio.ByteBuffer -import java.nio.charset.StandardCharsets -import java.util.concurrent.CopyOnWriteArrayList -import java.util.stream.Stream -import kotlin.streams.asSequence -import kotlin.streams.asStream -import dev.slimevr.serial.SerialPort as SlimeSerialPort - -class SerialPortWrapper(val port: UsbSerialPort) : SlimeSerialPort() { - override val portLocation: String - get() = port.device.deviceName - override val descriptivePortName: String - get() = "${port.device.productName} (${port.device.deviceName})" - - override val vendorId: Int - get() = port.device.vendorId - - override val productId: Int - get() = port.device.productId -} - -class AndroidSerialHandler(val activity: AppCompatActivity) : - SerialHandler(), - SerialInputOutputManager.Listener { - - private var usbIoManager: SerialInputOutputManager? = null - - private val listeners: MutableList = CopyOnWriteArrayList() - private var lastKnownPorts = setOf() - private val manager = activity.getSystemService(Context.USB_SERVICE) as UsbManager - private var currentPort: SerialPortWrapper? = null - private var requestingPermission: String = "" - private var readBuffer: StringBuilder = StringBuilder(1024) - - override val isConnected: Boolean - get() = currentPort?.port?.isOpen ?: false - - override val knownPorts: Stream - get() = getPorts() - .asSequence() - .map { SerialPortWrapper(it.ports[0]) } - .filter { isKnownBoard(it) } - .asStream() - - val usbReceiver: BroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - UsbManager.ACTION_USB_DEVICE_ATTACHED, UsbManager.ACTION_USB_DEVICE_DETACHED -> { - // Use device from `UsbManager.EXTRA_DEVICE` if this is a problem - detectNewPorts() - } - - ACTION_USB_PERMISSION -> { - // TODO: We can probably receive this event in the server to avoid - // polling, but for now we can just ignore it. (Note: This event is - // not currently registered, so it will never fire.) - } - } - } - } - - init { - val intentFilter = IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED) - intentFilter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) - - // Listen for USB device attach/detach - ContextCompat.registerReceiver( - activity, - usbReceiver, - intentFilter, - ContextCompat.RECEIVER_NOT_EXPORTED, - ) - - // Detect initial serial ports - detectNewPorts() - } - - private fun getPorts(): List = UsbSerialProber.getDefaultProber().findAllDrivers(manager) - - private fun onNewDevice(port: SerialPortWrapper) { - // If we missed clearing this on disconnect/close, clear it on discovery - if (requestingPermission == port.portLocation) { - requestingPermission = "" - } - - LogManager.info("[SerialHandler] Device added: ${port.descriptivePortName}") - listeners.forEach { it.onNewSerialDevice(port) } - } - - private fun onDeviceDel(port: SerialPortWrapper) { - // Remove permission request on disconnect so reconnecting re-requests - if (requestingPermission == port.portLocation) { - requestingPermission = "" - } - - // If we're currently using this port, close it - currentPort?.portLocation.let { currentPortLocation -> - if (currentPortLocation == port.portLocation) { - closeSerial() - } - } - - // If this port is still open for whatever reason, close it - if (port.port.isOpen) { - port.port.close() - } - - LogManager.info("[SerialHandler] Device removed: ${port.descriptivePortName}") - listeners.forEach { it.onSerialDeviceDeleted(port) } - } - - private fun detectNewPorts() { - val addDifferences = knownPorts.asSequence() - lastKnownPorts - val delDifferences = lastKnownPorts - knownPorts.asSequence().toSet() - lastKnownPorts = knownPorts.asSequence().toSet() - addDifferences.forEach { onNewDevice(it) } - delDifferences.forEach { onDeviceDel(it) } - } - - override fun addListener(channel: SerialListener) { - listeners.add(channel) - } - - override fun removeListener(channel: SerialListener) { - listeners.removeIf { channel === it } - } - - @Synchronized - override fun openSerial(portLocation: String?, auto: Boolean): Boolean { - LogManager.info("[SerialHandler] Trying to open: $portLocation, auto: $auto") - lastKnownPorts = knownPorts.asSequence().toSet() - val newPort = lastKnownPorts.find { - (!auto && it.portLocation == portLocation) || (auto && isKnownBoard(it)) - } - - if (newPort == null) { - LogManager.info( - "[SerialHandler] No serial ports found to connect to (${lastKnownPorts.size}) total ports", - ) - return false - } - - if (isConnected) { - val port = currentPort!! - if (newPort != port) { - LogManager.info( - "[SerialHandler] Closing current serial port " + - port.descriptivePortName, - ) - closeSerial() - } else { - LogManager.info("[SerialHandler] Reusing already open port") - listeners.forEach { it.onSerialConnected(port) } - return true - } - } - - LogManager.info( - "[SerialHandler] Trying to connect to new serial port " + - newPort.descriptivePortName, - ) - - if (!manager.hasPermission(newPort.port.device)) { - val flags = PendingIntent.FLAG_IMMUTABLE - val usbPermissionIntent = PendingIntent.getBroadcast( - activity, - 0, - Intent(ACTION_USB_PERMISSION), - flags, - ) - if (requestingPermission != newPort.portLocation) { - LogManager.info("[SerialHandler] Requesting permission for ${newPort.portLocation}") - manager.requestPermission(newPort.port.device, usbPermissionIntent) - requestingPermission = newPort.portLocation - } else { - LogManager.info("[SerialHandler] Already requested permission for ${newPort.portLocation}, skipping") - } - LogManager.warning( - "[SerialHandler] Can't open serial port ${newPort.descriptivePortName}, invalid permissions", - ) - return false - } - - // If we have permission, we aren't requesting anymore - requestingPermission = "" - - val connection = manager.openDevice(newPort.port.device) - if (connection == null) { - LogManager.warning( - "[SerialHandler] Can't open serial port ${newPort.descriptivePortName}, connection failed", - ) - return false - } - newPort.port.open(connection) - newPort.port.setParameters(115200, 8, 1, UsbSerialPort.PARITY_NONE) - usbIoManager = SerialInputOutputManager(newPort.port, this).apply { - start() - } - listeners.forEach { it.onSerialConnected(newPort) } - currentPort = newPort - LogManager.info("[SerialHandler] Serial port ${newPort.descriptivePortName} is open") - return true - } - - @Synchronized - private fun writeSerial(serialText: String, print: Boolean = false) { - try { - currentPort?.port?.write("${serialText}\n".toByteArray(), 0) - if (print) { - addLog("-> $serialText\n") - } - } catch (e: IOException) { - addLog("[!] Serial error: ${e.message}\n") - LogManager.warning("[SerialHandler] Serial port write error", e) - } - } - - override fun rebootRequest() { - writeSerial("REBOOT") - } - - override fun factoryResetRequest() { - writeSerial("FRST") - } - - override fun infoRequest() { - writeSerial("GET INFO") - } - - override fun wifiScanRequest() { - writeSerial("GET WIFISCAN") - } - - override fun customCommandRequest(command: String) { - writeSerial(command) - } - - override fun closeSerial() { - try { - if (isConnected) { - currentPort?.port?.close() - } - listeners.forEach { it.onSerialDisconnected() } - LogManager.info( - "[SerialHandler] Port ${currentPort?.descriptivePortName} closed okay", - ) - usbIoManager?.stop() - usbIoManager = null - currentPort = null - requestingPermission = "" - readBuffer.clear() - } catch (e: Exception) { - LogManager.warning( - "[SerialHandler] Error closing port ${currentPort?.descriptivePortName}", - e, - ) - } - } - - override fun write(buff: ByteArray) { - currentPort?.port?.write(buff, 0) - } - - @Synchronized - override fun setWifi(ssid: String, passwd: String) { - writeSerial("SET WIFI \"${ssid}\" \"${passwd}\"") - addLog("-> SET WIFI \"$ssid\" \"${passwd.replace(".".toRegex(), "*")}\"\n") - } - - override fun getCurrentPort(): SlimeSerialPort? = this.currentPort - - private fun addLog(str: String, server: Boolean = true) { - LogManager.info("[Serial] $str") - listeners.forEach { it.onSerialLog(str, server) } - } - - override fun onNewData(data: ByteArray?) { - if (data != null) { - // Collect serial in a buffer until newline (or character limit) - // This is somewhat of a workaround for Android serial buffer being smaller - // than on desktop, so we don't read full lines and it causes parsing issues - readBuffer.append(StandardCharsets.UTF_8.decode(ByteBuffer.wrap(data))) - - if (readBuffer.contains('\n') || readBuffer.length >= 1024) { - addLog(readBuffer.toString(), false) - readBuffer.clear() - } - } - } - - override fun onRunError(e: java.lang.Exception?) {} - - companion object { - private const val ACTION_USB_PERMISSION = "dev.slimevr.USB_PERMISSION" - } -} diff --git a/server/android/src/main/java/dev/slimevr/android/serial/flash.kt b/server/android/src/main/java/dev/slimevr/android/serial/flash.kt new file mode 100644 index 0000000000..5235ed0aba --- /dev/null +++ b/server/android/src/main/java/dev/slimevr/android/serial/flash.kt @@ -0,0 +1,137 @@ +package dev.slimevr.android.serial + +import android.hardware.usb.UsbManager +import com.hoho.android.usbserial.driver.UsbSerialPort +import com.hoho.android.usbserial.driver.UsbSerialProber +import dev.llelievr.espflashkotlin.Flasher +import dev.llelievr.espflashkotlin.FlasherSerialInterface +import dev.llelievr.espflashkotlin.FlashingProgressListener +import dev.slimevr.firmware.DownloadedFirmwarePart +import dev.slimevr.firmware.FirmwareFlasher +import dev.slimevr.serial.FlashingHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class AndroidFlashingHandler(private val usbManager: UsbManager) : FlashingHandler { + private var port: UsbSerialPort? = null + private var readTimeout: Int = 1000 + private val pendingBytes = ArrayDeque() + + override fun openSerial(portObj: Any) { + val portLocation = portObj as? String ?: error("expected port location string") + val driver = UsbSerialProber.getDefaultProber() + .findAllDrivers(usbManager) + .find { driver -> driver.device.deviceName == portLocation } + ?: error("Unable to find serial port $portLocation") + + val connection = usbManager.openDevice(driver.device) + ?: error("Unable to open USB device for $portLocation") + + val usbPort = driver.ports[0] + usbPort.open(connection) + usbPort.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE) + this.port = usbPort + } + + override fun closeSerial() { + port?.close() + port = null + pendingBytes.clear() + } + + override fun write(data: ByteArray) { + val p = port ?: error("no port to write") + p.write(data, readTimeout) + } + + override fun read(length: Int): ByteArray { + val p = port ?: error("no port to read") + val result = ByteArray(length) + var offset = 0 + + while (offset < length && pendingBytes.isNotEmpty()) { + result[offset++] = pendingBytes.removeFirst() + } + + if (offset < length) { + val remaining = ByteArray(length - offset) + val read = p.read(remaining, readTimeout) + remaining.copyInto(result, offset, 0, read) + offset += read + } + + return result.copyOf(offset) + } + + override fun setDTR(value: Boolean) { + val p = port ?: error("no port to set DTR") + p.setDTR(value) + } + + override fun setRTS(value: Boolean) { + val p = port ?: error("no port to set RTS") + p.setRTS(value) + } + + override fun changeBaud(baud: Int) { + val p = port ?: error("no port to change baud") + p.setParameters(baud, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE) + } + + override fun setReadTimeout(timeout: Long) { + readTimeout = timeout.toInt().coerceAtLeast(1) + } + + override fun availableBytes(): Int { + val p = port ?: return 0 + val peekBuffer = ByteArray(256) + val count = try { + p.read(peekBuffer, 0) + } catch (_: Exception) { + 0 + } + for (i in 0 until count) pendingBytes.addLast(peekBuffer[i]) + return pendingBytes.size + } + + override fun flushIOBuffers() { + val p = port ?: error("no port to flush") + pendingBytes.clear() + p.purgeHwBuffers(true, true) + } +} + +object AndroidFirmwareFlasher : FirmwareFlasher { + override suspend fun flash( + portLocation: String, + handler: FlashingHandler, + parts: List, + onProgress: (Int) -> Unit, + ) { + val flasher = Flasher( + object : FlasherSerialInterface { + override fun openSerial(port: Any) = handler.openSerial(port) + override fun closeSerial() = handler.closeSerial() + override fun write(data: ByteArray) = handler.write(data) + override fun read(length: Int) = handler.read(length) + override fun setDTR(value: Boolean) = handler.setDTR(value) + override fun setRTS(value: Boolean) = handler.setRTS(value) + override fun changeBaud(baud: Int) = handler.changeBaud(baud) + override fun setReadTimeout(timeout: Long) = handler.setReadTimeout(timeout) + override fun availableBytes() = handler.availableBytes() + override fun flushIOBuffers() = handler.flushIOBuffers() + }, + ) + for (part in parts) { + flasher.addBin(part.data, part.offset) + } + flasher.addProgressListener( + object : FlashingProgressListener { + override fun progress(progress: Float) { + onProgress((progress * 100).toInt()) + } + }, + ) + withContext(Dispatchers.IO) { flasher.flash(portLocation) } + } +} \ No newline at end of file diff --git a/server/android/src/main/java/dev/slimevr/android/serial/serial.kt b/server/android/src/main/java/dev/slimevr/android/serial/serial.kt new file mode 100644 index 0000000000..2a630975df --- /dev/null +++ b/server/android/src/main/java/dev/slimevr/android/serial/serial.kt @@ -0,0 +1,197 @@ +package dev.slimevr.android.serial + +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbManager +import android.util.Log +import androidx.core.content.ContextCompat +import com.hoho.android.usbserial.driver.UsbSerialPort +import com.hoho.android.usbserial.driver.UsbSerialProber +import com.hoho.android.usbserial.util.SerialInputOutputManager +import dev.slimevr.AppLogger +import dev.slimevr.serial.SerialPortHandle +import dev.slimevr.serial.SerialPortInfo +import dev.slimevr.serial.SerialServer +import dev.slimevr.util.safeLaunch +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull + +private const val TAG = "SerialServer" + +private const val ACTION_USB_SERIAL_PERMISSION = "dev.slimevr.android.USB_SERIAL_PERMISSION" +private const val POLL_INTERVAL_MS = 3000L + +private val SUPPORTED_BOARDS: Set> = setOf( + Pair(0x1A86, 0x7522), // CH340 + Pair(0x1A86, 0x7523), // CH340 + Pair(0x1A86, 0x5523), // CH341 + Pair(0x1A86, 0x55D3), // CH343 + Pair(0x1A86, 0x55D4), // CH9102x + Pair(0x10C4, 0xEA60), // CP210x + Pair(0x303A, 0x1001), // ESP32-S3 + Pair(0x303A, 0x0002), // ESP32 + Pair(0x0403, 0x6001), // FTDI FT232 +) + +private fun isKnownBoard(vid: Int, pid: Int) = SUPPORTED_BOARDS.contains(vid to pid) + +private fun openAndroidPort( + portLocation: String, + usbManager: UsbManager, + scope: CoroutineScope, + onDataReceived: suspend (String, String) -> Unit, + onPortDisconnected: suspend (String) -> Unit, +): SerialPortHandle? { + val driver = UsbSerialProber.getDefaultProber() + .findAllDrivers(usbManager) + .find { driver -> driver.device.deviceName == portLocation } + ?: return null + + if (!usbManager.hasPermission(driver.device)) return null + + val connection = usbManager.openDevice(driver.device) ?: return null + val port = driver.ports[0] + + try { + port.open(connection) + port.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE) + port.dtr = false + port.rts = false + } catch (e: Exception) { + Log.e(TAG, "Failed to open Android serial port $portLocation", e) + try { connection.close() } catch (_: Exception) {} + return null + } + + val readBuffer = StringBuilder(1024) + + val ioManager = SerialInputOutputManager( + port, + object : SerialInputOutputManager.Listener { + override fun onNewData(data: ByteArray) { + readBuffer.append(data.toString(Charsets.UTF_8)) + var newlineIdx = readBuffer.indexOf("\n") + while (newlineIdx >= 0) { + val line = readBuffer.substring(0, newlineIdx).trimEnd() + readBuffer.delete(0, newlineIdx + 1) + scope.safeLaunch { onDataReceived(portLocation, line) } + newlineIdx = readBuffer.indexOf("\n") + } + if (readBuffer.length >= 1024) readBuffer.clear() + } + + override fun onRunError(e: Exception) { + scope.safeLaunch { onPortDisconnected(portLocation) } + } + }, + ) + ioManager.start() + + return SerialPortHandle( + portLocation = portLocation, + descriptivePortName = "${driver.device.productName ?: portLocation} ($portLocation)", + writeCommand = { text -> port.write("$text\n".toByteArray(), 0) }, + close = { + ioManager.stop() + try { port.close() } catch (_: Exception) {} + }, + ) +} + +private suspend fun runAndroidSerialPoller( + context: Context, + usbManager: UsbManager, + server: SerialServer, + wakeSignal: Channel, +) { + val permissionRequested = mutableSetOf() + var lastKnown: Set = emptySet() + val permissionIntent = PendingIntent.getBroadcast( + context, + 0, + Intent(ACTION_USB_SERIAL_PERMISSION).apply { setPackage(context.packageName) }, + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + while (true) { + try { + val current = withContext(Dispatchers.IO) { + UsbSerialProber.getDefaultProber() + .findAllDrivers(usbManager) + .filter { driver -> isKnownBoard(driver.device.vendorId, driver.device.productId) } + .associateBy { driver -> driver.device.deviceName } + } + + for ((deviceName, driver) in current) { + if (!usbManager.hasPermission(driver.device) && deviceName !in permissionRequested) { + AppLogger.serial.info("Requesting USB serial permission for $deviceName") + usbManager.requestPermission(driver.device, permissionIntent) + permissionRequested.add(deviceName) + } + } + + val authorized = current.filter { (_, driver) -> usbManager.hasPermission(driver.device) } + + val added = authorized.keys - lastKnown + val removed = lastKnown - authorized.keys + + for (loc in added) { + val driver = authorized.getValue(loc) + permissionRequested.remove(loc) + server.onPortDetected( + SerialPortInfo( + portLocation = loc, + descriptivePortName = "${driver.device.productName ?: loc} ($loc)", + vendorId = driver.device.vendorId, + productId = driver.device.productId, + ), + ) + } + for (loc in removed) { + server.onPortLost(loc) + } + + lastKnown = authorized.keys + permissionRequested.retainAll(current.keys) + } catch (e: Exception) { + AppLogger.serial.error(e, "Error polling Android serial ports") + } + + withTimeoutOrNull(POLL_INTERVAL_MS) { wakeSignal.receive() } + } +} + +fun createAndroidSerialServer(context: Context, scope: CoroutineScope): SerialServer { + val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager + val wakeSignal = Channel(Channel.CONFLATED) + + val usbReceiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + wakeSignal.trySend(Unit) + } + } + + val intentFilter = IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED).apply { + addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + addAction(ACTION_USB_SERIAL_PERMISSION) + } + ContextCompat.registerReceiver(context, usbReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED) + + val server = SerialServer.create( + openPort = { portLocation, onDataReceived, onPortDisconnected -> + openAndroidPort(portLocation, usbManager, scope, onDataReceived, onPortDisconnected) + }, + openFlashingPort = { AndroidFlashingHandler(usbManager) }, + scope = scope, + ) + + scope.safeLaunch { runAndroidSerialPoller(context, usbManager, server, wakeSignal) } + + return server +} \ No newline at end of file diff --git a/server/android/src/main/java/dev/slimevr/android/tracking/trackers/hid/AndroidHIDDevice.kt b/server/android/src/main/java/dev/slimevr/android/tracking/trackers/hid/AndroidHIDDevice.kt deleted file mode 100644 index 8d4302872e..0000000000 --- a/server/android/src/main/java/dev/slimevr/android/tracking/trackers/hid/AndroidHIDDevice.kt +++ /dev/null @@ -1,93 +0,0 @@ -package dev.slimevr.android.tracking.trackers.hid - -import android.hardware.usb.UsbConstants -import android.hardware.usb.UsbDevice -import android.hardware.usb.UsbDeviceConnection -import android.hardware.usb.UsbEndpoint -import android.hardware.usb.UsbInterface -import android.hardware.usb.UsbManager -import java.io.Closeable - -/** - * A wrapper over Android's [UsbDevice] for HID devices. - */ -class AndroidHIDDevice(hidDevice: UsbDevice, usbManager: UsbManager) : Closeable { - - val deviceName = hidDevice.deviceName - val serialNumber = hidDevice.serialNumber - val manufacturerName = hidDevice.manufacturerName - val productName = hidDevice.productName - - val hidInterface: UsbInterface - - val endpointIn: UsbEndpoint - val endpointOut: UsbEndpoint? - - val deviceConnection: UsbDeviceConnection - - init { - hidInterface = findHidInterface(hidDevice)!! - - val (endpointIn, endpointOut) = findHidIO(hidInterface) - this.endpointIn = endpointIn!! - this.endpointOut = endpointOut - - deviceConnection = usbManager.openDevice(hidDevice)!! - - deviceConnection.claimInterface(hidInterface, true) - } - - override fun close() { - deviceConnection.releaseInterface(hidInterface) - deviceConnection.close() - } - - companion object { - /** - * Find the HID interface. - * - * @return - * Return the HID interface if found, otherwise null. - */ - private fun findHidInterface(usbDevice: UsbDevice): UsbInterface? { - val interfaceCount: Int = usbDevice.interfaceCount - - for (interfaceIndex in 0 until interfaceCount) { - val usbInterface = usbDevice.getInterface(interfaceIndex) - - if (usbInterface.interfaceClass == UsbConstants.USB_CLASS_HID) { - return usbInterface - } - } - - return null - } - - /** - * Find the HID endpoints. - * - * @return - * Return the HID endpoints if found, otherwise null. - */ - private fun findHidIO(usbInterface: UsbInterface): Pair { - val endpointCount: Int = usbInterface.endpointCount - - var usbEndpointIn: UsbEndpoint? = null - var usbEndpointOut: UsbEndpoint? = null - - for (endpointIndex in 0 until endpointCount) { - val usbEndpoint = usbInterface.getEndpoint(endpointIndex) - - if (usbEndpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT) { - if (usbEndpoint.direction == UsbConstants.USB_DIR_OUT) { - usbEndpointOut = usbEndpoint - } else { - usbEndpointIn = usbEndpoint - } - } - } - - return Pair(usbEndpointIn, usbEndpointOut) - } - } -} diff --git a/server/android/src/main/java/dev/slimevr/android/tracking/trackers/hid/AndroidHIDManager.kt b/server/android/src/main/java/dev/slimevr/android/tracking/trackers/hid/AndroidHIDManager.kt deleted file mode 100644 index 22b537a906..0000000000 --- a/server/android/src/main/java/dev/slimevr/android/tracking/trackers/hid/AndroidHIDManager.kt +++ /dev/null @@ -1,258 +0,0 @@ -package dev.slimevr.android.tracking.trackers.hid - -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.hardware.usb.UsbDevice -import android.hardware.usb.UsbManager -import androidx.core.content.ContextCompat -import dev.slimevr.VRServer -import dev.slimevr.config.config -import dev.slimevr.tracking.trackers.Device -import dev.slimevr.tracking.trackers.Tracker -import dev.slimevr.tracking.trackers.TrackerStatus -import dev.slimevr.tracking.trackers.hid.HIDCommon -import dev.slimevr.tracking.trackers.hid.HIDCommon.Companion.HID_TRACKER_PID -import dev.slimevr.tracking.trackers.hid.HIDCommon.Companion.HID_TRACKER_RECEIVER_PID -import dev.slimevr.tracking.trackers.hid.HIDCommon.Companion.HID_TRACKER_RECEIVER_VID -import dev.slimevr.tracking.trackers.hid.HIDCommon.Companion.PACKET_SIZE -import dev.slimevr.tracking.trackers.hid.HIDDevice -import io.eiren.util.logging.LogManager -import java.nio.ByteBuffer -import java.util.function.Consumer - -const val ACTION_USB_PERMISSION = "dev.slimevr.USB_PERMISSION" - -/** - * Handles Android USB Host HID dongles and receives tracker data from them. - */ -class AndroidHIDManager( - name: String, - private val trackersConsumer: Consumer, - private val context: Context, -) : Thread(name) { - private val devices: MutableList = mutableListOf() - private val devicesBySerial: MutableMap> = HashMap() - private val devicesByHID: MutableMap> = HashMap() - private val connByHID: MutableMap = HashMap() - private val lastDataByHID: MutableMap = HashMap() - private val usbManager: UsbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager - - val usbReceiver: BroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - when (intent.action) { - UsbManager.ACTION_USB_DEVICE_ATTACHED -> { - (intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) as UsbDevice?)?.let { - checkConfigureDevice(it, requestPermission = true) - } - } - - UsbManager.ACTION_USB_DEVICE_DETACHED -> { - (intent.getParcelableExtra(UsbManager.EXTRA_DEVICE) as UsbDevice?)?.let { - removeDevice(it) - } - } - - ACTION_USB_PERMISSION -> { - deviceEnumerate(false) - } - } - } - } - - private fun proceedWithDeviceConfiguration(hidDevice: UsbDevice) { - // This is the original logic from checkConfigureDevice after permission is confirmed - LogManager.info("[TrackerServer] USB Permission granted for ${hidDevice.deviceName}. Proceeding with configuration.") - - // Close any existing connection (do we still have one?) - this.connByHID[hidDevice]?.close() - // Open new HID connection with USB device - this.connByHID[hidDevice] = AndroidHIDDevice(hidDevice, usbManager) - - val serial = hidDevice.serialNumber ?: "Unknown USB Device ${hidDevice.deviceId}" - this.devicesBySerial[serial]?.let { - this.devicesByHID[hidDevice] = it - synchronized(this.devices) { - for (id in it) { - val device = this.devices[id] - for (value in device.trackers.values) { - if (value.status == TrackerStatus.DISCONNECTED) value.status = TrackerStatus.OK - } - } - } - LogManager.info("[TrackerServer] Linked HID device reattached: $serial") - return - } - - val list: MutableList = mutableListOf() - this.devicesBySerial[serial] = list - this.devicesByHID[hidDevice] = list - this.lastDataByHID[hidDevice] = 0 // initialize last data received - LogManager.info("[TrackerServer] (Probably) Compatible HID device detected: $serial") - } - - fun checkConfigureDevice(usbDevice: UsbDevice, requestPermission: Boolean = false) { - if (usbDevice.vendorId == HID_TRACKER_RECEIVER_VID && (usbDevice.productId == HID_TRACKER_RECEIVER_PID || usbDevice.productId == HID_TRACKER_PID)) { - if (usbManager.hasPermission(usbDevice)) { - LogManager.info("[TrackerServer] Already have permission for ${usbDevice.deviceName}") - proceedWithDeviceConfiguration(usbDevice) - } else if (requestPermission) { - LogManager.info("[TrackerServer] Requesting permission for ${usbDevice.deviceName}") - val permissionIntent = PendingIntent.getBroadcast( - context, - 0, - Intent(ACTION_USB_PERMISSION).apply { setPackage(context.packageName) }, // Explicitly set package - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - usbManager.requestPermission(usbDevice, permissionIntent) - } - } - } - - private fun removeDevice(hidDevice: UsbDevice) { - this.devicesByHID[hidDevice]?.let { - synchronized(this.devices) { - for (id in it) { - val device = this.devices[id] - for (value in device.trackers.values) { - if (value.status == TrackerStatus.OK) { - value.status = - TrackerStatus.DISCONNECTED - } - } - } - } - - this.devicesByHID.remove(hidDevice) - - val oldConn = this.connByHID.remove(hidDevice) - val serial = oldConn?.serialNumber ?: "Unknown" - oldConn?.close() - - LogManager.info("[TrackerServer] Linked HID device removed: $serial") - } - } - - private fun dataRead() { - synchronized(devicesByHID) { - var devicesPresent = false - var devicesDataReceived = false - val q = intArrayOf(0, 0, 0, 0) - val a = intArrayOf(0, 0, 0) - val m = intArrayOf(0, 0, 0) - for ((hidDevice, deviceList) in devicesByHID) { - val dataReceived = ByteArray(64) - val conn = connByHID[hidDevice]!! - val dataRead = conn.deviceConnection.bulkTransfer(conn.endpointIn, dataReceived, dataReceived.size, 0) - - // LogManager.info("[TrackerServer] HID data read ($dataRead bytes): ${dataReceived.contentToString()}") - - devicesPresent = true // Even if the device has no data - if (dataRead > 0) { - // Process data - // The data is always received as 64 bytes, this check no longer works - if (dataRead % PACKET_SIZE != 0) { - LogManager.info("[TrackerServer] Malformed HID packet, ignoring") - continue // Don't continue with this data - } - devicesDataReceived = true // Data is received and is valid (not malformed) - lastDataByHID[hidDevice] = 0 // reset last data received - val packetCount = dataRead / PACKET_SIZE - var i = 0 - while (i < packetCount * PACKET_SIZE) { - // Common packet data - val packetType = dataReceived[i].toUByte().toInt() - val id = dataReceived[i + 1].toUByte().toInt() - val deviceId = id - - // Register device - if (packetType == 255) { // device register packet from receiver - val buffer = ByteBuffer.wrap(dataReceived, i + 2, 8) - buffer.order(java.nio.ByteOrder.LITTLE_ENDIAN) - val addr = buffer.getLong() and 0xFFFFFFFFFFFF - val deviceName = String.format("%012X", addr) - HIDCommon.deviceIdLookup(devices, hidDevice.serialNumber, deviceId, deviceName, deviceList) // register device - // server wants tracker to be unique, so use combination of hid serial and full id - i += PACKET_SIZE - continue - } - - val device: HIDDevice? = HIDCommon.deviceIdLookup(devices, hidDevice.serialNumber, deviceId, null, deviceList) - if (device == null) { // not registered yet - i += PACKET_SIZE - continue - } - - HIDCommon.processPacket(dataReceived, i, packetType, device, q, a, m, trackersConsumer) - i += PACKET_SIZE - } - // LogManager.info("[TrackerServer] HID received $packetCount tracker packets") - } else { - lastDataByHID[hidDevice] = lastDataByHID[hidDevice]!! + 1 // increment last data received - } - } - if (!devicesPresent) { - sleep(10) // No hid device, "empty loop" so sleep to save the poor cpu - } else if (!devicesDataReceived) { - sleep(1) // read has no timeout, no data also causes an "empty loop" - } - } - } - - private fun deviceEnumerate(requestPermission: Boolean = false) { - val trackersOverHID: Boolean = VRServer.instance.configManager.vrConfig.hidConfig.trackersOverHID - val hidDeviceList: MutableList = usbManager.deviceList.values.filter { - it.vendorId == HID_TRACKER_RECEIVER_VID && (it.productId == HID_TRACKER_RECEIVER_PID || (trackersOverHID && it.productId == HID_TRACKER_PID)) - }.toMutableList() - synchronized(devicesByHID) { - // Work on devicesByHid and add/remove as necessary - val removeList: MutableList = devicesByHID.keys.toMutableList() - removeList.removeAll(hidDeviceList) - for (device in removeList) { - removeDevice(device) - } - - hidDeviceList.removeAll(devicesByHID.keys) // addList - for (device in hidDeviceList) { - // This will handle permission check/request - checkConfigureDevice(device, requestPermission) - } - } - } - - override fun run() { - val intentFilter = IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED) - intentFilter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) - intentFilter.addAction(ACTION_USB_PERMISSION) - - // Listen for USB device attach/detach - ContextCompat.registerReceiver( - context, - usbReceiver, - intentFilter, - ContextCompat.RECEIVER_NOT_EXPORTED, - ) - - // Enumerate existing devices - deviceEnumerate(true) - - // Data read loop - while (true) { - try { - sleep(0) // Possible performance impact - } catch (e: InterruptedException) { - currentThread().interrupt() - break - } - dataRead() // not in try catch? - } - } - - fun getDevices(): List = devices - - companion object { - private const val resetSourceName = "TrackerServer" - } -} diff --git a/server/android/src/main/java/dev/slimevr/android/udp/address.kt b/server/android/src/main/java/dev/slimevr/android/udp/address.kt new file mode 100644 index 0000000000..df78a1059e --- /dev/null +++ b/server/android/src/main/java/dev/slimevr/android/udp/address.kt @@ -0,0 +1,7 @@ +package dev.slimevr.android.udp + +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.toJavaAddress + +fun resolveAndroidUdpAddress(addr: InetSocketAddress): String = + (addr.toJavaAddress() as java.net.InetSocketAddress).hostString \ No newline at end of file diff --git a/server/android/src/main/java/dev/slimevr/android/vrchat/oscquery.kt b/server/android/src/main/java/dev/slimevr/android/vrchat/oscquery.kt new file mode 100644 index 0000000000..e61fc5c2ef --- /dev/null +++ b/server/android/src/main/java/dev/slimevr/android/vrchat/oscquery.kt @@ -0,0 +1,20 @@ +package dev.slimevr.android.vrchat + +import java.net.InetAddress +import java.net.NetworkInterface +import java.util.Collections + +fun resolveAndroidOscQueryAddress(): String = try { + val candidates = Collections.list(NetworkInterface.getNetworkInterfaces()) + .asSequence() + .filter { iface -> iface.isUp && !iface.isLoopback && !iface.isVirtual } + .flatMap { iface -> Collections.list(iface.inetAddresses).asSequence() } + .filter { address -> !address.isLoopbackAddress && !address.hostAddress!!.contains(':') } + .toList() + + val siteLocal = candidates.firstOrNull { address -> address.isSiteLocalAddress } + (siteLocal ?: candidates.firstOrNull())?.hostAddress + ?: InetAddress.getLoopbackAddress().hostAddress +} catch (_: Exception) { + InetAddress.getLoopbackAddress().hostAddress +} \ No newline at end of file diff --git a/server/android/src/main/res/values/strings.xml b/server/android/src/main/res/values/strings.xml index 8f6ce23324..35969e69ad 100644 --- a/server/android/src/main/res/values/strings.xml +++ b/server/android/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ SlimeVR + Stop Server \ No newline at end of file diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index cc47e4b928..ee789abb32 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -32,7 +32,8 @@ kotlin { val commonMain by getting { kotlin.srcDir("src/main/java") dependencies { - implementation(project(":solarxr-protocol:generated")) + api(project(":solarxr-protocol:generated")) + api("com.google.flatbuffers:flatbuffers-java:22.10.26") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") implementation("com.mayakapps.kache:kache:2.1.1") implementation("io.klogging:klogging:0.11.7") diff --git a/server/core/src/main/java/dev/slimevr/feature-flags.kt b/server/core/src/main/java/dev/slimevr/feature-flags.kt index 75ba7b43bc..e72363b828 100644 --- a/server/core/src/main/java/dev/slimevr/feature-flags.kt +++ b/server/core/src/main/java/dev/slimevr/feature-flags.kt @@ -4,4 +4,5 @@ data class FeatureFlags( var steam: Boolean = false, var skipCheckUdev: Boolean = false, var udevRulesInstalled: Boolean? = null, + val supportsSteamVR: Boolean = true, ) \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/firmware/download.kt b/server/core/src/main/java/dev/slimevr/firmware/download.kt index 96847af9ed..aee090e954 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/download.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/download.kt @@ -3,6 +3,7 @@ package dev.slimevr.firmware import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.get import java.security.MessageDigest @@ -25,7 +26,13 @@ data class DownloadedFirmwarePart( } } -private val firmwareHttpClient = HttpClient(CIO) +private val firmwareHttpClient = HttpClient(CIO) { + install(HttpTimeout) { + connectTimeoutMillis = 15_000 + requestTimeoutMillis = 120_000 + socketTimeoutMillis = 30_000 + } +} suspend fun downloadFirmware(url: String, digest: String): ByteArray { val data: ByteArray = firmwareHttpClient.get(url).body() diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 0df486e42b..6de86e8739 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -1,5 +1,6 @@ package dev.slimevr.solarxr +import dev.slimevr.AppLogger import dev.slimevr.VRServer import dev.slimevr.device.DeviceState import dev.slimevr.skeleton.BoneState @@ -152,7 +153,7 @@ class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : Sola try { receiver.sendDataFeed(createDatafeedFrame(server = server, skeleton = skeleton, datafeedConfig = config, index = index)) } catch (e: Exception) { - dev.slimevr.AppLogger.solarxr.error(e, "Error sending data feed") + AppLogger.solarxr.error(e, "Error sending data feed") } delay(minTime) } diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt index 49a7ac35e6..c0dcb46393 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt @@ -29,7 +29,9 @@ class TrackingChecklist( ) { fun startObserving(appContext: AppContextProvider) { val stepBehaviours: List = buildList { - add(SteamVRCheckBehaviour(appContext.server)) + if (appContext.featureFlags.supportsSteamVR) { + add(SteamVRCheckBehaviour(appContext.server)) + } add(HMDCheckBehaviour(appContext.server)) add(TrackerRestCheckBehaviour(appContext.server)) add(TrackerErrorCheckBehaviour(appContext.server)) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index 44a56b4814..69ca5e4987 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -3,7 +3,6 @@ package dev.slimevr.desktop.ipc import dev.slimevr.AppContextProvider import dev.slimevr.AppLogger import dev.slimevr.getSocketDirectory -import dev.slimevr.solarxr.handleSolarXRBridge import dev.slimevr.util.safeLaunch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow diff --git a/server/desktop/src/main/java/dev/slimevr/solarxr/ipc.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt similarity index 90% rename from server/desktop/src/main/java/dev/slimevr/solarxr/ipc.kt rename to server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt index ec66d0024a..4b8355e26e 100644 --- a/server/desktop/src/main/java/dev/slimevr/solarxr/ipc.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt @@ -1,10 +1,12 @@ -package dev.slimevr.solarxr +package dev.slimevr.desktop.ipc import com.google.flatbuffers.FlatBufferBuilder import dev.slimevr.AppContextProvider import dev.slimevr.VRServerActions import dev.slimevr.fbscodegen.runtime.JvmFlatBufferReader import dev.slimevr.fbscodegen.runtime.JvmFlatBufferWriter +import dev.slimevr.solarxr.SolarXRBridge +import dev.slimevr.solarxr.onSolarXRMessage import io.ktor.util.moveToByteArray import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow @@ -38,4 +40,4 @@ suspend fun handleSolarXRBridge( } finally { bridge.disconnect() } -} +} \ No newline at end of file diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt index e38e3bbcd6..decacb466a 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt @@ -7,7 +7,6 @@ import com.sun.jna.platform.win32.WinError import com.sun.jna.platform.win32.WinNT import com.sun.jna.ptr.IntByReference import dev.slimevr.AppContextProvider -import dev.slimevr.solarxr.handleSolarXRBridge import dev.slimevr.util.safeLaunch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ws-server.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ws-server.kt index f022389356..90f2f6a4d4 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ws-server.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ws-server.kt @@ -2,7 +2,6 @@ package dev.slimevr.desktop.ipc import dev.slimevr.AppContextProvider import dev.slimevr.AppLogger -import dev.slimevr.solarxr.handleSolarXRBridge import io.ktor.server.application.install import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty From 95b64883f695d95f71a61439dfbd333c6f8c2424 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Fri, 29 May 2026 21:23:43 +0200 Subject: [PATCH 142/350] Fix HID not workey --- .../main/java/dev/slimevr/android/MainActivity.kt | 3 +-- .../src/main/java/dev/slimevr/android/hid/hid.kt | 14 ++++++++------ .../core/src/main/java/dev/slimevr/hid/packets.kt | 2 +- server/desktop/build.gradle.kts | 1 - .../src/main/java/dev/slimevr/desktop/hid/hid.kt | 10 +++++----- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/server/android/src/main/java/dev/slimevr/android/MainActivity.kt b/server/android/src/main/java/dev/slimevr/android/MainActivity.kt index 9ace3366fe..e8573d3be5 100644 --- a/server/android/src/main/java/dev/slimevr/android/MainActivity.kt +++ b/server/android/src/main/java/dev/slimevr/android/MainActivity.kt @@ -89,7 +89,6 @@ class MainActivity : AppCompatActivity() { guiWebView.settings.javaScriptEnabled = true guiWebView.settings.domStorageEnabled = true - // TODO: Let code know it is in android, should be gone when we start using tauri guiWebView.addJavascriptInterface(AndroidJsObject(), "__ANDROID__") guiWebView.settings.setSupportZoom(true) @@ -109,4 +108,4 @@ class MainActivity : AppCompatActivity() { companion object { private const val TAG = "MainActivity" } -} \ No newline at end of file +} diff --git a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt index 3c4d6e53f9..0bc7266504 100644 --- a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt +++ b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt @@ -8,6 +8,7 @@ import android.content.IntentFilter import android.hardware.usb.UsbConstants import android.hardware.usb.UsbDevice import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface import android.hardware.usb.UsbManager import androidx.core.content.ContextCompat import dev.slimevr.AppContextProvider @@ -41,7 +42,7 @@ private val HID_PRODUCT_RULES = listOf( private fun isCompatibleDevice(vid: Int, pid: Int) = HID_PRODUCT_RULES.any { rule -> vid == rule.vendorId && (pid and rule.productMask) == rule.productId } -private fun findHidInputEndpoint(device: UsbDevice): UsbEndpoint? { +private fun findHidInputEndpoint(device: UsbDevice): Pair? { for (ifaceIdx in 0 until device.interfaceCount) { val iface = device.getInterface(ifaceIdx) if (iface.interfaceClass != UsbConstants.USB_CLASS_HID) continue @@ -49,7 +50,7 @@ private fun findHidInputEndpoint(device: UsbDevice): UsbEndpoint? { val endpoint = iface.getEndpoint(epIdx) if (endpoint.type == UsbConstants.USB_ENDPOINT_XFER_INT && endpoint.direction == UsbConstants.USB_DIR_IN - ) return endpoint + ) return iface to endpoint } } return null @@ -89,7 +90,8 @@ fun createAndroidHIDManager(context: Context, appContext: AppContextProvider, sc usbManager.deviceList.values .filter { device -> isCompatibleDevice(device.vendorId, device.productId) } .associate { device -> device.deviceName to device } - } catch (_: Exception) { + } catch (e: Exception) { + AppLogger.hid.error(e, "HID enumeration failed") emptyMap() } } @@ -118,11 +120,12 @@ fun createAndroidHIDManager(context: Context, appContext: AppContextProvider, sc if (deviceName in active) continue permissionRequested.remove(deviceName) - val endpoint = findHidInputEndpoint(device) - if (endpoint == null) { + val hidEndpoint = findHidInputEndpoint(device) + if (hidEndpoint == null) { AppLogger.hid.warn("No HID input endpoint found for $deviceName") continue } + val (iface, endpoint) = hidEndpoint val connection = withContext(Dispatchers.IO) { usbManager.openDevice(device) } if (connection == null) { @@ -130,7 +133,6 @@ fun createAndroidHIDManager(context: Context, appContext: AppContextProvider, sc continue } - val iface = device.getInterface(0) connection.claimInterface(iface, true) val serial = device.serialNumber ?: deviceName diff --git a/server/core/src/main/java/dev/slimevr/hid/packets.kt b/server/core/src/main/java/dev/slimevr/hid/packets.kt index e562e964d9..c9dda5d94a 100644 --- a/server/core/src/main/java/dev/slimevr/hid/packets.kt +++ b/server/core/src/main/java/dev/slimevr/hid/packets.kt @@ -153,7 +153,7 @@ private fun parseSingleHIDPacket(data: ByteArray, i: Int): HIDPacket? { return when (packetType) { 255 -> { val addr = readLE48Unsigned(data, i + 2) - HIDDeviceRegister(hidId, "%012X".format(addr)) + HIDDeviceRegister(hidId, "%012X".format(addr.toLong())) } 0 -> { diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index 85ec7ba1dc..dcd5134bb9 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -126,6 +126,5 @@ buildConfig { tasks.run { standardInput = System.`in` // this is not working - jvmArgs("--enable-native-access=ALL-UNNAMED") args = listOf("run") } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index 9e5aa55de5..1e6d5e3e4c 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -9,6 +9,7 @@ import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -66,7 +67,8 @@ fun createDesktopHIDManager(appContext: AppContextProvider, scope: CoroutineScop val found = withContext(Dispatchers.IO) { try { enumerateCompatibleDevices() - } catch (_: Exception) { + } catch (e: Exception) { + AppLogger.hid.error(e, "HID enumeration failed") emptyMap() } } @@ -93,7 +95,7 @@ fun createDesktopHIDManager(appContext: AppContextProvider, scope: CoroutineScop val serial = hidDevice.serialNumber ?: path AppLogger.hid.info("HID device detected: $serial") - val deviceJob = Job(scope.coroutineContext[Job]) + val deviceJob = SupervisorJob(scope.coroutineContext[Job]) val deviceScope = CoroutineScope(scope.coroutineContext + deviceJob) val receiver = HIDReceiver.create( @@ -113,9 +115,7 @@ fun createDesktopHIDManager(appContext: AppContextProvider, scope: CoroutineScop } } when { - data == null -> return@safeLaunch - - // read error, device gone + data == null -> return@safeLaunch // read error, device gone data.isNotEmpty() -> parseHIDPackets(data).forEach { receiver.packetEvents.emit(it) } else -> delay(1) // no data yet, yield without busy-spinning From 1ebd8a4f02732697bb720d7e84670ba7531ce4a1 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Sat, 30 May 2026 02:29:31 +0200 Subject: [PATCH 143/350] Code cleanup --- .../core/src/main/java/dev/slimevr/context/context.kt | 4 ++-- .../java/dev/slimevr/context/debug/ContextDebug.kt | 4 +--- .../dev/slimevr/context/debug/LoggingMiddleware.kt | 10 +++++----- .../src/main/java/dev/slimevr/device/behaviours.kt | 6 +++--- server/core/src/main/java/dev/slimevr/device/module.kt | 10 +++++++--- .../src/main/java/dev/slimevr/driver/behaviours.kt | 1 + .../main/java/dev/slimevr/heightcalibration/module.kt | 2 +- .../core/src/main/java/dev/slimevr/hid/behaviours.kt | 1 + server/core/src/main/java/dev/slimevr/osc/transport.kt | 8 ++++---- .../core/src/main/java/dev/slimevr/oscquery/server.kt | 2 +- .../core/src/main/java/dev/slimevr/solarxr/serial.kt | 2 +- .../core/src/main/java/dev/slimevr/udp/behaviours.kt | 3 ++- .../core/src/main/java/dev/slimevr/udp/connection.kt | 2 +- server/core/src/main/java/dev/slimevr/vrcosc/input.kt | 3 ++- server/core/src/test/java/dev/slimevr/TestServer.kt | 4 ++++ .../dev/slimevr/context/ContextCallerCaptureTest.kt | 4 ++-- .../java/dev/slimevr/firmware/DoSerialFlashTest.kt | 4 +++- .../test/java/dev/slimevr/firmware/OtaUploadTest.kt | 2 ++ .../slimevr/provisioning/ProvisioningManagerTest.kt | 4 +++- .../desktop/src/main/java/dev/slimevr/desktop/Main.kt | 4 ++-- 20 files changed, 48 insertions(+), 32 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index a65c9f722b..2bd0307ce8 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -1,6 +1,6 @@ package dev.slimevr.context -import dev.slimevr.context.debug.ContextDebug +import dev.slimevr.context.debug.contextDebugEnabled import dev.slimevr.context.debug.DebugMiddleware import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope @@ -126,7 +126,7 @@ class Context( val mutableStateFlow = MutableStateFlow(initialState) val contextJob = SupervisorJob(scope.coroutineContext[Job]) val scopeWithName = CoroutineScope(scope.coroutineContext + contextJob + CoroutineName(name)) - val effectiveDebugMiddleware = if (ContextDebug.enabled) debugMiddleware else null + val effectiveDebugMiddleware = if (contextDebugEnabled) debugMiddleware else null val context = Context( mutableStateFlow, scopeWithName, diff --git a/server/core/src/main/java/dev/slimevr/context/debug/ContextDebug.kt b/server/core/src/main/java/dev/slimevr/context/debug/ContextDebug.kt index 4b7d3d360b..c985fc90c1 100644 --- a/server/core/src/main/java/dev/slimevr/context/debug/ContextDebug.kt +++ b/server/core/src/main/java/dev/slimevr/context/debug/ContextDebug.kt @@ -1,5 +1,3 @@ package dev.slimevr.context.debug -object ContextDebug { - var enabled: Boolean = false -} +var contextDebugEnabled: Boolean = false \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt b/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt index 68c96778f0..109952a90a 100644 --- a/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt +++ b/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt @@ -17,7 +17,7 @@ private object Ansi { enum class DiffStyle { INLINE, MULTILINE } -class LoggingMiddleware( +class LoggingMiddleware( private val logNoOps: Boolean = false, private val diffStyle: DiffStyle = DiffStyle.MULTILINE, private val allow: Set>? = null, @@ -31,7 +31,7 @@ class LoggingMiddleware( } private fun isAllowed(action: A): Boolean { - val klass = action!!::class + val klass = action::class if (klass in block) return false if (allow != null && klass !in allow) return false return true @@ -40,7 +40,7 @@ class LoggingMiddleware( override fun onDispatch(caller: String?, before: S, action: A, after: S) { if (!isAllowed(action)) return if (!logNoOps && before == after) return - val actionName = "${Ansi.YELLOW}${action!!::class.simpleName ?: "UnknownAction"}${Ansi.RESET}" + val actionName = "${Ansi.YELLOW}${action::class.simpleName ?: "UnknownAction"}${Ansi.RESET}" val callerSuffix = if (caller != null) " ${Ansi.DIM}(from $caller)${Ansi.RESET}" else "" println(formatLine("$tag $actionName$callerSuffix", before, after)) } @@ -49,7 +49,7 @@ class LoggingMiddleware( val visible = actions.filter { action -> isAllowed(action) } if (visible.isEmpty()) return if (!logNoOps && before == after) return - val names = visible.joinToString(", ") { action -> action!!::class.simpleName ?: "?" } + val names = visible.joinToString(", ") { action -> action::class.simpleName ?: "?" } val actionName = "${Ansi.MAGENTA}batch${Ansi.RESET} ${Ansi.YELLOW}[$names]${Ansi.RESET}" val callerSuffix = if (caller != null) " ${Ansi.DIM}(from $caller)${Ansi.RESET}" else "" println(formatLine("$tag $actionName$callerSuffix", before, after)) @@ -126,5 +126,5 @@ private fun fieldChanges(before: String, after: String): List { val afterMap = parseFields(after) return beforeMap.keys.intersect(afterMap.keys) .filter { key -> beforeMap[key] != afterMap[key] } - .map { key -> FieldChange(key, beforeMap[key]!!, afterMap[key]!!) } + .map { key -> FieldChange(key, beforeMap.getValue(key), afterMap.getValue(key)) } } diff --git a/server/core/src/main/java/dev/slimevr/device/behaviours.kt b/server/core/src/main/java/dev/slimevr/device/behaviours.kt index fba8c2f071..c1de86b6d7 100644 --- a/server/core/src/main/java/dev/slimevr/device/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/device/behaviours.kt @@ -9,9 +9,9 @@ object DeviceStatsBehaviour : DeviceBehaviour { is DeviceActions.PacketStats -> state.copy(packetsReceived = action.packetsReceived, packetsLost = action.packetsLost) } - override fun observe(receiver: DeviceContext) { - receiver.state.onEach { + override fun observe(receiver: Device) { + receiver.context.state.onEach { // AppLogger.device.info("Device state changed", it) - }.launchIn(receiver.scope) + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/device/module.kt b/server/core/src/main/java/dev/slimevr/device/module.kt index 7036dfa8d1..815ee1716f 100644 --- a/server/core/src/main/java/dev/slimevr/device/module.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -1,5 +1,6 @@ package dev.slimevr.device +import dev.slimevr.AppContextProvider import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.context.debug.DiffStyle @@ -44,14 +45,16 @@ sealed interface DeviceActions { } typealias DeviceContext = Context -typealias DeviceBehaviour = Behaviour +typealias DeviceBehaviour = Behaviour class Device( val context: DeviceContext, + val appContext: AppContextProvider, ) { companion object { fun create( scope: CoroutineScope, + appContext: AppContextProvider, id: Int, name: String = "Device $id", manufacturer: String = "SlimeVR", @@ -92,8 +95,9 @@ class Device( ), name = "Device[$address]", ) - behaviours.forEach { it.observe(context) } - return Device(context = context) + val device = Device(context = context, appContext = appContext) + behaviours.forEach { it.observe(device) } + return device } } } diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 24a92b2fe2..33a564f894 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -98,6 +98,7 @@ object DriverBaseBehaviour : DriverBridgeBehaviour { val deviceId = server.nextHandle() val newDevice = Device.create( scope = scope, + appContext = receiver.appContext, id = deviceId, name = name, manufacturer = manufacturer, diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt index 5a60c7ec57..113cbd77d3 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt @@ -76,7 +76,7 @@ class HeightCalibrationManager( TrackerSnapshot(position = position, rotation = s.rawRotation) } }, - ) { snapshots -> snapshots.minByOrNull { it.position.y }!! } + ) { snapshots -> snapshots.minByOrNull { it.position.y } ?: error("snapshots is empty; controllers check above should have prevented this") } } fun start() { diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index aa5e88000f..232fceed36 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -48,6 +48,7 @@ object HIDRegistrationBehaviour : HIDReceiverBehaviour { val deviceId = receiver.appContext.server.nextHandle() val device = Device.create( scope = receiver.appContext.server.context.scope, + appContext = receiver.appContext, id = deviceId, address = packet.address, macAddress = packet.address, diff --git a/server/core/src/main/java/dev/slimevr/osc/transport.kt b/server/core/src/main/java/dev/slimevr/osc/transport.kt index dba70e8538..b785550de6 100644 --- a/server/core/src/main/java/dev/slimevr/osc/transport.kt +++ b/server/core/src/main/java/dev/slimevr/osc/transport.kt @@ -18,8 +18,8 @@ class OscSender(private val address: String, private val port: Int) { private var socket: ConnectedDatagramSocket? = null private suspend fun socket(): ConnectedDatagramSocket { - if (socket == null) socket = aSocket(selectorManager).udp().connect(InetSocketAddress(address, port)) - return socket!! + val s = socket ?: aSocket(selectorManager).udp().connect(InetSocketAddress(address, port)).also { socket = it } + return s } suspend fun send(message: OscMessage) = socket().send(Datagram(encodeMessage(message).toBuffer(), InetSocketAddress(address, port))) @@ -36,8 +36,8 @@ class OscReceiver(private val port: Int) { private var running = false private suspend fun socket(): BoundDatagramSocket { - if (socket == null) socket = aSocket(selectorManager).udp().bind(InetSocketAddress("0.0.0.0", port)) - return socket!! + val s = socket ?: aSocket(selectorManager).udp().bind(InetSocketAddress("0.0.0.0", port)).also { socket = it } + return s } suspend fun listenBundles(onBundle: (OscBundle) -> Unit) { diff --git a/server/core/src/main/java/dev/slimevr/oscquery/server.kt b/server/core/src/main/java/dev/slimevr/oscquery/server.kt index bd83b59d19..1445a2788f 100644 --- a/server/core/src/main/java/dev/slimevr/oscquery/server.kt +++ b/server/core/src/main/java/dev/slimevr/oscquery/server.kt @@ -91,7 +91,7 @@ class OscQueryServer( // Pin the advertised address to our resolved local IP. Otherwise dns-sd-kt // falls back to `getLocalAddresses().firstOrNull()` which iterates every NIC - // and may pick a virtual / wrong-subnet adapter — making the service + // and may pick a virtual / wrong-subnet adapter, making the service // unreachable from headsets on the actual LAN. private fun createService(type: String, name: String, port: Int, txt: Map): NetService = serviceFactory(type, name, port, 0, 1, listOf(address), txt) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt index 62c9f9e5ad..10000764d2 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt @@ -39,7 +39,7 @@ class SerialBehaviour(private val serialServer: SerialServer) : SolarXRBridgeBeh .distinctUntilChanged() .onEach { ports -> (ports.keys - prevPortKeys).forEach { key -> - receiver.sendRpc(NewSerialDeviceResponse(device = ports[key]!!.toSerialDevice())) + receiver.sendRpc(NewSerialDeviceResponse(device = ports.getValue(key).toSerialDevice())) } prevPortKeys = ports.keys.toSet() } diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 96ad98b96a..25a5186107 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -142,8 +142,9 @@ object HandshakeBehaviour : UDPConnectionBehaviour { } val deviceId = receiver.appContext.server.nextHandle() val newDevice = Device.create( - id = deviceId, scope = receiver.appContext.server.context.scope, + appContext = receiver.appContext, + id = deviceId, address = state.address, macAddress = data.macString, origin = DeviceOrigin.UDP, diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 126b41db29..ec4fa15d83 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -28,7 +28,7 @@ data class SensorConfigFlags( data class UDPConnectionState( // Pre-resolved IP string. InetSocketAddress.hostname triggers a reverse DNS lookup so we - // can't derive this on demand — it must be resolved once by the platform (see UdpServer.addressResolver) + // can't derive this on demand; it must be resolved once by the platform (see UdpServer.addressResolver) // and cached here. remoteAddress is kept separately on UDPConnection only for sending datagrams. val address: String, val lastPacket: Long, diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt index 26ecc4e128..15c11a398e 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt @@ -27,7 +27,7 @@ import solarxr_protocol.rpc.VRCOSCInputState * created lazily when their first packet arrives and reused afterwards. * * The runtime cannot remove existing devices/trackers from the VRServer, so on - * disable we only mark them DISCONNECTED — they are reused on the next enable. + * disable we only mark them DISCONNECTED; they are reused on the next enable. */ private class VRSystemTrackerRegistry( private val appContext: AppContextProvider, @@ -89,6 +89,7 @@ private class VRSystemTrackerRegistry( val id = appContext.server.nextHandle() val device = Device.create( scope = manager.context.scope, + appContext = appContext, id = id, address = "vrchat-vrsystem", origin = DeviceOrigin.VRC, diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index bfefe7abe4..50203f6bdd 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -102,6 +102,10 @@ private object NoopConfigStorage : ConfigStorage { override suspend fun openTextFile(path: String): TextFileHandle = error("Not used in tests") } +fun buildTestAppContext(server: VRServer): AppContextProvider = object : TestAppContext() { + override val server: VRServer = server +} + abstract class TestAppContext : AppContextProvider { override val featureFlags: FeatureFlags = FeatureFlags() override val config: AppConfig get() = error("not used in test") diff --git a/server/core/src/test/java/dev/slimevr/context/ContextCallerCaptureTest.kt b/server/core/src/test/java/dev/slimevr/context/ContextCallerCaptureTest.kt index 92baca2e0c..3916c4ceb5 100644 --- a/server/core/src/test/java/dev/slimevr/context/ContextCallerCaptureTest.kt +++ b/server/core/src/test/java/dev/slimevr/context/ContextCallerCaptureTest.kt @@ -1,6 +1,6 @@ package dev.slimevr.context -import dev.slimevr.context.debug.ContextDebug +import dev.slimevr.context.debug.contextDebugEnabled import dev.slimevr.context.debug.DebugMiddleware import kotlin.test.AfterTest import kotlin.test.Test @@ -38,7 +38,7 @@ class ContextCallerCaptureTest { @Test fun `dispatch reports calling behaviour`() { - ContextDebug.enabled = true + contextDebugEnabled = true val middleware = RecordingDebugMiddleware() val context = Context.create( diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt index 792cae10b7..550fb2c4cf 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -3,6 +3,7 @@ package dev.slimevr.firmware import dev.slimevr.VRServer import dev.slimevr.VRServerActions import dev.slimevr.buildTestSettings +import dev.slimevr.buildTestAppContext import dev.slimevr.buildTestVrServerStub import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions @@ -313,7 +314,8 @@ class DoSerialFlashTest { server.onDataReceived("COM1", "looking for the server") delay(300) val device = Device.create( - backgroundScope, + scope = backgroundScope, + appContext = buildTestAppContext(vrServer), id = vrServer.nextHandle(), address = "192.168.1.100", macAddress = "AA:BB:CC:DD:EE:FF", diff --git a/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt b/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt index 35e55c3557..c19454c148 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt @@ -2,6 +2,7 @@ package dev.slimevr.firmware import dev.slimevr.VRServer import dev.slimevr.VRServerActions +import dev.slimevr.buildTestAppContext import dev.slimevr.buildTestVrServerStub import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions @@ -30,6 +31,7 @@ private fun addUdpDevice( ): Device { val device = Device.create( scope = scope, + appContext = buildTestAppContext(server), id = id, address = "192.168.1.100", macAddress = macAddress, diff --git a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt index a825719ab1..e7960c73ec 100644 --- a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt +++ b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt @@ -4,6 +4,7 @@ import dev.slimevr.VRServer import dev.slimevr.VRServerActions import dev.slimevr.buildTestSerialServer import dev.slimevr.buildTestSettings +import dev.slimevr.buildTestAppContext import dev.slimevr.buildTestVrServer import dev.slimevr.context.Context import dev.slimevr.device.Device @@ -37,7 +38,8 @@ private fun buildManager(serialServer: dev.slimevr.serial.SerialServer, scope: C // Injects a fully connected device into the VRServer, simulating a tracker appearing on the network. private fun connectDevice(vrServer: VRServer, mac: String, scope: CoroutineScope) { val device = Device.create( - scope, + scope = scope, + appContext = buildTestAppContext(vrServer), id = vrServer.nextHandle(), address = "192.168.1.100", macAddress = mac, 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 dcfc67209d..fe777d7752 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -10,7 +10,7 @@ import dev.slimevr.Platform import dev.slimevr.VRServer import dev.slimevr.bvh.BVHManager import dev.slimevr.config.AppConfig -import dev.slimevr.context.debug.ContextDebug +import dev.slimevr.context.debug.contextDebugEnabled import dev.slimevr.desktop.config.DesktopConfigStorage import dev.slimevr.desktop.hid.createDesktopHIDManager import dev.slimevr.desktop.install.executeShellCommand @@ -38,7 +38,7 @@ import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.runBlocking fun main(args: Array) = runBlocking { - ContextDebug.enabled = System.getProperty("slimevr.debug.context") == "true" || + contextDebugEnabled = System.getProperty("slimevr.debug.context") == "true" || System.getenv("SLIMEVR_DEBUG_CONTEXT") == "true" val featureFlags = FeatureFlags() From 0c0aac0415b120b900efe11f3405cc1145d8b9e5 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Sat, 30 May 2026 03:22:41 +0200 Subject: [PATCH 144/350] More cleanup / consistency changes --- server/README.md | 23 ++++--- .../src/main/java/dev/slimevr/behaviours.kt | 2 +- .../java/dev/slimevr/config/behaviours.kt | 6 +- .../main/java/dev/slimevr/config/module.kt | 66 +++++++++++-------- .../main/java/dev/slimevr/config/settings.kt | 6 +- .../src/main/java/dev/slimevr/config/user.kt | 2 +- .../main/java/dev/slimevr/context/context.kt | 22 +++---- .../java/dev/slimevr/device/behaviours.kt | 2 +- .../main/java/dev/slimevr/device/module.kt | 6 +- .../java/dev/slimevr/driver/behaviours.kt | 2 +- .../main/java/dev/slimevr/driver/module.kt | 2 +- .../java/dev/slimevr/firmware/behaviours.kt | 2 +- .../main/java/dev/slimevr/firmware/module.kt | 2 +- .../slimevr/heightcalibration/behaviours.kt | 2 +- .../dev/slimevr/heightcalibration/module.kt | 2 +- .../main/java/dev/slimevr/hid/behaviours.kt | 14 ++-- .../src/main/java/dev/slimevr/hid/module.kt | 14 ++-- .../java/dev/slimevr/networkprofile/module.kt | 4 +- .../dev/slimevr/provisioning/behaviours.kt | 2 +- .../java/dev/slimevr/provisioning/module.kt | 2 +- .../java/dev/slimevr/serial/behaviours.kt | 4 +- .../java/dev/slimevr/serial/connection.kt | 6 +- .../main/java/dev/slimevr/serial/module.kt | 8 ++- .../main/java/dev/slimevr/solarxr/datafeed.kt | 12 ++-- .../dev/slimevr/solarxr/installed-info.kt | 2 +- .../main/java/dev/slimevr/solarxr/module.kt | 10 +-- .../java/dev/slimevr/tracker/behaviours.kt | 16 ++--- .../main/java/dev/slimevr/tracker/module.kt | 12 ++-- .../dev/slimevr/trackingchecklist/module.kt | 4 +- .../main/java/dev/slimevr/udp/behaviours.kt | 28 ++++---- .../main/java/dev/slimevr/udp/connection.kt | 28 ++++---- .../src/main/java/dev/slimevr/udp/server.kt | 4 +- .../java/dev/slimevr/vrchat/behaviours.kt | 2 +- .../main/java/dev/slimevr/vrchat/module.kt | 2 +- .../src/main/java/dev/slimevr/vrserver.kt | 6 +- 35 files changed, 175 insertions(+), 152 deletions(-) diff --git a/server/README.md b/server/README.md index a962344b06..0b2554697d 100644 --- a/server/README.md +++ b/server/README.md @@ -85,13 +85,17 @@ This is why `observe` receives the module (or context) rather than taking a raw ### Stateless vs. Stateful Behaviours -Behaviours with no constructor dependencies are `object`s - singletons, zero allocation: +All behaviours are `class`es, instantiated at the call site: ```kotlin -object PacketBehaviour : UDPConnectionBehaviour { ... } +class PacketBehaviour : UDPConnectionBehaviour { ... } + +val behaviours = listOf(PacketBehaviour(), PacketLossBehaviour(), HandshakeBehaviour()) ``` -Behaviours that need external services are `class`es, constructed at the call site: +Even behaviours with no constructor arguments are `class`es. `object` is banned for behaviours because `observe()` often needs to capture mutable state (timers, counters, flags). An `object` shares that state across every module instance that uses it — a subtle, hard-to-trace bug. Requiring `class` enforces that each module instance gets its own behaviour instance, with its own isolated state. The allocation cost is negligible. + +Behaviours that need external services take them as constructor arguments: ```kotlin class TrackerConfigBehaviour( @@ -104,7 +108,7 @@ When a behaviour is conditional (depends on a nullable service), use `buildList` ```kotlin val behaviours: List = buildList { - add(AlwaysPresentBehaviour) + add(AlwaysPresentBehaviour()) optionalService?.let { add(OptionalServiceBehaviour(it)) } } ``` @@ -392,7 +396,7 @@ Create a context directly with the behaviours you want to test. No module class, val context = Context.create( initialState = FirmwareManagerState(jobs = mapOf()), scope = this, - behaviours = listOf(FirmwareManagerBaseBehaviour), + behaviours = listOf(FirmwareManagerBaseBehaviour()), ) context.dispatch(FirmwareManagerActions.UpdateJob(...)) assertEquals(FirmwareUpdateStatus.UPLOADING, context.state.value.jobs["COM1"]?.status) @@ -407,7 +411,7 @@ val serialServer = buildTestSerialServer(backgroundScope) val context = Context.create( initialState = ProvisioningManager.INITIAL_STATE, scope = backgroundScope, - behaviours = listOf(ProvisioningManagerBaseBehaviour), + behaviours = listOf(ProvisioningManagerBaseBehaviour()), ) val manager = ProvisioningManager(context = context, serialServer = serialServer, scope = backgroundScope) manager.startObserving() @@ -425,7 +429,7 @@ val appContext = object : TestAppContext() { override val skeleton = skeleton } val context = Context.create( - initialState = SolarXRBridgeState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), + initialState = SolarXRBridgeState(dataFeedConfigs = listOf()), scope = backgroundScope, behaviours = listOf(DataFeedInitBehaviour(server, skeleton)), ) @@ -489,7 +493,7 @@ class MyModule(val context: MyContext, val server: VRServer) { val context = Context.create( initialState = MyState(false), scope = scope, - behaviours = listOf(MyCoreBehaviour), + behaviours = listOf(MyCoreBehaviour()), ) return MyModule(context, ctx.server) } @@ -520,7 +524,8 @@ receiver.packetEvents.on { event -> ## Style Conventions -- **Prefer plain functions over classes.** A single-method interface should almost always be a function type (`() -> Unit`, `suspend (ByteArray) -> Unit`). A class with no mutable state should be a plain function or `object`. When in doubt, write a function. +- **Prefer plain functions over classes.** A single-method interface should almost always be a function type (`() -> Unit`, `suspend (ByteArray) -> Unit`). When in doubt, write a function. +- **Behaviours are always `class`, never `object`.** Even behaviours with no constructor arguments. `observe()` frequently captures mutable state (timers, counters, flags), and a singleton `object` would share that state across every module instance — a subtle, cross-connection bug. Instantiate every behaviour at the call site: `listOf(PacketBehaviour(), PingBehaviour())`. - **Prefer plain functions over extension functions.** Only use extensions when the receiver type is genuinely the primary subject. - **State data classes are all `val`.** Mutable fields in a state class are a design mistake - use `var` in a plain class if you need local mutability, never in state. - **Use `sealed interface` for actions**, not `sealed class` - no constructor overhead. diff --git a/server/core/src/main/java/dev/slimevr/behaviours.kt b/server/core/src/main/java/dev/slimevr/behaviours.kt index 5d27aff418..75408163b6 100644 --- a/server/core/src/main/java/dev/slimevr/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/behaviours.kt @@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -object BaseBehaviour : VRServerBehaviour { +class BaseBehaviour : VRServerBehaviour { override fun reduce(state: VRServerState, action: VRServerActions): VRServerState = when (action) { is VRServerActions.NewTracker -> state.copy(trackers = state.trackers + (action.trackerId to action.context)) is VRServerActions.NewDevice -> state.copy(devices = state.devices + (action.deviceId to action.context)) diff --git a/server/core/src/main/java/dev/slimevr/config/behaviours.kt b/server/core/src/main/java/dev/slimevr/config/behaviours.kt index 4b63110b25..54271564f8 100644 --- a/server/core/src/main/java/dev/slimevr/config/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/config/behaviours.kt @@ -1,13 +1,13 @@ package dev.slimevr.config -object DefaultGlobalConfigBehaviour : GlobalConfigBehaviour { +class DefaultGlobalConfigBehaviour : GlobalConfigBehaviour { override fun reduce(state: GlobalConfigState, action: GlobalConfigActions) = when (action) { is GlobalConfigActions.SetUserProfile -> state.copy(selectedUserProfile = action.name) is GlobalConfigActions.SetSettingsProfile -> state.copy(selectedSettingsProfile = action.name) } } -object DefaultSettingsBehaviour : SettingsBehaviour { +class DefaultSettingsBehaviour : SettingsBehaviour { override fun reduce(state: SettingsState, action: SettingsActions) = when (action) { is SettingsActions.Update -> state.copy(data = action.transform(state.data)) @@ -24,7 +24,7 @@ object DefaultSettingsBehaviour : SettingsBehaviour { } } -object DefaultUserBehaviour : UserConfigBehaviour { +class DefaultUserBehaviour : UserConfigBehaviour { override fun reduce(state: UserConfigState, action: UserConfigActions) = when (action) { is UserConfigActions.Update -> state.copy(data = action.transform(state.data)) is UserConfigActions.LoadProfile -> action.newState diff --git a/server/core/src/main/java/dev/slimevr/config/module.kt b/server/core/src/main/java/dev/slimevr/config/module.kt index 9ca987ec9a..a74b855b87 100644 --- a/server/core/src/main/java/dev/slimevr/config/module.kt +++ b/server/core/src/main/java/dev/slimevr/config/module.kt @@ -25,7 +25,38 @@ sealed interface GlobalConfigActions { } typealias GlobalConfigContext = Context -typealias GlobalConfigBehaviour = Behaviour +typealias GlobalConfigBehaviour = Behaviour + +class GlobalConfig( + val context: GlobalConfigContext, +) { + fun startObserving() = context.observeAll(this) + + companion object { + suspend fun create(scope: CoroutineScope, storage: ConfigStorage): GlobalConfig { + val initialState = loadFileWithBackup(storage, "global.json", GlobalConfigState()) { + parseAndMigrateGlobalConfig(it) + } + val behaviours = listOf(DefaultGlobalConfigBehaviour()) + val context = Context.create( + initialState = initialState, + scope = scope, + behaviours = behaviours, + name = "GlobalConfig", + ) + val globalConfig = GlobalConfig(context) + globalConfig.startObserving() + launchAutosave( + scope = scope, + state = context.state, + storage = storage, + toPath = { "global.json" }, + serialize = { jsonConfig.encodeToString(it) }, + ) + return globalConfig + } + } +} private fun migrateGlobalConfig(json: JsonObject): JsonObject { val version = json["version"]?.jsonPrimitive?.intOrNull ?: 0 @@ -41,48 +72,29 @@ private fun parseAndMigrateGlobalConfig(raw: String): GlobalConfigState { } class AppConfig( - val globalContext: GlobalConfigContext, + val globalConfig: GlobalConfig, val userConfig: UserConfig, val settings: Settings, ) { suspend fun switchUserProfile(name: String) { - globalContext.dispatch(GlobalConfigActions.SetUserProfile(name)) + globalConfig.context.dispatch(GlobalConfigActions.SetUserProfile(name)) userConfig.swap(name) } suspend fun switchSettingsProfile(name: String) { - globalContext.dispatch(GlobalConfigActions.SetSettingsProfile(name)) + globalConfig.context.dispatch(GlobalConfigActions.SetSettingsProfile(name)) settings.swap(name) } companion object { suspend fun create(scope: CoroutineScope, storage: ConfigStorage): AppConfig { - val initialGlobal = loadFileWithBackup(storage, "global.json", GlobalConfigState()) { - parseAndMigrateGlobalConfig(it) - } - - val behaviours = listOf(DefaultGlobalConfigBehaviour) - val globalContext = Context.create( - initialState = initialGlobal, - scope = scope, - behaviours = behaviours, - name = "GlobalConfig", - ) - behaviours.forEach { it.observe(globalContext) } - - launchAutosave( - scope = scope, - state = globalContext.state, - storage = storage, - toPath = { "global.json" }, - serialize = { jsonConfig.encodeToString(it) }, - ) + val globalConfig = GlobalConfig.create(scope, storage) - val userConfig = UserConfig.create(scope, storage, initialGlobal.selectedUserProfile) - val settings = Settings.create(scope, storage, initialGlobal.selectedSettingsProfile) + val userConfig = UserConfig.create(scope, storage, globalConfig.context.state.value.selectedUserProfile) + val settings = Settings.create(scope, storage, globalConfig.context.state.value.selectedSettingsProfile) return AppConfig( - globalContext = globalContext, + globalConfig = globalConfig, userConfig = userConfig, settings = settings, ) diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 8122e1f62e..a208e28659 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -78,6 +78,8 @@ class Settings( private val storage: ConfigStorage, private val settingsDir: String, ) { + fun startObserving() = context.observeAll(this) + private var autosaveJob: Job = startAutosave() private fun startAutosave() = launchAutosave( @@ -109,7 +111,7 @@ class Settings( } val initialState = SettingsState(name = name, data = initialData) - val behaviours = listOf(DefaultSettingsBehaviour) + val behaviours = listOf(DefaultSettingsBehaviour()) val context = Context.create( initialState = initialState, scope = scope, @@ -117,7 +119,7 @@ class Settings( name = "Settings[$name]", ) val settings = Settings(context, scope = scope, storage = storage, settingsDir = settingsDir) - behaviours.forEach { it.observe(settings) } + settings.startObserving() return settings } } diff --git a/server/core/src/main/java/dev/slimevr/config/user.kt b/server/core/src/main/java/dev/slimevr/config/user.kt index ab8acff7b8..44f323f1cd 100644 --- a/server/core/src/main/java/dev/slimevr/config/user.kt +++ b/server/core/src/main/java/dev/slimevr/config/user.kt @@ -87,7 +87,7 @@ class UserConfig( val context = Context.create( initialState = initialState, scope = scope, - behaviours = listOf(DefaultUserBehaviour), + behaviours = listOf(DefaultUserBehaviour()), name = "UserConfig[$name]", ) val userConfig = UserConfig(context, scope = scope, storage = storage, userConfigDir = userConfigDir) diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index 2bd0307ce8..f2cc479af9 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -10,9 +10,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi interface Behaviour { fun reduce(state: S, action: A): S = state @@ -21,23 +20,20 @@ interface Behaviour { // TODO: missing remove + prob a unobserve or something to clear the stuff launched // in a observe -> each behaviour is prob gonna need its own scope +@OptIn(ExperimentalAtomicApi::class) class BehaviourList( initial: List>, ) { - private val mutex = Mutex() - private var items: List> = initial.toList() + private val items = AtomicReference(initial.toList()) - fun addAll(newItems: List>) = runBlocking { - mutex.withLock { - items = items + newItems + fun addAll(newItems: List>) { + while (true) { + val current = items.load() + if (items.compareAndSet(current, current + newItems)) break } } - fun snapshot(): List> = runBlocking { - mutex.withLock { - items.toList() - } - } + fun snapshot(): List> = items.load() } class ManagedContext( diff --git a/server/core/src/main/java/dev/slimevr/device/behaviours.kt b/server/core/src/main/java/dev/slimevr/device/behaviours.kt index c1de86b6d7..a5e830a265 100644 --- a/server/core/src/main/java/dev/slimevr/device/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/device/behaviours.kt @@ -3,7 +3,7 @@ package dev.slimevr.device import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -object DeviceStatsBehaviour : DeviceBehaviour { +class DeviceStatsBehaviour : DeviceBehaviour { override fun reduce(state: DeviceState, action: DeviceActions) = when (action) { is DeviceActions.Update -> action.transform(state) is DeviceActions.PacketStats -> state.copy(packetsReceived = action.packetsReceived, packetsLost = action.packetsLost) diff --git a/server/core/src/main/java/dev/slimevr/device/module.kt b/server/core/src/main/java/dev/slimevr/device/module.kt index 815ee1716f..2146dac586 100644 --- a/server/core/src/main/java/dev/slimevr/device/module.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -51,6 +51,8 @@ class Device( val context: DeviceContext, val appContext: AppContextProvider, ) { + fun startObserving() = context.observeAll(this) + companion object { fun create( scope: CoroutineScope, @@ -84,7 +86,7 @@ class Device( packetsLost = 0L, ) - val behaviours = listOf(DeviceStatsBehaviour) + val behaviours = listOf(DeviceStatsBehaviour()) val context = Context.create( initialState = deviceState, scope = scope, @@ -96,7 +98,7 @@ class Device( name = "Device[$address]", ) val device = Device(context = context, appContext = appContext) - behaviours.forEach { it.observe(device) } + device.startObserving() return device } } diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 33a564f894..de9284cfce 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.TrackerStatus -object DriverBaseBehaviour : DriverBridgeBehaviour { +class DriverBaseBehaviour : DriverBridgeBehaviour { override fun reduce(state: DriverBridgeState, action: DriverBridgeActions): DriverBridgeState = when (action) { is DriverBridgeActions.AddTracker -> state.copy(trackers = state.trackers + (action.id to action.trackerId)) is DriverBridgeActions.UpdateProtocolVersion -> state.copy(protocolVersion = action.version) diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt index d6b12a58f0..04b9f9c58e 100644 --- a/server/core/src/main/java/dev/slimevr/driver/module.kt +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -62,7 +62,7 @@ class DriverBridge( companion object { fun create(id: Int, appContext: AppContextProvider, scope: CoroutineScope): DriverBridge { - val behaviours = listOf(DriverBaseBehaviour) + val behaviours = listOf(DriverBaseBehaviour()) val managedContext = ManagedContext.create( initialState = DriverBridgeState( diff --git a/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt b/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt index a052831261..c4e50dbbdb 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/behaviours.kt @@ -1,6 +1,6 @@ package dev.slimevr.firmware -object FirmwareManagerBaseBehaviour : FirmwareManagerBehaviour { +class FirmwareManagerBaseBehaviour : FirmwareManagerBehaviour { override fun reduce(state: FirmwareManagerState, action: FirmwareManagerActions) = when (action) { is FirmwareManagerActions.UpdateJob -> state.copy( jobs = state.jobs + (action.jobStatus.portLocation to action.jobStatus), diff --git a/server/core/src/main/java/dev/slimevr/firmware/module.kt b/server/core/src/main/java/dev/slimevr/firmware/module.kt index a8e1d528ae..0b1f5a9530 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/module.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/module.kt @@ -128,7 +128,7 @@ class FirmwareManager( companion object { fun create(ctx: Phase1ContextProvider, scope: CoroutineScope, flasher: FirmwareFlasher): FirmwareManager { - val behaviours = listOf(FirmwareManagerBaseBehaviour) + val behaviours = listOf(FirmwareManagerBaseBehaviour()) val context = Context.create( initialState = FirmwareManagerState(jobs = mapOf()), scope = scope, diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt index 2ee1d403b6..b93f3f7ebc 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt @@ -54,7 +54,7 @@ private fun isHmdLeveled(snapshot: TrackerSnapshot): Boolean { return (up dot Vector3.POS_Y) >= HEAD_ANGLE_THRESHOLD } -object CalibrationBehaviour : HeightCalibrationBehaviourType { +class CalibrationBehaviour : HeightCalibrationBehaviourType { override fun reduce(state: HeightCalibrationState, action: HeightCalibrationActions) = when (action) { is HeightCalibrationActions.Update -> state.copy( status = action.status, diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt index 113cbd77d3..379855c5d7 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt @@ -95,7 +95,7 @@ class HeightCalibrationManager( ctx: Phase1ContextProvider, scope: CoroutineScope, ): HeightCalibrationManager { - val behaviours = listOf(CalibrationBehaviour) + val behaviours = listOf(CalibrationBehaviour()) val context = Context.create( initialState = INITIAL_HEIGHT_CALIBRATION_STATE, scope = scope, diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 232fceed36..2594602945 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import solarxr_protocol.datatypes.TrackerStatus -object HIDRegistrationBehaviour : HIDReceiverBehaviour { +class HIDRegistrationBehaviour : HIDReceiverBehaviour { override fun reduce(state: HIDReceiverState, action: HIDReceiverActions) = when (action) { is HIDReceiverActions.DeviceRegistered -> state.copy( trackers = state.trackers + @@ -62,7 +62,7 @@ object HIDRegistrationBehaviour : HIDReceiverBehaviour { } } -object HIDDeviceInfoBehaviour : HIDReceiverBehaviour { +class HIDDeviceInfoBehaviour : HIDReceiverBehaviour { override fun reduce(state: HIDReceiverState, action: HIDReceiverActions): HIDReceiverState = when (action) { is HIDReceiverActions.TrackerRegistered -> { val existing = state.trackers[action.hidId] ?: return state @@ -123,7 +123,7 @@ object HIDDeviceInfoBehaviour : HIDReceiverBehaviour { } } -object HIDRotationBehaviour : HIDReceiverBehaviour { +class HIDRotationBehaviour : HIDReceiverBehaviour { override fun observe(receiver: HIDReceiver) { receiver.packetEvents.onPacket { packet -> val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket @@ -147,7 +147,7 @@ object HIDRotationBehaviour : HIDReceiverBehaviour { } } -object HIDBatteryBehaviour : HIDReceiverBehaviour { +class HIDBatteryBehaviour : HIDReceiverBehaviour { override fun observe(receiver: HIDReceiver) { receiver.packetEvents.onPacket { packet -> receiver.getDevice(packet.hidId)?.context?.dispatch( @@ -182,7 +182,7 @@ object HIDBatteryBehaviour : HIDReceiverBehaviour { private const val HID_TIMEOUT_MS = 2_000L -object HIDSleepBehaviour : HIDReceiverBehaviour { +class HIDSleepBehaviour : HIDReceiverBehaviour { override fun observe(receiver: HIDReceiver) { val sleepJobs = mutableMapOf() val idleJobs = mutableMapOf() @@ -236,7 +236,7 @@ object HIDSleepBehaviour : HIDReceiverBehaviour { } } -object HIDStatusBehaviour : HIDReceiverBehaviour { +class HIDStatusBehaviour : HIDReceiverBehaviour { override fun observe(receiver: HIDReceiver) { receiver.packetEvents.onPacket { packet -> if (receiver.getTracker(packet.hidId) == null) return@onPacket @@ -247,7 +247,7 @@ object HIDStatusBehaviour : HIDReceiverBehaviour { } } -object HIDPacketLossBehaviour : HIDReceiverBehaviour { +class HIDPacketLossBehaviour : HIDReceiverBehaviour { override fun observe(receiver: HIDReceiver) { receiver.packetEvents.onPacket { packet -> receiver.getDevice(packet.hidId)?.context?.dispatch( diff --git a/server/core/src/main/java/dev/slimevr/hid/module.kt b/server/core/src/main/java/dev/slimevr/hid/module.kt index 958a2fd4bd..58d2280dbf 100644 --- a/server/core/src/main/java/dev/slimevr/hid/module.kt +++ b/server/core/src/main/java/dev/slimevr/hid/module.kt @@ -59,13 +59,13 @@ class HIDReceiver( scope: CoroutineScope, ): HIDReceiver { val behaviours = listOf( - HIDRegistrationBehaviour, - HIDDeviceInfoBehaviour, - HIDRotationBehaviour, - HIDBatteryBehaviour, - HIDStatusBehaviour, - HIDPacketLossBehaviour, - HIDSleepBehaviour, + HIDRegistrationBehaviour(), + HIDDeviceInfoBehaviour(), + HIDRotationBehaviour(), + HIDBatteryBehaviour(), + HIDStatusBehaviour(), + HIDPacketLossBehaviour(), + HIDSleepBehaviour(), ) val context = Context.create( diff --git a/server/core/src/main/java/dev/slimevr/networkprofile/module.kt b/server/core/src/main/java/dev/slimevr/networkprofile/module.kt index 7d5d236693..dfb8316444 100644 --- a/server/core/src/main/java/dev/slimevr/networkprofile/module.kt +++ b/server/core/src/main/java/dev/slimevr/networkprofile/module.kt @@ -58,7 +58,7 @@ sealed interface NetworkProfileActions { typealias NetworkProfileContext = Context typealias NetworkProfileBehaviourType = Behaviour -object DefaultNetworkProfileBehaviour : NetworkProfileBehaviourType { +class DefaultNetworkProfileBehaviour : NetworkProfileBehaviourType { override fun reduce(state: NetworkProfileState, action: NetworkProfileActions) = when (action) { is NetworkProfileActions.UpdateNetworks -> state.copy(publicNetworks = action.networks) } @@ -72,7 +72,7 @@ class NetworkProfileManager(val context: NetworkProfileContext) { val context = Context.create( initialState = NetworkProfileState(isSupported = isSupported), scope = scope, - behaviours = listOf(DefaultNetworkProfileBehaviour), + behaviours = listOf(DefaultNetworkProfileBehaviour()), debugMiddleware = LoggingMiddleware( allow = setOf(NetworkProfileActions.UpdateNetworks::class), diffStyle = DiffStyle.MULTILINE, diff --git a/server/core/src/main/java/dev/slimevr/provisioning/behaviours.kt b/server/core/src/main/java/dev/slimevr/provisioning/behaviours.kt index fcf2fee737..aebaedb30b 100644 --- a/server/core/src/main/java/dev/slimevr/provisioning/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/provisioning/behaviours.kt @@ -2,7 +2,7 @@ package dev.slimevr.provisioning import dev.slimevr.provisioning.ProvisioningManager.Companion.INITIAL_STATE -object ProvisioningManagerBaseBehaviour : ProvisioningManagerBehaviour { +class ProvisioningManagerBaseBehaviour : ProvisioningManagerBehaviour { override fun reduce(state: ProvisioningManagerState, action: ProvisioningActions) = when (action) { is ProvisioningActions.PortSelected -> state.copy( portLocation = action.portLocation, diff --git a/server/core/src/main/java/dev/slimevr/provisioning/module.kt b/server/core/src/main/java/dev/slimevr/provisioning/module.kt index 5f5d99f435..4252996a26 100644 --- a/server/core/src/main/java/dev/slimevr/provisioning/module.kt +++ b/server/core/src/main/java/dev/slimevr/provisioning/module.kt @@ -91,7 +91,7 @@ data class ProvisioningManager( ) fun create(ctx: Phase1ContextProvider, scope: CoroutineScope): ProvisioningManager { - val behaviours = listOf(ProvisioningManagerBaseBehaviour) + val behaviours = listOf(ProvisioningManagerBaseBehaviour()) val context = Context.create( initialState = INITIAL_STATE, scope = scope, diff --git a/server/core/src/main/java/dev/slimevr/serial/behaviours.kt b/server/core/src/main/java/dev/slimevr/serial/behaviours.kt index 15c6082739..bb376b9a69 100644 --- a/server/core/src/main/java/dev/slimevr/serial/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/serial/behaviours.kt @@ -2,7 +2,7 @@ package dev.slimevr.serial internal const val MAX_LOG_LINES = 500 -object SerialServerBaseBehaviour : SerialServerBehaviour { +class SerialServerBaseBehaviour : SerialServerBehaviour { override fun reduce(state: SerialServerState, action: SerialServerActions) = when (action) { is SerialServerActions.PortDetected -> state.copy(availablePorts = state.availablePorts + (action.info.portLocation to action.info)) @@ -18,7 +18,7 @@ object SerialServerBaseBehaviour : SerialServerBehaviour { } } -object SerialLogBehaviour : SerialConnectionBehaviour { +class SerialLogBehaviour : SerialConnectionBehaviour { override fun reduce(state: SerialConnectionState, action: SerialConnectionActions) = when (action) { is SerialConnectionActions.LogLine -> { val lines = if (state.logLines.size >= MAX_LOG_LINES) state.logLines.drop(1) else state.logLines diff --git a/server/core/src/main/java/dev/slimevr/serial/connection.kt b/server/core/src/main/java/dev/slimevr/serial/connection.kt index 1ba0be3b55..fe0c4ae279 100644 --- a/server/core/src/main/java/dev/slimevr/serial/connection.kt +++ b/server/core/src/main/java/dev/slimevr/serial/connection.kt @@ -34,9 +34,11 @@ sealed interface SerialConnection { val context: SerialConnectionContext, val handle: SerialPortHandle, ) : SerialConnection { + fun startObserving() = context.observeAll(this) + companion object { fun create(handle: SerialPortHandle, scope: CoroutineScope): Console { - val behaviours = listOf(SerialLogBehaviour) + val behaviours = listOf(SerialLogBehaviour()) val context = Context.create( initialState = SerialConnectionState( portLocation = handle.portLocation, @@ -50,7 +52,7 @@ sealed interface SerialConnection { ) val conn = Console(context = context, handle = handle) - behaviours.forEach { it.observe(conn) } + conn.startObserving() return conn } } diff --git a/server/core/src/main/java/dev/slimevr/serial/module.kt b/server/core/src/main/java/dev/slimevr/serial/module.kt index 8bd033f817..ffdb73d5e0 100644 --- a/server/core/src/main/java/dev/slimevr/serial/module.kt +++ b/server/core/src/main/java/dev/slimevr/serial/module.kt @@ -44,7 +44,7 @@ sealed interface SerialServerActions { } typealias SerialServerContext = Context -typealias SerialServerBehaviour = Behaviour +typealias SerialServerBehaviour = Behaviour class SerialServer( val context: SerialServerContext, @@ -56,6 +56,8 @@ class SerialServer( private val openFlashingPortFactory: () -> FlashingHandler, ) { + fun startObserving() = context.observeAll(this) + fun onPortDetected(info: SerialPortInfo) { context.dispatch(SerialServerActions.PortDetected(info)) } @@ -121,7 +123,7 @@ class SerialServer( openFlashingPort: () -> FlashingHandler, scope: CoroutineScope, ): SerialServer { - val behaviours = listOf(SerialServerBaseBehaviour) + val behaviours = listOf(SerialServerBaseBehaviour()) val context = Context.create( initialState = SerialServerState(availablePorts = mapOf(), connections = mapOf()), scope = scope, @@ -133,7 +135,7 @@ class SerialServer( openPortFactory = openPort, openFlashingPortFactory = openFlashingPort, ) - behaviours.forEach { it.observe(context) } + server.startObserving() return server } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 6de86e8739..868403aac4 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -134,17 +134,14 @@ fun createDatafeedFrame( class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : SolarXRBridgeBehaviour { override fun reduce(state: SolarXRBridgeState, action: SolarXRBridgeActions) = when (action) { - is SolarXRBridgeActions.SetConfig -> state.copy( - dataFeedConfigs = action.configs, - datafeedTimers = action.timers, - ) + is SolarXRBridgeActions.SetConfig -> state.copy(dataFeedConfigs = action.configs) } override fun observe(receiver: SolarXRBridge) { receiver.dataFeedDispatcher.on { event -> val datafeeds = event.dataFeeds ?: return@on - receiver.context.state.value.datafeedTimers.forEach { it.cancelAndJoin() } + receiver.datafeedTimers.forEach { it.cancelAndJoin() } val timers = datafeeds.mapIndexed { index, config -> receiver.context.scope.safeLaunch { @@ -160,9 +157,8 @@ class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : Sola } } - receiver.context.dispatch( - SolarXRBridgeActions.SetConfig(datafeeds, timers = timers), - ) + receiver.datafeedTimers = timers + receiver.context.dispatch(SolarXRBridgeActions.SetConfig(datafeeds)) } receiver.dataFeedDispatcher.on { event -> diff --git a/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt b/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt index d179e90f0b..b8ab2175c0 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt @@ -3,7 +3,7 @@ package dev.slimevr.solarxr import solarxr_protocol.rpc.InstalledInfoRequest import solarxr_protocol.rpc.InstalledInfoResponse -object InstalledInfoBehaviour : SolarXRBridgeBehaviour { +class InstalledInfoBehaviour : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { val udevRulesInstalled = receiver.appContext.featureFlags.udevRulesInstalled ?: return@on diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 42c095be25..f4bfe9a048 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -17,11 +17,10 @@ import solarxr_protocol.rpc.RpcMessageHeader data class SolarXRBridgeState( val dataFeedConfigs: List, - val datafeedTimers: List, ) sealed interface SolarXRBridgeActions { - data class SetConfig(val configs: List, val timers: List) : SolarXRBridgeActions + data class SetConfig(val configs: List) : SolarXRBridgeActions } typealias SolarXRBridgeContext = Context @@ -48,6 +47,9 @@ class SolarXRBridge( val outbound: EventDispatcher = EventDispatcher(), private val managedContext: ManagedContext? = null, ) { + // Jobs are mutable handles with no meaningful equality; storing them in state + // would break distinctUntilChanged and data class copy semantics. + internal var datafeedTimers: List = emptyList() fun dispose() = managedContext?.dispose() suspend fun sendRpc(message: RpcMessage) = outbound.emit(MessageBundle(rpcMsgs = listOf(RpcMessageHeader(message = message)))) @@ -80,7 +82,7 @@ class SolarXRBridge( add(MagBehaviour(appContext)) add(KnownTrackersBehaviour(appContext.config.settings)) add(BvhBehaviour(appContext.bvhManager)) - add(InstalledInfoBehaviour) + add(InstalledInfoBehaviour()) } fun create( @@ -89,7 +91,7 @@ class SolarXRBridge( scope: CoroutineScope, ): SolarXRBridge { val managedContext = ManagedContext.create( - initialState = SolarXRBridgeState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), + initialState = SolarXRBridgeState(dataFeedConfigs = listOf()), scope = scope, behaviours = buildBehaviours(appContext), name = "SolarXR[$id]", diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 8e3fabbc3d..bcd87a9e06 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -24,12 +24,12 @@ private const val ALLOWED_BODY_ACCEL = 2.0f private const val ALLOWED_BODY_ACCEL_SQUARED = ALLOWED_BODY_ACCEL * ALLOWED_BODY_ACCEL private const val TAP_WINDOW_PER_TAP_S = 0.3f -object TrackerTapDetectionBehaviour : TrackerBehaviour { - override fun observe(receiver: Tracker) { - val accelList = ArrayDeque>() - val tapTimestamps = ArrayDeque() - var waitForLowAccel = false +class TrackerTapDetectionBehaviour : TrackerBehaviour { + private val accelList = ArrayDeque>() + private val tapTimestamps = ArrayDeque() + private var waitForLowAccel = false + override fun observe(receiver: Tracker) { // TODO: only enable this on the trackers that have taps assigned to them receiver.context.state .distinctUntilChangedBy { it.rawAcceleration } @@ -82,7 +82,7 @@ object TrackerTapDetectionBehaviour : TrackerBehaviour { } } -object TrackerBasicBehaviour : TrackerBehaviour { +class TrackerBasicBehaviour : TrackerBehaviour { override fun reduce(state: TrackerState, action: TrackerActions) = when (action) { is TrackerActions.Update -> action.transform(state) @@ -171,7 +171,7 @@ object TrackerBasicBehaviour : TrackerBehaviour { } } -object TrackerTPSBehaviour : TrackerBehaviour { +class TrackerTPSBehaviour : TrackerBehaviour { @OptIn(ExperimentalAtomicApi::class) override fun observe(receiver: Tracker) { val count = AtomicInt(0) @@ -197,7 +197,7 @@ object TrackerTPSBehaviour : TrackerBehaviour { } } -object TrackerToSkeletonBehaviour : TrackerBehaviour { +class TrackerToSkeletonBehaviour : TrackerBehaviour { override fun observe(receiver: Tracker) { receiver.context.state diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 088969802c..3eb288d166 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -56,6 +56,8 @@ class Tracker( val context: TrackerContext, val appContext: AppContextProvider, ) { + fun startObserving() = context.observeAll(this) + companion object { fun create( scope: CoroutineScope, @@ -99,11 +101,11 @@ class Tracker( } val behaviours = listOf( - TrackerBasicBehaviour, + TrackerBasicBehaviour(), TrackerConfigBehaviour(settings, hardwareId), - TrackerTPSBehaviour, - TrackerTapDetectionBehaviour, - TrackerToSkeletonBehaviour, + TrackerTPSBehaviour(), + TrackerTapDetectionBehaviour(), + TrackerToSkeletonBehaviour(), ) val context = Context.create( initialState = trackerState, @@ -116,7 +118,7 @@ class Tracker( name = "Tracker[$hardwareId]", ) val tracker = Tracker(context = context, appContext) - behaviours.forEach { it.observe(tracker) } + tracker.startObserving() return tracker } } diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt index c0dcb46393..2da509c8bb 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt @@ -18,7 +18,7 @@ sealed interface TrackingChecklistActions { typealias TrackingChecklistContext = Context typealias TrackingChecklistBehaviourType = Behaviour -object ChecklistBaseBehaviour : TrackingChecklistBehaviourType { +class ChecklistBaseBehaviour : TrackingChecklistBehaviourType { override fun reduce(state: TrackingChecklistState, action: TrackingChecklistActions): TrackingChecklistState = when (action) { is TrackingChecklistActions.UpdateStep -> state.copy(steps = state.steps + (action.id to action.step)) } @@ -45,7 +45,7 @@ class TrackingChecklist( companion object { fun create(scope: CoroutineScope): TrackingChecklist { - val initialBehaviours = listOf(ChecklistBaseBehaviour) + val initialBehaviours = listOf(ChecklistBaseBehaviour()) val context = Context.create( initialState = TrackingChecklistState(), scope = scope, diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 25a5186107..712353596a 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -23,7 +23,7 @@ import kotlin.random.Random internal const val CONNECTION_TIMEOUT_MS = 5000L internal const val CONNECTION_REMOVAL_MS = 30_000L -object PacketBehaviour : UDPConnectionBehaviour { +class PacketBehaviour : UDPConnectionBehaviour { override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { is UDPConnectionActions.LastPacket -> { var newState = state.copy(lastPacket = action.time) @@ -53,7 +53,7 @@ object PacketBehaviour : UDPConnectionBehaviour { } } -object PacketLossBehaviour : UDPConnectionBehaviour { +class PacketLossBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { var totalPacketsReceived = 0L var acceptedPackets = 0L @@ -84,7 +84,7 @@ object PacketLossBehaviour : UDPConnectionBehaviour { } } -object PingBehaviour : UDPConnectionBehaviour { +class PingBehaviour : UDPConnectionBehaviour { override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { is UDPConnectionActions.StartPing -> state.copy(lastPing = state.lastPing.copy(startTime = action.startTime, id = action.pingId)) else -> state @@ -121,7 +121,7 @@ object PingBehaviour : UDPConnectionBehaviour { } } -object HandshakeBehaviour : UDPConnectionBehaviour { +class HandshakeBehaviour : UDPConnectionBehaviour { override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { is UDPConnectionActions.Handshake -> state.copy(didHandshake = true, deviceId = action.deviceId) is UDPConnectionActions.TimedOut -> state.copy(didHandshake = false) @@ -203,7 +203,7 @@ object HandshakeBehaviour : UDPConnectionBehaviour { } } -object TimeoutBehaviour : UDPConnectionBehaviour { +class TimeoutBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { receiver.context.scope.safeLaunch { while (isActive) { @@ -230,7 +230,7 @@ object TimeoutBehaviour : UDPConnectionBehaviour { } } -object DisconnectBehaviour : UDPConnectionBehaviour { +class DisconnectBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { var removalJob: Job? = null receiver.context.state @@ -258,7 +258,7 @@ object DisconnectBehaviour : UDPConnectionBehaviour { } } -object DeviceStatsBehaviour : UDPConnectionBehaviour { +class DeviceStatsBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { receiver.packetEvents.onPacket { event -> val device = receiver.getDevice() ?: return@onPacket @@ -282,7 +282,7 @@ object DeviceStatsBehaviour : UDPConnectionBehaviour { } } -object SensorInfoBehaviour : UDPConnectionBehaviour { +class SensorInfoBehaviour : UDPConnectionBehaviour { private suspend fun assignTracker(receiver: UDPConnection, device: Device, event: PacketEvent): Pair { val deviceState = device.context.state.value val mac = deviceState.macAddress ?: run { @@ -377,7 +377,7 @@ object SensorInfoBehaviour : UDPConnectionBehaviour { } } -object SensorRotationBehaviour : UDPConnectionBehaviour { +class SensorRotationBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { receiver.packetEvents.onPacket { event -> val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket @@ -401,7 +401,7 @@ object SensorRotationBehaviour : UDPConnectionBehaviour { } } -object BundledPacketBehaviour : UDPConnectionBehaviour { +class BundledPacketBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { receiver.packetEvents.onPacket { event -> for (packet in event.data.packets) { @@ -423,7 +423,7 @@ object BundledPacketBehaviour : UDPConnectionBehaviour { } } -object FlagsBehaviour : UDPConnectionBehaviour { +class FlagsBehaviour : UDPConnectionBehaviour { override fun reduce( state: UDPConnectionState, action: UDPConnectionActions, @@ -441,7 +441,7 @@ object FlagsBehaviour : UDPConnectionBehaviour { } } -object TemperatureBehaviour : UDPConnectionBehaviour { +class TemperatureBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { receiver.packetEvents.onPacket { event -> val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket @@ -450,7 +450,7 @@ object TemperatureBehaviour : UDPConnectionBehaviour { } } -object SensorConfigBehaviour : UDPConnectionBehaviour { +class SensorConfigBehaviour : UDPConnectionBehaviour { override fun reduce(state: UDPConnectionState, action: UDPConnectionActions) = when (action) { is UDPConnectionActions.SetSensorConfig -> state.copy( sensorConfigFlags = state.sensorConfigFlags + (action.sensorId to action.flags), @@ -477,7 +477,7 @@ object SensorConfigBehaviour : UDPConnectionBehaviour { } } -object AckConfigBehaviour : UDPConnectionBehaviour { +class AckConfigBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { receiver.packetEvents.onPacket { event -> val configType = SensorConfigType.fromId(event.data.configType) ?: return@onPacket diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index ec4fa15d83..5bfb369ed7 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -92,20 +92,20 @@ class UDPConnection( scope: CoroutineScope, ): UDPConnection { val behaviours = listOf( - PacketBehaviour, - PacketLossBehaviour, - HandshakeBehaviour, - TimeoutBehaviour, - DisconnectBehaviour, - PingBehaviour, - DeviceStatsBehaviour, - SensorInfoBehaviour, - SensorRotationBehaviour, - BundledPacketBehaviour, - FlagsBehaviour, - TemperatureBehaviour, - SensorConfigBehaviour, - AckConfigBehaviour, + PacketBehaviour(), + PacketLossBehaviour(), + HandshakeBehaviour(), + TimeoutBehaviour(), + DisconnectBehaviour(), + PingBehaviour(), + DeviceStatsBehaviour(), + SensorInfoBehaviour(), + SensorRotationBehaviour(), + BundledPacketBehaviour(), + FlagsBehaviour(), + TemperatureBehaviour(), + SensorConfigBehaviour(), + AckConfigBehaviour(), ) val context = Context.create( diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index 2496d1e2d3..01daffb661 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -28,7 +28,7 @@ sealed interface UdpServerActions { typealias UdpServerContext = Context typealias UdpServerBehaviour = Behaviour -object UdpServerBaseBehaviour : UdpServerBehaviour { +class UdpServerBaseBehaviour : UdpServerBehaviour { override fun reduce(state: UdpServerState, action: UdpServerActions) = when (action) { is UdpServerActions.ConnectionAdded -> state.copy(connections = state.connections + (action.address to action.conn)) is UdpServerActions.ConnectionRemoved -> state.copy(connections = state.connections - action.address) @@ -116,7 +116,7 @@ class UdpServer(val context: UdpServerContext, private val addressResolver: (Ine val context = Context.create( initialState = INITIAL_STATE, scope = scope, - behaviours = listOf(UdpServerBaseBehaviour), + behaviours = listOf(UdpServerBaseBehaviour()), name = "UdpServer", ) val server = UdpServer(context, addressResolver) diff --git a/server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt b/server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt index 848ad692b1..d83f7cac45 100644 --- a/server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/vrchat/behaviours.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -object DefaultVRCConfigBehaviour : VRCConfigBehaviour { +class DefaultVRCConfigBehaviour : VRCConfigBehaviour { override fun reduce(state: VRCConfigState, action: VRCConfigActions) = when (action) { is VRCConfigActions.UpdateValues -> state.copy(currentValues = action.values) diff --git a/server/core/src/main/java/dev/slimevr/vrchat/module.kt b/server/core/src/main/java/dev/slimevr/vrchat/module.kt index c2f8033321..2ca0925a13 100644 --- a/server/core/src/main/java/dev/slimevr/vrchat/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrchat/module.kt @@ -55,7 +55,7 @@ class VRCConfigManager( isSupported: Boolean, values: Flow, ): VRCConfigManager { - val behaviours = listOf(DefaultVRCConfigBehaviour) + val behaviours = listOf(DefaultVRCConfigBehaviour()) val context = Context.create( initialState = VRCConfigState( diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index 95ebcb26bd..24b26857f3 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -36,13 +36,15 @@ class VRServer( ) { private val handleCounter: AtomicInt = AtomicInt(0) + fun startObserving() = context.observeAll(this) + fun nextHandle() = handleCounter.incrementAndFetch() fun getTracker(id: Int) = context.state.value.trackers[id] fun getDevice(id: Int) = context.state.value.devices[id] companion object { fun create(scope: CoroutineScope): VRServer { - val behaviours = listOf(BaseBehaviour) + val behaviours = listOf(BaseBehaviour()) val context = Context.create( initialState = VRServerState( trackers = emptyMap(), @@ -55,7 +57,7 @@ class VRServer( name = "VRServer", ) val server = VRServer(context = context) - behaviours.forEach { it.observe(server) } + server.startObserving() return server } } From facce888a03b8f77ec96d2a856a7ba6f2ddf06c5 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Sat, 30 May 2026 03:29:02 +0200 Subject: [PATCH 145/350] Slightly better context --- .../src/main/java/dev/slimevr/context/context.kt | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index f2cc479af9..f0abc14c51 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -10,8 +10,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import kotlin.concurrent.atomics.AtomicReference -import kotlin.concurrent.atomics.ExperimentalAtomicApi interface Behaviour { fun reduce(state: S, action: A): S = state @@ -20,20 +18,17 @@ interface Behaviour { // TODO: missing remove + prob a unobserve or something to clear the stuff launched // in a observe -> each behaviour is prob gonna need its own scope -@OptIn(ExperimentalAtomicApi::class) class BehaviourList( initial: List>, ) { - private val items = AtomicReference(initial.toList()) + @Volatile + private var items: List> = initial.toList() fun addAll(newItems: List>) { - while (true) { - val current = items.load() - if (items.compareAndSet(current, current + newItems)) break - } + items = items + newItems } - fun snapshot(): List> = items.load() + fun snapshot(): List> = items } class ManagedContext( From 5f3b1fd0a5c83721fd2621c21c85ed1002ff5cf6 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Sat, 30 May 2026 08:12:33 +0200 Subject: [PATCH 146/350] Make tests work --- .../src/test/java/dev/slimevr/TestServer.kt | 4 ++- .../context/ContextCallerCaptureTest.kt | 4 +-- .../context/ContextScopeIsolationTest.kt | 4 +-- .../dev/slimevr/firmware/DoSerialFlashTest.kt | 18 +++++----- .../reducers/FirmwareManagerReducerTest.kt | 12 +++---- .../HeightCalibrationTest.kt | 34 +++++++++---------- .../provisioning/ProvisioningManagerTest.kt | 2 +- .../dev/slimevr/serial/SerialServerTest.kt | 29 ++++++++-------- .../reducers/SerialConnectionReducerTest.kt | 12 ++++--- .../java/dev/slimevr/solarxr/DataFeedTest.kt | 2 +- 10 files changed, 63 insertions(+), 58 deletions(-) diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 50203f6bdd..f272834e79 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -29,6 +29,7 @@ import dev.slimevr.vmc.VMCManager import dev.slimevr.vrchat.VRCConfigManager import dev.slimevr.vrcosc.VRCOSCManager import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create( @@ -62,7 +63,7 @@ fun buildTestUserConfig(scope: CoroutineScope): UserConfig { val context = Context.create( initialState = UserConfigState(data = UserConfigData(), name = "test"), scope = scope, - behaviours = listOf(DefaultUserBehaviour), + behaviours = listOf(DefaultUserBehaviour()), name = "TestUserConfig", ) val userConfig = UserConfig(context, scope = scope, storage = NoopConfigStorage, userConfigDir = "user") @@ -108,6 +109,7 @@ fun buildTestAppContext(server: VRServer): AppContextProvider = object : TestApp abstract class TestAppContext : AppContextProvider { override val featureFlags: FeatureFlags = FeatureFlags() + override val skeleton: Skeleton get() = error("not used in test") override val config: AppConfig get() = error("not used in test") override val serialServer: SerialServer get() = error("not used in test") override val firmwareManager: FirmwareManager get() = error("not used in test") diff --git a/server/core/src/test/java/dev/slimevr/context/ContextCallerCaptureTest.kt b/server/core/src/test/java/dev/slimevr/context/ContextCallerCaptureTest.kt index 3916c4ceb5..ab2668da21 100644 --- a/server/core/src/test/java/dev/slimevr/context/ContextCallerCaptureTest.kt +++ b/server/core/src/test/java/dev/slimevr/context/ContextCallerCaptureTest.kt @@ -10,7 +10,7 @@ private sealed interface TestAction { object Increment : TestAction } -private object DispatchingBehaviour : Behaviour> { +private class DispatchingBehaviour : Behaviour> { override fun reduce(state: Int, action: TestAction): Int = when (action) { TestAction.Increment -> state + 1 } @@ -44,7 +44,7 @@ class ContextCallerCaptureTest { val context = Context.create( initialState = 0, scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined), - behaviours = listOf(DispatchingBehaviour), + behaviours = listOf(DispatchingBehaviour()), debugMiddleware = middleware, name = "TestContext", ) diff --git a/server/core/src/test/java/dev/slimevr/context/ContextScopeIsolationTest.kt b/server/core/src/test/java/dev/slimevr/context/ContextScopeIsolationTest.kt index 0b293e2b0c..60f45035e2 100644 --- a/server/core/src/test/java/dev/slimevr/context/ContextScopeIsolationTest.kt +++ b/server/core/src/test/java/dev/slimevr/context/ContextScopeIsolationTest.kt @@ -12,7 +12,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue -private object NoopBehaviour : Behaviour +private class NoopBehaviour : Behaviour class ContextScopeIsolationTest { @Test @@ -25,7 +25,7 @@ class ContextScopeIsolationTest { val context = Context.create( initialState = 0, scope = parentScope, - behaviours = listOf(NoopBehaviour), + behaviours = listOf(NoopBehaviour()), name = "IsolatedContext", ) diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt index 550fb2c4cf..df0ec44a36 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -70,7 +70,7 @@ class DoSerialFlashTest { @Test fun `emits ERROR_DEVICE_NOT_FOUND when port is not available`() = runTest { - val server = buildSerialServer(this) + val server = buildSerialServer(backgroundScope) val vrServer = buildTestVrServerStub(backgroundScope) val statuses = mutableListOf() @@ -93,7 +93,7 @@ class DoSerialFlashTest { @Test fun `emits ERROR_DEVICE_NOT_FOUND when port already has a connection`() = runTest { - val server = buildSerialServer(this) + val server = buildSerialServer(backgroundScope) server.onPortDetected(fakePort()) server.openConnection("COM1") val statuses = mutableListOf() @@ -117,7 +117,7 @@ class DoSerialFlashTest { @Test fun `emits ERROR_UPLOAD_FAILED when flash throws`() = runTest { - val server = buildSerialServer(this, ::fakeFlashHandler) + val server = buildSerialServer(backgroundScope, ::fakeFlashHandler) server.onPortDetected(fakePort()) val statuses = mutableListOf() @@ -142,7 +142,7 @@ class DoSerialFlashTest { @Test fun `emits ERROR_DEVICE_NOT_FOUND when device has not reconnected after flash`() = runTest { // Port not back in availablePorts yet, openConnection inside doSerialFlashPostFlash is a no-op - val server = buildSerialServer(this) + val server = buildSerialServer(backgroundScope) val statuses = mutableListOf() doSerialFlashPostFlash( @@ -162,7 +162,7 @@ class DoSerialFlashTest { @OptIn(ExperimentalCoroutinesApi::class) @Test fun `emits ERROR_PROVISIONING_FAILED when MAC not received within timeout`() = runTest { - val server = buildSerialServer(this) + val server = buildSerialServer(backgroundScope) server.onPortDetected(fakePort()) server.openConnection("COM1") val statuses = mutableListOf() @@ -189,7 +189,7 @@ class DoSerialFlashTest { @OptIn(ExperimentalCoroutinesApi::class) @Test fun `emits ERROR_PROVISIONING_FAILED when ssid or password is null`() = runTest { - val server = buildSerialServer(this) + val server = buildSerialServer(backgroundScope) server.onPortDetected(fakePort()) server.openConnection("COM1") val statuses = mutableListOf() @@ -220,7 +220,7 @@ class DoSerialFlashTest { @OptIn(ExperimentalCoroutinesApi::class) @Test fun `emits ERROR_PROVISIONING_FAILED when wifi does not connect within timeout`() = runTest { - val server = buildSerialServer(this) + val server = buildSerialServer(backgroundScope) server.onPortDetected(fakePort()) server.openConnection("COM1") val statuses = mutableListOf() @@ -253,7 +253,7 @@ class DoSerialFlashTest { @OptIn(ExperimentalCoroutinesApi::class) @Test fun `emits ERROR_TIMEOUT when tracker does not appear within timeout`() = runTest { - val server = buildSerialServer(this) + val server = buildSerialServer(backgroundScope) server.onPortDetected(fakePort()) server.openConnection("COM1") val statuses = mutableListOf() @@ -288,7 +288,7 @@ class DoSerialFlashTest { @OptIn(ExperimentalCoroutinesApi::class) @Test fun `emits DONE when everything succeeds`() = runTest { - val server = buildSerialServer(this) + val server = buildSerialServer(backgroundScope) server.onPortDetected(fakePort()) server.openConnection("COM1") val vrServer = buildTestVrServerStub(backgroundScope) diff --git a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt index 8e6cd6da1f..e0fd237c52 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/reducers/FirmwareManagerReducerTest.kt @@ -26,14 +26,14 @@ private fun serialJob(port: String, status: FirmwareUpdateStatus, progress: Int class FirmwareManagerReducerTest { private fun makeContext(scope: kotlinx.coroutines.CoroutineScope) = Context.create( initialState = FirmwareManagerState(jobs = mapOf()), - behaviours = listOf(FirmwareManagerBaseBehaviour), + behaviours = listOf(FirmwareManagerBaseBehaviour()), scope = scope, name = "FirmwareManagerReducerTest", ) @Test fun `UpdateJob adds a new job`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) context.dispatch(serialJob("COM1", FirmwareUpdateStatus.UPLOADING, 42)) @@ -45,7 +45,7 @@ class FirmwareManagerReducerTest { @Test fun `UpdateJob replaces an existing job`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) context.dispatch(serialJob("COM1", FirmwareUpdateStatus.DOWNLOADING)) context.dispatch(serialJob("COM1", FirmwareUpdateStatus.UPLOADING, 75)) @@ -59,7 +59,7 @@ class FirmwareManagerReducerTest { @Test fun `UpdateJob tracks multiple ports independently`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) context.dispatch(serialJob("COM1", FirmwareUpdateStatus.UPLOADING, 10)) context.dispatch(serialJob("COM2", FirmwareUpdateStatus.DOWNLOADING)) @@ -71,7 +71,7 @@ class FirmwareManagerReducerTest { @Test fun `RemoveJob removes an existing job`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) context.dispatch(serialJob("COM1", FirmwareUpdateStatus.UPLOADING, 50)) context.dispatch(FirmwareManagerActions.RemoveJob("COM1")) @@ -82,7 +82,7 @@ class FirmwareManagerReducerTest { @Test fun `RemoveJob on unknown port is a no-op`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) context.dispatch(serialJob("COM1", FirmwareUpdateStatus.UPLOADING, 50)) context.dispatch(FirmwareManagerActions.RemoveJob("COM2")) diff --git a/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt index 9b577880fc..7dd4e4b510 100644 --- a/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt @@ -41,7 +41,7 @@ private val STANDING_POSITION = Vector3(0f, 1.7f, 0f) private fun makeContext(scope: kotlinx.coroutines.CoroutineScope) = HeightCalibrationContext.create( initialState = INITIAL_HEIGHT_CALIBRATION_STATE, scope = scope, - behaviours = listOf(CalibrationBehaviour), + behaviours = listOf(CalibrationBehaviour()), name = "HeightCalibrationTest", ) @@ -93,7 +93,7 @@ private suspend fun TestScope.completeHeightPhase( class HeightCalibrationReducerTest { @Test fun `Update changes status and height`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) context.dispatch(HeightCalibrationActions.Update(UserHeightCalibrationStatus.RECORDING_HEIGHT, 1.65f)) @@ -105,7 +105,7 @@ class HeightCalibrationReducerTest { class HeightCalibrationSessionTest { @Test fun `session starts in RECORDING_FLOOR`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -117,7 +117,7 @@ class HeightCalibrationSessionTest { @Test fun `controller too high does not change status`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -131,7 +131,7 @@ class HeightCalibrationSessionTest { @Test fun `controller not pointing down transitions to WAITING_FOR_CONTROLLER_PITCH`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -145,7 +145,7 @@ class HeightCalibrationSessionTest { @Test fun `stable floor transitions to WAITING_FOR_RISE`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -159,7 +159,7 @@ class HeightCalibrationSessionTest { @Test fun `HMD below rise threshold stays WAITING_FOR_RISE`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -175,7 +175,7 @@ class HeightCalibrationSessionTest { @Test fun `HMD not leveled transitions to WAITING_FOR_FW_LOOK`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -191,7 +191,7 @@ class HeightCalibrationSessionTest { @Test fun `stable HMD at valid height transitions to DONE`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -207,7 +207,7 @@ class HeightCalibrationSessionTest { @Test fun `stable HMD below HEIGHT_MIN transitions to ERROR_TOO_SMALL`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -222,7 +222,7 @@ class HeightCalibrationSessionTest { @Test fun `stable HMD above HEIGHT_MAX transitions to ERROR_TOO_HIGH`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -237,7 +237,7 @@ class HeightCalibrationSessionTest { @Test fun `unstable floor sample resets controller stability window`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -263,7 +263,7 @@ class HeightCalibrationSessionTest { @Test fun `out-of-threshold HMD sample resets height stability window`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -290,7 +290,7 @@ class HeightCalibrationSessionTest { @Test fun `timeout fires ERROR_TIMEOUT`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = safeLaunch { runCalibrationSession(context, buildTestUserConfig(backgroundScope), hmdFlow, controllerFlow) } @@ -303,7 +303,7 @@ class HeightCalibrationSessionTest { @Test fun `controller pitch recovery leads to WAITING_FOR_RISE`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -321,7 +321,7 @@ class HeightCalibrationSessionTest { @Test fun `leveling HMD after WAITING_FOR_FW_LOOK transitions to RECORDING_HEIGHT`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -341,7 +341,7 @@ class HeightCalibrationSessionTest { @Test fun `HMD rising above threshold transitions to RECORDING_HEIGHT`() = runTest { - val context = makeContext(this) + val context = makeContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) diff --git a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt index e7960c73ec..531ce5c3e5 100644 --- a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt +++ b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt @@ -29,7 +29,7 @@ private fun buildManager(serialServer: dev.slimevr.serial.SerialServer, scope: C val context = Context.create( initialState = ProvisioningManager.INITIAL_STATE, scope = scope, - behaviours = listOf(ProvisioningManagerBaseBehaviour), + behaviours = listOf(ProvisioningManagerBaseBehaviour()), name = "ProvisioningManagerTest", ) return ProvisioningManager(context = context, serialServer = serialServer, settings = buildTestSettings(scope), scope = scope).also { it.startObserving() } diff --git a/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt b/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt index 11dc0da356..20a0f7f099 100644 --- a/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt +++ b/server/core/src/test/java/dev/slimevr/serial/SerialServerTest.kt @@ -1,6 +1,7 @@ package dev.slimevr.serial -import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertIs @@ -35,7 +36,7 @@ class SerialServerTest { val server = SerialServer.create( openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, - scope = this, + scope = backgroundScope, ) server.onPortDetected(fakePort()) @@ -50,7 +51,7 @@ class SerialServerTest { val server = SerialServer.create( openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, - scope = this, + scope = backgroundScope, ) server.onPortDetected(fakePort()) server.openConnection("COM1") @@ -65,7 +66,7 @@ class SerialServerTest { val server = SerialServer.create( openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, - scope = this, + scope = backgroundScope, ) // No onPortDetected call, port is not in availablePorts @@ -80,7 +81,7 @@ class SerialServerTest { val server = SerialServer.create( openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, - scope = this, + scope = backgroundScope, ) server.onPortDetected(fakePort()) val handler = server.openForFlashing("COM1")!! @@ -90,7 +91,7 @@ class SerialServerTest { // The scope.launch inside closeSerial has not run yet assertIs(server.context.state.value.connections["COM1"]) - advanceUntilIdle() + advanceTimeBy(1) // Now the dispatched RemoveConnection has run assertNull(server.context.state.value.connections["COM1"]) @@ -101,7 +102,7 @@ class SerialServerTest { val server = SerialServer.create( openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, - scope = this, + scope = backgroundScope, ) server.onPortDetected(fakePort()) @@ -115,7 +116,7 @@ class SerialServerTest { val server = SerialServer.create( openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, - scope = this, + scope = backgroundScope, ) server.onPortDetected(fakePort()) server.openConnection("COM1") @@ -131,7 +132,7 @@ class SerialServerTest { val server = SerialServer.create( openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, - scope = this, + scope = backgroundScope, ) server.onPortDetected(fakePort()) server.openForFlashing("COM1") @@ -148,12 +149,12 @@ class SerialServerTest { val server = SerialServer.create( openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, - scope = this, + scope = backgroundScope, ) server.onPortDetected(fakePort()) val firstHandler = server.openForFlashing("COM1")!! firstHandler.closeSerial() - advanceUntilIdle() + advanceTimeBy(1) // Connection is gone, port is still available, can flash again val secondHandler = server.openForFlashing("COM1") @@ -168,12 +169,12 @@ class SerialServerTest { val server = SerialServer.create( openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, - scope = this, + scope = backgroundScope, ) server.onPortDetected(fakePort()) val handler = server.openForFlashing("COM1")!! handler.closeSerial() - advanceUntilIdle() + advanceTimeBy(1) server.openConnection("COM1") @@ -185,7 +186,7 @@ class SerialServerTest { val server = SerialServer.create( openPort = { loc, _, _ -> fakePortHandle(loc) }, openFlashingPort = ::fakeFlashingHandler, - scope = this, + scope = backgroundScope, ) server.onPortDetected(fakePort()) server.openForFlashing("COM1") diff --git a/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt b/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt index 1bd3b9ccba..4ef66dce6f 100644 --- a/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt +++ b/server/core/src/test/java/dev/slimevr/serial/reducers/SerialConnectionReducerTest.kt @@ -8,6 +8,8 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse class SerialConnectionReducerTest { + private val behaviour = SerialLogBehaviour() + private fun state(lines: List = emptyList(), connected: Boolean = true) = SerialConnectionState( portLocation = "COM1", descriptivePortName = "Test Port", @@ -17,20 +19,20 @@ class SerialConnectionReducerTest { @Test fun `LogLine appends to empty log`() { - val result = SerialLogBehaviour.reduce(state(), SerialConnectionActions.LogLine("hello")) + val result = behaviour.reduce(state(), SerialConnectionActions.LogLine("hello")) assertEquals(listOf("hello"), result.logLines) } @Test fun `LogLine appends to existing log`() { - val result = SerialLogBehaviour.reduce(state(listOf("a", "b")), SerialConnectionActions.LogLine("c")) + val result = behaviour.reduce(state(listOf("a", "b")), SerialConnectionActions.LogLine("c")) assertEquals(listOf("a", "b", "c"), result.logLines) } @Test fun `LogLine drops oldest line when at capacity`() { val full = state(lines = List(500) { "line $it" }) - val result = SerialLogBehaviour.reduce(full, SerialConnectionActions.LogLine("new")) + val result = behaviour.reduce(full, SerialConnectionActions.LogLine("new")) assertEquals(500, result.logLines.size) assertEquals("line 1", result.logLines.first()) assertEquals("new", result.logLines.last()) @@ -39,14 +41,14 @@ class SerialConnectionReducerTest { @Test fun `LogLine does not drop below capacity`() { val almostFull = state(lines = List(499) { "line $it" }) - val result = SerialLogBehaviour.reduce(almostFull, SerialConnectionActions.LogLine("new")) + val result = behaviour.reduce(almostFull, SerialConnectionActions.LogLine("new")) assertEquals(500, result.logLines.size) assertEquals("line 0", result.logLines.first()) } @Test fun `Disconnected sets connected to false`() { - val result = SerialLogBehaviour.reduce(state(connected = true), SerialConnectionActions.Disconnected) + val result = behaviour.reduce(state(connected = true), SerialConnectionActions.Disconnected) assertFalse(result.connected) } } diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt index 80cebe263b..67079ad4c9 100644 --- a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -23,7 +23,7 @@ private fun testConn(backgroundScope: kotlinx.coroutines.CoroutineScope, onSend: override val skeleton = skeleton } val context = Context.create( - initialState = SolarXRBridgeState(dataFeedConfigs = listOf(), datafeedTimers = listOf()), + initialState = SolarXRBridgeState(dataFeedConfigs = listOf()), scope = backgroundScope, behaviours = listOf(DataFeedInitBehaviour(server, skeleton)), name = "SolarXRDataFeedTest", From c43b44ccc8cd360357a3f33c620ffb5d9297466a Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Tue, 2 Jun 2026 03:05:52 -0400 Subject: [PATCH 147/350] Add mounting reset --- .../java/dev/slimevr/tracker/behaviours.kt | 23 +++++++++- .../dev/slimevr/tracker/sessioncalibration.kt | 20 +++++++++ .../slimevr/tracker/SessionCalibrationTest.kt | 42 +++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index bcd87a9e06..81cff99b55 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -146,6 +146,8 @@ class TrackerBasicBehaviour : TrackerBehaviour { attitudeAlignment = attitudeAlignment, ) + // TODO: Immediately apply calibration on `state.rawRotation` so we don't + // need to worry about desync of `state.rotation` state.copy(sessionCalibration = sessionCalibration) } @@ -161,12 +163,29 @@ class TrackerBasicBehaviour : TrackerBehaviour { headingCorrection = headingCorrection, ) + // TODO: Apply calibration on `state.rawRotation` -> `state.rotation` state.copy(sessionCalibration = sessionCalibration) } is TrackerActions.MountingReset -> { - // TODO: Actually add mounting reset - state + val cal = state.sessionCalibration + + val headingAlignment = estimateHeadingAlign( + state.rawRotation, + action.referenceRotation, + cal?.headingCorrection ?: Quaternion.IDENTITY, + cal?.attitudeAlignment ?: Quaternion.IDENTITY, + state.mountingOrientation ?: Quaternion.IDENTITY, + ) + + val sessionCalibration = state.sessionCalibration?.copy( + headingAlignment = headingAlignment, + ) ?: SessionCalibration( + headingAlignment = headingAlignment, + ) + + // TODO: Apply calibration on `state.rawRotation` -> `state.rotation` + state.copy(sessionCalibration = sessionCalibration) } } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt index 137355917f..0fd1ba3a0f 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt @@ -3,6 +3,7 @@ package dev.slimevr.tracker import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 +import kotlin.math.atan2 typealias RawRotation = Quaternion typealias RawAcceleration = Vector3 @@ -76,3 +77,22 @@ fun estimateAttitudeAlign( rawRotation: RawRotation, headingCorrect: HeadingCorrection, ): AttitudeAlignment = (headingCorrect * rawRotation).inv() + +fun estimateHeadingAlign( + rawRotation: RawRotation, + referenceRotation: Quaternion, + headingCorrect: HeadingCorrection = Quaternion.IDENTITY, + attitudeAlign: AttitudeAlignment = Quaternion.IDENTITY, + headingAlign: HeadingAlignment = Quaternion.IDENTITY, +): HeadingAlignment { + val refHeading = eulerHeading(referenceRotation) + val rotation = applyCalibration( + rawRotation, + headingCorrect, + attitudeAlign, + headingAlign, + ) + val pitchRoll = (refHeading.inv() * rotation).sandwichUnitY() + val yawAngle = atan2(pitchRoll.x, pitchRoll.z) + return Quaternion.rotationAroundYAxis(yawAngle) +} diff --git a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt index d073d5b8be..cc3469c277 100644 --- a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt @@ -274,6 +274,7 @@ class SessionCalibrationTest { message = "Estimated heading correction is wrong", ) + // TODO: See if we can avoid using twinNearest val estimatedAttitudeAlign = estimateAttitudeAlign(rawRotation, estimatedHeadingCorrect) quaternionAssertEquals( @@ -283,6 +284,47 @@ class SessionCalibrationTest { ) } + @TestFactory + fun estimateHeadingAlignTests(): List { + val frontRot = Quaternion(0.707f, 0.707f, 0f, 0f) + return heading.flatMap { hA -> + heading.map { ref -> + DynamicTest.dynamicTest( + "( hA: $hA, ref: $ref )", + ) { + testEstimateHeadingAlign( + ref * frontRot, + hA, + ref + ) + } + } + } + } + + fun testEstimateHeadingAlign( + calibratedRotation: CalibratedRotation, + headingAlign: HeadingAlignment, + reference: Quaternion, + ) { + // Only undo heading + val rawRotation = undoCalibration( + calibratedRotation, + headingAlign = headingAlign, + ) + + // TODO: See if we can avoid using twinNearest + val estimateHeadingAlign = estimateHeadingAlign( + rawRotation, + reference, + ) + quaternionAssertEquals( + headingAlign, + estimateHeadingAlign.twinNearest(headingAlign), + message = "Estimated heading alignment is wrong", + ) + } + companion object { // 5 steps val step = (-180..180 step 72).map { it.toFloat() } From a4aa76aa82337bbfc1140d57aa46e2f1a38995b5 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Fri, 12 Jun 2026 22:19:58 -0400 Subject: [PATCH 148/350] Add session calibrations to SolarXR --- .../main/java/dev/slimevr/solarxr/module.kt | 5 ++- .../dev/slimevr/solarxr/sessioncalibration.kt | 45 +++++++++++++++++++ .../java/dev/slimevr/tracker/behaviours.kt | 2 +- 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index f4bfe9a048..3d607c9e68 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -53,9 +53,9 @@ class SolarXRBridge( fun dispose() = managedContext?.dispose() suspend fun sendRpc(message: RpcMessage) = outbound.emit(MessageBundle(rpcMsgs = listOf(RpcMessageHeader(message = message)))) - + suspend fun sendDataFeed(frame: DataFeedMessageHeader) = outbound.emit(MessageBundle(dataFeedMsgs = listOf(frame))) - + fun disconnect() { dispose() appContext.server.context.dispatch(VRServerActions.SolarXRDisconnected(id)) @@ -83,6 +83,7 @@ class SolarXRBridge( add(KnownTrackersBehaviour(appContext.config.settings)) add(BvhBehaviour(appContext.bvhManager)) add(InstalledInfoBehaviour()) + add(SessionCalibrationBehaviour(appContext.server)) } fun create( diff --git a/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt new file mode 100644 index 0000000000..1d3703ef23 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt @@ -0,0 +1,45 @@ +package dev.slimevr.solarxr + +import dev.slimevr.VRServer +import dev.slimevr.tracker.TrackerActions +import io.github.axisangles.ktmath.Quaternion +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.ResetRequest +import solarxr_protocol.rpc.ResetType + +class SessionCalibrationBehaviour( + private val server: VRServer, +) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { + receiver.rpcDispatcher.on { req -> + val bodyParts = req.bodyParts + val allTrackers = server.context.state.value.trackers + + val trackers = if (bodyParts != null) { + allTrackers.filterValues { + bodyParts.contains(it.context.state.value.bodyPart) + } + } else { + allTrackers + } + val reference = allTrackers.firstNotNullOfOrNull { + val trackerState = it.value.context.state.value + if (trackerState.bodyPart == BodyPart.HEAD) { + trackerState.rotation + } else { + null + } + } ?: Quaternion.IDENTITY + + val action = when (req.resetType) { + ResetType.Yaw -> TrackerActions.YawReset(reference) + ResetType.Full -> TrackerActions.FullReset(reference) + ResetType.Mounting -> TrackerActions.MountingReset(reference) + else -> return@on + } + for ((_, tracker) in trackers) { + tracker.context.dispatch(action) + } + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 81cff99b55..4231c0f94f 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -221,7 +221,7 @@ class TrackerToSkeletonBehaviour : TrackerBehaviour { override fun observe(receiver: Tracker) { receiver.context.state .filter { it.bodyPart != null } - .map { Pair(it.bodyPart, it.rawRotation) } + .map { Pair(it.bodyPart, it.rotation) } .distinctUntilChanged() .onEach { (bodyPart, rotation) -> bodyPart?.let { From a75a1ad19538b657639e202f815d8c0dc778df46 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Fri, 12 Jun 2026 23:19:29 -0400 Subject: [PATCH 149/350] Fix bodyParts condition --- .../src/main/java/dev/slimevr/solarxr/sessioncalibration.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt index 1d3703ef23..88d819db8e 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt @@ -15,7 +15,7 @@ class SessionCalibrationBehaviour( val bodyParts = req.bodyParts val allTrackers = server.context.state.value.trackers - val trackers = if (bodyParts != null) { + val trackers = if (!bodyParts.isNullOrEmpty()) { allTrackers.filterValues { bodyParts.contains(it.context.state.value.bodyPart) } From 1d554334cccbfc614b6042419f219400f7155583 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Fri, 12 Jun 2026 23:19:41 -0400 Subject: [PATCH 150/350] Update tracker datafeed --- server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 868403aac4..8210a06f97 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -61,7 +61,9 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas tps = if (trackerMask.tps == true) tracker.tps else null, temp = if (trackerMask.temp == true && tracker.imuTemp != null) Temperature(temp = tracker.imuTemp) else null, rawAcceleration = if (trackerMask.rawAcceleration == true) tracker.rawAcceleration.let { Vec3f(it.x, it.y, it.z) } else null, - linearAcceleration = if (trackerMask.linearAcceleration == true) tracker.rawAcceleration.let { Vec3f(it.x, it.y, it.z) } else null, // FIXME: temp value + linearAcceleration = if (trackerMask.linearAcceleration == true) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null, + rotationReferenceAdjusted = if (trackerMask.rotationReferenceAdjusted == true) tracker.rotation.let { Quat(it.x, it.y, it.z, it.w) } else null, + rotationIdentityAdjusted = if (trackerMask.rotationIdentityAdjusted == true) tracker.rotation.let { Quat(it.x, it.y, it.z, it.w) } else null, // FIXME: uses reference adjusted ) private fun createDevice( From fe2a7bf4423c051cf82c58c90e33fcc44da5cb1f Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Fri, 12 Jun 2026 23:29:40 -0400 Subject: [PATCH 151/350] Fix UDP tracker rotation --- .../core/src/main/java/dev/slimevr/udp/behaviours.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 712353596a..7dca714cc1 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -1,5 +1,6 @@ package dev.slimevr.udp +import com.jme3.math.FastMath import dev.slimevr.AppLogger import dev.slimevr.VRServerActions import dev.slimevr.device.Device @@ -9,6 +10,7 @@ import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import dev.slimevr.tracker.TrackerIdNum import dev.slimevr.util.safeLaunch +import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChangedBy @@ -20,6 +22,10 @@ import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.UnknownDeviceHandshakeNotification import kotlin.random.Random +/** + * Change between IMU axes and OpenGL/SteamVR axes + */ +internal val AXES_OFFSET = Quaternion.fromRotationVector(-FastMath.HALF_PI, 0f, 0f) internal const val CONNECTION_TIMEOUT_MS = 5000L internal const val CONNECTION_REMOVAL_MS = 30_000L @@ -381,12 +387,12 @@ class SensorRotationBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { receiver.packetEvents.onPacket { event -> val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.SetRotation(rotation = event.data.rotation)) + tracker.context.dispatch(TrackerActions.SetRotation(rotation = AXES_OFFSET * event.data.rotation)) } receiver.packetEvents.onPacket { event -> val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.SetRotation(rotation = event.data.rotation, acceleration = event.data.acceleration)) + tracker.context.dispatch(TrackerActions.SetRotation(rotation = AXES_OFFSET * event.data.rotation, acceleration = event.data.acceleration)) } receiver.packetEvents.onPacket { event -> @@ -396,7 +402,7 @@ class SensorRotationBehaviour : UDPConnectionBehaviour { receiver.packetEvents.onPacket { event -> val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.SetRotation(rotation = event.data.rotation)) + tracker.context.dispatch(TrackerActions.SetRotation(rotation = AXES_OFFSET * event.data.rotation)) } } } From 2559cdf9bce37275a6b4bf5be262df3a757be846 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Sat, 13 Jun 2026 00:22:18 -0400 Subject: [PATCH 152/350] Handle "null" yaw reset --- .../src/main/java/dev/slimevr/solarxr/sessioncalibration.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt index 88d819db8e..cdcb39b08a 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt @@ -32,7 +32,8 @@ class SessionCalibrationBehaviour( } ?: Quaternion.IDENTITY val action = when (req.resetType) { - ResetType.Yaw -> TrackerActions.YawReset(reference) + // TODO: SolarXR incorrectly treats 0 as null + ResetType.Yaw, null -> TrackerActions.YawReset(reference) ResetType.Full -> TrackerActions.FullReset(reference) ResetType.Mounting -> TrackerActions.MountingReset(reference) else -> return@on From 10df44ee2ba9ac036fba5d76a4c4c5de82683bbe Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 15 Jun 2026 20:22:43 +0200 Subject: [PATCH 153/350] Fix flashing error --- server/core/src/main/java/dev/slimevr/firmware/serial.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/firmware/serial.kt b/server/core/src/main/java/dev/slimevr/firmware/serial.kt index 74593f8e66..151879a786 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/serial.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/serial.kt @@ -48,7 +48,7 @@ suspend fun doSerialFlash( parts.map { part -> val url = part.url ?: error("missing url") val digest = part.digest ?: error("missing digest") - val offset = part.offset?.toInt() ?: error("missing offset") + val offset = (part.offset ?: 0u).toInt() DownloadedFirmwarePart( data = downloadFirmware(url, digest), offset = offset, From e85bb265374e3bf2b39ad7495afbb27fe4a5f182 Mon Sep 17 00:00:00 2001 From: Erimel Date: Mon, 15 Jun 2026 17:48:58 -0400 Subject: [PATCH 154/350] portObj -> port --- .../desktop/src/main/java/dev/slimevr/desktop/serial/flash.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/serial/flash.kt b/server/desktop/src/main/java/dev/slimevr/desktop/serial/flash.kt index 68b7ebcda5..023467a2a1 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/serial/flash.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/serial/flash.kt @@ -13,8 +13,8 @@ import com.fazecast.jSerialComm.SerialPort as JSerialPort class DesktopFlashingHandler : FlashingHandler { private var port: JSerialPort? = null - override fun openSerial(portObj: Any) { - val portLocation = portObj as? String ?: error("expected port location string") + override fun openSerial(port: Any) { + val portLocation = port as? String ?: error("expected port location string") val comPort = JSerialPort.getCommPorts().find { it.portLocation == portLocation } ?: error("Unable to find port $portLocation") if (comPort.isOpen) { From 40bb3d1cbe5d2c320d99c25b0ed58c1cdf01c216 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Mon, 15 Jun 2026 17:49:14 -0400 Subject: [PATCH 155/350] Remove reset else branch --- .../core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt index cdcb39b08a..bbd80e2967 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt @@ -36,7 +36,6 @@ class SessionCalibrationBehaviour( ResetType.Yaw, null -> TrackerActions.YawReset(reference) ResetType.Full -> TrackerActions.FullReset(reference) ResetType.Mounting -> TrackerActions.MountingReset(reference) - else -> return@on } for ((_, tracker) in trackers) { tracker.context.dispatch(action) From ba5660be33a4ca7a2095105afd140e43f72f5f88 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Mon, 15 Jun 2026 19:01:45 -0400 Subject: [PATCH 156/350] Hardcode enable ServerGuards --- .../src/main/java/dev/slimevr/skeleton/module.kt | 2 +- .../src/main/java/dev/slimevr/solarxr/datafeed.kt | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index befb6ebaff..198aa32963 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -91,7 +91,7 @@ class Skeleton( ProportionsBehaviour(), ScaledProportionsBehaviour(ctx.config.userConfig), HeightLogBehaviour(), - YouSpinMeRightRoundBehaviour(inputHz = 50f), + // YouSpinMeRightRoundBehaviour(inputHz = 50f), ComputedSkeletonBehaviour( processors = listOf( // PredictionProcessor(predictionAmount = 0.3f), diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 8210a06f97..a1f1095da1 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -16,6 +16,7 @@ import solarxr_protocol.data_feed.DataFeedUpdate import solarxr_protocol.data_feed.PollDataFeed import solarxr_protocol.data_feed.StartDataFeed import solarxr_protocol.data_feed.device_data.DeviceData +import solarxr_protocol.data_feed.server.ServerGuards import solarxr_protocol.data_feed.tracker.TrackerData import solarxr_protocol.data_feed.tracker.TrackerDataMask import solarxr_protocol.data_feed.tracker.TrackerInfo @@ -110,6 +111,12 @@ private fun createBone(bone: BoneState): solarxr_protocol.data_feed.Bone = solar headPositionG = bone.headPosition.let { Vec3f(it.x, it.y, it.z) }, ) +// TODO: Actually figure this stuff out +private fun createServerGuards(): ServerGuards = ServerGuards( + candomounting = true, + candoyawreset = true, +) + fun createDatafeedFrame( server: VRServer, datafeedConfig: DataFeedConfig, @@ -125,10 +132,16 @@ fun createDatafeedFrame( } else { null } + val serverGuards = if (datafeedConfig.serverGuardsMask == true) { + createServerGuards() + } else { + null + } return DataFeedMessageHeader( message = DataFeedUpdate( devices = if (datafeedConfig.dataMask?.deviceData != null) devices else null, bones = bones, + serverGuards = serverGuards, index = index.toUByte(), ), ) From cf46ca9f9f1bed27508af6c09eee7ffc5e5312a5 Mon Sep 17 00:00:00 2001 From: Erimel Date: Mon, 15 Jun 2026 19:37:38 -0400 Subject: [PATCH 157/350] Don't localize ports in GUI OSC --- gui/src/components/settings/pages/VRCOSCSettings.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gui/src/components/settings/pages/VRCOSCSettings.tsx b/gui/src/components/settings/pages/VRCOSCSettings.tsx index 2354d6d15a..1828640f24 100644 --- a/gui/src/components/settings/pages/VRCOSCSettings.tsx +++ b/gui/src/components/settings/pages/VRCOSCSettings.tsx @@ -239,7 +239,7 @@ function StatusCard({ <> {l10n.getString('settings-osc-vrchat-status-input-listening', { - port: status.inputPort ?? 0, + port: `${status.inputPort ?? 0}`, })} {inputState === VRCOSCInputState.ERROR && status.inputError ? ( @@ -278,7 +278,7 @@ function StatusCard({ : 'settings-osc-vrchat-status-output-target', { address: asString(status.targetAddress), - port: status.targetPort ?? 0, + port: `${status.targetPort ?? 0}`, source: sourceLabel, } )} @@ -320,7 +320,7 @@ function StatusCard({ {l10n.getString( 'settings-osc-vrchat-status-oscquery-advertising', - { port: status.oscqueryAdvertisedPort ?? 0 } + { port: `${status.oscqueryAdvertisedPort ?? 0}` } )} {oscQueryState === VRCOSCOscQueryState.FOUND && From 0755413b87c81e51b9ff797ca3d646fa0d178c95 Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 16 Jun 2026 20:12:14 -0400 Subject: [PATCH 158/350] Finish TapDetection logic with config --- .../main/java/dev/slimevr/config/settings.kt | 22 +++ .../java/dev/slimevr/tracker/behaviours.kt | 178 +++++++++++++----- 2 files changed, 155 insertions(+), 45 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index a208e28659..9936c9a0a0 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -29,6 +29,27 @@ data class TrackerConfig( val magEnabled: Boolean? = null, ) +@Serializable +data class TapDetectionConfig ( + var yawResetDelay: Float = 0.2f, + var fullResetDelay: Float = 1.0f, + var mountingResetDelay: Float = 1.0f, + var yawResetEnabled: Boolean = true, + var fullResetEnabled: Boolean = true, + var mountingResetEnabled: Boolean = true, + @Serializable(with = BodyPartSerializer::class) + var yawResetBodyPart: BodyPart? = BodyPart.CHEST, + @Serializable(with = BodyPartSerializer::class) + var mountingResetBodyPart: BodyPart? = BodyPart.RIGHT_UPPER_LEG, + @Serializable(with = BodyPartSerializer::class) + var fullResetBodyPart: BodyPart? = BodyPart.LEFT_UPPER_LEG, + var yawResetTaps: Int = 2, + var fullResetTaps: Int = 3, + var mountingResetTaps: Int = 3, + var numberTrackersOverThreshold: Int = 1 +) { +} + @Serializable data class SettingsConfigState( val trackerPort: Int = 6969, @@ -40,6 +61,7 @@ data class SettingsConfigState( val vrcOscConfig: VRCOSCConfig = defaultVrcOscConfig(), val vmcConfig: VMCConfig = VMCConfig(), val version: Int = SETTINGS_CONFIG_VERSION, + val tapDetectionConfig: TapDetectionConfig = TapDetectionConfig(), ) private fun defaultVrcOscConfig() = VRCOSCConfig() diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 4231c0f94f..d2358ac3fd 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -1,84 +1,172 @@ package dev.slimevr.tracker +import dev.slimevr.AppLogger +import dev.slimevr.VRServerState +import dev.slimevr.config.SettingsState import dev.slimevr.skeleton.SkeletonActions import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.TrackerStatus import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.TimeSource private const val NS_CONVERTER = 1.0e9f private const val CLUMP_TIME_NS = 0.06f * NS_CONVERTER -private const val NEEDED_ACCEL_DELTA = 4.0f -private const val ALLOWED_BODY_ACCEL = 2.0f +private const val NEEDED_ACCEL_DELTA = 6.0f +private const val ALLOWED_BODY_ACCEL = 2.25f private const val ALLOWED_BODY_ACCEL_SQUARED = ALLOWED_BODY_ACCEL * ALLOWED_BODY_ACCEL -private const val TAP_WINDOW_PER_TAP_S = 0.3f +private const val TAP_WINDOW_PER_TAP_NS = 0.3f * NS_CONVERTER class TrackerTapDetectionBehaviour : TrackerBehaviour { private val accelList = ArrayDeque>() private val tapTimestamps = ArrayDeque() private var waitForLowAccel = false + data class TapDetectionContext( + val actionToExecute: TrackerActions?, + val tapsNeeded: Int, + val actionDelay: Float, + val numberTrackersOverThreshold: Int + ) + + @OptIn(ExperimentalCoroutinesApi::class) override fun observe(receiver: Tracker) { - // TODO: only enable this on the trackers that have taps assigned to them - receiver.context.state - .distinctUntilChangedBy { it.rawAcceleration } - .onEach { current -> - val now = System.nanoTime() - val magnitude = current.rawAcceleration.len() - - accelList.add(magnitude to now) - while (accelList.isNotEmpty() && now - accelList.first().second > CLUMP_TIME_NS) { - accelList.removeFirst() + // Outer flow (loading) is refreshed when TapDetection config or a tracker's bodyPart or status changes + combine( + receiver.appContext.config.settings.context.state + .distinctUntilChangedBy { configState -> + configState.data.tapDetectionConfig }, + receiver.appContext.server.context.state + .flatMapLatest { serverState -> + combine(serverState.trackers.values.map { tracker -> tracker.context.state }) + { trackerStates -> + trackerStates.map { it.bodyPart to it.status } + } } + .distinctUntilChanged() + ) { configState, _ -> + loadTapDetection(receiver, configState, receiver.appContext.server.context.state.value) + } + // Inner flow (process) is refreshed everytime this tracker's acceleration is updated + .flatMapLatest { tapDetectionContext -> + receiver.context.state + .filter { tapDetectionContext.actionToExecute != null } + .distinctUntilChangedBy { it.rawAcceleration } + .onEach { currentTracker -> + processTapDetection(receiver, currentTracker, tapDetectionContext) + } + }.launchIn(receiver.context.scope) + } - val max = accelList.maxOfOrNull { it.first } ?: 0f - val min = accelList.minOfOrNull { it.first } ?: 0f - val accelDelta = max - min + // Loads TapDetection config + private fun loadTapDetection( + receiver: Tracker, + configState: SettingsState, + serverState: VRServerState + ): TapDetectionContext { + val tapDetectionConfig = configState.data.tapDetectionConfig + val trackersBodyParts = serverState.trackers.values + .map { it.context.state.value.bodyPart } + .toSet() + + val referenceRotation = serverState.trackers.firstNotNullOfOrNull { + val trackerState = it.value.context.state.value + if (trackerState.status == TrackerStatus.OK && trackerState.bodyPart == BodyPart.HEAD) { + trackerState.rotation + } else null + } ?: Quaternion.IDENTITY + + val yawResetBodyPart = listOf(tapDetectionConfig.yawResetBodyPart, BodyPart.UPPER_CHEST, BodyPart.CHEST, BodyPart.HIP, BodyPart.WAIST) + .firstOrNull { it in trackersBodyParts } + val fullResetBodyPart = listOf(tapDetectionConfig.fullResetBodyPart, BodyPart.LEFT_UPPER_LEG, BodyPart.LEFT_LOWER_LEG) + .firstOrNull { it in trackersBodyParts } + val mountingResetBodyPart = listOf(tapDetectionConfig.mountingResetBodyPart, BodyPart.RIGHT_UPPER_LEG, BodyPart.RIGHT_LOWER_LEG) + .firstOrNull { it in trackersBodyParts } + + val (actionToExecute, tapsNeeded, actionDelay) = when (receiver.context.state.value.bodyPart) { + null -> Triple(null, 0, 0f) + yawResetBodyPart if tapDetectionConfig.yawResetEnabled -> + Triple(TrackerActions.YawReset(referenceRotation), tapDetectionConfig.yawResetTaps, tapDetectionConfig.yawResetDelay) + fullResetBodyPart if tapDetectionConfig.fullResetEnabled -> + Triple(TrackerActions.FullReset(referenceRotation), tapDetectionConfig.fullResetTaps, tapDetectionConfig.fullResetDelay) + mountingResetBodyPart if tapDetectionConfig.mountingResetEnabled -> + Triple(TrackerActions.MountingReset(referenceRotation), tapDetectionConfig.mountingResetTaps, tapDetectionConfig.mountingResetDelay) + else -> Triple(null, 0, 0f) + } - if (accelDelta > NEEDED_ACCEL_DELTA && !waitForLowAccel) { - val othersOverThreshold = receiver.appContext.server.context.state.value.trackers.values - .count { it.context.state.value.id != current.id && it.context.state.value.rawAcceleration.lenSq() > ALLOWED_BODY_ACCEL_SQUARED } - if (othersOverThreshold <= 1) { - tapTimestamps.add(now) - waitForLowAccel = true - } - } + resetTapDetection() - if (max < ALLOWED_BODY_ACCEL) { - waitForLowAccel = false - } + return TapDetectionContext(actionToExecute, tapsNeeded, actionDelay, tapDetectionConfig.numberTrackersOverThreshold) + } - if (tapTimestamps.isNotEmpty()) { - val totalWindowNs = (TAP_WINDOW_PER_TAP_S * tapTimestamps.size * NS_CONVERTER).toLong() - while (tapTimestamps.isNotEmpty() && now - tapTimestamps.first() > totalWindowNs) { - tapTimestamps.removeFirst() - } + // Logic loop for tap detection + private fun processTapDetection( + receiver: Tracker, + currentTracker: TrackerState, + context: TapDetectionContext + ) { + if (context.actionToExecute == null) return - if (tapTimestamps.isNotEmpty()) { - val lastTapTime = tapTimestamps.last() - if (now - lastTapTime > (TAP_WINDOW_PER_TAP_S * NS_CONVERTER).toLong()) { - val count = tapTimestamps.size - if (count >= 2) { - println("Detected $count taps on ${receiver.context.state.value.id}") - // TODO trigger the tap action - } - tapTimestamps.clear() - } + val now = System.nanoTime() + + accelList.add(currentTracker.rawAcceleration.len() to now) + while (accelList.isNotEmpty() && now - accelList.first().second > CLUMP_TIME_NS) { + accelList.removeFirst() + } + + val max = accelList.maxOfOrNull { it.first } ?: 0f + val min = accelList.minOfOrNull { it.first } ?: 0f + val accelDelta = max - min + + if (accelDelta > NEEDED_ACCEL_DELTA && !waitForLowAccel) { + val othersOverThreshold = receiver.appContext.server.context.state.value.trackers.values + .count { it.context.state.value.id != currentTracker.id && it.context.state.value.rawAcceleration.lenSq() > ALLOWED_BODY_ACCEL_SQUARED } + if (othersOverThreshold < context.numberTrackersOverThreshold) { + tapTimestamps.add(now) + waitForLowAccel = true + } + } + + if (max < ALLOWED_BODY_ACCEL) waitForLowAccel = false + + if (tapTimestamps.isNotEmpty()) { + val totalWindowNs = (TAP_WINDOW_PER_TAP_NS * tapTimestamps.size).toLong() + while (tapTimestamps.isNotEmpty() && now - tapTimestamps.first() > totalWindowNs) { + tapTimestamps.removeFirst() + } + + if (tapTimestamps.isNotEmpty() && now - tapTimestamps.last() > TAP_WINDOW_PER_TAP_NS.toLong()) { + if (tapTimestamps.size >= context.tapsNeeded) { + receiver.context.scope.safeLaunch { + AppLogger.tracker.info("TapDetection triggered ${context.actionToExecute}") + delay((context.actionDelay * 1000).toLong().milliseconds) + receiver.appContext.server.context.state.value.trackers.values.forEach { it.context.dispatch(context.actionToExecute) } } + resetTapDetection() } } - .launchIn(receiver.context.scope) + } + } + + private fun resetTapDetection(){ + accelList.clear() + tapTimestamps.clear() + waitForLowAccel = false } } @@ -203,7 +291,7 @@ class TrackerTPSBehaviour : TrackerBehaviour { var mark = TimeSource.Monotonic.markNow() while (isActive) { try { - delay(1000) + delay(1000.milliseconds) val elapsed = mark.elapsedNow() val tps = count.exchange(0) * 1000L / elapsed.inWholeMilliseconds receiver.context.dispatch(TrackerActions.Update { copy(tps = tps.toUShort()) }) From 2f5b2eaa947e0039db846f514b4d299d8d6fba49 Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 16 Jun 2026 23:12:24 -0400 Subject: [PATCH 159/350] Move VMCConfig + TapDetection solarxr --- .../main/java/dev/slimevr/config/settings.kt | 18 ++++--- .../main/java/dev/slimevr/solarxr/module.kt | 4 +- .../main/java/dev/slimevr/solarxr/settings.kt | 3 ++ .../java/dev/slimevr/solarxr/tapdetection.kt | 54 +++++++++++++++++++ .../src/main/java/dev/slimevr/solarxr/vmc.kt | 2 +- .../java/dev/slimevr/tracker/behaviours.kt | 13 ++++- .../main/java/dev/slimevr/vmc/behaviours.kt | 1 + .../src/main/java/dev/slimevr/vmc/mapping.kt | 1 + .../src/main/java/dev/slimevr/vmc/module.kt | 13 +---- 9 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 9936c9a0a0..859875f6c2 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -2,15 +2,12 @@ package dev.slimevr.config import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.vmc.VMCConfig import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject @@ -50,6 +47,17 @@ data class TapDetectionConfig ( ) { } +@Serializable +data class VMCConfig( + val enabled: Boolean = true, + val portOut: Int = 39539, + val portIn: Int = 39540, + val address: String = "172.17.0.1", + val mirrorTracking: Boolean = false, + val anchorAtHips: Boolean = false, + val vrmJson: String? = null, +) + @Serializable data class SettingsConfigState( val trackerPort: Int = 6969, @@ -58,14 +66,12 @@ data class SettingsConfigState( val trackers: Map = emptyMap(), val globalMagEnabled: Boolean = true, val allowedUdpDevices: Set = emptySet(), - val vrcOscConfig: VRCOSCConfig = defaultVrcOscConfig(), + val vrcOscConfig: VRCOSCConfig = VRCOSCConfig(), val vmcConfig: VMCConfig = VMCConfig(), val version: Int = SETTINGS_CONFIG_VERSION, val tapDetectionConfig: TapDetectionConfig = TapDetectionConfig(), ) -private fun defaultVrcOscConfig() = VRCOSCConfig() - private fun migrateSettingsConfig(json: JsonObject): JsonObject { val version = json["version"]?.jsonPrimitive?.intOrNull ?: 0 return when { diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 3d607c9e68..4c7db2ee2c 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -6,6 +6,7 @@ import dev.slimevr.VRServerActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.context.ManagedContext +import dev.slimevr.tracker.TrackerTapDetectionBehaviour import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import solarxr_protocol.MessageBundle @@ -71,9 +72,10 @@ class SolarXRBridge( appContext.vrcConfigManager?.let { vrc -> add(VrcBehaviour(vrc, appContext.server, userHeight = { appContext.skeleton.context.state.value.userHeight })) } + add(TapDetectionBehaviour(appContext.config.settings)) add(VrcOscBehaviour(appContext.vrcOscManager)) add(VmcBehaviour(appContext.vmcManager)) - add(SettingsBehaviour(appContext.vmcManager)) + add(SettingsBehaviour(appContext.config.settings, appContext.vmcManager)) add(HeightCalibrationBehaviour(appContext.heightCalibrationManager)) add(ProvisioningBehaviour(appContext.server, appContext.provisioningManager)) add(SkeletonBehaviour(appContext.config.userConfig, appContext.skeleton)) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/settings.kt b/server/core/src/main/java/dev/slimevr/solarxr/settings.kt index e300033d32..9061e64060 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/settings.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/settings.kt @@ -1,5 +1,6 @@ package dev.slimevr.solarxr +import dev.slimevr.config.Settings import dev.slimevr.vmc.VMCManager import solarxr_protocol.rpc.SettingsRequest import solarxr_protocol.rpc.SettingsResponse @@ -10,12 +11,14 @@ import solarxr_protocol.rpc.SettingsResponse // TODO: Would be nice to split settings based on their solarxr behaviour. // TODO: Almost all build functions could be their own request. making it easier to work with class SettingsBehaviour( + private val settings: Settings, private val vmcManager: VMCManager, ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { receiver.sendRpc( SettingsResponse( + tapDetectionSettings = buildTapDetectionSettings(settings.context.state.value.data.tapDetectionConfig), vmcOsc = buildVmcOscSettings(vmcManager.context.state.value.config), ), ) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt new file mode 100644 index 0000000000..7638f3fc04 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt @@ -0,0 +1,54 @@ +package dev.slimevr.solarxr + +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions +import dev.slimevr.config.TapDetectionConfig +import solarxr_protocol.rpc.ChangeSettingsRequest +import solarxr_protocol.rpc.TapDetectionSettings + +class TapDetectionBehaviour( + private val settings: Settings, + ) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { + receiver.rpcDispatcher.on { req -> + val tapDetectionSettings = req.tapDetectionSettings ?: return@on + + val oldConfig = settings.context.state.value.data.tapDetectionConfig + val newConfig = TapDetectionConfig( + yawResetDelay = tapDetectionSettings.yawResetDelay ?: oldConfig.yawResetDelay, + fullResetDelay = tapDetectionSettings.fullResetDelay ?: oldConfig.fullResetDelay, + mountingResetDelay = tapDetectionSettings.mountingResetDelay ?: oldConfig.mountingResetDelay, + yawResetEnabled = tapDetectionSettings.yawResetEnabled ?: false, + fullResetEnabled = tapDetectionSettings.fullResetEnabled ?: false, + mountingResetEnabled = tapDetectionSettings.mountingResetEnabled ?: false, + yawResetTaps = tapDetectionSettings.yawResetTaps?.toInt() ?: oldConfig.yawResetTaps, + fullResetTaps = tapDetectionSettings.fullResetTaps?.toInt() ?: oldConfig.fullResetTaps, + mountingResetTaps = tapDetectionSettings.mountingResetTaps?.toInt() ?: oldConfig.mountingResetTaps, + yawResetBodyPart = tapDetectionSettings.yawResetTracker ?: oldConfig.yawResetBodyPart, + fullResetBodyPart = tapDetectionSettings.fullResetTracker ?: oldConfig.fullResetBodyPart, + mountingResetBodyPart = tapDetectionSettings.mountingResetTracker ?: oldConfig.mountingResetBodyPart, + numberTrackersOverThreshold = tapDetectionSettings.numberTrackersOverThreshold?.toInt() ?: oldConfig.numberTrackersOverThreshold, + ) + + settings.context.dispatch(SettingsActions.Update { copy(tapDetectionConfig = newConfig) }) + + // TODO setupMode + } + } +} + +fun buildTapDetectionSettings(config: TapDetectionConfig): TapDetectionSettings = TapDetectionSettings( + yawResetDelay = config.yawResetDelay, + fullResetDelay = config.fullResetDelay, + mountingResetDelay = config.mountingResetDelay, + yawResetEnabled = config.yawResetEnabled, + fullResetEnabled = config.fullResetEnabled, + mountingResetEnabled = config.mountingResetEnabled, + yawResetTaps = config.yawResetTaps.toUByte(), + fullResetTaps = config.fullResetTaps.toUByte(), + mountingResetTaps = config.mountingResetTaps.toUByte(), + yawResetTracker = config.yawResetBodyPart, + fullResetTracker = config.fullResetBodyPart, + mountingResetTracker = config.mountingResetBodyPart, + numberTrackersOverThreshold = config.numberTrackersOverThreshold.toUByte() +) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt index a5345d8410..3bf7526ab9 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt @@ -1,7 +1,7 @@ package dev.slimevr.solarxr +import dev.slimevr.config.VMCConfig import dev.slimevr.vmc.VMCActions -import dev.slimevr.vmc.VMCConfig import dev.slimevr.vmc.VMCManager import solarxr_protocol.rpc.ChangeSettingsRequest import solarxr_protocol.rpc.OSCSettings diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index d2358ac3fd..f16b149135 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -84,6 +84,8 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { .map { it.context.state.value.bodyPart } .toSet() + // Get reference rotation for reset actions + // TODO need a helper method or something for this val referenceRotation = serverState.trackers.firstNotNullOfOrNull { val trackerState = it.value.context.state.value if (trackerState.status == TrackerStatus.OK && trackerState.bodyPart == BodyPart.HEAD) { @@ -98,8 +100,9 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { val mountingResetBodyPart = listOf(tapDetectionConfig.mountingResetBodyPart, BodyPart.RIGHT_UPPER_LEG, BodyPart.RIGHT_LOWER_LEG) .firstOrNull { it in trackersBodyParts } + // Switch case for each possible action val (actionToExecute, tapsNeeded, actionDelay) = when (receiver.context.state.value.bodyPart) { - null -> Triple(null, 0, 0f) + null -> Triple(null, 0, 0f) // BodyParts above could be null yawResetBodyPart if tapDetectionConfig.yawResetEnabled -> Triple(TrackerActions.YawReset(referenceRotation), tapDetectionConfig.yawResetTaps, tapDetectionConfig.yawResetDelay) fullResetBodyPart if tapDetectionConfig.fullResetEnabled -> @@ -124,7 +127,10 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { val now = System.nanoTime() + // Get the acceleration of the tracker and store it accelList.add(currentTracker.rawAcceleration.len() to now) + + // Remove old stored accelerations (if they are too old) while (accelList.isNotEmpty() && now - accelList.first().second > CLUMP_TIME_NS) { accelList.removeFirst() } @@ -133,18 +139,22 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { val min = accelList.minOfOrNull { it.first } ?: 0f val accelDelta = max - min + // Check for a single tap if (accelDelta > NEEDED_ACCEL_DELTA && !waitForLowAccel) { val othersOverThreshold = receiver.appContext.server.context.state.value.trackers.values .count { it.context.state.value.id != currentTracker.id && it.context.state.value.rawAcceleration.lenSq() > ALLOWED_BODY_ACCEL_SQUARED } if (othersOverThreshold < context.numberTrackersOverThreshold) { tapTimestamps.add(now) + // After a tap, a lower acceleration is needed before another one waitForLowAccel = true } } + // Achieved low accel? if (max < ALLOWED_BODY_ACCEL) waitForLowAccel = false if (tapTimestamps.isNotEmpty()) { + // Remove old stored taps (if they are too old) val totalWindowNs = (TAP_WINDOW_PER_TAP_NS * tapTimestamps.size).toLong() while (tapTimestamps.isNotEmpty() && now - tapTimestamps.first() > totalWindowNs) { tapTimestamps.removeFirst() @@ -152,6 +162,7 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { if (tapTimestamps.isNotEmpty() && now - tapTimestamps.last() > TAP_WINDOW_PER_TAP_NS.toLong()) { if (tapTimestamps.size >= context.tapsNeeded) { + // Taps completed! receiver.context.scope.safeLaunch { AppLogger.tracker.info("TapDetection triggered ${context.actionToExecute}") delay((context.actionDelay * 1000).toLong().milliseconds) diff --git a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt index b57797b09a..09bdc7bea7 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt @@ -3,6 +3,7 @@ package dev.slimevr.vmc import dev.slimevr.AppLogger import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions +import dev.slimevr.config.VMCConfig import dev.slimevr.context.Behaviour import dev.slimevr.osc.OscArg import dev.slimevr.osc.OscBundle diff --git a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt index b414dba0ed..ab1b82798a 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt @@ -1,6 +1,7 @@ package dev.slimevr.vmc import com.jme3.math.FastMath +import dev.slimevr.config.VMCConfig import dev.slimevr.skeleton.BoneState import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 diff --git a/server/core/src/main/java/dev/slimevr/vmc/module.kt b/server/core/src/main/java/dev/slimevr/vmc/module.kt index 445dfb3a2d..c583f42381 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/module.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/module.kt @@ -1,22 +1,11 @@ package dev.slimevr.vmc import dev.slimevr.Phase1ContextProvider +import dev.slimevr.config.VMCConfig import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.skeleton.Skeleton import kotlinx.coroutines.CoroutineScope -import kotlinx.serialization.Serializable - -@Serializable -data class VMCConfig( - val enabled: Boolean = true, - val portOut: Int = 39539, - val portIn: Int = 39540, - val address: String = "172.17.0.1", - val mirrorTracking: Boolean = false, - val anchorAtHips: Boolean = false, - val vrmJson: String? = null, -) data class VMCState(val config: VMCConfig = VMCConfig()) From 431774be165f1bce3cfe7a5d7644b9c6420f72ed Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 17 Jun 2026 16:26:08 -0400 Subject: [PATCH 160/350] tap setupMode wip --- .../src/main/java/dev/slimevr/config/settings.kt | 3 ++- .../main/java/dev/slimevr/solarxr/tapdetection.kt | 9 ++++----- .../src/main/java/dev/slimevr/tracker/behaviours.kt | 12 +++++++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 859875f6c2..3f2047534a 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -43,7 +43,8 @@ data class TapDetectionConfig ( var yawResetTaps: Int = 2, var fullResetTaps: Int = 3, var mountingResetTaps: Int = 3, - var numberTrackersOverThreshold: Int = 1 + var numberTrackersOverThreshold: Int = 1, + var setupMode: Boolean = false ) { } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt index 7638f3fc04..c4391cba96 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt @@ -18,9 +18,9 @@ class TapDetectionBehaviour( yawResetDelay = tapDetectionSettings.yawResetDelay ?: oldConfig.yawResetDelay, fullResetDelay = tapDetectionSettings.fullResetDelay ?: oldConfig.fullResetDelay, mountingResetDelay = tapDetectionSettings.mountingResetDelay ?: oldConfig.mountingResetDelay, - yawResetEnabled = tapDetectionSettings.yawResetEnabled ?: false, - fullResetEnabled = tapDetectionSettings.fullResetEnabled ?: false, - mountingResetEnabled = tapDetectionSettings.mountingResetEnabled ?: false, + yawResetEnabled = tapDetectionSettings.yawResetEnabled == true, + fullResetEnabled = tapDetectionSettings.fullResetEnabled == true, + mountingResetEnabled = tapDetectionSettings.mountingResetEnabled == true, yawResetTaps = tapDetectionSettings.yawResetTaps?.toInt() ?: oldConfig.yawResetTaps, fullResetTaps = tapDetectionSettings.fullResetTaps?.toInt() ?: oldConfig.fullResetTaps, mountingResetTaps = tapDetectionSettings.mountingResetTaps?.toInt() ?: oldConfig.mountingResetTaps, @@ -28,11 +28,10 @@ class TapDetectionBehaviour( fullResetBodyPart = tapDetectionSettings.fullResetTracker ?: oldConfig.fullResetBodyPart, mountingResetBodyPart = tapDetectionSettings.mountingResetTracker ?: oldConfig.mountingResetBodyPart, numberTrackersOverThreshold = tapDetectionSettings.numberTrackersOverThreshold?.toInt() ?: oldConfig.numberTrackersOverThreshold, + setupMode = tapDetectionSettings.setupMode == true, ) settings.context.dispatch(SettingsActions.Update { copy(tapDetectionConfig = newConfig) }) - - // TODO setupMode } } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index f16b149135..2a724010df 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -80,9 +80,12 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { serverState: VRServerState ): TapDetectionContext { val tapDetectionConfig = configState.data.tapDetectionConfig - val trackersBodyParts = serverState.trackers.values - .map { it.context.state.value.bodyPart } - .toSet() + + // If setupMode is true, double tap to assign + if (tapDetectionConfig.setupMode) { + println("SetupMode is enabled!") + return TapDetectionContext(null, 2, 0f, tapDetectionConfig.numberTrackersOverThreshold) + } // Get reference rotation for reset actions // TODO need a helper method or something for this @@ -93,6 +96,9 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { } else null } ?: Quaternion.IDENTITY + val trackersBodyParts = serverState.trackers.values + .map { it.context.state.value.bodyPart } + .toSet() val yawResetBodyPart = listOf(tapDetectionConfig.yawResetBodyPart, BodyPart.UPPER_CHEST, BodyPart.CHEST, BodyPart.HIP, BodyPart.WAIST) .firstOrNull { it in trackersBodyParts } val fullResetBodyPart = listOf(tapDetectionConfig.fullResetBodyPart, BodyPart.LEFT_UPPER_LEG, BodyPart.LEFT_LOWER_LEG) From fe3bee24178b022152ce9ab99a035d9673b7b53f Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 17 Jun 2026 16:55:33 -0400 Subject: [PATCH 161/350] Mute ConvertLongToDuration warning --- .idea/inspectionProfiles/Project_Default.xml | 1 + server/core/src/main/java/dev/slimevr/solarxr/mag.kt | 1 - server/core/src/main/java/dev/slimevr/tracker/behaviours.kt | 5 ++--- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index 7fe922d352..dd667131c1 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -3,6 +3,7 @@ dokumentaci k tomuto tématu! vrc_config-page-big_menu = Sledování & IK (Velké Menu) vrc_config-page-big_menu-desc = Nastavení souvicející s IK ve velké nabídce nastavení vrc_config-page-wrist_menu = Sledování & IK (Zápěstní menu) +vrc_config-page-wrist_menu-desc = Nastavení související s IK najdete v malém (zápěstním) menu vrc_config-on = Zapnuto vrc_config-off = Vypnuto vrc_config-invalid = Máte špatně nakonfigurované VRChat nastavení! @@ -1241,6 +1370,7 @@ vrc_config-mute-btn = Ztlumení vrc_config-unmute-btn = Zrušit ztlumení vrc_config-legacy_mode = Použít starší řešení IK vrc_config-disable_shoulder_tracking = Vypnout sledování ramen +vrc_config-shoulder_width_compensation = Kompenzace Šířky Ramen vrc_config-spine_mode = Režim páteře FTB vrc_config-tracker_model = Model FBT trackeru vrc_config-avatar_measurement_type = Meření avataru @@ -1272,3 +1402,28 @@ error_collection_modal-cancel = Nesouhlasím ## Tracking checklist section +tracking_checklist-settings-close = Zavřít +tracking_checklist-status-incomplete = Nejste připraveni používat SlimeVR! +tracking_checklist-status-complete = Jste připravení k použití SlimeVR +tracking_checklist-FULL_RESET = Proveďte plné obnovení +tracking_checklist-STEAMVR_DISCONNECTED = SteamVR není zapnut +tracking_checklist-STEAMVR_DISCONNECTED-desc = SteamVR není zapnut. Používáte ho pro VR? +tracking_checklist-STEAMVR_DISCONNECTED-open = Spusťte SteamVR +tracking_checklist-TRACKERS_REST_CALIBRATION = Kalibrujte vaše trackery +tracking_checklist-TRACKER_ERROR = Trackery s chybami +tracking_checklist-VRCHAT_SETTINGS = Nakonfigurujte nastavení VRChat +tracking_checklist-VRCHAT_SETTINGS-open = Přejít k varování ve VRChat +tracking_checklist-NETWORK_PROFILE_PUBLIC = Změňte profil sítě +tracking_checklist-NETWORK_PROFILE_PUBLIC-open = Otevřete Ovládací Panel +tracking_checklist-STAY_ALIGNED_CONFIGURED = Nakonfigurujte Zůstaň Srovnaný +tracking_checklist-ignore = Ignorovat +preview-mocap_mode_soon = Režim Mocap (brzy™) +preview-disable_render = Vypnout vykreslování +preview-disabled_render = Vykreslování vypnuto +toolbar-mounting_calibration = Kalibrace nasazení +toolbar-mounting_calibration-default = Tělo +toolbar-mounting_calibration-feet = Chodidla +toolbar-mounting_calibration-fingers = Prsty +toolbar-drift_reset = Restartování driftu +toolbar-assigned_trackers = { $count } trackery/ů přiřazeno +toolbar-unassigned_trackers = { $count } trackey/ů nepřiřazeno diff --git a/gui/public/i18n/da/translation.ftl b/gui/public/i18n/da/translation.ftl index 194aee4223..d40beec97d 100644 --- a/gui/public/i18n/da/translation.ftl +++ b/gui/public/i18n/da/translation.ftl @@ -480,9 +480,11 @@ onboarding-setup_warning = onboarding-setup_warning-skip = Spring opsætning over onboarding-setup_warning-cancel = Fortsæt konfigurationen +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = Gå tilbage til introduktion onboarding-wifi_creds-skip = Spring Wi-Fi-indstillinger over onboarding-wifi_creds-submit = Færdig! onboarding-wifi_creds-ssid = @@ -497,6 +499,9 @@ onboarding-wifi_creds-password = onboarding-reset_tutorial-back = Gå tilbage til monteringskalibrering onboarding-reset_tutorial = Start forfra +## Install info + + ## Setup start onboarding-home = Velkommen til SlimeVR diff --git a/gui/public/i18n/de/translation.ftl b/gui/public/i18n/de/translation.ftl index cb0fe4b18d..1271586bb3 100644 --- a/gui/public/i18n/de/translation.ftl +++ b/gui/public/i18n/de/translation.ftl @@ -115,6 +115,11 @@ board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3 board_type-HARITORA = Haritora board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1 board_type-GLOVE_IMU_SLIMEVR_DEV = SlimeVR Dev-IMU-Handschuh +board_type-GESTURES = Gesten +board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini +board_type-GENERIC_NRF = Generisches nRF +board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR Dev Butterfly +board_type-SLIMEVR_BUTTERFLY = SlimeVR Butterfly ## Proportions @@ -180,6 +185,8 @@ reset-mounting-fingers = Fingerkalibrierung reset-yaw = Horizontaler Reset reset-error-no_feet_tracker = Kein Fußtracker zugewiesen reset-error-no_fingers_tracker = Kein Fingertracker zugewiesen +reset-error-mounting-need_full_reset = Ein vollständiger Reset ist vor der Tracker-Ausrichtung erforderlich. +reset-error-yaw-need_full_reset = Für den Yaw-Reset ist ein vollständiger Reset erforderlich. ## Serial detection stuff @@ -199,6 +206,7 @@ navbar-trackers_assign = Tracker-Zuordnung navbar-mounting = Tracker-Ausrichtung navbar-onboarding = Einrichtungs-Assistent navbar-settings = Einstellungen +navbar-connect_trackers = Tracker verbinden ## Biovision hierarchy recording @@ -269,6 +277,7 @@ tracker-table-column-name = Name tracker-table-column-type = Typ tracker-table-column-battery = Batterie tracker-table-column-ping = Latenz +tracker-table-column-packet_loss = Paketverlust tracker-table-column-tps = TPS tracker-table-column-temperature = Temp. °C tracker-table-column-linear-acceleration = Beschleunigung X/Y/Z @@ -310,6 +319,9 @@ tracker-infos-magnetometer-status-v1 = [ENABLED] Angeschalten *[NOT_SUPPORTED] Nicht unterstützt } +tracker-infos-packet_loss = Paketverlust +tracker-infos-packets_lost = Pakete verloren +tracker-infos-packets_received = Pakete empfangen ## Tracker settings @@ -347,6 +359,9 @@ tracker-settings-update-up_to_date = Auf dem neusten Stand tracker-settings-update-blocked = Update nicht verfügbar. Weitere Veröffentlichungen sind nicht verfügbar. tracker-settings-update = Jetzt aktualisieren tracker-settings-update-title = Firmware-Version +tracker-settings-current-version = Aktuelle +tracker-settings-latest-version = Aktuelleste +tracker-settings-build-date = Herstellungsdatum ## Tracker part card info @@ -511,14 +526,19 @@ settings-general-tracker_mechanics-use_mag_on_all_trackers-description = Verwendet das Magnetometer auf allen Trackern, die über eine kompatible Firmware verfügen, um den Drift in stabilen magnetischen Umgebungen zu reduzieren. Kann pro Tracker in den Einstellungen des Trackers deaktiviert werden. Bitte schalten Sie keinen der Tracker aus, während Sie dies umschalten! settings-general-tracker_mechanics-use_mag_on_all_trackers-label = Magnetometer auf Trackern verwenden +settings-general-tracker_mechanics-trackers_over_usb = Tracker über USB +settings-general-tracker_mechanics-trackers_over_usb-enabled-label = Erlaube HID-Tracker eine USB-Direktverbindung settings-stay_aligned = Stay Aligned settings-stay_aligned-description = Stay Aligned reduziert Drift, indem es deine Tracker schrittweise an deine entspannten Posen anpasst. settings-stay_aligned-setup-label = Stay Aligned einrichten settings-stay_aligned-setup-description = Sie müssen Stay Aligned einrichten, um es zu aktivieren. settings-stay_aligned-warnings-drift_compensation = ⚠ Bitte schalten Sie die Driftkompensation aus! Diese steht in Konflikt mit Stay Aligned. settings-stay_aligned-enabled-label = Tracker anpassen +settings-stay_aligned-hide_yaw_correction-label = Anpassung ausblenden (zum Vergleich ohne Stay Aligned) settings-stay_aligned-general-label = Allgemein settings-stay_aligned-relaxed_poses-label = Entspannte Posen +settings-stay_aligned-relaxed_poses-standing = Tracker im Stehen anpassen +settings-stay_aligned-relaxed_poses-sitting = Tracker anpassen, während du auf einem Stuhl sitzt settings-stay_aligned-relaxed_poses-save_pose = Pose speichern settings-stay_aligned-relaxed_poses-reset_pose = Pose zurücksetzen settings-stay_aligned-relaxed_poses-close = Schließen @@ -546,6 +566,8 @@ settings-general-fk_settings-leg_tweak-floor_clip-description = Bodenclip kann d settings-general-fk_settings-leg_tweak-toe_snap-description = Zehen-Ausrichtung versucht, die Rotation Ihrer Füße zu erraten, wenn keine Fuß-Tracker verwendet werden. settings-general-fk_settings-leg_tweak-foot_plant-description = Fußkorrektur richtet Ihre Füße parallel zum Boden aus, wenn sie den Boden berühren. settings-general-fk_settings-leg_fk = Beintracking +settings-general-fk_settings-leg_fk-reset_mounting_feet-description-v1 = Erzwinge Fußausrichtungs-Kalibrierung während der Körperausrichtungs-Kalibrierung. +settings-general-fk_settings-leg_fk-reset_mounting_feet-v1 = Fuß-Ausrichtung kalibrieren settings-general-fk_settings-enforce_joint_constraints = Gelenkgrenzen settings-general-fk_settings-enforce_joint_constraints-enforce_constraints = Grenzen erzwingen settings-general-fk_settings-enforce_joint_constraints-enforce_constraints-description = Verhindert, dass sich Gelenke über ihre Grenzen hinaus drehen @@ -845,10 +867,12 @@ settings-utils-advanced-open_logs-label = Ordner öffnen settings-home-list-layout = Layout der Tracker-Liste settings-home-list-layout-desc = Wählen Sie eines der möglichen Startbildschirm-Layouts aus +settings-home-list-layout-grid = Raster settings-home-list-layout-table = Tabelle ## Tracking Checlist +settings-tracking_checklist-active_steps = Aktive Schritte ## Setup/onboarding menu @@ -860,9 +884,12 @@ onboarding-setup_warning = Warnung: Für gutes Tracking ist die Ersteinri onboarding-setup_warning-skip = Einrichtung überspringen onboarding-setup_warning-cancel = Einrichtung fortsetzen +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = Zurück zur Einführung +onboarding-wifi_creds-v2 = Tracker mit WLAN onboarding-wifi_creds-skip = WLAN-Zugangsdaten überspringen onboarding-wifi_creds-submit = Weiter! onboarding-wifi_creds-ssid = @@ -872,6 +899,8 @@ onboarding-wifi_creds-ssid-required = WLAN-Name ist erforderlich onboarding-wifi_creds-password = .label = Passwort .placeholder = Passwort eingeben +onboarding-wifi_creds-dongle-title = Tracker mit einem Dongle +onboarding-wifi_creds-dongle-continue = Fahre mit einem Dongle fort ## Mounting setup @@ -898,6 +927,9 @@ onboarding-reset_tutorial-2 = Sie müssen sich in einer "Skifahren"-Pose, wie im Tracker-Ausrichtung-Assistenten gezeigt wird, befinden. Nach dem Auslösen wird der Reset nach 3 Sekunden (konfigurierbar) durchgeführt. +## Install info + + ## Setup start onboarding-home = Willkommen zu SlimeVR @@ -970,6 +1002,7 @@ onboarding-assignment_tutorial-done = Ich habe Aufkleber und Bänder angebracht! onboarding-assign_trackers-back = Zurück zu den WLAN-Zugangsdaten onboarding-assign_trackers-title = Tracker zuweisen onboarding-assign_trackers-description = Wählen Sie nun aus, welcher Tracker wo befestigt ist. Klicken Sie auf einen Ort, an dem der Tracker platziert ist. +onboarding-assign_trackers-unassign_all = Alle Trackerzuweisungen aufheben # Look at translation of onboarding-connect_tracker-connected_trackers on how to use plurals # $assigned (Number) - Trackers that have been assigned a body part # $trackers (Number) - Trackers connected to the server @@ -1115,6 +1148,9 @@ onboarding-automatic_mounting-mounting_reset-title = Befestigungs-Reset onboarding-automatic_mounting-mounting_reset-step-0 = 1. Beugen Sie sich in die "Skifahren"-Pose mit gebeugten Beinen, geneigtem Oberkörper und gebeugten Armen. onboarding-automatic_mounting-mounting_reset-step-1 = 2. Drücken Sie die Schaltfläche "Befestigungs-Reset" und warten Sie 3 Sekunden, bevor die Drehungen der Tracker gesetzt werden. onboarding-automatic_mounting-preparation-title = Vorbereitung +onboarding-automatic_mounting-preparation-v2-step-0 = 1. Drücke den Knopf "Kompletter Reset". +onboarding-automatic_mounting-preparation-v2-step-1 = 2. Stehe aufrecht mit den Armen an den Seiten. Schaue unbedingt nach vorne. +onboarding-automatic_mounting-preparation-v2-step-2 = 3. Halte die Position, bis 3 Sekunden abgelaufen sind. onboarding-automatic_mounting-put_trackers_on-title = Legen Sie Ihre Tracker an onboarding-automatic_mounting-put_trackers_on-description = Um die Drehung der Tracker zu kalibrieren, werden die Tracker verwendet, welche Sie gerade zugewiesen haben. Ziehen Sie alle Ihre Tracker an, in der Abbildung rechts können sie sehen um welchen Tracker es sich handelt. onboarding-automatic_mounting-put_trackers_on-next = Ich habe alle meine Tracker angelegt @@ -1128,6 +1164,7 @@ onboarding-manual_proportions-fine_tuning_button-disabled-tooltip = Bitte schlie onboarding-manual_proportions-export = Proportionen exportieren onboarding-manual_proportions-import = Proportionen importieren onboarding-manual_proportions-file_type = Körperproportions-Datei +onboarding-manual_proportions-grouped_proportions = Gruppierte Proportionen onboarding-manual_proportions-all_proportions = Alle Proportionen onboarding-manual_proportions-estimated_height = Geschätzte Benutzergröße @@ -1216,10 +1253,26 @@ onboarding-automatic_proportions-smol_warning-cancel = Zurück ## User height calibration +onboarding-user_height-title = Wie groß bist du? +onboarding-user_height-description = Wir brauchen deine Größe, um deine Körperproportionen zu berechnen und deine Bewegungen genau darzustellen. Du kannst dies entweder SlimeVR berechnen lassen oder deine Höhe manuell eingeben. +onboarding-user_height-calculate = Berechne meine Körpergröße automatisch +onboarding-user_height-next_step = Fortfahren und speichern +onboarding-user_height-manual-proportions = Manuelle Körperproportionen +onboarding-user_height-calibration-title = Kalibrierungsfortschritt +onboarding-user_height-calibration-WAITING_FOR_RISE = Steh wieder auf +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK = Steh wieder auf und schau nach vorne +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-ok = Achte darauf, dass dein Kopf waagerecht ist onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-low = Schauen sie nicht auf den Boden onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-high = Schauen sie nicht zu hoch nach oben onboarding-user_height-calibration-WAITING_FOR_CONTROLLER_PITCH = Achten sie darauf, dass der Controller nach unten zeigt +onboarding-user_height-calibration-RECORDING_HEIGHT = Steh wieder auf und steh still! onboarding-user_height-calibration-DONE = Erfolg! +onboarding-user_height-calibration-ERROR_TIMEOUT = Die Kalibrierung ist abgelaufen, versuche es nochmal. +onboarding-user_height-calibration-ERROR_TOO_HIGH = Die erkannte Benutzerhöhe ist zu hoch, versuche es erneut. +onboarding-user_height-calibration-error = Kalibrierung fehlgeschlagen +onboarding-user_height-reset-warning = + Achtung: Die Proportionen werden zurückgesetzt und auf Basis deiner Körpergröße neu berechnet. + Bist du dir sicher? ## Stay Aligned setup @@ -1230,20 +1283,34 @@ onboarding-stay_aligned-put_trackers_on-description = Um Ihre Ruheposen zu speic onboarding-stay_aligned-put_trackers_on-trackers_warning = Sie haben derzeit weniger als 5 Tracker verbunden und zugewiesen! Dies ist die Mindestanzahl an Trackern, die erforderlich sind, damit Stay Aligned richtig funktioniert. onboarding-stay_aligned-put_trackers_on-next = Ich habe alle meine Tracker angelegt onboarding-stay_aligned-verify_mounting-title = Tracker-Ausrichtung +onboarding-stay_aligned-verify_mounting-step-1 = 1. Bewege dich im Stehen. +onboarding-stay_aligned-verify_mounting-step-2 = 2. Setz dich hin und bewege deine Beine und Füße. +onboarding-stay_aligned-verify_mounting-step-3 = 3. Wenn deine Tracker nicht an der richtigen Stelle sind, drücke "Ausrichtungskalibrierung wiederholen". onboarding-stay_aligned-verify_mounting-redo_mounting = Tracker-Ausrichtungskalibrierung wiederholen onboarding-stay_aligned-preparation-title = Vorbereitung onboarding-stay_aligned-preparation-tip = Achten Sie darauf, aufrecht zu stehen. Schauen Sie nach vorne und lassen Sie die Arme an den Seiten hängen. +onboarding-stay_aligned-relaxed_poses-standing-title = Entspannte Stehpose onboarding-stay_aligned-relaxed_poses-standing-step-0 = 1. Nehmen Sie eine bequeme Haltung ein. Entspannen Sie sich! onboarding-stay_aligned-relaxed_poses-standing-step-1-v2 = 2. Drücken Sie die Taste „Pose speichern“. +onboarding-stay_aligned-relaxed_poses-sitting-title = Entspannte Im-Stuhl-sitzen-Pose +onboarding-stay_aligned-relaxed_poses-sitting-step-0 = 1. Nehme eine bequeme Haltung ein. Entspanne dich! +onboarding-stay_aligned-relaxed_poses-sitting-step-1-v2 = 2. Drücke die Taste „Pose speichern“. +onboarding-stay_aligned-relaxed_poses-flat-title = Entspannte Sitzposition auf dem Boden +onboarding-stay_aligned-relaxed_poses-flat-step-0 = 1. Setz dich mit den Beinen nach vorne auf den Boden. Entspann dich! +onboarding-stay_aligned-relaxed_poses-flat-step-1-v2 = 2. Drücke die Taste „Pose speichern“. onboarding-stay_aligned-relaxed_poses-skip_step = Überspringen +onboarding-stay_aligned-done-title = Stay aligned aktiviert! +onboarding-stay_aligned-done-description = Dein Stay Aligned-Setup ist komplett! onboarding-stay_aligned-previous_step = Zurück onboarding-stay_aligned-next_step = Weiter onboarding-stay_aligned-restart = Neu starten onboarding-stay_aligned-done = Fertig +onboarding-stay_aligned-manual_mounting-done = Fertig ## Home home-no_trackers = Keine Tracker erkannt oder zugewiesen +home-settings = Startseiten-Einstellungen home-settings-close = Schließen ## Trackers Still On notification @@ -1280,12 +1347,19 @@ firmware_tool = DIY Firmware-Tool firmware_tool-description = Erlaubt ihnen das Konfigurieren und Flashen von DIY Trackern firmware_tool-not_available = Das Firmware Tool ist im Moment nicht verfügbar. Versuche sie später erneut! firmware_tool-not_compatible = Das Firmware Tool ist nicht mit dieser Version des Servers kompatibel. Bitte den Server aktualisieren! +firmware_tool-select_source = Wähle die Firmware zum Flashen aus +firmware_tool-select_source-description = Wähle die Firmware aus, die du auf deinem Board flashen möchtest +firmware_tool-select_source-error = Quellen konnten nicht geladen werden firmware_tool-select_source-board_type = Boardtyp firmware_tool-select_source-firmware = Firmware-Quelle firmware_tool-select_source-version = Firmware-Version firmware_tool-select_source-official = Offiziell firmware_tool-select_source-dev = Dev +firmware_tool-select_source-not_selected = Keine Quelle ausgewählt +firmware_tool-select_source-no_boards = Keine verfügbaren Boards für diese Quelle +firmware_tool-select_source-no_versions = Keine verfügbaren Versionen für diese Quelle firmware_tool-board_defaults = Konfigurieren Sie Ihr Board +firmware_tool-board_defaults-description = Stelle die Pins oder Einstellungen relativ zu deiner Hardware ein firmware_tool-board_defaults-add = Hinzufügen firmware_tool-board_defaults-reset = Auf Standard zurücksetzen firmware_tool-board_defaults-error-required = Erforderliches Feld @@ -1398,7 +1472,12 @@ unknown_device-modal-forget = Ignorieren # VRChat config warnings vrc_config-page-title = VRChat Konfigurations-Warnungen vrc_config-page-desc = Diese Seite zeigt den Zustand deiner VRChat-Einstellungen und zeigt, welche Einstellungen mit SlimeVR inkompatibel sind. Es wird dringend empfohlen, alle hier angezeigten Warnungen zu beheben, um das beste Nutzererlebnis mit SlimeVR zu gewährleisten. +vrc_config-page-help = Kannst du die Einstellungen nicht finden? vrc_config-page-help-desc = Schauen Sie sich unsere Dokumentation zu diesem Thema an! +vrc_config-page-big_menu = Tracking & IK (Großes Menü) +vrc_config-page-big_menu-desc = Einstellungen im Zusammenhang mit IK im großen Einstellungsmenü +vrc_config-page-wrist_menu = Tracking & IK (Handgelenkmenü) +vrc_config-page-wrist_menu-desc = Einstellungen im Zusammenhang mit IK im kleinen Einstellungsmenü (Handgelenkmenü) vrc_config-on = An vrc_config-off = Aus vrc_config-invalid = Sie haben falsch konfigurierte VRChat-Einstellungen! @@ -1409,13 +1488,23 @@ vrc_config-current_value = Aktueller Wert vrc_config-mute = Warnung stummschalten vrc_config-mute-btn = Stummschalten vrc_config-unmute-btn = Stummschaltung aufheben +vrc_config-legacy_mode = Verwende Legacy IK Solving vrc_config-disable_shoulder_tracking = Schultertracking deaktivieren +vrc_config-shoulder_width_compensation = Schulterbreitenkompensation +vrc_config-spine_mode = FBT-Wirbelsäulenmodus +vrc_config-tracker_model = FBT-Trackermodell +vrc_config-avatar_measurement_type = Avatar-Messung +vrc_config-calibration_range = Kalibrierungsbereich +vrc_config-calibration_visuals = Display-Kalibrierungsvisualisierungen vrc_config-user_height = Echte Benutzergröße vrc_config-spine_mode-UNKNOWN = Unbekannt +vrc_config-spine_mode-LOCK_BOTH = Beide sperren vrc_config-spine_mode-LOCK_HEAD = Kopf sperren vrc_config-spine_mode-LOCK_HIP = Hüfte sperren vrc_config-tracker_model-UNKNOWN = Unbekannt vrc_config-tracker_model-AXIS = Achse +vrc_config-tracker_model-BOX = Box +vrc_config-tracker_model-SPHERE = Sphäre vrc_config-tracker_model-SYSTEM = System vrc_config-avatar_measurement_type-UNKNOWN = Unbekannt vrc_config-avatar_measurement_type-HEIGHT = Höhe @@ -1433,22 +1522,41 @@ error_collection_modal-cancel = Ich will nicht ## Tracking checklist section +tracking_checklist = Tracking-Checkliste +tracking_checklist-settings = Einstellungen der Tracking-Checkliste tracking_checklist-settings-close = Schließen +tracking_checklist-status-incomplete = Du bist nicht darauf vorbereitet, SlimeVR zu benutzen! tracking_checklist-status-partial = { $count -> [one] Sie haben 1 Warnung! *[other] Sie haben { $count } Warnungen! } +tracking_checklist-status-complete = Du bist bereit, SlimeVR zu nutzen! tracking_checklist-MOUNTING_CALIBRATION = Tracker-Ausrichtung durchführen +tracking_checklist-FEET_MOUNTING_CALIBRATION = Führe eine Fußmontage-Kalibrierung durch +tracking_checklist-FULL_RESET = Führe einen vollständigen Reset durch +tracking_checklist-FULL_RESET-desc = Manche Tracker benötigen eine erneute Kalibrierung. tracking_checklist-STEAMVR_DISCONNECTED = SteamVR läuft nicht tracking_checklist-STEAMVR_DISCONNECTED-desc = SteamVR läuft nicht. Nutzen sie es für VR? tracking_checklist-STEAMVR_DISCONNECTED-open = SteamVR starten +tracking_checklist-TRACKERS_REST_CALIBRATION = Kalibriere deine Tracker tracking_checklist-TRACKERS_REST_CALIBRATION-desc = Sie haben keine Tracker-Kalibrierung durchgeführt. Bitte lassen Sie Ihre Tracker (gelb markiert) für einige Sekunden auf einer stabilen Oberfläche ruhen. tracking_checklist-TRACKER_ERROR = Tracker mit Fehlern +tracking_checklist-TRACKER_ERROR-desc = Einige deiner Tracker haben einen Fehler. Bitte starte die gelb markierten Tracker neu. tracking_checklist-VRCHAT_SETTINGS = VRChat-Einstellungen konfigurieren +tracking_checklist-VRCHAT_SETTINGS-desc = Du hast die VRChat-Einstellungen falsch konfiguriert! Das kann sich negativ auf dein Tracking auswirken. tracking_checklist-VRCHAT_SETTINGS-open = Gehen sie zu den VRChat-Warnungen +tracking_checklist-UNASSIGNED_HMD = VR-Headset nicht dem Kopf zugewiesen +tracking_checklist-UNASSIGNED_HMD-desc = Das VR-Headset sollte als Kopf-Tracker zugewiesen sein. +tracking_checklist-NETWORK_PROFILE_PUBLIC = Ändere dein Netzwerkprofil +tracking_checklist-NETWORK_PROFILE_PUBLIC-desc = + { $count -> + [one] Dein Netzwerkprofil ist derzeit auf Öffentlich ({ $adapters }) eingestellt. Dies wird für das ordnungsgemäße Funktionieren von SlimeVR nicht empfohlen. Hier erfährst du, wie du das beheben kannst. + *[other] Einige deiner Netzwerkadapter sind auf Öffentlich eingestellt:¶{ $adapters }¶Das wird nicht empfohlen, damit SlimeVR ordnungsgemäß funktioniert.¶Hier erfährst du, wie du das beheben kannst. + } tracking_checklist-NETWORK_PROFILE_PUBLIC-open = Kontrollpanel öffnen tracking_checklist-STAY_ALIGNED_CONFIGURED = Stay Aligned konfigurieren +tracking_checklist-STAY_ALIGNED_CONFIGURED-desc = Zeichne die Stay Aligned-Posen auf, um Drift zu reduzieren tracking_checklist-STAY_ALIGNED_CONFIGURED-open = Öffne den Stay Aligned Assistent tracking_checklist-ignore = Ignorieren preview-mocap_mode_soon = Mocap-Modus (Bald™) @@ -1458,5 +1566,6 @@ toolbar-mounting_calibration = Tracker-Ausrichtung toolbar-mounting_calibration-default = Körper toolbar-mounting_calibration-feet = Füße toolbar-mounting_calibration-fingers = Finger +toolbar-drift_reset = Drift-Reset toolbar-assigned_trackers = { $count } Tracker zugewiesen toolbar-unassigned_trackers = { $count } Tracker nicht zugewiesen diff --git a/gui/public/i18n/el/translation.ftl b/gui/public/i18n/el/translation.ftl index 7cf3261152..ccda4f2598 100644 --- a/gui/public/i18n/el/translation.ftl +++ b/gui/public/i18n/el/translation.ftl @@ -291,12 +291,18 @@ settings-osc-vrchat-network-trackers-feet = Πόδια ## Setup/onboarding menu +## Quiz + + ## Wi-Fi setup ## Mounting setup +## Install info + + ## Setup start diff --git a/gui/public/i18n/en-x-owo/translation.ftl b/gui/public/i18n/en-x-owo/translation.ftl index a348a27609..9ac71b27cb 100644 --- a/gui/public/i18n/en-x-owo/translation.ftl +++ b/gui/public/i18n/en-x-owo/translation.ftl @@ -739,9 +739,11 @@ onboarding-setup_warning = onboarding-setup_warning-skip = skipy setup onboarding-setup_warning-cancel = continu setup +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = gaww bawwk to intwoduction onboarding-wifi_creds-skip = skipy wi-fi settiwyngs onboarding-wifi_creds-submit = suwbmyt! onboarding-wifi_creds-ssid = @@ -777,6 +779,9 @@ onboarding-reset_tutorial-2 = u need to be on pose wike you awe skiing wike it's shown on da Pawtomatic Meownting wizawd and you have a 3 second deway (configuwabwe) befowe it gets twiggewed. +## Install info + + ## Setup start onboarding-home = wewcome to SwimeVR diff --git a/gui/public/i18n/en/translation.ftl b/gui/public/i18n/en/translation.ftl index abb0aa8d0e..e18de0829a 100644 --- a/gui/public/i18n/en/translation.ftl +++ b/gui/public/i18n/en/translation.ftl @@ -524,8 +524,10 @@ settings-general-steamvr-trackers-right_hand = Right hand settings-general-steamvr-trackers-tracker_toggling = Automatic tracker assignment settings-general-steamvr-trackers-tracker_toggling-description = Automatically handles toggling SteamVR trackers on or off depending on your current tracker assignments. settings-general-steamvr-trackers-tracker_toggling-label = Automatic tracker assignment -settings-general-steamvr-trackers-hands-warning = Warning: hand trackers will override your controllers. - Are you sure? +settings-general-steamvr-trackers-hands-warning = Warning: Enabling the SteamVR hand trackers will disable inputs from real controllers. + This should only be enabled if you are using SlimeVR for hand tracking. + + Are you sure you want to do this? settings-general-steamvr-trackers-hands-warning-cancel = Cancel settings-general-steamvr-trackers-hands-warning-done = Yes @@ -633,6 +635,9 @@ settings-general-fk_settings-enforce_joint_constraints-correct_constraints-descr settings-general-fk_settings-ik = Position data settings-general-fk_settings-ik-use_position = Use Position data settings-general-fk_settings-ik-use_position-description = Enables the use of position data from trackers that provide it. When enabling this make sure to full reset and recalibrate in game. +settings-general-fk_settings-velocity_settings = Velocity Settings +settings-general-fk_settings-velocity_settings-description = Send derived velocity data to SteamVR. Required for Natural Locomotion support. May cause jitter in FBT. +settings-general-fk_settings-velocity_settings-send_derived_velocity = Send derived velocity to driver settings-general-fk_settings-arm_fk = Arm tracking settings-general-fk_settings-arm_fk-description = Force arms to be tracked from the headset (HMD) even if positional hand data is available. settings-general-fk_settings-arm_fk-force_arms = Force arms from HMD @@ -669,7 +674,7 @@ settings-general-fk_settings-self_localization-description = Mocap Mode allows t ## Gesture control settings (tracker tapping) settings-general-gesture_control = Gesture control settings-general-gesture_control-subtitle = Tap based resets -settings-general-gesture_control-description = Allows for resets to be triggered by tapping a tracker. The tracker highest up on your torso is used for Yaw Reset, the tracker highest up on your left leg is used for Full Reset, and the tracker highest up on your right leg is used for Mounting Calibration. Taps must occur within the time limit of 0.3 seconds times the number of taps to be recognized. +settings-general-gesture_control-description = Allows for resets to be triggered by tapping a tracker. Taps must occur within the time limit of 0.3 seconds times the number of taps to be recognized. # This is a unit: 3 taps, 2 taps, 1 tap # $amount (Number) - Amount of taps (touches to the tracker's case) settings-general-gesture_control-taps = { $amount -> @@ -685,12 +690,17 @@ settings-general-gesture_control-trackers = { $amount -> settings-general-gesture_control-yawResetEnabled = Enable tap to yaw reset settings-general-gesture_control-yawResetDelay = Yaw reset delay settings-general-gesture_control-yawResetTaps = Taps for yaw reset +settings-general-gesture_control-yawResetTracker = Yaw Reset Tracker settings-general-gesture_control-fullResetEnabled = Enable tap to full reset settings-general-gesture_control-fullResetDelay = Full reset delay settings-general-gesture_control-fullResetTaps = Taps for full reset +settings-general-gesture_control-fullResetTracker = Full Reset Tracker settings-general-gesture_control-mountingResetEnabled = Enable tap to perform mounting calibration settings-general-gesture_control-mountingResetDelay = Mounting calibration delay settings-general-gesture_control-mountingResetTaps = Taps for mounting calibration +settings-general-gesture_control-mountingResetTracker = Mounting Reset Tracker +settings-general-gesture_control-yawResetTracker-title = Yaw Reset Tracker +settings-general-gesture_control-fullResetTracker-title = Full Reset Tracker # The number of trackers that can have higher acceleration before a tap is rejected settings-general-gesture_control-numberTrackersOverThreshold = Trackers over threshold settings-general-gesture_control-numberTrackersOverThreshold-description = Increase this value if tap detection is not working. Do not increase it above what is needed to make tap detection work as it would cause more false positives. @@ -968,8 +978,50 @@ onboarding-setup_warning = onboarding-setup_warning-skip = Skip setup onboarding-setup_warning-cancel = Continue setup +## Quiz +onboarding-quiz_continue = Continue +onboarding-quiz_back = Back + +onboarding-quiz-more_sets_modal-title = Have you connected all of your trackers? +onboarding-quiz-more_sets_modal-desc = If you have sets of different models, we can connect them right now! +onboarding-quiz-more_sets_modal-confirm = I have connected all my trackers +onboarding-quiz-more_sets_modal-cancel = I want to connect more trackers + +onboarding-quiz-slimeset-title = What type of trackers are you connecting? +onboarding-quiz-slimeset-description = If you have multiple sets, you will be asked again later in the process +onboarding-quiz-slimeset-official-sets = Official SlimeVR Trackers +onboarding-quiz-slimeset-thirdparty-sets = Third-party or DIY Trackers +onboarding-quiz-slimeset-answer-regular = SlimeVR V1.0 & V1.2 +onboarding-quiz-slimeset-answer-butterfly = Butterfly +onboarding-quiz-slimeset-answer-wifi = WiFi-based Slime +onboarding-quiz-slimeset-answer-dongle = Dongle-based Slime + +onboarding-quiz-usage-title = What are you using your trackers for? +onboarding-quiz-usage-description = If you plan on using SlimeVR for multiple purposes, you can change the affected settings later. +onboarding-quiz-usage-answer-VRC = VR Gaming (e.g. VRChat) +onboarding-quiz-usage-answer-mocap_vtubing = Mocap and VTubing + +onboarding-quiz-runtime-title = Do you run games via SteamVR, or on the headset itself (standalone)? +onboarding-quiz-runtime-answer-steamvr = SteamVR +onboarding-quiz-runtime-answer-standalone = Standalone + +onboarding-quiz-mocap_preferences-title = Mocap Preferences +onboarding-quiz-mocap_preferences-desc = Specify how you plan to use SlimeVR for mocap or VTubing +onboarding-quiz-mocap_preferences-playspace-title = What is your playspace? +onboarding-quiz-mocap_preferences-playspace-desc = If standing, SlimeVR will try to track walking movement instead of anchoring you in one spot. +onboarding-quiz-mocap_preferences-playspace-sitting = Sitting +onboarding-quiz-mocap_preferences-playspace-standing = Standing +onboarding-quiz-mocap_preferences-vrm_model-title = Do you have a VRM model? (Optional) +onboarding-quiz-mocap_preferences-vrm_model-desc = Loading a VRM model will improve tracking quality and compatibility with applications that use VMC. +onboarding-quiz-mocap_preferences-head_tracker-title = Are you wearing a tracker or VR headset on your head? +onboarding-quiz-mocap_preferences-head_tracker-yes = Yes +onboarding-quiz-mocap_preferences-head_tracker-no = No +onboarding-quiz-mocap_preferences-head_tracker_location-title = Where is your head tracker located? +onboarding-quiz-mocap_preferences-head_tracker_location-forehead = Forehead +onboarding-quiz-mocap_preferences-head_tracker_location-face = Face + ## Wi-Fi setup -onboarding-wifi_creds-back = Go back to introduction +onboarding-wifi_creds-back-v2 = Go back onboarding-wifi_creds-v2 = Trackers using Wi-Fi # This cares about multilines onboarding-wifi_creds-description-v2 = @@ -1014,6 +1066,11 @@ onboarding-reset_tutorial-2 = Tap the highlighted tracker { $taps } times to tri You need to be in a pose like you are skiing as shown in the Automatic Mounting wizard, and you have a 3 second delay (configurable) before it gets triggered. +## Install info +install-info_udev-rules_modal_title = Hardware udev access rules not found +install-info_udev-rules_warning = Access rules via udev are required for serial console access & dongle connection. Paste the following command into your terminal to add the udev rules. +install-info_udev-rules_modal_button = Close +install-info_udev-rules_modal-dont-show-again_checkbox = Don't show again ## Setup start onboarding-home = Welcome to SlimeVR onboarding-home-start = Let's get set up! @@ -1340,6 +1397,7 @@ onboarding-user_height-description = We need your height to calculate your body onboarding-user_height-need_head_tracker = A headset and controllers with positional tracking are required to perform the calibration. onboarding-user_height-calculate = Calculate my height automatically onboarding-user_height-next_step = Continue and save +onboarding-user_height-prev_step = Back onboarding-user_height-manual-proportions = Manual Proportions onboarding-user_height-calibration-title = Calibration Progress onboarding-user_height-calibration-RECORDING_FLOOR = Touch the floor with the tip of your controller @@ -1502,6 +1560,7 @@ firmware_tool-flashing_step-description = firmware_tool-flashing_step-warning-v2 = Do not unplug or turn off the tracker during the upload process unless told to, it may make your board unusable firmware_tool-flashing_step-flash_more = Flash more trackers firmware_tool-flashing_step-exit = Exit +firmware_tool-flashing_step-onboarding_continue = Continue ## firmware tool build status firmware_tool-build-QUEUED = Waiting to build.... @@ -1635,8 +1694,11 @@ tracking_checklist-FULL_RESET-desc = Some trackers need a reset to be performed. tracking_checklist-STEAMVR_DISCONNECTED = SteamVR not running tracking_checklist-STEAMVR_DISCONNECTED-desc = SteamVR is not running. Are you using it for VR? tracking_checklist-STEAMVR_DISCONNECTED-open = Launch SteamVR +tracking_checklist-STEAMVR_HANDS_ENABLED = Hand trackers toggled on +tracking_checklist-STEAMVR_HANDS_ENABLED-desc = You have enabled the SteamVR virtual hand trackers. This will cause button inputs to not work in SteamVR and in games. +tracking_checklist-STEAMVR_HANDS_ENABLED-go = Disable them tracking_checklist-TRACKERS_REST_CALIBRATION = Calibrate your trackers -tracking_checklist-TRACKERS_REST_CALIBRATION-desc = You didn't perform tracker calibration. Please let your trackers (highlighted in yellow) rest on a stable surface for a few secconds. +tracking_checklist-TRACKERS_REST_CALIBRATION-desc = You didn't perform tracker calibration. Please let your trackers (highlighted in yellow) rest on a stable surface for a few seconds. tracking_checklist-TRACKER_ERROR = Trackers with Errors tracking_checklist-TRACKER_ERROR-desc = Some of your trackers have an error. Please restart the trackers highlighted in yellow. tracking_checklist-VRCHAT_SETTINGS = Configure VRChat settings diff --git a/gui/public/i18n/es-419/translation.ftl b/gui/public/i18n/es-419/translation.ftl index 33323e375c..c0f68535be 100644 --- a/gui/public/i18n/es-419/translation.ftl +++ b/gui/public/i18n/es-419/translation.ftl @@ -10,7 +10,7 @@ websocket-connecting = Cargando... websocket-connection_lost = ¡El servidor falló! websocket-connection_lost-desc = Parece que el servidor de SlimeVR ha dejado de funcionar. Revise los registros y reinicie el programa. -websocket-timedout = No se ha podido conectar al servidor. +websocket-timedout = No se ha podido conectar al servidor websocket-timedout-desc = Parece que el servidor de SlimeVR ha dejado de funcionar o se agotó el tiempo de espera de la conexión. Revise los registros y reinicie el programa websocket-error-close = Salir de SlimeVR websocket-error-logs = Abrir la carpeta de registros @@ -33,6 +33,10 @@ tips-failed_webgl = Fallo al inicializar WebGL. ## Units +unit-meter = Metro +unit-foot = Pie +unit-inch = Pulgada +unit-cm = cm ## Body parts @@ -111,6 +115,11 @@ board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3 board_type-HARITORA = Haritora board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1 board_type-GLOVE_IMU_SLIMEVR_DEV = Guante SlimeVR Dev IMU +board_type-GESTURES = Gestos +board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini +board_type-GENERIC_NRF = nRF Genérico +board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR Dev Butterfly +board_type-SLIMEVR_BUTTERFLY = SlimeVR Butterfly ## Proportions @@ -252,6 +261,10 @@ reset-mounting = Reinicio de montura reset-mounting-feet = Restablecer montura de los pies reset-mounting-fingers = Restablecer montura de los dedos reset-yaw = Reinicio horizontal +reset-error-no_feet_tracker = Tracker de pie sin asignar +reset-error-no_fingers_tracker = Tracker de dedos sin asignar +reset-error-mounting-need_full_reset = Es necesario un reinicio completo antes de montar +reset-error-yaw-need_full_reset = Es necesario un reinicio completo antes del reinicio horizontal ## Serial detection stuff @@ -271,10 +284,12 @@ navbar-trackers_assign = Asignación de sensores navbar-mounting = Calibración de montura navbar-onboarding = Asistente de configuración navbar-settings = Ajustes +navbar-connect_trackers = Conectar Trackers ## Biovision hierarchy recording bvh-start_recording = Grabar BVH +bvh-stop_recording = Guardar grabación BVH bvh-recording = Grabando... bvh-save_title = Guardar grabación BVH @@ -340,6 +355,7 @@ tracker-table-column-name = Nombre tracker-table-column-type = Tipo tracker-table-column-battery = Batería tracker-table-column-ping = Latencia +tracker-table-column-packet_loss = Pérdida de paquetes tracker-table-column-tps = TPS tracker-table-column-temperature = Temp. °C tracker-table-column-linear-acceleration = Accel. X/Y/Z @@ -381,33 +397,36 @@ tracker-infos-magnetometer-status-v1 = [ENABLED] Habilitado *[NOT_SUPPORTED] No soportado } +tracker-infos-packet_loss = Pérdida de paquetes +tracker-infos-packets_lost = Paquetes perdidos +tracker-infos-packets_received = Paquetes recibidos ## Tracker settings -tracker-settings-back = Volver a la lista de sensores -tracker-settings-title = Ajustes de los sensores +tracker-settings-back = Volver a la lista de trackers +tracker-settings-title = Ajustes de los trackers tracker-settings-assignment_section = Asignación tracker-settings-assignment_section-description = Parte del cuerpo asignado al sensor. tracker-settings-assignment_section-edit = Editar asignación tracker-settings-mounting_section = Posición de montura -tracker-settings-mounting_section-description = ¿Dónde está montado el sensor? +tracker-settings-mounting_section-description = ¿Dónde está montado el tracker? tracker-settings-mounting_section-edit = Editar montura tracker-settings-drift_compensation_section = Permitir compensación de desviación -tracker-settings-drift_compensation_section-description = ¿Este sensor deberia compensar la desviación? +tracker-settings-drift_compensation_section-description = ¿Este tracker debería compensar la desviación? tracker-settings-drift_compensation_section-edit = Permitir compensación de desviación tracker-settings-use_mag = Permitir el uso del magnetómetro en este tracker # Multiline! tracker-settings-use_mag-description = ¿Debería este tracker usar el magnetómetro para reducir la desviacion cuando se permite el uso del magnetómetro? ¡Por favor, no apagues tu tracker mientras alternas esto! - + Primero debes permitir el uso del magnetómetro, haga clic aquí para ir al ajuste. tracker-settings-use_mag-label = Permitir el uso del magnetómetro # The . means it's an attribute and it's related to the top key. # In this case that is the settings for the assignment section. -tracker-settings-name_section = Nombre del sensor +tracker-settings-name_section = Nombre del tracker tracker-settings-name_section-description = Dale un apodo bonito :) tracker-settings-name_section-placeholder = Pata izquierda de Manteca -tracker-settings-name_section-label = Nombre del sensor +tracker-settings-name_section-label = Nombre del tracker tracker-settings-forget = Olvidar tracker tracker-settings-forget-description = Remueve el tracker del servidor de SlimeVR y lo previene de conectarse hasta que el servidor se reinicie. La configuración del tracker no se perderá. tracker-settings-forget-label = Olvidar tracker @@ -418,6 +437,9 @@ tracker-settings-update-up_to_date = Actualizado tracker-settings-update-blocked = Actualización no disponible. No hay otras versiones disponibles tracker-settings-update = Actualizar ahora tracker-settings-update-title = Versión del firmware +tracker-settings-current-version = Actual +tracker-settings-latest-version = Último +tracker-settings-build-date = Fecha de fabricación ## Tracker part card info @@ -426,11 +448,11 @@ tracker-part_card-unassigned = Sin asignar ## Body assignment menu -body_assignment_menu = ¿Dónde quieres que esté este sensor? -body_assignment_menu-description = Selecciona la posición donde quieres asignar este sensor. También puedes escoger administrar todos los sensores al mismo tiempo en vez de uno por uno. +body_assignment_menu = ¿Dónde quieres que esté este tracker? +body_assignment_menu-description = Selecciona la posición donde quieres asignar este tracker. También puedes escoger administrar todos los trackers al mismo tiempo en vez de uno por uno. body_assignment_menu-show_advanced_locations = Mostrar posiciones de asignación avanzadas -body_assignment_menu-manage_trackers = Administrar todos los sensores -body_assignment_menu-unassign_tracker = Desasignar sensor +body_assignment_menu-manage_trackers = Administrar todos los trackers +body_assignment_menu-unassign_tracker = Desasignar tracker ## Tracker assignment menu @@ -439,8 +461,8 @@ body_assignment_menu-unassign_tracker = Desasignar sensor # # We are using it here because english doesn't require changing the text in each case but # maybe your language does. --tracker_selection-part = ¿Qué sensor asignar a tu -tracker_selection_menu-NONE = ¿Qué sensor quieres que esté sin asignar? +-tracker_selection-part = ¿Qué tracker asignar a tu +tracker_selection_menu-NONE = ¿Qué tracker quieres que esté sin asignar? tracker_selection_menu-HEAD = { -tracker_selection-part } cabeza? tracker_selection_menu-NECK = { -tracker_selection-part } cuello? tracker_selection_menu-RIGHT_SHOULDER = { -tracker_selection-part } hombro derecho? @@ -463,8 +485,8 @@ tracker_selection_menu-LEFT_UPPER_LEG = { -tracker_selection-part } muslo izquie tracker_selection_menu-LEFT_LOWER_LEG = { -tracker_selection-part } tobillo izquiero? tracker_selection_menu-LEFT_FOOT = { -tracker_selection-part } pie izquierdo? tracker_selection_menu-LEFT_CONTROLLER = { -tracker_selection-part } mando izquierdo? -tracker_selection_menu-unassigned = Sensores sin asignar -tracker_selection_menu-assigned = Sensores asignados +tracker_selection_menu-unassigned = Trackers sin asignar +tracker_selection_menu-assigned = Trackers asignados tracker_selection_menu-dont_assign = No asignar # This line cares about multilines. # text means that the text should be bold. @@ -476,7 +498,7 @@ tracker_selection_menu-neck_warning-cancel = Cancelar ## Mounting menu -mounting_selection_menu = ¿Dónde quieres colocar el sensor? +mounting_selection_menu = ¿Dónde quieres colocar el tracker? mounting_selection_menu-close = Cerrar ## Sidebar settings @@ -484,17 +506,19 @@ mounting_selection_menu-close = Cerrar settings-sidebar-title = Ajustes settings-sidebar-general = General settings-sidebar-steamvr = SteamVR -settings-sidebar-tracker_mechanics = Mecánicas del sensor +settings-sidebar-tracker_mechanics = Mecánicas del tracker settings-sidebar-stay_aligned = Mantente Alineado settings-sidebar-fk_settings = Ajustes de FK settings-sidebar-gesture_control = Control de gestos settings-sidebar-interface = Interfaz settings-sidebar-osc_router = Router OSC -settings-sidebar-osc_trackers = Sensores OSC de VRChat +settings-sidebar-osc_trackers = Trackers OSC de VRChat settings-sidebar-osc_vmc = VMC settings-sidebar-utils = Utilidades settings-sidebar-serial = Consola serial settings-sidebar-appearance = Apariencia +settings-sidebar-home = Pantalla de Inicio +settings-sidebar-checklist = Lista de Tracking settings-sidebar-notifications = Notificaciones settings-sidebar-behavior = Comportamiento settings-sidebar-firmware-tool = Herramienta de firmware DIY @@ -558,12 +582,12 @@ settings-general-tracker_mechanics-drift_compensation-prediction = Compensación # This cares about multilines settings-general-tracker_mechanics-drift_compensation-prediction-description = Predice la desviación horizontal y compensa cuando esta mas allá del rango previamente medido. - Activa esto si el sensor esta girando continuamente en el eje horizontal. + Activa esto si el tracker esta girando continuamente en el eje horizontal. settings-general-tracker_mechanics-drift_compensation-prediction-label = Compensación mediante la predicción del desvío settings-general-tracker_mechanics-drift_compensation_warning = Advertencia: Solo usa la compensación de desviación si necesitas reiniciar muy seguido (cada ~5-10 minutos). - + Algunos IMUs propensos a reinicios frecuentes incluyen: Joy-Cons, owoTrack y MPU (sin un firmware reciente). settings-general-tracker_mechanics-drift_compensation_warning-cancel = Cancelar @@ -580,6 +604,9 @@ settings-general-tracker_mechanics-use_mag_on_all_trackers-description = Usa el magnetómetro en todos los trackers que tienen un firmware compatible, lo que reduce la desviación en entornos magnéticos estables. Se puede desactivar por sensor en la configuración del sensor. ¡Por favor, no apagues ninguno de los trackers mientras activas esta opción! settings-general-tracker_mechanics-use_mag_on_all_trackers-label = Usar magnetómetro en trackers +settings-general-tracker_mechanics-trackers_over_usb = Trackers por USB +settings-general-tracker_mechanics-trackers_over_usb-description = Permite recibir datos de trackers HID por USB. ¡Asegúrate de que los trackers conectados tengan conexión por HID activados! +settings-general-tracker_mechanics-trackers_over_usb-enabled-label = Permite a los trackers HID conectarse directamente por USB settings-stay_aligned = Mantente Alineado settings-stay_aligned-description = Mantente Alineado reduce el desvío ajustando gradualmente tus trackers para calzar tus poses relajadas. settings-stay_aligned-setup-label = Configurar Mantente Alineado @@ -651,7 +678,7 @@ settings-general-fk_settings-skeleton_settings-extended_spine_model = Modelo ext settings-general-fk_settings-skeleton_settings-extended_pelvis_model = Modelo extendido del pelvis settings-general-fk_settings-skeleton_settings-extended_knees_model = Modelo extendido de la rodilla settings-general-fk_settings-skeleton_settings-ratios = Radios del esqueleto -settings-general-fk_settings-skeleton_settings-ratios-description = Cambia los valores de los ajustes del esqueleto. Podes llegar a necesitar reajustar tus proporciones después de cambiar estos valores. +settings-general-fk_settings-skeleton_settings-ratios-description = Cambia los valores de los ajustes del esqueleto. Podrías llegar a necesitar reajustar tus proporciones después de cambiar estos valores. settings-general-fk_settings-skeleton_settings-impute_waist_from_chest_hip = Imputar de la cintura al pecho hasta la cadera settings-general-fk_settings-skeleton_settings-impute_waist_from_chest_legs = Imputar de la cintura al pecho hasta las piernas settings-general-fk_settings-skeleton_settings-impute_hip_from_chest_legs = Imputar de la cadera al pecho hasta las piernas @@ -752,9 +779,9 @@ settings-general-interface-discord_presence-message = settings-interface-behavior-error_tracking = Recopilación de errores a través de Sentry.io settings-interface-behavior-error_tracking-description_v2 =

Consientes a la recopilación de datos de errores anonimizados?

- + No recopilamos información personal como tu dirección IP o credenciales del Wi-Fi. ¡SlimeVR respeta tu privacidad! - + Para proveer la mejor experiencia de usuario, recopilamos reportes de errores anonimizados, métricas de rendimiento, e información del sistema operativo. Esto nos ayuda a detectar errores y problemas con SlimeVR. Estas métricas son recopiladas a través de Sentry.io. settings-interface-behavior-error_tracking-label = Enviar errores a los desarrolladores settings-interface-behavior-bvh_directory = Carpeta para guardar grabaciones de BVH @@ -822,7 +849,7 @@ settings-osc-vrchat-description-v1 = Cambia los ajustes específicos de los trackers OSC utilizados para enviar datos de seguimiento a aplicaciones sin SteamVR (ej. VRChat en Quest). Asegúrate de activar OSC en VRChat a través del menú Acción en OSC > Activado. - Para permitir la recepción de datos del HMD y de los mandos desde VRChat, ve a los ajustes de tu menú principal + Para permitir la recepción de datos del HMD y de los mandos desde VRChat, ve a los ajustes de tu menú principal en Tracking & IK > Permitir el envío de datos OSC de seguimiento de RV de cabeza y muñeca. settings-osc-vrchat-enable = Habilitar settings-osc-vrchat-enable-description = Habilita el envio y recibo de datos. @@ -925,9 +952,15 @@ settings-utils-advanced-open_logs-label = Abrir carpeta ## Home Screen +settings-home-list-layout = Diseño de la lista de Trackers +settings-home-list-layout-desc = Selecciona uno de los posibles diseños de la pantalla de inicio +settings-home-list-layout-grid = Cuadrícula +settings-home-list-layout-table = Tabla ## Tracking Checlist +settings-tracking_checklist-active_steps = Pasos Activos +settings-tracking_checklist-active_steps-desc = Lista de todos los pasos en la lista de tracking. Puedes elegir desactivar pasos específicos. ## Setup/onboarding menu @@ -941,9 +974,52 @@ onboarding-setup_warning = onboarding-setup_warning-skip = Saltar configuración onboarding-setup_warning-cancel = Continuar configuración +## Quiz + +onboarding-quiz_continue = Continuar +onboarding-quiz_back = Atrás +onboarding-quiz-more_sets_modal-title = ¿Has conectado todos tus trackers? +onboarding-quiz-more_sets_modal-desc = Si tienes sets de diferentes modelos, ¡los podemos conectar ahora! +onboarding-quiz-more_sets_modal-confirm = He conectado todos mis trackers +onboarding-quiz-more_sets_modal-cancel = Quiero conectar mas trackers +onboarding-quiz-slimeset-title = ¿Que tipo de trackers estás conectando? +onboarding-quiz-slimeset-description = Si tienes múltiples sets, se te preguntará de nuevo más adelante en el proceso +onboarding-quiz-slimeset-official-sets = Trackers SlimeVR Oficiales +onboarding-quiz-slimeset-thirdparty-sets = Trackers DIY o de terceros +onboarding-quiz-slimeset-answer-regular = SlimeVR V1.0 y V1.2 +onboarding-quiz-slimeset-answer-butterfly = Mariposa +onboarding-quiz-usage-title = ¿Para que usas tus trackers? +onboarding-quiz-usage-description = Si planeas usar SlimeVR para múltiples propósitos, puedes cambiar los ajustes afectados más adelante. +onboarding-quiz-usage-answer-VRC = Juegos VR (por ejemplo, VRChat) +onboarding-quiz-usage-answer-mocap_vtubing = Mocap y VTubing +onboarding-quiz-runtime-title = ¿Ejecutas los juegos por SteamVR o directamente del casco (independiente)? +onboarding-quiz-runtime-answer-steamvr = SteamVR +onboarding-quiz-runtime-answer-standalone = Independiente +onboarding-quiz-mocap_preferences-title = Preferencias de Mocap +onboarding-quiz-mocap_preferences-desc = Especifica cómo planeas usar SlimeVR para mocap o VTubing +onboarding-quiz-mocap_preferences-playspace-title = ¿Cuál es tu espacio de juego? +onboarding-quiz-mocap_preferences-playspace-desc = Si estás de pie, SlimeVR intentará trackear el movimiento al caminar en vez de anclarte en un lugar. +onboarding-quiz-mocap_preferences-playspace-sitting = Sentado +onboarding-quiz-mocap_preferences-playspace-standing = De pie +onboarding-quiz-mocap_preferences-vrm_model-title = ¿Tienes un modelo VRM? (Opcional) +onboarding-quiz-mocap_preferences-vrm_model-desc = Cargar un modelo VRM mejorará la calidad de trackeo y la compatibilidad con aplicaciones que usan VMC. +onboarding-quiz-mocap_preferences-head_tracker-title = ¿Tienes un tracker o un casco VR en tu cabeza? +onboarding-quiz-mocap_preferences-head_tracker-yes = Sí +onboarding-quiz-mocap_preferences-head_tracker-no = No +onboarding-quiz-mocap_preferences-head_tracker_location-title = ¿Donde se encuentra el tracker de cabeza? +onboarding-quiz-mocap_preferences-head_tracker_location-forehead = Frente +onboarding-quiz-mocap_preferences-head_tracker_location-face = Cara + ## Wi-Fi setup -onboarding-wifi_creds-back = Volver a la introducción +onboarding-wifi_creds-back-v2 = Volver a la introducción +onboarding-wifi_creds-v2 = Trackers utilizando Wi-Fi +# This cares about multilines +onboarding-wifi_creds-description-v2 = + La mayoría de trackers (como los trackers oficiales de SlimeVR) utilizan Wi-Fi para conectar al servidor. + Por favor utiliza las credenciales de la red Wi-Fi donde tu dispositivo esta actualmente conectado. + + ¡Asegúrate de utilizar una conexión Wi-Fi 2.4Ghz para tus trackers! onboarding-wifi_creds-skip = Saltar ajustes de Wi-Fi onboarding-wifi_creds-submit = ¡Enviar! onboarding-wifi_creds-ssid = @@ -953,32 +1029,41 @@ onboarding-wifi_creds-ssid-required = Se requiere el nombre del Wi-Fi onboarding-wifi_creds-password = .label = Contraseña .placeholder = Ingresa la contraseña +onboarding-wifi_creds-dongle-title = Trackers utilizando un dongle +onboarding-wifi_creds-dongle-description = ¡Si tus trackers llegaron con un dongle, conéctalo a tu dispositivo y deberías estar listo para usar! +onboarding-wifi_creds-dongle-wip = Esta sección es un trabajo en progreso. Una página dedicada para administrar trackers que se conectan via dongle sera hecha pronto. +onboarding-wifi_creds-dongle-continue = Continuar con un dongle ## Mounting setup onboarding-reset_tutorial-back = Volver a la calibración de montura onboarding-reset_tutorial = Reiniciar tutorial -onboarding-reset_tutorial-explanation = Mientras estés usando tus trackers, estos pueden empezar a desalinearse por el drift horizontal del IMU, o porque los moviste físicamente. Hay varias formas de arreglar este tipo de problemas. +onboarding-reset_tutorial-explanation = Mientras estés usando tus trackers, estos pueden empezar a desalinearse por el desvío horizontal del IMU, o porque los moviste físicamente. Hay varias formas de arreglar este tipo de problemas. onboarding-reset_tutorial-skip = Saltar paso # Cares about multiline onboarding-reset_tutorial-0 = Toca { $taps } veces el tracker resaltado para activar el reinicio horizontal. - + Esto va a hacer que tus sensores miren para la misma dirección que tu HMD. # Cares about multiline onboarding-reset_tutorial-1 = Toca { $taps } veces el tracker resaltado para activar el reinicio completo. - - Se requiere que estas de forma parada (pose en i). Esto tiene un delay de 3 segundos (configurable) antes de que actualmente suceda. - Esto reinicia completamente la posición y rotación de todos tus sensores, debería de arreglar la mayoría de tus problemas. + + Se requiere que estés de pie (pose en i). Esto tiene una demora de 3 segundos (configurable) antes de que realmente suceda. + Esto reinicia completamente la posición y rotación de todos tus trackers. Debería de arreglar la mayoría de los problemas. # Cares about multiline onboarding-reset_tutorial-2 = Toca { $taps } veces el tracker resaltado para activar el reinicio de montura. - + El reinicio de montura ayuda en como tus sensores están puestos en tu cuerpo, ya que si los movistes o cambiaste para donde están orientados bastante, esto debería de ayudar. - + Requiere que estas en una pose como que estás esquiando, como se muestra en el tutorial de montura automática y tenes un retraso de 3 segundos (configurable) antes de que actualmente suceda. +## Install info + +install-info_udev-rules_modal_button = Cerrar +install-info_udev-rules_modal-dont-show-again_checkbox = No mostrar de nuevo + ## Setup start onboarding-home = Bienvenido a SlimeVR @@ -1058,6 +1143,7 @@ onboarding-assignment_tutorial-done = ¡Puse las correas y stickers! onboarding-assign_trackers-back = Volver a las credenciales Wi-Fi onboarding-assign_trackers-title = Asignación de sensores onboarding-assign_trackers-description = Debes escoger dónde van los sensores. Has clic en la ubicación donde quieras colocar un sensor +onboarding-assign_trackers-unassign_all = Des-asignar todos los trackers # Look at translation of onboarding-connect_tracker-connected_trackers on how to use plurals # $assigned (Number) - Trackers that have been assigned a body part # $trackers (Number) - Trackers connected to the server @@ -1203,6 +1289,8 @@ onboarding-automatic_mounting-done-restart = Volver al inicio onboarding-automatic_mounting-mounting_reset-title = Reinicio de montura onboarding-automatic_mounting-mounting_reset-step-0 = 1. Arrodíllate en una posición de «esquiar» con tus piernas dobladas, la parte superior de tu cuerpo inclinada hacia adelante, y tus brazos doblados. onboarding-automatic_mounting-mounting_reset-step-1 = 2. Presiona el botón «Reinicio de montura» y espera 3 segundos hasta que se reinicie la montura. +onboarding-automatic_mounting-mounting_reset-feet-step-0 = 1. Párate de puntillas con ambos pies apuntando hacia el frente. Alternativamente puedes hacerlo sentándote en una silla. +onboarding-automatic_mounting-mounting_reset-feet-step-1 = 2. Presiona el botón "Calibración de pies" y espera por 3 segundos hasta que la orientación de los trackers se reinicie. onboarding-automatic_mounting-preparation-title = Preparación onboarding-automatic_mounting-preparation-v2-step-0 = 1. Presiona el botón «Reinicio completo». onboarding-automatic_mounting-preparation-v2-step-1 = 2. Párate recto con los brazos a tus lados. Asegúrate de mirar hacia adelante. @@ -1214,6 +1302,7 @@ onboarding-automatic_mounting-return-home = Hecho ## Tracker manual proportions setupa +onboarding-manual_proportions-back-scaled = Regresar a Proporciones Escaladas onboarding-manual_proportions-title = Proporciones de cuerpo manuales onboarding-manual_proportions-fine_tuning_button = Ajustar automáticamente las proporciones onboarding-manual_proportions-fine_tuning_button-disabled-tooltip = Por favor conecte un visor VR para utilizar el ajuste automático @@ -1313,6 +1402,31 @@ onboarding-automatic_proportions-smol_warning-cancel = Volver ## User height calibration +onboarding-user_height-title = ¿Cuál es tu altura? +onboarding-user_height-description = Necesitamos tu altura para calcular tus proporciones corporales y representar tus movimientos de manera precisa. Puedes dejar que SlimeVR lo calcule, o puedes ingresar tu altura manualmente. +onboarding-user_height-need_head_tracker = Un casco y controles con rastreo posicional son requeridos para realizar la calibración. +onboarding-user_height-calculate = Calcular mi altura automáticamente +onboarding-user_height-next_step = Continuar y guardar +onboarding-user_height-prev_step = Atrás +onboarding-user_height-manual-proportions = Proporciones Manuales +onboarding-user_height-calibration-title = Progreso de Calibración +onboarding-user_height-calibration-RECORDING_FLOOR = Toca el suelo con la punta de tu control +onboarding-user_height-calibration-WAITING_FOR_RISE = Vuelve a pararte +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK = Vuelve a pararte y mira hacia adelante +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-ok = Asegúrate de que tu cabeza este derecha +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-low = No mires al suelo +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-high = No mires demasiado arriba +onboarding-user_height-calibration-WAITING_FOR_CONTROLLER_PITCH = Asegúrate que el control este apuntando hacia abajo +onboarding-user_height-calibration-RECORDING_HEIGHT = ¡Vuelve a pararte y no te muevas! +onboarding-user_height-calibration-DONE = ¡Éxito! +onboarding-user_height-calibration-ERROR_TIMEOUT = Calibración agotada, inténtalo de nuevo. +onboarding-user_height-calibration-ERROR_TOO_HIGH = La altura del usuario detectada es demasiado alta, inténtalo de nuevo. +onboarding-user_height-calibration-ERROR_TOO_SMALL = La altura del usuario detectada es demasiado baja. Asegúrate de pararte derecho y mirar hacia el frente al final de la calibración. +onboarding-user_height-calibration-error = Calibración Fallida +onboarding-user_height-manual-tip = Mientras ajustas tu altura, intenta poses distintas y ve como el esqueleto se ajusta a tu cuerpo. +onboarding-user_height-reset-warning = + Peligro: Esto reiniciará tus proporciones para ser basadas en tu altura. + ¿Seguro quieres hacer esto? ## Stay Aligned setup @@ -1347,10 +1461,12 @@ onboarding-stay_aligned-previous_step = Atrás onboarding-stay_aligned-next_step = Siguiente onboarding-stay_aligned-restart = Reiniciar onboarding-stay_aligned-done = Hecho +onboarding-stay_aligned-manual_mounting-done = Hecho ## Home home-no_trackers = No hay sensores detectados o asignados +home-settings = Ajustes de la Página de Inicio home-settings-close = Cerrar ## Trackers Still On notification @@ -1396,6 +1512,9 @@ firmware_tool-select_source-firmware = Fuente del Firmware firmware_tool-select_source-version = Versión del Firmware firmware_tool-select_source-official = Oficial firmware_tool-select_source-dev = Desarrollo +firmware_tool-select_source-not_selected = Ninguna fuente seleccionada +firmware_tool-select_source-no_boards = No hay placas disponibles para esta fuente +firmware_tool-select_source-no_versions = No hay versiones disponibles para esta fuente firmware_tool-board_defaults = Configura tu placa firmware_tool-board_defaults-description = Establece los pines o ajustes relativos a tu hardware firmware_tool-board_defaults-add = Añadir @@ -1417,9 +1536,12 @@ firmware_tool-flash_method_step-serial-v2 = firmware_tool-flashbtn_step = Presione el botón de boot firmware_tool-flashbtn_step-description = Antes de pasar al siguiente paso, hay algunas cosas que debe hacer firmware_tool-flashbtn_step-board_SLIMEVR = Apague el sensor, retire la carcasa (si la hay), conecte un cable USB a esta computadora y, a continuación, realice uno de los siguientes pasos de acuerdo con la revisión de la placa SlimeVR: +firmware_tool-flashbtn_step-board_SLIMEVR-r11-v2 = Enciende el tracker mientras haces corto en el segundo pad rectangular de FLASH desde el borde en la parte superior de la placa con el protector metálico del microcontrolador. El LED del tracker debería hacer un parpadeo breve. +firmware_tool-flashbtn_step-board_SLIMEVR-r12-v2 = Enciende el tracker mientras haces corto en pad circular de FLASH en la parte superior de la placa con el protector metálico del microcontrolador. El LED del tracker debería hacer un parpadeo breve. +firmware_tool-flashbtn_step-board_SLIMEVR-r14-v2 = Enciende el tracker mientras pulsas el botón FLASH en la parte superior de la placa. El LED del tracker deberia hacer un parpadeo breve. firmware_tool-flashbtn_step-board_OTHER = Antes de flashear, probablemente tendrá que poner el sensor en modo bootloader. - La mayoría de las veces, esto significa presionar el botón de boot en la placa antes de que comience el proceso de flasheo. Si el proceso de flasheo se agota al comienzo, probablemente significa que el sensor no estaba en modo bootloader. + La mayoría de las veces, esto significa presionar el botón de boot en la placa antes de que comience el proceso de flasheo. Si el proceso de flasheo se agota al comienzo, probablemente significa que el sensor no estaba en modo bootloader. Por favor, consulte las instrucciones de flasheo de su placa para saber cómo activar el modo bootloader. firmware_tool-flash_method_ota-title = Flashear por Wi-Fi firmware_tool-flash_method_ota-devices = Dispositivos OTA detectados: @@ -1432,10 +1554,11 @@ firmware_tool-flash_method_serial-no_devices = No se han detectado dispositivos firmware_tool-build_step = Compilando firmware_tool-build_step-description = El firmware se está compilando, por favor espere firmware_tool-flashing_step = Flasheando -firmware_tool-flashing_step-description = Sus sensores se están flasheando, por favor siga las instrucciones en la pantalla +firmware_tool-flashing_step-description = Sus trackers se están flasheando, por favor siga las instrucciones en la pantalla firmware_tool-flashing_step-warning-v2 = No desconectes o apagues el tracker durante el proceso de subida a menos que se te indique, puede causar que tu placa quede inutilizable. firmware_tool-flashing_step-flash_more = Flashear más sensores firmware_tool-flashing_step-exit = Salir +firmware_tool-flashing_step-onboarding_continue = Continuar ## firmware tool build status @@ -1491,7 +1614,7 @@ tray_or_exit_modal-title = ¿Que debería hacer el botón de cerrar? # Multiline text tray_or_exit_modal-description = Esto te deja escoger si quieres cerrar el servidor o minimizarlo a la bandeja cuando se presiona el botón de cerrar. - + ¡Puedes cambiar esto luego en los ajustes de la interfaz! tray_or_exit_modal-radio-exit = Salir al cerrar tray_or_exit_modal-radio-tray = Minimizar a la bandeja del sistema @@ -1552,16 +1675,69 @@ vrc_config-avatar_measurement_type-ARM_SPAN = Longitud del brazo error_collection_modal-title = ¿Podemos recopilar errores? error_collection_modal-description_v2 = { settings-interface-behavior-error_tracking-description_v2 } - + Tu puedes cambiar esta configuración más tarde en la sección de comportamiento de la pagina de configuración. error_collection_modal-confirm = Acepto error_collection_modal-cancel = No quiero ## Tracking checklist section +tracking_checklist = Lista de Tracking +tracking_checklist-settings = Ajustes de la Lista de Tracking tracking_checklist-settings-close = Cerrar +tracking_checklist-status-incomplete = ¡No estás listo para usar SlimeVR! +tracking_checklist-status-partial = + { $count -> + [one] ¡Tienes 1 advertencia! + [many] ¡Tienes { $count } advertencias! + *[other] { "" } + } +tracking_checklist-status-complete = ¡Estás listo para usar SlimeVR! +tracking_checklist-MOUNTING_CALIBRATION = Realizar una calibración de montura +tracking_checklist-FEET_MOUNTING_CALIBRATION = Realizar una calibración de montura de los pies +tracking_checklist-FULL_RESET = Realizar un reinicio completo +tracking_checklist-FULL_RESET-desc = Algunos trackers necesitan realizar un reinicio. +tracking_checklist-STEAMVR_DISCONNECTED = SteamVR no se está ejecutando +tracking_checklist-STEAMVR_DISCONNECTED-desc = SteamVR no se esta ejecutando. ¿Lo estas usando para VR? tracking_checklist-STEAMVR_DISCONNECTED-open = Abrir SteamVR +tracking_checklist-STEAMVR_HANDS_ENABLED = Trackers de mano activados +tracking_checklist-STEAMVR_HANDS_ENABLED-desc = Has activado los trackers de mano virtuales de SteamVR. Esto causará que los botones no funcionarán en SteamVR ni en juegos. +tracking_checklist-STEAMVR_HANDS_ENABLED-go = Desactívalos tracking_checklist-TRACKERS_REST_CALIBRATION = Calibra tus trackers +tracking_checklist-TRACKERS_REST_CALIBRATION-desc = No realizaste una calibración para los trackers. Por favor deja reposar tus trackers (resaltados en amarillo) en una superficie estable por unos segundos. +tracking_checklist-TRACKER_ERROR = Trackers con Errores +tracking_checklist-TRACKER_ERROR-desc = Algunos de tus trackers tienen un error. Por favor reinicia el tracker resaltado en amarillo. +tracking_checklist-VRCHAT_SETTINGS = Configurar ajustes de VRChat +tracking_checklist-VRCHAT_SETTINGS-desc = ¡Tienes ajustes mal puestos en VRChat! Esto puede impactar negativamente tu tracking. +tracking_checklist-VRCHAT_SETTINGS-open = Ir a Advertencias de VRChat +tracking_checklist-UNASSIGNED_HMD = Casco VR sin asignar a Cabeza +tracking_checklist-UNASSIGNED_HMD-desc = El casco VR debería estar asignado como un tracker de cabeza. +tracking_checklist-NETWORK_PROFILE_PUBLIC = Cambia tu perfil de red +tracking_checklist-NETWORK_PROFILE_PUBLIC-desc = + { $count -> + [one] + Tu perfil de red esta actualmente configurado como Público ({ $adapters }). + Esto no es recomendado para el correcto funcionamiento de SlimeVR. + Ve como arreglarlo aquí + [many] + Algunos de tus adaptadores de red están configurados como públicos: + { $adapters } + Esto no es recomendado para el correcto funcionamiento de SlimeVR. + Ve como arreglarlo aquí + *[other] { "" } + } +tracking_checklist-NETWORK_PROFILE_PUBLIC-open = Abrir Panel de Control +tracking_checklist-STAY_ALIGNED_CONFIGURED = Configurar Stay Aligned +tracking_checklist-STAY_ALIGNED_CONFIGURED-desc = Graba las poses de Stay Aligned para reducir el desvío +tracking_checklist-STAY_ALIGNED_CONFIGURED-open = Abrir el ayudante de Stay Aligned tracking_checklist-ignore = Ignorar +preview-mocap_mode_soon = Modo Mocap (Pronto™) +preview-disable_render = Desactivar renderizado +preview-disabled_render = Renderizado desactivado +toolbar-mounting_calibration = Calibración de montura +toolbar-mounting_calibration-default = Cuerpo +toolbar-mounting_calibration-feet = Pies +toolbar-mounting_calibration-fingers = Dedos +toolbar-drift_reset = Reinicio de Desviación toolbar-assigned_trackers = { $count } trackers asignados toolbar-unassigned_trackers = { $count } trackers sin asignar diff --git a/gui/public/i18n/es-ES/translation.ftl b/gui/public/i18n/es-ES/translation.ftl index 3ecac8012c..0604c5a1a0 100644 --- a/gui/public/i18n/es-ES/translation.ftl +++ b/gui/public/i18n/es-ES/translation.ftl @@ -33,6 +33,10 @@ tips-failed_webgl = No se pudo iniciar WebGL. ## Units +unit-meter = Metro +unit-foot = Pie +unit-inch = Pulgada +unit-cm = cm ## Body parts @@ -241,10 +245,12 @@ navbar-trackers_assign = Asignación de trackers navbar-mounting = Calibración de montura navbar-onboarding = Asistente de Configuración navbar-settings = Configuración +navbar-connect_trackers = Conectar Trackers ## Biovision hierarchy recording bvh-start_recording = Grabar BVH +bvh-stop_recording = Guardar grabación BVH bvh-recording = Grabando... bvh-save_title = Guardar grabación BVH @@ -381,6 +387,7 @@ tracker-settings-name_section-label = Nombre del tracker tracker-settings-forget = Olvidar tracker tracker-settings-forget-description = Elimina el tracker del servidor SlimeVR y evita que se conecte a él hasta que se reinicie el servidor. La configuración del tracker no se perderá. tracker-settings-forget-label = Olvidar tracker +tracker-settings-update-incompatible = No se puede actualizar. Versión de placa o firmware incompatible tracker-settings-update-low-battery = No se puede actualizar. Batería inferior al 50% tracker-settings-update-up_to_date = Actualizado tracker-settings-update-blocked = Actualización no disponible. No hay otras versiones disponibles @@ -451,6 +458,7 @@ mounting_selection_menu-close = Cerrar settings-sidebar-title = Configuración settings-sidebar-general = General +settings-sidebar-steamvr = SteamVR settings-sidebar-tracker_mechanics = Mecánicas del tracker settings-sidebar-stay_aligned = Mantener Alineado settings-sidebar-fk_settings = Configuración del tracking @@ -896,9 +904,11 @@ onboarding-setup_warning = onboarding-setup_warning-skip = Omitir configuración onboarding-setup_warning-cancel = Continuar con la configuración +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = Volver a la introducción onboarding-wifi_creds-skip = Omitir configuración Wi-Fi onboarding-wifi_creds-submit = ¡Enviar! onboarding-wifi_creds-ssid = @@ -934,6 +944,9 @@ onboarding-reset_tutorial-2 = Necesitas estar en una pose como si estuvieras esquiando como se muestra en el asistente de montaje automático y tienes un retraso de 3 segundos (configurable) antes de que se active. +## Install info + + ## Setup start onboarding-home = Bienvenido a SlimeVR diff --git a/gui/public/i18n/et/translation.ftl b/gui/public/i18n/et/translation.ftl index 99f5c768d2..349517d628 100644 --- a/gui/public/i18n/et/translation.ftl +++ b/gui/public/i18n/et/translation.ftl @@ -556,9 +556,11 @@ onboarding-setup_warning = onboarding-setup_warning-skip = Jäta seadistamine vahele onboarding-setup_warning-cancel = Jätka seadistamist +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = Mine tagasi juhistele onboarding-wifi_creds-skip = Jätke Wi-Fi seaded vahele. onboarding-wifi_creds-submit = Jätka! onboarding-wifi_creds-ssid = @@ -593,6 +595,9 @@ onboarding-reset_tutorial-2 = Peate olema poosis, nagu suusatate, nagu see on näidatud automaatse paigaldamise viisardil, ja teil on 3-sekundiline viivitus (konfigureeritav), enne kui see käivitub. +## Install info + + ## Setup start onboarding-home = Tere tulemast SlimeVR-i diff --git a/gui/public/i18n/fi/translation.ftl b/gui/public/i18n/fi/translation.ftl index 74fd9f65d2..59b4da0174 100644 --- a/gui/public/i18n/fi/translation.ftl +++ b/gui/public/i18n/fi/translation.ftl @@ -589,9 +589,11 @@ onboarding-setup_warning = onboarding-setup_warning-skip = Ohita asennus onboarding-setup_warning-cancel = Jatka asennusta +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = Palaa esittelyyn onboarding-wifi_creds-skip = Ohita Wi-Fi-asetukset onboarding-wifi_creds-submit = Lähetä! onboarding-wifi_creds-ssid = @@ -626,6 +628,9 @@ onboarding-reset_tutorial-2 = Sinun on oltava hiihto-asennossa, kuten on näytettynä Automaattisessa Asennuksessa ja sinulla on 3 sekunnin viive (muokattavissa), ennen kuin se käynnistyy. +## Install info + + ## Setup start onboarding-home = Tervetuloa SlimeVR:ään diff --git a/gui/public/i18n/fr/translation.ftl b/gui/public/i18n/fr/translation.ftl index 2969dc2f2a..8593ca40de 100644 --- a/gui/public/i18n/fr/translation.ftl +++ b/gui/public/i18n/fr/translation.ftl @@ -7,7 +7,7 @@ ## Websocket (server) status -websocket-connecting = Connexion au serveur +websocket-connecting = Chargement... websocket-connection_lost = Connexion avec le serveur perdue. Reconnexion... websocket-connection_lost-desc = Il semble que le serveur SlimeVR ait planté. Vérifiez les logs et redémarrez le programme. websocket-timedout = Impossible de se connecter au serveur @@ -33,7 +33,7 @@ tips-failed_webgl = Échec de l'initialisation de WebGL. ## Units -unit-meter = Metre +unit-meter = Mètre unit-foot = Pied unit-inch = Pouce unit-cm = cm @@ -115,6 +115,11 @@ board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3 board_type-HARITORA = Haritora board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1 board_type-GLOVE_IMU_SLIMEVR_DEV = SlimeVR Dev IMU Glove +board_type-GESTURES = Gestes +board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini +board_type-GENERIC_NRF = nRF Générique +board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR Dev Butterfly +board_type-SLIMEVR_BUTTERFLY = SlimeVR Butterfly ## Proportions @@ -225,7 +230,7 @@ skeleton_bone-LOWER_ARM-desc = skeleton_bone-HAND_Y = Distance Y des mains skeleton_bone-HAND_Y-desc = Ceci est la distance verticale entre vos poignets et le milieu de vos main. - Pour l’ajuster pour la capture de mouvement, ajustez correctement la longueur des bras et modifiez-la jusqu’à ce que votre + Pour l’ajuster pour la capture de mouvement, ajustez correctement la longueur des bras et modifiez-la jusqu’à ce que vos capteurs de main soient alignés verticalement avec le milieu de vos mains. Pour l’ajuster pour le suivi des coudes à partir de vos manettes, réglez la longueur des bras à 0 et modifiez-la jusqu’à ce que vos capteurs de coude soient alignés verticalement avec vos poignets. @@ -256,9 +261,10 @@ reset-mounting = Réinitialiser l'alignement reset-mounting-feet = Réinitialiser l'alignement des pieds reset-mounting-fingers = Réinitialiser l'alignement des doigts reset-yaw = Réinitialisation horizontale -reset-error-no_feet_tracker = Aucun traqueur de pieds n’est assigné -reset-error-no_fingers_tracker = Aucun traqueur de doigts n'est assigné +reset-error-no_feet_tracker = Aucun capteur de pieds n’est assigné +reset-error-no_fingers_tracker = Aucun capteur de doigts n'est assigné reset-error-mounting-need_full_reset = Nécessite une réinitialisation complète avant de le monter +reset-error-yaw-need_full_reset = Nécessite une réinitialisation complète avant une réinitialisation horizontale ## Serial detection stuff @@ -278,6 +284,7 @@ navbar-trackers_assign = Attribution des capteurs navbar-mounting = Alignement des capteurs navbar-onboarding = Assistant de configuration navbar-settings = Réglages +navbar-connect_trackers = Connecter les capteurs ## Biovision hierarchy recording @@ -348,6 +355,7 @@ tracker-table-column-name = Nom tracker-table-column-type = Type tracker-table-column-battery = Batterie tracker-table-column-ping = Ping +tracker-table-column-packet_loss = Pertes de paquets tracker-table-column-tps = TPS tracker-table-column-temperature = Temp. °C tracker-table-column-linear-acceleration = Accél. X/Y/Z @@ -389,6 +397,9 @@ tracker-infos-magnetometer-status-v1 = [ENABLED] Activé *[NOT_SUPPORTED] Non pris en charge } +tracker-infos-packet_loss = Pertes de paquets +tracker-infos-packets_lost = Paquets perdus +tracker-infos-packets_received = Paquets reçus ## Tracker settings @@ -426,6 +437,9 @@ tracker-settings-update-up_to_date = À jour tracker-settings-update-blocked = Mise à jour non disponible. Aucune autre version disponible tracker-settings-update = Mettre à jour maintenant tracker-settings-update-title = Version du micrologiciel +tracker-settings-current-version = Actuel +tracker-settings-latest-version = Dernière version +tracker-settings-build-date = Date de build ## Tracker part card info @@ -504,6 +518,7 @@ settings-sidebar-utils = Utilitaires settings-sidebar-serial = Console série settings-sidebar-appearance = Apparence settings-sidebar-home = Ecran d'accueil +settings-sidebar-checklist = Checklist de suivi settings-sidebar-notifications = Notifications settings-sidebar-behavior = Comportement settings-sidebar-firmware-tool = Outil de micrologiciel DIY @@ -589,6 +604,9 @@ settings-general-tracker_mechanics-use_mag_on_all_trackers-description = Utilise le magnétomètre sur tous les capteurs dotés d'un micrologiciel compatible, réduisant ainsi la dérive dans des environnements magnétiques stables. Peut être désactivé par capteur dans les paramètres du capteur. Ne fermez aucun des capteurs en changeant cette option ! settings-general-tracker_mechanics-use_mag_on_all_trackers-label = Utiliser le magnétomètre sur les capteurs +settings-general-tracker_mechanics-trackers_over_usb = Capteurs via USB +settings-general-tracker_mechanics-trackers_over_usb-description = Permet de recevoir des données de suivi HID via USB. Assurez-vous que les capteurs connectés ont la connexion via HID activée ! +settings-general-tracker_mechanics-trackers_over_usb-enabled-label = Permettre aux capteurs HID de se connecter directement via USB settings-stay_aligned = Garder Aligné settings-stay_aligned-description = Garder Aligné réduit la dérive en ajustant progressivement vos capteurs pour qu’ils correspondent à vos postures détendues. settings-stay_aligned-setup-label = Configurer Garder Aligné @@ -851,7 +869,7 @@ settings-osc-vrchat-network-port_out = settings-osc-vrchat-network-address = Adresse réseau settings-osc-vrchat-network-address-description-v1 = Choisissez l'adresse à laquelle envoyer des données. Peut être laissé intact pour VRChat. settings-osc-vrchat-network-address-placeholder = Adresse IP VRChat -settings-osc-vrchat-network-trackers = capteurs +settings-osc-vrchat-network-trackers = Capteurs settings-osc-vrchat-network-trackers-description = Sélectionner quels capteurs envoyer via OSC. settings-osc-vrchat-network-trackers-chest = Poitrine settings-osc-vrchat-network-trackers-hip = Hanche @@ -931,11 +949,15 @@ settings-utils-advanced-open_logs-label = Ouvrir le dossier ## Home Screen +settings-home-list-layout = Disposition de la liste des capteurs settings-home-list-layout-desc = Sélectionnez l'une des dispositions possibles de l'écran d'accueil +settings-home-list-layout-grid = Grille +settings-home-list-layout-table = Tableau ## Tracking Checlist settings-tracking_checklist-active_steps = Etapes actives +settings-tracking_checklist-active_steps-desc = Liste de toutes les étapes de la checklist de suivi. Vous pouvez choisir de désactiver certaines étapes. ## Setup/onboarding menu @@ -949,9 +971,18 @@ onboarding-setup_warning = onboarding-setup_warning-skip = Passer la configuration onboarding-setup_warning-cancel = Continuer la configuration +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = Retour à l'introduction +onboarding-wifi_creds-v2 = Capteurs utilisant le Wi-Fi +# This cares about multilines +onboarding-wifi_creds-description-v2 = + La plupart des capteurs (comme les capteurs officiels SlimeVR) utilisent le Wi-Fi pour se connecter au serveur. + Veuillez utiliser les identifiants du réseau Wi-Fi auquel votre appareil est actuellement connecté. + + Assurez-vous d’utiliser une connexion Wi-Fi 2,4 GHz pour vos capteurs ! onboarding-wifi_creds-skip = Passer configuration Wi-Fi onboarding-wifi_creds-submit = Valider onboarding-wifi_creds-ssid = @@ -961,6 +992,10 @@ onboarding-wifi_creds-ssid-required = Le nom du Wi-Fi est requis onboarding-wifi_creds-password = .label = Mot de passe du Wi-Fi .placeholder = Mot de passe +onboarding-wifi_creds-dongle-title = Capteurs utilisant un dongle +onboarding-wifi_creds-dongle-description = Si vos capteurs ont été livrés avec un dongle, branchez-le à votre appareil et vous devriez être prêt ! +onboarding-wifi_creds-dongle-wip = Cette section est en cours de développement. Une page dédiée à la gestion des capteurs connectés via un dongle sera bientôt créée. +onboarding-wifi_creds-dongle-continue = Continuer avec un dongle ## Mounting setup @@ -987,6 +1022,9 @@ onboarding-reset_tutorial-2 = Accroupissez-vous dans une position de « ski » comme affiché sur l'assistant d'alignement des capteurs. Vous aurez un délai de 3 secondes (configurable) avant la réinitialisation. +## Install info + + ## Setup start onboarding-home = Bienvenue sur SlimeVR @@ -1066,6 +1104,7 @@ onboarding-assignment_tutorial-done = J'ai mis les autocollants et les sangles ! onboarding-assign_trackers-back = Revenir aux identifiants Wi-Fi onboarding-assign_trackers-title = Attribuer des capteurs onboarding-assign_trackers-description = Choisissons où mettre chaque capteur. +onboarding-assign_trackers-unassign_all = Désattribuer tout les capteurs # Look at translation of onboarding-connect_tracker-connected_trackers on how to use plurals # $assigned (Number) - Trackers that have been assigned a body part # $trackers (Number) - Trackers connected to the server @@ -1210,6 +1249,8 @@ onboarding-automatic_mounting-done-restart = Retourner au début onboarding-automatic_mounting-mounting_reset-title = Réinitialisation de l'alignement onboarding-automatic_mounting-mounting_reset-step-0 = 1. Accroupissez-vous dans une pose de "ski" avec les jambes pliées, le haut du corps incliné vers l'avant et les bras pliés. onboarding-automatic_mounting-mounting_reset-step-1 = 2. Appuyez sur le bouton "Réinitialiser l'alignement" et attendez 3 secondes avant que l'alignement des capteurs se calibre. +onboarding-automatic_mounting-mounting_reset-feet-step-0 = 1. Mettez-vous sur la pointe des pieds, les deux pieds pointés vers l’avant. Vous pouvez aussi le faire assis sur une chaise. +onboarding-automatic_mounting-mounting_reset-feet-step-1 = 2. Appuyez sur le bouton « Calibration des pieds » et attendez 3 secondes avant que l’orientation de l'alignement des capteurs ne se réinitialise. onboarding-automatic_mounting-preparation-title = Préparation onboarding-automatic_mounting-preparation-v2-step-0 = 1. Appuyez sur le bouton « Réinitialisation complète ». onboarding-automatic_mounting-preparation-v2-step-1 = 2. Tenez-vous droit debout, les bras le long du corps. Assurez-vous de regarder vers l’avant. @@ -1221,6 +1262,7 @@ onboarding-automatic_mounting-return-home = Terminé ## Tracker manual proportions setupa +onboarding-manual_proportions-back-scaled = Retour aux proportions mises à l'échelle onboarding-manual_proportions-title = Proportions manuelles du corps onboarding-manual_proportions-fine_tuning_button = Automatiquement ajuster les proportions onboarding-manual_proportions-fine_tuning_button-disabled-tooltip = Veuillez connecter un casque VR pour utiliser l'ajustement automatique @@ -1321,9 +1363,29 @@ onboarding-automatic_proportions-smol_warning-cancel = Retour ## User height calibration onboarding-user_height-title = Quelle est votre taille ? +onboarding-user_height-description = Nous avons besoin de votre taille pour calculer les proportions de votre corps ainsi que pour représenter précisément vos mouvements. Vous pouvez laisser SlimeVR la calculer ou entrer votre taille manuellement. onboarding-user_height-need_head_tracker = Un casque VR (ou capteur de tête) et des manettes à position absolue sont nécessaires pour calculer votre taille. onboarding-user_height-calculate = Calculer ma taille automatiquement onboarding-user_height-next_step = Continuer et enregistrer +onboarding-user_height-manual-proportions = Proportions manuelles +onboarding-user_height-calibration-title = Progression de la calibration +onboarding-user_height-calibration-RECORDING_FLOOR = Touchez le sol avec l'extrémité de votre contrôleur +onboarding-user_height-calibration-WAITING_FOR_RISE = Relevez-vous +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK = Relevez-vous et regardez droit devant vous +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-ok = Assurez-vous que votre tête est bien droite +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-low = Ne regardez pas vers le sol +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-high = Ne regardez pas trop haut +onboarding-user_height-calibration-WAITING_FOR_CONTROLLER_PITCH = Assurez-vous que votre manette pointe vers le bas +onboarding-user_height-calibration-RECORDING_HEIGHT = Relevez-vous et restez immobile ! +onboarding-user_height-calibration-DONE = Succès ! +onboarding-user_height-calibration-ERROR_TIMEOUT = Délais de calibration expiré, veuillez réessayer. +onboarding-user_height-calibration-ERROR_TOO_HIGH = La taille détectée est trop grande, veuillez réessayez. +onboarding-user_height-calibration-ERROR_TOO_SMALL = La taille détectée est trop petite. Veuillez rester droit et regardez devant vous à la fin de la calibration. +onboarding-user_height-calibration-error = Calibration échouée +onboarding-user_height-manual-tip = En ajustant votre taille, essayez différentes poses et regardez comment le squelette suit vos mouvements. +onboarding-user_height-reset-warning = + Attention : Cette action réinitialisera vos proportions pour être basées sur votre taille. + Êtes-vous sûr de vouloir continuer ? ## Stay Aligned setup @@ -1358,10 +1420,13 @@ onboarding-stay_aligned-previous_step = Précédent onboarding-stay_aligned-next_step = Prochain onboarding-stay_aligned-restart = Recommencer onboarding-stay_aligned-done = Fait +onboarding-stay_aligned-manual_mounting-done = Terminé ## Home home-no_trackers = Aucun capteur détecté ou attribué +home-settings = Paramètres de la page d'accueil +home-settings-close = Fermer ## Trackers Still On notification @@ -1406,6 +1471,9 @@ firmware_tool-select_source-firmware = Source du micrologiciel firmware_tool-select_source-version = Version du micrologiciel firmware_tool-select_source-official = Officiel firmware_tool-select_source-dev = Dev +firmware_tool-select_source-not_selected = Aucune source sélectionnée +firmware_tool-select_source-no_boards = Aucune carte disponible pour cette source +firmware_tool-select_source-no_versions = Aucune version disponible pour cette source firmware_tool-board_defaults = Configurez votre carte firmware_tool-board_defaults-description = Réglez les broches ou réglages pour votre matériel firmware_tool-board_defaults-add = Ajouter @@ -1427,6 +1495,9 @@ firmware_tool-flash_method_step-serial-v2 = firmware_tool-flashbtn_step = Appuyez sur le bouton boot firmware_tool-flashbtn_step-description = Avant de passer à l'étape suivante, il y a quelques choses que vous devez faire firmware_tool-flashbtn_step-board_SLIMEVR = Éteignez le capteur, retirez le boîtier (s'il y en a un), connectez un câble USB à votre ordinateur, puis effectuez l'une des étapes suivantes en fonction de la révision de votre carte SlimeVR : +firmware_tool-flashbtn_step-board_SLIMEVR-r11-v2 = Allumez le capteur tout en court-circuitant le second pad FLASH rectangulaire à partir du bord en haut de la carte jusqu’à la protection métallique du microcontrôleur. La LED du capteur devrait faire un clignotement rapide. +firmware_tool-flashbtn_step-board_SLIMEVR-r12-v2 = Allumez le capteur tout en court-circuitant le pad FLASH circulaire sur le dessus de la carte à la protection métallique du microcontrôleur. La LED du capteur devrait faire un clignotement rapide. +firmware_tool-flashbtn_step-board_SLIMEVR-r14-v2 = Allumez le capteur tout en appuyant sur le bouton FLASH sur le dessus de la carte. La LED du capteur devrait faire un clignotement brièvement. firmware_tool-flashbtn_step-board_OTHER = Avant de flash le capteur, vous devrez probablement le mettre en mode bootloader. La plupart du temps, il s'agit d'appuyer sur le bouton boot de la carte avant que le processus de flash ne commence. @@ -1569,3 +1640,54 @@ error_collection_modal-cancel = Je ne veux pas ## Tracking checklist section +tracking_checklist = Checklist de suivi +tracking_checklist-settings = Paramètres de lachecklist de suivi +tracking_checklist-settings-close = Fermer +tracking_checklist-status-incomplete = Vous n’êtes pas prêt à utiliser SlimeVR ! +tracking_checklist-status-partial = + { $count -> + [one] Vous avez 1 avertissement ! + *[other] Vous avez { $count } avertissements ! + } +tracking_checklist-status-complete = Vous êtes prêt à utiliser SlimeVR ! +tracking_checklist-MOUNTING_CALIBRATION = Effectuer une calibration de l'alignement +tracking_checklist-FEET_MOUNTING_CALIBRATION = Effectuer une calibration de l'alignement des pieds +tracking_checklist-FULL_RESET = Faire une réinitialisation complète +tracking_checklist-FULL_RESET-desc = Certains capteurs nécessitent une réinitialisation. +tracking_checklist-STEAMVR_DISCONNECTED = SteamVR n'est pas lancé +tracking_checklist-STEAMVR_DISCONNECTED-desc = SteamVR n'est pas lancé. L’utilisez-vous pour la VR ? +tracking_checklist-STEAMVR_DISCONNECTED-open = Lancer SteamVR +tracking_checklist-TRACKERS_REST_CALIBRATION = Calibrer vos capteurs +tracking_checklist-TRACKERS_REST_CALIBRATION-desc = Vous n’avez pas fait de calibration de capteur. Veuillez laisser vos capteurs (surlignés en jaune) reposer sur une surface stable pendant quelques seconds. +tracking_checklist-TRACKER_ERROR = Capteurs avec erreur +tracking_checklist-TRACKER_ERROR-desc = Certains de vos capteurs ont une erreur. Veuillez redémarrer les capteurs surlignés en jaune. +tracking_checklist-VRCHAT_SETTINGS = Configurez les paramètres de VRChat +tracking_checklist-VRCHAT_SETTINGS-desc = Vous avez mal configuré les paramètres de VRChat ! Cela peut dégrader votre suivi. +tracking_checklist-VRCHAT_SETTINGS-open = Aller sur les avertissements de VRChat +tracking_checklist-UNASSIGNED_HMD = Casque VR non attribué à la tête +tracking_checklist-UNASSIGNED_HMD-desc = Le casque VR devrait être attribué en tant que capteur de la tête. +tracking_checklist-NETWORK_PROFILE_PUBLIC = Modifier votre profil de réseau +tracking_checklist-NETWORK_PROFILE_PUBLIC-desc = + { $count -> + [one] Votre profil de réseau est actuellement défini comme étant public. Ce n’est pas recommandé pour le fonctionnement correct de SlimeVR. Voyez comment y remédier ici. + *[other] + Certains de vos adaptateurs réseau sont réglés sur public : + { $adapters } + Ce n’est pas recommandé pour que SlimeVR fonctionne correctement. + Voyez comment y remédier ici. + } +tracking_checklist-NETWORK_PROFILE_PUBLIC-open = Ouvrir le panneau de configuration +tracking_checklist-STAY_ALIGNED_CONFIGURED = Configurer Garder Aligné +tracking_checklist-STAY_ALIGNED_CONFIGURED-desc = Enregistrez les poses Garder Aligné pour réduire la dérive +tracking_checklist-STAY_ALIGNED_CONFIGURED-open = Ouvrir l'assistant de Garder Aligné +tracking_checklist-ignore = Ignorer +preview-mocap_mode_soon = Mode Mocap (Bientôt™) +preview-disable_render = Désactiver le rendu +preview-disabled_render = Rendu désactivé +toolbar-mounting_calibration = Calibration de l'alignement +toolbar-mounting_calibration-default = Corps +toolbar-mounting_calibration-feet = Pieds +toolbar-mounting_calibration-fingers = Doigts +toolbar-drift_reset = Réinitialisation de la dérive +toolbar-assigned_trackers = { $count } capteurs assignés +toolbar-unassigned_trackers = { $count } capteurs non assignés diff --git a/gui/public/i18n/he/translation.ftl b/gui/public/i18n/he/translation.ftl index 7d62efbe74..be53bd98b1 100644 --- a/gui/public/i18n/he/translation.ftl +++ b/gui/public/i18n/he/translation.ftl @@ -291,12 +291,18 @@ settings-osc-vrchat-network-trackers-elbows = מרפקים ## Setup/onboarding menu +## Quiz + + ## Wi-Fi setup ## Mounting setup +## Install info + + ## Setup start diff --git a/gui/public/i18n/it/translation.ftl b/gui/public/i18n/it/translation.ftl index cf9ef140db..c54b36eb8f 100644 --- a/gui/public/i18n/it/translation.ftl +++ b/gui/public/i18n/it/translation.ftl @@ -907,9 +907,11 @@ onboarding-setup_warning = onboarding-setup_warning-skip = Salta la configurazione onboarding-setup_warning-cancel = Continua la configurazione +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = Torna all'introduzione onboarding-wifi_creds-skip = Salta impostazioni Wi-Fi onboarding-wifi_creds-submit = Conferma! onboarding-wifi_creds-ssid = @@ -945,6 +947,9 @@ onboarding-reset_tutorial-2 = Devi essere in posa come se stessi sciando come mostrato nella procedura guidata di posizionamento automatico e ha un ritardo di 3 secondi (configurabile) prima che venga attivato. +## Install info + + ## Setup start onboarding-home = Benvenuti a SlimeVR diff --git a/gui/public/i18n/ja/translation.ftl b/gui/public/i18n/ja/translation.ftl index ed7bc5ef54..1cc09dd31b 100644 --- a/gui/public/i18n/ja/translation.ftl +++ b/gui/public/i18n/ja/translation.ftl @@ -574,9 +574,11 @@ onboarding-wip = 実行中 onboarding-setup_warning-skip = セットアップをスキップする onboarding-setup_warning-cancel = セットアップを続行する +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = 戻る onboarding-wifi_creds-skip = Wi-Fi設定をスキップする onboarding-wifi_creds-submit = 実行! onboarding-wifi_creds-ssid = @@ -592,6 +594,9 @@ onboarding-reset_tutorial-back = マウントキャリブレーションに戻 onboarding-reset_tutorial = リセットチュートリアル onboarding-reset_tutorial-skip = ステップをスキップする +## Install info + + ## Setup start onboarding-home = SlimeVRへようこそ diff --git a/gui/public/i18n/ko/translation.ftl b/gui/public/i18n/ko/translation.ftl index 0f41d9be86..a168aa220b 100644 --- a/gui/public/i18n/ko/translation.ftl +++ b/gui/public/i18n/ko/translation.ftl @@ -740,9 +740,11 @@ onboarding-setup_warning = onboarding-setup_warning-skip = 설정 건너뛰기 onboarding-setup_warning-cancel = 설정 계속하기 +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = 처음으로 돌아가기 onboarding-wifi_creds-skip = Wi-Fi 설정 건너뛰기 onboarding-wifi_creds-submit = 저장! onboarding-wifi_creds-ssid = @@ -778,6 +780,9 @@ onboarding-reset_tutorial-2 = 자동 착용 방향 설정 마법사에서 봤던 것처럼 스키를 타는 듯한 자세로 몸을 구부리고 있으세요. 탭한 다음 3초 뒤에(설정에서 변경 가능) 보정이 시작될 거예요. +## Install info + + ## Setup start onboarding-home = SlimeVR에 어서오세요! diff --git a/gui/public/i18n/lt/translation.ftl b/gui/public/i18n/lt/translation.ftl index 1e29bf447b..6a0272dc84 100644 --- a/gui/public/i18n/lt/translation.ftl +++ b/gui/public/i18n/lt/translation.ftl @@ -609,12 +609,18 @@ settings-general-gesture_control-trackers = ## Setup/onboarding menu +## Quiz + + ## Wi-Fi setup ## Mounting setup +## Install info + + ## Setup start diff --git a/gui/public/i18n/nb-NO/translation.ftl b/gui/public/i18n/nb-NO/translation.ftl index 7d9efa5544..5c17645314 100644 --- a/gui/public/i18n/nb-NO/translation.ftl +++ b/gui/public/i18n/nb-NO/translation.ftl @@ -494,9 +494,11 @@ onboarding-skip = Hopp over oppsett onboarding-continue = Fortsett onboarding-wip = Arbeid pågår +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = Gå tilbake til introduksjonen onboarding-wifi_creds-skip = Hopp over Wi-Fi innstillinger onboarding-wifi_creds-submit = Send inn! onboarding-wifi_creds-ssid = @@ -512,6 +514,9 @@ onboarding-reset_tutorial-back = Gå tilbake til monterings kalibrering onboarding-reset_tutorial = Nullstill opplæringen onboarding-reset_tutorial-skip = Hopp over trinn +## Install info + + ## Setup start onboarding-home = Velkommen til SlimeVR diff --git a/gui/public/i18n/nl/translation.ftl b/gui/public/i18n/nl/translation.ftl index 6eea2b254b..9827078404 100644 --- a/gui/public/i18n/nl/translation.ftl +++ b/gui/public/i18n/nl/translation.ftl @@ -115,6 +115,11 @@ board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3 board_type-HARITORA = Haritora board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1 board_type-GLOVE_IMU_SLIMEVR_DEV = SlimeVR Dev IMU Handschoen +board_type-GESTURES = Gebaren +board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini +board_type-GENERIC_NRF = Generic nRF +board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR Dev Butterfly +board_type-SLIMEVR_BUTTERFLY = SlimeVR Butterfly ## Proportions @@ -255,8 +260,8 @@ reset-mounting-fingers = Reset vingermontage reset-yaw = Yaw Reset reset-error-no_feet_tracker = Geen voet-tracker toegewezen reset-error-no_fingers_tracker = Geen vingertracker toegewezen -reset-error-mounting-need_full_reset = U heeft een volledige reset nodig voordat u de montagekalibratie kunt uitvoeren. -reset-error-yaw-need_full_reset = U heeft een volledige reset nodig voordat u de yaw reset kunt uitvoeren. +reset-error-mounting-need_full_reset = Je hebt een volledige reset nodig voordat je een montagekalibratie kunt uitvoeren. +reset-error-yaw-need_full_reset = Je hebt een volledige reset nodig voordat je een yaw reset kunt uitvoeren. ## Serial detection stuff @@ -276,6 +281,7 @@ navbar-trackers_assign = Tracker-toewijzing navbar-mounting = Montage-kalibratie navbar-onboarding = Installatiewizard navbar-settings = Instellingen +navbar-connect_trackers = Verbind Trackers ## Biovision hierarchy recording @@ -346,6 +352,7 @@ tracker-table-column-name = Naam tracker-table-column-type = Type tracker-table-column-battery = Batterij tracker-table-column-ping = Ping +tracker-table-column-packet_loss = Pakketverlies tracker-table-column-tps = TPS tracker-table-column-temperature = Temp. °C tracker-table-column-linear-acceleration = Accel. X/Y/Z @@ -387,6 +394,9 @@ tracker-infos-magnetometer-status-v1 = [ENABLED] Ingeschakeld *[NOT_SUPPORTED] Niet ondersteund } +tracker-infos-packet_loss = Pakketverlies +tracker-infos-packets_lost = Verloren pakketten +tracker-infos-packets_received = Ontvangen pakketten ## Tracker settings @@ -404,8 +414,8 @@ tracker-settings-drift_compensation_section-edit = Laat drift compensatie toe tracker-settings-use_mag = Sta de magnetometer toe op deze tracker. # Multiline! tracker-settings-use_mag-description = - Wilt u dat deze tracker de magnetometer gebruikt om drift te verminderen wanneer de magnetometer is toegestaan? Zet de tracker niet uit terwijl u dit aan of uit zet. - U moet eerst de magnetometer toestemming geven,click hier om naar de instellingen te gaan. + Wilt je dat deze tracker de magnetometer gebruikt om drift te verminderen wanneer de magnetometer is toegestaan? Zet de tracker niet uit terwijl je dit aan of uit zet. + Je moet eerst de magnetometer toestemming geven,click hier om naar de instellingen te gaan. tracker-settings-use_mag-label = Laat magnetometer toe # The . means it's an attribute and it's related to the top key. # In this case that is the settings for the assignment section. @@ -423,6 +433,9 @@ tracker-settings-update-up_to_date = Up to date. tracker-settings-update-blocked = Update is niet beschikbaar. Er zijn geen andere versies beschikbaar. tracker-settings-update = Werk nu bij. tracker-settings-update-title = Firmware versie +tracker-settings-current-version = Actueel +tracker-settings-latest-version = Nieuwste +tracker-settings-build-date = Creatiedatum ## Tracker part card info @@ -583,6 +596,9 @@ settings-general-tracker_mechanics-use_mag_on_all_trackers-description = Gebruikt magnetometer op alle trackers die er een compatibele firmware voor hebben, waardoor drift in stabiele magnetische omgevingen wordt verminderd. Je kan dit per individuele tracker uit zetten in de instellingen van de tracker. Sluit geen van de trackers af terwijl u dit in- en uitschakelt! settings-general-tracker_mechanics-use_mag_on_all_trackers-label = Gebruik magnetometer op de trackers +settings-general-tracker_mechanics-trackers_over_usb = Trackers via USB +settings-general-tracker_mechanics-trackers_over_usb-description = Maakt het mogelijk om HID-trackergegevens via USB te ontvangen. Zorg ervoor dat verbonden trackers "verbinding over HID" hebben ingeschakeld! +settings-general-tracker_mechanics-trackers_over_usb-enabled-label = Laat HID-trackers direct via USB verbinden settings-stay_aligned = Blijf in lijn settings-stay_aligned-description = Blijf in lijn vermindert drift door je trackers geleidelijk aan te passen zodat ze overeenkomen met je ontspannen houdingen. settings-stay_aligned-setup-label = Blijf in lijn instellen @@ -634,7 +650,7 @@ settings-general-fk_settings-enforce_joint_constraints-correct_constraints = Cor settings-general-fk_settings-enforce_joint_constraints-correct_constraints-description = Corrigeer gewrichtsrotaties wanneer ze hun limiet overschrijden settings-general-fk_settings-ik = Positie gegevens settings-general-fk_settings-ik-use_position = Positiegegevens gebruiken -settings-general-fk_settings-ik-use_position-description = Maakt gebruik van positiegegevens mogelijk van de trackers die deze leveren. Waneer u dit inschakelt, zorg er voor dat u een volledige reset doet en in het spel opnieuw kalibreert. +settings-general-fk_settings-ik-use_position-description = Maakt gebruik van positiegegevens mogelijk van de trackers die deze leveren. Zorg er voor dat je een volledige reset doet en opnieuw kalibreert in het spel wanneer je dit inschakelt. settings-general-fk_settings-arm_fk = Arm tracking settings-general-fk_settings-arm_fk-description = Verander de manier waarop de armen worden getrackt. settings-general-fk_settings-arm_fk-force_arms = Dwing armen vanuit HMD @@ -755,9 +771,9 @@ settings-general-interface-discord_presence-message = } settings-interface-behavior-error_tracking = Foutverzameling via Sentry.io settings-interface-behavior-error_tracking-description_v2 = -

Geeft u toestemming voor het verzamelen van geanonimiseerde foutgegevens?

+

Geef je toestemming voor het verzamelen van geanonimiseerde foutgegevens?

- We verzamelen geen persoonlijke informatie zoals uw IP-adres of draadloze inloggegevens. SlimeVR hecht veel waarde aan uw privacy! + We verzamelen geen persoonlijke informatie zoals jouw IP-adres of draadloze inloggegevens. SlimeVR hecht veel waarde aan je privacy! Om de beste gebruikerservaring te bieden, verzamelen we geanonimiseerde foutrapporten, prestatiestatistieken en informatie over het besturingssysteem. Dit helpt ons bij het detecteren van fouten en problemen met SlimeVR. Deze statistieken worden verzameld via Sentry.io. settings-interface-behavior-error_tracking-label = Stuur fouten naar de ontwikkelaars @@ -904,14 +920,14 @@ settings-utils-advanced-reset-all-label = Alles resetten settings-utils-advanced-reset_warning = { $type -> [gui] - WaarschuwingHiermee worden al uw GUI instellingen teruggezet naar de standaardinstellingen. - Weet u zeker dat u dit wilt doen? + WaarschuwingHiermee worden al je GUI instellingen teruggezet naar de standaardinstellingen. + Weet je zeker dat je dit wilt doen? [server] - WaarschuwingHiermee worden al uw tracking instellingen teruggezet naar de standaardinstellingen. - Weet u zeker dat u dit wilt doen? + WaarschuwingHiermee worden al je tracking instellingen teruggezet naar de standaardinstellingen. + Weet je zeker dat je dit wilt doen? *[all] - Waarschuwing: Hiermee worden al uw instellingen teruggezet naar de standaardinstellingen. - Weet u zeker dat u dit wilt doen? + Waarschuwing: Hiermee worden al je instellingen teruggezet naar de standaardinstellingen. + Weet je zeker dat je dit wilt doen? } settings-utils-advanced-reset_warning-reset = Instellingen resetten settings-utils-advanced-reset_warning-cancel = Annuleren @@ -946,9 +962,18 @@ onboarding-setup_warning = onboarding-setup_warning-skip = Setupgids overslaan onboarding-setup_warning-cancel = Doorgaan met setupgids +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = Ga terug naar de introductie +onboarding-wifi_creds-v2 = Trackers die Wi-Fi gebruiken +# This cares about multilines +onboarding-wifi_creds-description-v2 = + De meeste trackers (zoals de officiële SlimeVR-trackers) gebruiken Wi-Fi om verbinding te maken met de server. + Gebruik de inloggegevens van het Wi-Fi-netwerk waarmee je apparaat momenteel is verbonden. + + Zorg ervoor dat je een 2,4GHz-Wi-Fi-verbinding gebruikt voor jouw trackers! onboarding-wifi_creds-skip = WiFi-instellingen overslaan onboarding-wifi_creds-submit = Verzenden! onboarding-wifi_creds-ssid = @@ -958,6 +983,10 @@ onboarding-wifi_creds-ssid-required = Wi-Fi-naam is vereist onboarding-wifi_creds-password = .label = Paswoord .placeholder = Vul paswoord in +onboarding-wifi_creds-dongle-title = Trackers met een dongle +onboarding-wifi_creds-dongle-description = Als je trackers met een dongle zijn geleverd, steek die dan in je apparaat en je bent klaar om te beginnen! +onboarding-wifi_creds-dongle-wip = Dit gedeelte is nog in ontwikkeling. Er komt binnenkort een aparte pagina om trackers te beheren die via een dongle verbinden. +onboarding-wifi_creds-dongle-continue = Ga verder met een dongle ## Mounting setup @@ -984,6 +1013,9 @@ onboarding-reset_tutorial-2 = Je moet in een houding staan alsof je aan het skiën bent, zoals wordt weergegeven in de Automatische montage wizard, je hebt een vertraging van 3 seconden (instelbaar) voordat deze wordt geactiveerd. +## Install info + + ## Setup start onboarding-home = Welkom bij SlimeVR @@ -1206,7 +1238,7 @@ onboarding-automatic_mounting-done-restart = Terug naar start onboarding-automatic_mounting-mounting_reset-title = Montage-reset onboarding-automatic_mounting-mounting_reset-step-0 = 1. Ga staan in een "skie"-houding met gebogen benen, je bovenlichaam naar voren gekanteld en armen gebogen. onboarding-automatic_mounting-mounting_reset-step-1 = 2. Druk op de knop "Reset montage" en wacht 3 seconden voordat de montagerichtingen van de trackers opnieuw worden ingesteld. -onboarding-automatic_mounting-mounting_reset-feet-step-0 = 1. Sta op uw tenen met beide voeten naar voren gericht. u kunt het ook zittend op een stoel doen. +onboarding-automatic_mounting-mounting_reset-feet-step-0 = 1. Sta op je tenen met beide voeten naar voren gericht. Je kunt het ook zittend op een stoel doen. onboarding-automatic_mounting-mounting_reset-feet-step-1 = 2. Druk op de knop "Voetkalibratie" en wacht 3 seconden voordat de montageoriëntaties van de trackers gereset worden. onboarding-automatic_mounting-preparation-title = Voorbereiding onboarding-automatic_mounting-preparation-v2-step-0 = 1. Druk op de knop "Volledige reset". @@ -1246,26 +1278,26 @@ onboarding-automatic_proportions-requirements-title = Vereisten # Each line of text is a different list item onboarding-automatic_proportions-requirements-descriptionv2 = Je hebt voldaan aan de minimale vereisten om je voeten te tracken (over het algemeen 5 trackers). Je hebt je trackers en headset aan en draagt ze. Je trackers en headset zijn verbonden met de SlimeVR server en werken naar behoren (zonder haperingen, loskoppelingen etc.). Je headset stuurt positiedata naar de SlimeVR server (dit vereist doorgaans dat SteamVR draait en verbonden is met SlimeVR via de SlimeVR SteamVR-driver). De tracking werkt en registreert je bewegingen nauwkeurig (je hebt bijvoorbeeld een volledige reset uitgevoerd en de trackers bewegen in de juiste richting bij schoppen, bukken, zitten etc.). onboarding-automatic_proportions-requirements-next = Ik heb de vereisten gelezen -onboarding-automatic_proportions-check_height-title-v3 = Meet de hoogte van uw headset -onboarding-automatic_proportions-check_height-description-v2 = De hoogte van uw headset (HMD) moet iets minder zijn dan uw volledige lengte, aangezien headsets uw ooghoogte meten. Deze meting wordt gebruikt als basis voor uw lichaamsverhoudingen. +onboarding-automatic_proportions-check_height-title-v3 = Meet de hoogte van je headset +onboarding-automatic_proportions-check_height-description-v2 = De hoogte van je headset (HMD) moet iets minder zijn dan jouw volledige lengte, aangezien headsets je ooghoogte meten. Deze meting wordt gebruikt als basis voor je lichaamsverhoudingen. # All the text is in bold! onboarding-automatic_proportions-check_height-calculation_warning-v3 = Begin met meten terwijl je rechtop staat om je lengte te meten. Let erop dat je je handen niet hoger dan je headset tilt, want dat kan de meting beïnvloeden! onboarding-automatic_proportions-check_height-guardian_tip = Als je een losse VR-bril gebruikt, zorg er dan voor dat je guardian/veilige zone is ingeschakeld zodat je lengte correct is gekalibreerd! # Context is that the height is unknown onboarding-automatic_proportions-check_height-unknown = Onbekend # Shows an element below it -onboarding-automatic_proportions-check_height-hmd_height2 = De hoogte van uw headset is: +onboarding-automatic_proportions-check_height-hmd_height2 = De hoogte van je headset is: onboarding-automatic_proportions-check_height-measure-start = Begin met meten onboarding-automatic_proportions-check_height-measure-stop = Stoppen met meten onboarding-automatic_proportions-check_height-measure-reset = Probeer opnieuw te meten onboarding-automatic_proportions-check_height-next_step = Ze zijn goed -onboarding-automatic_proportions-check_floor_height-title = Meet uw vloerhoogte (optioneel) -onboarding-automatic_proportions-check_floor_height-description = In sommige gevallen wordt uw vloerhoogte mogelijk niet correct ingesteld door uw headset, waardoor de hoogte van de headset hoger wordt gemeten dan zou moeten. U kunt de "hoogte" van uw vloer meten om de hoogte van uw headset te corrigeren. +onboarding-automatic_proportions-check_floor_height-title = Meet je vloerhoogte (optioneel) +onboarding-automatic_proportions-check_floor_height-description = In sommige gevallen wordt je vloerhoogte mogelijk niet correct ingesteld door je headset, waardoor de hoogte van de headset hoger wordt gemeten dan zou moeten. Je kunt de "hoogte" van je vloer meten om de hoogte van je headset te corrigeren. # All the text is in bold! onboarding-automatic_proportions-check_floor_height-calculation_warning-v2 = Begin met meten en zet een controller op je vloer om de hoogte te meten. Als je zeker weet dat je vloerhoogte klopt, kun je deze stap overslaan. # Shows an element below it -onboarding-automatic_proportions-check_floor_height-floor_height = Uw vloerhoogte is: -onboarding-automatic_proportions-check_floor_height-full_height = Uw geschatte volledige lengte is: +onboarding-automatic_proportions-check_floor_height-floor_height = Je vloerhoogte is: +onboarding-automatic_proportions-check_floor_height-full_height = Je geschatte volledige lengte is: onboarding-automatic_proportions-check_floor_height-measure-start = Begin met meten onboarding-automatic_proportions-check_floor_height-measure-stop = Stoppen met meten onboarding-automatic_proportions-check_floor_height-measure-reset = Probeer opnieuw te meten @@ -1306,7 +1338,7 @@ onboarding-automatic_proportions-error_modal-v2 = Bekijk de documentatie of word lid van onze Discord voor hulp ^_^ onboarding-automatic_proportions-error_modal-confirm = Begrepen! onboarding-automatic_proportions-smol_warning = - Uw ingestelde lengte van { $height } is lager dan de toegestane minimumlengte van { $minHeight }. + Jouw ingestelde lengte van { $height } is lager dan de toegestane minimumlengte van { $minHeight }. Voer de metingen opnieuw uit en controleer of ze correct zijn. onboarding-automatic_proportions-smol_warning-cancel = Ga terug @@ -1317,6 +1349,25 @@ onboarding-user_height-description = We hebben je lengte nodig om je lichaamspro onboarding-user_height-need_head_tracker = Voor de kalibratie zijn een headset en controllers met positionele tracking vereist. onboarding-user_height-calculate = Bereken mijn lengte automatisch onboarding-user_height-next_step = Doorgaan en opslaan +onboarding-user_height-manual-proportions = Handmatige lichaamsverhoudingen +onboarding-user_height-calibration-title = Vooruitgang van de kalibratie +onboarding-user_height-calibration-RECORDING_FLOOR = Raak de vloer aan met de punt van je controller +onboarding-user_height-calibration-WAITING_FOR_RISE = Sta weer op +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK = Sta weer op en kijk vooruit +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-ok = Zorg dat je hoofd vlak staat +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-low = Kijk niet naar de vloer +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-high = Kijk niet te veel omhoog +onboarding-user_height-calibration-WAITING_FOR_CONTROLLER_PITCH = Zorg dat de controller naar beneden wijst +onboarding-user_height-calibration-RECORDING_HEIGHT = Sta weer op en blijf stilstaan! +onboarding-user_height-calibration-DONE = Gelukt! +onboarding-user_height-calibration-ERROR_TIMEOUT = Kalibratie sessie is verlopen, probeer het opnieuw. +onboarding-user_height-calibration-ERROR_TOO_HIGH = De gedetecteerde gebruikershoogte is te hoog, probeer het opnieuw. +onboarding-user_height-calibration-ERROR_TOO_SMALL = De gedetecteerde gebruikerslengte is te klein. Zorg dat je voor het einde van de kalibratie rechtop staat en naar voren kijkt. +onboarding-user_height-calibration-error = Kalibratie mislukt +onboarding-user_height-manual-tip = Tijdens het aanpassen van je lengte kan je verschillende poses proberen en kijken hoe het skelet met jouw lichaam overeenkomt. +onboarding-user_height-reset-warning = + Waarschuwing: Dit zet je verhoudingen terug op basis van jouw lengte. + Weet je zeker dat je dit wilt doen? ## Stay Aligned setup @@ -1351,6 +1402,7 @@ onboarding-stay_aligned-previous_step = Vorige onboarding-stay_aligned-next_step = Volgende onboarding-stay_aligned-restart = Herstarten onboarding-stay_aligned-done = Klaar +onboarding-stay_aligned-manual_mounting-done = Klaar ## Home @@ -1397,20 +1449,50 @@ firmware_tool = DIY firmware-tool firmware_tool-description = Hiermee kan je uw DIY-trackers configureren en flashen firmware_tool-not_available = Oeps, de firmwaretool is momenteel niet beschikbaar. Kom later terug! firmware_tool-not_compatible = De firmwaretool is niet compatibel met deze versie van de server. Gelieve te updaten! -firmware_tool-select_source = Selecteer de firmware die u wilt flashen -firmware_tool-select_source-description = Selecteer de firmware die u op uw bord wilt flashen +firmware_tool-select_source = Selecteer de firmware die je wilt flashen +firmware_tool-select_source-description = Selecteer de firmware die je op jouw bord wilt flashen +firmware_tool-select_source-error = Kan bronnen niet laden +firmware_tool-select_source-board_type = Type bord +firmware_tool-select_source-firmware = Firmware-bron +firmware_tool-select_source-version = Firmware versie +firmware_tool-select_source-official = Officieel +firmware_tool-select_source-dev = Ontwikkelaar +firmware_tool-select_source-not_selected = Geen bron geselecteerd +firmware_tool-select_source-no_boards = Geen beschikbare borden voor deze bron +firmware_tool-select_source-no_versions = Geen beschikbare versies voor deze bron +firmware_tool-board_defaults = Configureer je bord +firmware_tool-board_defaults-description = Stel de pinnen of instellingen in ten opzichte van jouw hardware +firmware_tool-board_defaults-add = Toevoegen +firmware_tool-board_defaults-reset = Reset naar standaard +firmware_tool-board_defaults-error-required = Verplicht veld +firmware_tool-board_defaults-error-format = Ongeldig formaat +firmware_tool-board_defaults-error-format-number = Is geen nummer firmware_tool-flash_method_step = Flashing methode firmware_tool-flash_method_step-description = Kies de flashingsmethode die je wilt gebruiken +firmware_tool-flash_method_step-ota-v2 = + .label = Wi-Fi + .description = Gebruik de over-the-air methode. Jouw tracker zal via wifi de firmware bijwerken. Werkt alleen op trackers die al zijn ingesteld. +firmware_tool-flash_method_step-ota-info = + We gebruiken jouw wifi-inloggegevens om de tracker te flashen en te bevestigen dat alles correct werkte. + We slaan je wifi-gegevens niet op! +firmware_tool-flash_method_step-serial-v2 = + .label = USB + .description = Gebruik een USB kabel om jouw tracker up te daten. firmware_tool-flashbtn_step = Druk op de bootknop -firmware_tool-flashbtn_step-description = Voordat u naar de volgende stap gaat, zijn er een paar dingen die u moet doen. -firmware_tool-flashbtn_step-board_SLIMEVR = Zet de tracker uit, verwijder de behuizing (indien aanwezig), verbind een USB-kabel met deze computer en voer vervolgens een van de volgende stappen uit, afhankelijk van de revisie van uw SlimeVR-board: +firmware_tool-flashbtn_step-description = Voordat je naar de volgende stap gaat, zijn er een paar dingen die je moet doen. +firmware_tool-flashbtn_step-board_SLIMEVR = Zet de tracker uit, verwijder de behuizing (indien aanwezig), verbind een USB-kabel met deze computer en voer vervolgens een van de volgende stappen uit, afhankelijk van de revisie van je SlimeVR-bord: +firmware_tool-flashbtn_step-board_SLIMEVR-r11-v2 = Zet de tracker aan terwijl je het tweede rechthoekige FLASH-contact vlak bij de rand aan de bovenkant van de printplaat kortsluit tot het metalen schild van de microcontroller. De LED van de tracker zou kort moeten knipperen. +firmware_tool-flashbtn_step-board_SLIMEVR-r12-v2 = Zet de tracker aan terwijl je het ronde FLASH-contact aan de bovenkant van de printplaat kortsluit tot het metalen schild van de microcontroller. De LED van de tracker zou kort moeten knipperen. +firmware_tool-flashbtn_step-board_SLIMEVR-r14-v2 = Zet de tracker aan terwijl je de FLASH-knop aan de bovenkant van de printplaat ingedrukt houdt. De LED van de tracker zou kort moeten knipperen. firmware_tool-flashbtn_step-board_OTHER = - Voordat u gaat flashen, moet de tracker waarschijnlijk in de bootloader-modus worden gezet. + Voordat je gaat flashen, moet de tracker waarschijnlijk in de bootloader-modus worden gezet. Meestal betekent dit het indrukken van de bootknop op het board voordat het flashproces begint. - Als het flashproces time-out bij het begin van het flashen, betekent dit waarschijnlijk dat de tracker niet in de bootloader-modus stond. - Raadpleeg de flitsinstructies van uw board om te weten hoe u de bootloader-modus inschakelt. + Als het flashproces verloopt bij het begin van het flashen, betekent dit waarschijnlijk dat de tracker niet in de bootloader-modus stond. + Raadpleeg de flashing-instructies van je board om te weten hoe je de bootloader-modus inschakelt. +firmware_tool-flash_method_ota-title = Flashen over Wi-Fi firmware_tool-flash_method_ota-devices = Gedetecteerde OTA-apparaten: -firmware_tool-flash_method_ota-no_devices = Er zijn geen boards die via OTA bijgewerkt kunnen worden, zorg ervoor dat u het juiste boardtype heeft geselecteerd. +firmware_tool-flash_method_ota-no_devices = Er zijn geen boards die via OTA bijgewerkt kunnen worden, zorg ervoor dat je het juiste boardtype heeft geselecteerd. +firmware_tool-flash_method_serial-title = Flashen over USB firmware_tool-flash_method_serial-wifi = Wi-Fi-gegevens: firmware_tool-flash_method_serial-devices-label = Gedetecteerde serial apparaten: firmware_tool-flash_method_serial-devices-placeholder = Selecteer een serieel apparaat @@ -1425,7 +1507,10 @@ firmware_tool-flashing_step-exit = Sluit ## firmware tool build status +firmware_tool-build-QUEUED = Wachten om te maken.... firmware_tool-build-CREATING_BUILD_FOLDER = De buildmap maken +firmware_tool-build-DOWNLOADING_SOURCE = Broncode wordt gedownload +firmware_tool-build-EXTRACTING_SOURCE = Broncode wordt uitgepakt firmware_tool-build-BUILDING = Firmware wordt gebouwd firmware_tool-build-SAVING = De build opslaan firmware_tool-build-DONE = Build voltooid @@ -1540,6 +1625,31 @@ error_collection_modal-cancel = Ik wil het niet ## Tracking checklist section +tracking_checklist = Tracking Checklist +tracking_checklist-settings = Instellingen voor trackingchecklists +tracking_checklist-settings-close = Sluiten +tracking_checklist-status-incomplete = U bent niet voorbereid om SlimeVR te gebruiken! +tracking_checklist-status-partial = + { $count -> + [one] U heeft 1 waarschuwing! + *[other] U heeft { $count } waarschuwingen! + } +tracking_checklist-status-complete = U bent klaar om SlimeVR te gebruiken! +tracking_checklist-MOUNTING_CALIBRATION = Voer een montagekalibratie uit +tracking_checklist-FEET_MOUNTING_CALIBRATION = Voer een voetmontage-kalibratie uit +tracking_checklist-FULL_RESET = Voer een volledige reset uit +tracking_checklist-FULL_RESET-desc = Sommige trackers hebben een reset nodig +tracking_checklist-STEAMVR_DISCONNECTED = SteamVR draait niet +tracking_checklist-STEAMVR_DISCONNECTED-desc = SteamVR draait niet. Gebruik je het voor VR? +tracking_checklist-STEAMVR_DISCONNECTED-open = Open SteamVR +tracking_checklist-TRACKERS_REST_CALIBRATION = Kalibreer je trackers +tracking_checklist-TRACKERS_REST_CALIBRATION-desc = Je hebt geen tracker kalibratie uitgevoerd. Laat je Slimes (gemarkeerd met geel) rusten op een stabiele ondergrond voor een paar secondes. +tracking_checklist-TRACKER_ERROR = Trackers met fouten +tracking_checklist-TRACKER_ERROR-desc = Sommige van je trackers hebben een fout. Herstart de tracker die in het geel zijn gemarkeerd aub. +tracking_checklist-VRCHAT_SETTINGS = Configureer VRChat-instellingen +tracking_checklist-VRCHAT_SETTINGS-desc = Je hebt enkele VRchat-instellingen verkeerd geconfigureerd! Dit kan jouw trackingervaring negatief beïnvloeden. +tracking_checklist-VRCHAT_SETTINGS-open = Ga naar VRChat Waarschuwingen +tracking_checklist-UNASSIGNED_HMD = VR-headset niet toegewezen aan Hoofd tracking_checklist-UNASSIGNED_HMD-desc = De VR-headset moet worden toegewezen als hoofdtracker. tracking_checklist-NETWORK_PROFILE_PUBLIC = Verander je netwerkprofiel tracking_checklist-NETWORK_PROFILE_PUBLIC-desc = @@ -1547,12 +1657,12 @@ tracking_checklist-NETWORK_PROFILE_PUBLIC-desc = [one] Uw netwerk-profiel is op dit moment of publiek ingesteld ({ $adapters }) Dit wordt niet aanbevolen voor een goede werking van SlimeVR - Hiet leest u hoe u dit kunt oplossen + Hier lees je hoe je dit kan oplossen *[other] - Sommige van uw netwerkadapters staan ingesteld op openbaar: + Sommige van je netwerkadapters staan ingesteld op openbaar: { $adapters }. Dit wordt niet aanbevolen voor een goede werking van SlimeVR. - Hier leest u hoe u dit kunt oplossen. + Hier lees je hoe je dit kan oplossen. } tracking_checklist-NETWORK_PROFILE_PUBLIC-open = Open Configuratiescherm tracking_checklist-STAY_ALIGNED_CONFIGURED = Configureer Blijf in lijn diff --git a/gui/public/i18n/pl/translation.ftl b/gui/public/i18n/pl/translation.ftl index b2830b4e76..6aa835648f 100644 --- a/gui/public/i18n/pl/translation.ftl +++ b/gui/public/i18n/pl/translation.ftl @@ -960,9 +960,11 @@ onboarding-setup_warning = onboarding-setup_warning-skip = Pomiń konfigurację onboarding-setup_warning-cancel = Kontynuuj konfigurację +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = Cofnij się do początku onboarding-wifi_creds-skip = Pomiń ustawienia Wi-Fi onboarding-wifi_creds-submit = Potwierdź! onboarding-wifi_creds-ssid = @@ -998,6 +1000,9 @@ onboarding-reset_tutorial-2 = Musisz być w pozie, jakbyś jechał na nartach, tak jak jest to pokazane w kreatorze automatycznego montażu, i masz 3-sekundowe opóźnienie (konfigurowalne), zanim zostanie uruchomione. +## Install info + + ## Setup start onboarding-home = Witamy w SlimeVR diff --git a/gui/public/i18n/pt-BR/translation.ftl b/gui/public/i18n/pt-BR/translation.ftl index c3db0f7e1f..bf97e80420 100644 --- a/gui/public/i18n/pt-BR/translation.ftl +++ b/gui/public/i18n/pt-BR/translation.ftl @@ -7,11 +7,11 @@ ## Websocket (server) status -websocket-connecting = Conectando ao servidor -websocket-connection_lost = Conexão perdida com o servidor. Reconectando... -websocket-connection_lost-desc = Parece que o servidor do SlimeVR crashou. Verifique as logs e reinicie o programa +websocket-connecting = Carregando... +websocket-connection_lost = O servidor parou de funcionar! +websocket-connection_lost-desc = Parece que o servidor do SlimeVR parou de funcionar. Verifique os logs e reinicie o programa. websocket-timedout = Não foi possível conectar-se ao servidor -websocket-timedout-desc = Parece que o SlimeVR server crashou ou parou de responder. Verifique as logs e reinicie o programa +websocket-timedout-desc = Parece que o servidor do SlimeVR parou de funcionar ou atingiu o tempo limite. Verifique os logs e reinicie o programa. websocket-error-close = Sair do SlimeVR websocket-error-logs = Abrir a pasta de logs @@ -24,15 +24,19 @@ version_update-close = Fechar ## Tips -tips-find_tracker = Não tem certeza qual tracker é qual? Balance o tracker e ele destacará o item correspondente. -tips-do_not_move_heels = Tenha certeza de não mexer seus calcanhares durante a gravação! +tips-find_tracker = Não tem certeza de qual tracker é qual? Sacuda um tracker e o item correspondente será destacado. +tips-do_not_move_heels = Certifique-se de manter os calcanhares imóveis durante a gravação! tips-file_select = Arraste e solte arquivos para usar, ou pesquise. -tips-tap_setup = Pode tocar lentamente 2 vezes no seu tracker para o escolher em vez de o selecionar no menu. -tips-turn_on_tracker = Está usando trackers oficiais do SlimeVR? Lembre-se de ligar o seu tracker após conecta-lo ao computador! +tips-tap_setup = Você pode tocar lentamente no seu tracker 2 vezes para selecioná-lo, em vez de escolhê-lo pelo menu. +tips-turn_on_tracker = Está usando trackers oficiais do SlimeVR? Lembre-se de ligar o seu tracker após conectá-lo ao computador! tips-failed_webgl = Falha ao inicializar o WebGL. ## Units +unit-meter = Metros +unit-foot = Pés +unit-inch = Polegadas +unit-cm = cm ## Body parts @@ -42,11 +46,11 @@ body_part-NECK = Pescoço body_part-RIGHT_SHOULDER = Ombro direito body_part-RIGHT_UPPER_ARM = Braço superior direito body_part-RIGHT_LOWER_ARM = Antebraço direito -body_part-RIGHT_HAND = Mão Direita +body_part-RIGHT_HAND = Mão direita body_part-RIGHT_UPPER_LEG = Coxa direita body_part-RIGHT_LOWER_LEG = Tornozelo direito body_part-RIGHT_FOOT = Pé direito -body_part-UPPER_CHEST = Peito Superior +body_part-UPPER_CHEST = Peito superior body_part-CHEST = Peito body_part-WAIST = Cintura body_part-HIP = Quadril @@ -63,6 +67,30 @@ body_part-LEFT_THUMB_DISTAL = Distal do polegar esquerdo body_part-LEFT_INDEX_PROXIMAL = Indicador esquerdo proximal body_part-LEFT_INDEX_INTERMEDIATE = Indicador esquerdo intermediário body_part-LEFT_INDEX_DISTAL = Indicador esquerdo distal +body_part-LEFT_MIDDLE_PROXIMAL = Médio esquerdo proximal +body_part-LEFT_MIDDLE_INTERMEDIATE = Médio esquerdo intermediário +body_part-LEFT_MIDDLE_DISTAL = Médio distal esquerdo +body_part-LEFT_RING_PROXIMAL = Anelar esquerdo proximal +body_part-LEFT_RING_INTERMEDIATE = Anelar esquerdo intermediário +body_part-LEFT_RING_DISTAL = Anelar esquerdo distal +body_part-LEFT_LITTLE_PROXIMAL = Mindinho esquerdo proximal +body_part-LEFT_LITTLE_INTERMEDIATE = Mindinho esquerdo intermediário +body_part-LEFT_LITTLE_DISTAL = Mindinho esquerdo distal +body_part-RIGHT_THUMB_METACARPAL = Metacarpo do polegar direito +body_part-RIGHT_THUMB_PROXIMAL = Polegar direito proximal +body_part-RIGHT_THUMB_DISTAL = Distal do polegar direito +body_part-RIGHT_INDEX_PROXIMAL = Porção proximal do dedo indicador direito +body_part-RIGHT_INDEX_INTERMEDIATE = Porção intermediária do dedo indicador direito +body_part-RIGHT_INDEX_DISTAL = Porção distal do dedo indicador direito +body_part-RIGHT_MIDDLE_PROXIMAL = Porção proximal do dedo médio direito +body_part-RIGHT_MIDDLE_INTERMEDIATE = Porção intermediária do dedo médio direito +body_part-RIGHT_MIDDLE_DISTAL = Porção distal do dedo médio direito +body_part-RIGHT_RING_PROXIMAL = Porção proximal do dedo anelar direito +body_part-RIGHT_RING_INTERMEDIATE = Porção intermediária do dedo anelar direito +body_part-RIGHT_RING_DISTAL = Porção distal do dedo anelar direito +body_part-RIGHT_LITTLE_PROXIMAL = Porção proximal do dedo mínimo direito +body_part-RIGHT_LITTLE_INTERMEDIATE = Porção intermediária do dedo mínimo direito +body_part-RIGHT_LITTLE_DISTAL = Porção distal do dedo mínimo direito ## BoardType @@ -74,6 +102,8 @@ board_type-WEMOSD1MINI = Wemos D1 Mini board_type-TTGO_TBASE = TTGO T-Base board_type-ESP01 = ESP-01 board_type-SLIMEVR = SlimeVR +board_type-SLIMEVR_DEV = Placa do SlimeVR Dev +board_type-SLIMEVR_V1_2 = SlimeVR v1.2 board_type-LOLIN_C3_MINI = Lolin C3 Mini board_type-BEETLE32C3 = Beetle ESP32-C3 board_type-ESP32C3DEVKITM1 = Espressif ESP32-C3 DevKitM-1 @@ -85,34 +115,133 @@ board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3 board_type-HARITORA = Haritora board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1 board_type-GLOVE_IMU_SLIMEVR_DEV = SlimeVR Dev IMU Glove +board_type-GESTURES = Gestos +board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini +board_type-GENERIC_NRF = nRF genérico +board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR Dev Butterfly +board_type-SLIMEVR_BUTTERFLY = SlimeVR Butterfly ## Proportions skeleton_bone-NONE = Nada skeleton_bone-HEAD = Deslocamento da Cabeça -skeleton_bone-NECK = Tamanho do Pescoço -skeleton_bone-torso_group = Tamanho do Tronco -skeleton_bone-UPPER_CHEST = Tamanho do Peito Superior -skeleton_bone-CHEST_OFFSET = Chest Offset -skeleton_bone-CHEST = Chest Length -skeleton_bone-WAIST = Waist Length -skeleton_bone-HIP = Hip Length +skeleton_bone-HEAD-desc = + Esta é a distância do seu headset até o centro da sua cabeça. + Para ajustá-la, balance a cabeça da esquerda para a direita, como se estivesse discordando, + e modifique o valor até que qualquer movimento nos outros trackers seja insignificante. +skeleton_bone-NECK = Comprimento do Pescoço +skeleton_bone-NECK-desc = + Esta é a distância do centro da sua cabeça até a base do seu pescoço. + Para ajustá-la, mova a cabeça para cima e para baixo, como se estivesse assentindo, + ou incline a cabeça para a esquerda e para a direita, ajustando o valor até que qualquer movimento nos outros trackers seja insignificante. +skeleton_bone-torso_group = Comprimento do Tronco +skeleton_bone-torso_group-desc = + Esta é a distância da base do seu pescoço até os seus quadris. + Para ajustá-la, fique em pé com a postura ereta e modifique o valor até + que seus quadris virtuais se alinhem com os reais. +skeleton_bone-UPPER_CHEST = Comprimento do Peito Superior +skeleton_bone-UPPER_CHEST-desc = + Esta é a distância da base do seu pescoço até o meio do seu peito. + Para ajustá-la, configure corretamente o Comprimento do Tronco e faça ajustes em várias posições + (sentado, curvado, deitado, etc.) até que sua coluna virtual coincida com a real. +skeleton_bone-CHEST_OFFSET = Compensação do Peito +skeleton_bone-CHEST_OFFSET-desc = + Isso pode ser ajustado para mover o tracker virtual do peito para cima ou para baixo, + a fim de auxiliar na calibração em determinados jogos ou aplicativos que podem esperar que ele esteja mais alto ou mais baixo. +skeleton_bone-CHEST = Comprimento do Peito +skeleton_bone-CHEST-desc = + Esta é a distância do meio do seu peito até o meio da sua coluna. + Para ajustá-la, ajuste corretamente o Comprimento do Tronco e modifique o valor em várias posições + (sentado, inclinado para frente, deitado, etc.) até que sua coluna virtual corresponda à sua coluna real. +skeleton_bone-WAIST = Comprimento da Cintura +skeleton_bone-WAIST-desc = + Esta é a distância do meio da sua coluna até o seu umbigo. + Para ajustá-la, ajuste corretamente o Comprimento do Tronco e modifique o valor em várias posições + (sentado, inclinado para frente, deitado, etc.) até que sua coluna virtual corresponda à sua coluna real. +skeleton_bone-HIP = Comprimento do Quadril +skeleton_bone-HIP-desc = + Esta é a distância do seu umbigo até os seus quadris. + Para ajustá-la, configure corretamente o Comprimento do Tronco e modifique o valor em várias posições + (sentado, inclinado para frente, deitado, etc.) até que sua coluna virtual corresponda à sua coluna real. skeleton_bone-HIP_OFFSET = Compensação do Quadril +skeleton_bone-HIP_OFFSET-desc = + Isso pode ser ajustado para mover o tracker virtual do quadril para cima ou para baixo, + a fim de auxiliar na calibração em determinados jogos ou aplicativos que podem esperar que ele esteja na sua cintura. skeleton_bone-HIPS_WIDTH = Largura do Quadril -skeleton_bone-leg_group = Tamanho da Perna -skeleton_bone-UPPER_LEG = Upper Leg Length -skeleton_bone-LOWER_LEG = Lower Leg Length -skeleton_bone-FOOT_LENGTH = Tamanho do Pé -skeleton_bone-FOOT_SHIFT = Compensação do Pé +skeleton_bone-HIPS_WIDTH-desc = + Esta é a distância entre o início das suas pernas. + Para ajustá-la, execute "Redefinir Tudo" com as pernas retas e modifique-a até que suas pernas virtuais se alinhem horizontalmente com as pernas reais. +skeleton_bone-leg_group = Comprimento da Perna +skeleton_bone-leg_group-desc = + Esta é a distância dos seus quadris até os seus pés. + Para ajustá-la, configure corretamente o Comprimento do Tronco e faça ajustes + até que seus pés virtuais fiquem no mesmo nível dos reais. +skeleton_bone-UPPER_LEG = Comprimento da Coxa +skeleton_bone-UPPER_LEG-desc = + Esta é a distância dos seus quadris até os seus joelhos. + Para ajustá-la, configure corretamente o Comprimento das Pernas e faça ajustes + até que seus joelhos virtuais fiquem no mesmo nível dos reais. +skeleton_bone-LOWER_LEG = Comprimento da Parte Inferior da Perna +skeleton_bone-LOWER_LEG-desc = + Esta é a distância dos seus joelhos até os seus tornozelos. + Para ajustá-la, configure corretamente o Comprimento das Pernas e faça ajustes + até que seus joelhos virtuais fiquem no mesmo nível dos reais. +skeleton_bone-FOOT_LENGTH = Comprimento do Pé +skeleton_bone-FOOT_LENGTH-desc = + Esta é a distância dos seus tornozelos até os seus dedos dos pés. + Para ajustá-la, fique na ponta dos pés e faça ajustes até que seus pés virtuais permaneçam no lugar. +skeleton_bone-FOOT_SHIFT = Deslocamento do Pé +skeleton_bone-FOOT_SHIFT-desc = + Este valor é a distância horizontal do seu joelho até o seu tornozelo. + Ele leva em consideração que a parte inferior das pernas se projeta para trás quando você fica em pé. + Para ajustá-lo, defina o Comprimento do Pé como 0, execute "Redefinir Tudo" e ajuste até + que seus pés virtuais se alinhem com o meio dos seus tornozelos. skeleton_bone-SKELETON_OFFSET = Compensação do Esqueleto +skeleton_bone-SKELETON_OFFSET-desc = + Isso pode ser ajustado para deslocar todos os seus trackers para frente ou para trás. + Pode ser usado para ajudar na calibração em determinados jogos ou aplicativos + que podem esperar que seus trackers estejam mais à frente. skeleton_bone-SHOULDERS_DISTANCE = Distância dos Ombros +skeleton_bone-SHOULDERS_DISTANCE-desc = + Esta é a distância vertical da base do seu pescoço até os seus ombros. + Para ajustá-la, defina o Comprimento do Braço Superior como 0 e faça ajustes até que + seus trackers virtuais de cotovelo se alinhem verticalmente com os seus ombros reais. skeleton_bone-SHOULDERS_WIDTH = Largura dos Ombros -skeleton_bone-arm_group = Tamanho do Braço -skeleton_bone-UPPER_ARM = Tamanho do Braço Superior -skeleton_bone-LOWER_ARM = Distância do Antebraço -skeleton_bone-HAND_Y = Distância da mão Y -skeleton_bone-HAND_Z = Distância da mão Z +skeleton_bone-SHOULDERS_WIDTH-desc = + Esta é a distância horizontal da base do seu pescoço até os seus ombros. + Para ajustá-la, defina o Comprimento do Braço Superior como 0 e faça ajustes até que + seus trackers virtuais de cotovelo se alinhem horizontalmente com os seus ombros reais. +skeleton_bone-arm_group = Comprimento do Braço +skeleton_bone-arm_group-desc = + Esta é a distância entre seus ombros e seus pulsos. + Para ajustá-la, ajuste corretamente a Distância dos ombros, defina Distância das Mãos Y como 0 e modifique-a até que os trackers das mãos se alinhem com seus pulsos. +skeleton_bone-UPPER_ARM = Comprimento do Braço Superior +skeleton_bone-UPPER_ARM-desc = + Esta é a distância dos seus ombros até os seus cotovelos. + Para ajustá-la, configure corretamente o Comprimento do Braço e faça ajustes até que + seus trackers de cotovelo se alinhem com os seus cotovelos reais. +skeleton_bone-LOWER_ARM = Comprimento do Antebraço +skeleton_bone-LOWER_ARM-desc = + Esta é a distância entre seus cotovelos e seus pulsos. + Para ajustá-la, ajuste corretamente o Comprimento do Braço e modifique-o até + que os trackers de cotovelo se alinhem com seus cotovelos reais. +skeleton_bone-HAND_Y = Distância da Mão Y +skeleton_bone-HAND_Y-desc = + Esta é a distância vertical dos seus punhos até o meio da sua mão. + Para ajustá-la para captura de movimento, configure corretamente o Comprimento do Braço e faça ajustes + até que seus trackers de mão se alinhem verticalmente com o meio das suas mãos. + Para ajustá-la para rastreamento de cotovelo a partir dos controladores, defina o Comprimento do Braço como 0 + e faça ajustes até que seus trackers de cotovelo se alinhem verticalmente com os seus punhos. +skeleton_bone-HAND_Z = Distância da Mão Z +skeleton_bone-HAND_Z-desc = + Esta é a distância horizontal dos seus punhos até o meio da sua mão. + Para ajustá-la para captura de movimento, defina este valor como 0. + Para ajustá-la para rastreamento de cotovelo a partir dos controladores, defina o Comprimento do Braço como 0 e + faça ajustes até que seus trackers de cotovelo se alinhem horizontalmente com os seus punhos. skeleton_bone-ELBOW_OFFSET = Compensação do Cotovelo +skeleton_bone-ELBOW_OFFSET-desc = + Isso pode ser ajustado para mover seus trackers virtuais de cotovelo para cima ou para baixo, + a fim de ajudar a evitar que o VRChat vincule acidentalmente um tracker de cotovelo ao peito. ## Tracker reset buttons @@ -120,20 +249,26 @@ reset-reset_all = Redefinir todas as proporções reset-reset_all_warning-v2 = Aviso: Suas proporções serão redefinidas para o padrão baseado na sua altura configurada. Tem certeza que deseja fazer isso? -reset-reset_all_warning-reset = Resetar proporções +reset-reset_all_warning-reset = Redefinir proporções reset-reset_all_warning-cancel = Cancelar reset-reset_all_warning_default-v2 = Aviso: Sua altura não foi configurada, suas proporções serão redefinidas para os padrões com a altura padrão. Tem certeza que quer fazer isso? -reset-full = Reset Completo -reset-mounting = Reset de Posição -reset-yaw = Reset de guinada (yaw) +reset-full = Redefinir Tudo +reset-mounting = Calibração de Montagem +reset-mounting-feet = Calibrar Pés +reset-mounting-fingers = Calibrar Dedos +reset-yaw = Redefinir Rápido (Guinada) +reset-error-no_feet_tracker = Nenhum tracker de pés atribuído +reset-error-no_fingers_tracker = Nenhum tracker de dedos atribuído +reset-error-mounting-need_full_reset = É necessário fazer uma redefinição completa antes da montagem +reset-error-yaw-need_full_reset = É necessário executar "Redefinir Tudo" antes de "Redefinir Rápido (Guinada)" ## Serial detection stuff serial_detection-new_device-p0 = Novo dispositivo de serial detectado! serial_detection-new_device-p1 = Insira suas credenciais de Wi-Fi! -serial_detection-new_device-p2 = Selecione o que quer fazer com ele +serial_detection-new_device-p2 = Selecione o que deseja fazer com ele serial_detection-open_wifi = Conectar ao Wi-Fi serial_detection-open_serial = Abrir o Console Serial serial_detection-submit = Enviar! @@ -142,16 +277,19 @@ serial_detection-close = Fechar ## Navigation bar navbar-home = Início -navbar-body_proportions = Proporções do corpo +navbar-body_proportions = Proporções do Corpo navbar-trackers_assign = Atribuição de Tracker -navbar-mounting = Calibragem de Posição +navbar-mounting = Calibração de Montagem navbar-onboarding = Assistente de Configuração navbar-settings = Opções +navbar-connect_trackers = Conectar Trackers ## Biovision hierarchy recording bvh-start_recording = Gravar BVH +bvh-stop_recording = Salvar gravação BVH bvh-recording = Gravando... +bvh-save_title = Salvar gravação BVH ## Tracking pause @@ -160,38 +298,39 @@ tracking-paused = Retomar rastreamento ## Widget: Overlay settings -widget-overlay = Overlay -widget-overlay-is_visible_label = Mostrar Overlay na SteamVR -widget-overlay-is_mirrored_label = Mostrar Overlay como espelho +widget-overlay = Sobreposição +widget-overlay-is_visible_label = Mostrar Sobreposição no SteamVR +widget-overlay-is_mirrored_label = Mostrar Sobreposição como Espelho ## Widget: Drift compensation -widget-drift_compensation-clear = Refazer compensação de drift +widget-drift_compensation-clear = Limpar a compensação de drift ## Widget: Clear Mounting calibration -widget-clear_mounting = Limpar reset de posição +widget-clear_mounting = Limpar calibragem de posição ## Widget: Developer settings -widget-developer_mode = Modo de desenvolvedor -widget-developer_mode-high_contrast = High contrast -widget-developer_mode-precise_rotation = Precise rotation -widget-developer_mode-fast_data_feed = Fast data feed -widget-developer_mode-filter_slimes_and_hmd = Filter slimes and HMD -widget-developer_mode-sort_by_name = Sort by name +widget-developer_mode = Modo de Desenvolvedor +widget-developer_mode-high_contrast = Alto contraste +widget-developer_mode-precise_rotation = Rotação precisa +widget-developer_mode-fast_data_feed = Fluxo de dados rápido +widget-developer_mode-filter_slimes_and_hmd = Filtrar Slimes e HMD +widget-developer_mode-sort_by_name = Ordenar por nome widget-developer_mode-raw_slime_rotation = Rotação bruta -widget-developer_mode-more_info = More info +widget-developer_mode-more_info = Mais informações ## Widget: IMU Visualizer -widget-imu_visualizer = Rotação do tracker +widget-imu_visualizer = Dados de rastreamento widget-imu_visualizer-preview = Pré-visualização widget-imu_visualizer-hide = Esconder -widget-imu_visualizer-rotation_raw = Bruta +widget-imu_visualizer-rotation_raw = Rotação bruta widget-imu_visualizer-rotation_preview = Pré-visualizar rotação widget-imu_visualizer-acceleration = Aceleração widget-imu_visualizer-position = Posição +widget-imu_visualizer-stay_aligned = Manter Alinhado ## Widget: Skeleton Visualizer @@ -204,9 +343,9 @@ tracker-status-none = Sem Status tracker-status-busy = Ocupado tracker-status-error = Erro tracker-status-disconnected = Desconectado -tracker-status-occluded = Ocluso -tracker-status-ok = Conectado -tracker-status-timed_out = Tempo esgotado +tracker-status-occluded = Ocluído +tracker-status-ok = OK +tracker-status-timed_out = Tempo limite atingido ## Tracker status columns @@ -214,31 +353,33 @@ tracker-table-column-name = Nome tracker-table-column-type = Tipo tracker-table-column-battery = Bateria tracker-table-column-ping = Ping +tracker-table-column-packet_loss = Perda de Pacotes tracker-table-column-tps = TPS tracker-table-column-temperature = Temp. °C tracker-table-column-linear-acceleration = Aceleração. X/Y/Z tracker-table-column-rotation = Rotação X/Y/Z tracker-table-column-position = Posição X/Y/Z +tracker-table-column-stay_aligned = Manter Alinhado tracker-table-column-url = URL ## Tracker rotation tracker-rotation-front = Frente tracker-rotation-front_left = Frente-Esquerda -tracker-rotation-front_right = Frente-direita +tracker-rotation-front_right = Frente-Direita tracker-rotation-left = Esquerda tracker-rotation-right = Direita tracker-rotation-back = Atrás -tracker-rotation-back_left = Trás-esquerda -tracker-rotation-back_right = Trás-direita +tracker-rotation-back_left = Trás-Esquerda +tracker-rotation-back_right = Trás-Direita tracker-rotation-custom = Personalizado -tracker-rotation-overriden = (substituído pelo reset de posição) +tracker-rotation-overriden = (substituído pela calibração de montagem) ## Tracker information tracker-infos-manufacturer = Fabricante -tracker-infos-display_name = Nome de exibição -tracker-infos-custom_name = Nome personalizado +tracker-infos-display_name = Nome de Exibição +tracker-infos-custom_name = Nome Personalizado tracker-infos-url = URL do Tracker tracker-infos-version = Versão do firmware tracker-infos-hardware_rev = Revisão do hardware @@ -250,21 +391,24 @@ tracker-infos-network_version = Versão do protocolo tracker-infos-magnetometer = Magnetômetro tracker-infos-magnetometer-status-v1 = { $status -> - [DISABLED] Desabilitado - [ENABLED] Habilitado - *[NOT_SUPPORTED] Incompatível + [DISABLED] Desativado + [ENABLED] Ativado + *[NOT_SUPPORTED] Não Suportado } +tracker-infos-packet_loss = Perda de Pacotes +tracker-infos-packets_lost = Perda de Pacotes +tracker-infos-packets_received = Pacotes Recebidos ## Tracker settings -tracker-settings-back = Voltar para lista de trackers -tracker-settings-title = Opções dos trackers +tracker-settings-back = Voltar para a lista de trackers +tracker-settings-title = Opções dos Trackers tracker-settings-assignment_section = Atribuição -tracker-settings-assignment_section-description = Qual parte do seu corpo o tracker está atribuído para. +tracker-settings-assignment_section-description = A qual parte do corpo o tracker está atribuído. tracker-settings-assignment_section-edit = Editar atribuição -tracker-settings-mounting_section = Posicionamento -tracker-settings-mounting_section-description = Aonde o tracker está posicionado? -tracker-settings-mounting_section-edit = Editar posição +tracker-settings-mounting_section = Orientação de montagem +tracker-settings-mounting_section-description = Onde o tracker está posicionado? +tracker-settings-mounting_section-edit = Editar montagem tracker-settings-drift_compensation_section = Ligar a compensação de drift tracker-settings-drift_compensation_section-description = Esse tracker deverá compensar pelo drift quando a compensação de drift estiver ligado? tracker-settings-drift_compensation_section-edit = Ligar a compensação de drift @@ -284,10 +428,16 @@ tracker-settings-name_section-label = Nome do tracker tracker-settings-forget = Esquecer o tracker tracker-settings-forget-description = Remove o tracker do servidor SlimeVR e impede que ele se conecte a ele até que o servidor seja reiniciado. A configuração do tracker não será perdida. tracker-settings-forget-label = Esquecer o tracker +tracker-settings-update-unavailable-v2 = Nenhuma versão nova encontrada +tracker-settings-update-incompatible = Não é possível atualizar. Placa ou versão de firmware incompatível tracker-settings-update-low-battery = Não é possível atualizar. Bateria abaixo de 50% tracker-settings-update-up_to_date = Atualizado +tracker-settings-update-blocked = Atualização não disponível. Não há outras novas versões disponíveis tracker-settings-update = Atualizar agora tracker-settings-update-title = Versão do firmware +tracker-settings-current-version = Atual +tracker-settings-latest-version = Último +tracker-settings-build-date = Data da Compilação ## Tracker part card info @@ -296,8 +446,8 @@ tracker-part_card-unassigned = Não atribuído ## Body assignment menu -body_assignment_menu = Aonde você quer que esse tracker fique? -body_assignment_menu-description = Escolha um local onde você quer que esse tracker seja atribuído. Alternativamente você pode escolher arrumar todos os tracker de uma vez, ao invés de um por um. +body_assignment_menu = Onde você quer que esse tracker fique? +body_assignment_menu-description = Escolha a parte do corpo à qual deseja atribuir este tracker. Você também pode gerenciar todos os trackers de uma vez, em vez de configurá-los individualmente. body_assignment_menu-show_advanced_locations = Mostrar locais de atribuição avançados body_assignment_menu-manage_trackers = Arrumar todos os trackers body_assignment_menu-unassign_tracker = Desatribuir tracker @@ -309,33 +459,33 @@ body_assignment_menu-unassign_tracker = Desatribuir tracker # # We are using it here because english doesn't require changing the text in each case but # maybe your language does. --tracker_selection-part = Which tracker to assign to your +-tracker_selection-part = Qual tracker atribuir ao(à) seu(sua) tracker_selection_menu-NONE = Which tracker do you want to be unassigned? -tracker_selection_menu-HEAD = { -tracker_selection-part } head? -tracker_selection_menu-NECK = { -tracker_selection-part } neck? -tracker_selection_menu-RIGHT_SHOULDER = { -tracker_selection-part } right shoulder? -tracker_selection_menu-RIGHT_UPPER_ARM = { -tracker_selection-part } right upper arm? -tracker_selection_menu-RIGHT_LOWER_ARM = { -tracker_selection-part } right lower arm? -tracker_selection_menu-RIGHT_HAND = { -tracker_selection-part } right hand? -tracker_selection_menu-RIGHT_UPPER_LEG = { -tracker_selection-part } right thigh? -tracker_selection_menu-RIGHT_LOWER_LEG = { -tracker_selection-part } right ankle? -tracker_selection_menu-RIGHT_FOOT = { -tracker_selection-part } right foot? -tracker_selection_menu-RIGHT_CONTROLLER = { -tracker_selection-part } right controller? +tracker_selection_menu-HEAD = { -tracker_selection-part } cabeça? +tracker_selection_menu-NECK = { -tracker_selection-part } pescoço? +tracker_selection_menu-RIGHT_SHOULDER = { -tracker_selection-part } ombro direito? +tracker_selection_menu-RIGHT_UPPER_ARM = { -tracker_selection-part } braço superior direito? +tracker_selection_menu-RIGHT_LOWER_ARM = { -tracker_selection-part } antebraço direito? +tracker_selection_menu-RIGHT_HAND = { -tracker_selection-part } mão direita? +tracker_selection_menu-RIGHT_UPPER_LEG = { -tracker_selection-part } coxa direita? +tracker_selection_menu-RIGHT_LOWER_LEG = { -tracker_selection-part } tornozelo direito? +tracker_selection_menu-RIGHT_FOOT = { -tracker_selection-part } pé direito? +tracker_selection_menu-RIGHT_CONTROLLER = { -tracker_selection-part } controle direito? tracker_selection_menu-UPPER_CHEST = { -tracker_selection-part } peito superior? -tracker_selection_menu-CHEST = { -tracker_selection-part } chest? -tracker_selection_menu-WAIST = { -tracker_selection-part } waist? -tracker_selection_menu-HIP = { -tracker_selection-part } hip? -tracker_selection_menu-LEFT_SHOULDER = { -tracker_selection-part } left shoulder? -tracker_selection_menu-LEFT_UPPER_ARM = { -tracker_selection-part } left upper arm? -tracker_selection_menu-LEFT_LOWER_ARM = { -tracker_selection-part } left lower arm? -tracker_selection_menu-LEFT_HAND = { -tracker_selection-part } left hand? -tracker_selection_menu-LEFT_UPPER_LEG = { -tracker_selection-part } left thigh? -tracker_selection_menu-LEFT_LOWER_LEG = { -tracker_selection-part } left ankle? -tracker_selection_menu-LEFT_FOOT = { -tracker_selection-part } left foot? -tracker_selection_menu-LEFT_CONTROLLER = { -tracker_selection-part } left controller? -tracker_selection_menu-unassigned = Trackers não atribuídos -tracker_selection_menu-assigned = Trackers atribuídos -tracker_selection_menu-dont_assign = Não atribuir +tracker_selection_menu-CHEST = { -tracker_selection-part } peito? +tracker_selection_menu-WAIST = { -tracker_selection-part } cintura? +tracker_selection_menu-HIP = { -tracker_selection-part } quadril? +tracker_selection_menu-LEFT_SHOULDER = { -tracker_selection-part } ombro esquerdo? +tracker_selection_menu-LEFT_UPPER_ARM = { -tracker_selection-part } braço superior esquerdo? +tracker_selection_menu-LEFT_LOWER_ARM = { -tracker_selection-part } antebraço esquerdo? +tracker_selection_menu-LEFT_HAND = { -tracker_selection-part } mão esquerda? +tracker_selection_menu-LEFT_UPPER_LEG = { -tracker_selection-part } coxa esquerda? +tracker_selection_menu-LEFT_LOWER_LEG = { -tracker_selection-part } tornozelo esquerdo? +tracker_selection_menu-LEFT_FOOT = { -tracker_selection-part } pé esquerdo? +tracker_selection_menu-LEFT_CONTROLLER = { -tracker_selection-part } controle esquerdo? +tracker_selection_menu-unassigned = Trackers Não Atribuídos +tracker_selection_menu-assigned = Trackers Atribuídos +tracker_selection_menu-dont_assign = Não Atribuir # This line cares about multilines. # text means that the text should be bold. tracker_selection_menu-neck_warning = @@ -346,22 +496,27 @@ tracker_selection_menu-neck_warning-cancel = Cancelar ## Mounting menu -mounting_selection_menu = Aonde você quer que esse tracker fique? +mounting_selection_menu = Onde você quer que esse tracker fique? mounting_selection_menu-close = Fechar ## Sidebar settings settings-sidebar-title = Opções settings-sidebar-general = Geral +settings-sidebar-steamvr = SteamVR settings-sidebar-tracker_mechanics = Mecânicas do Tracker -settings-sidebar-fk_settings = Opções dos trackers +settings-sidebar-stay_aligned = Manter Alinhado +settings-sidebar-fk_settings = Opções dos Trackers settings-sidebar-gesture_control = Controle de Gestos settings-sidebar-interface = Interface settings-sidebar-osc_router = Roteador OSC settings-sidebar-osc_trackers = Trackers OSC do VRChat +settings-sidebar-osc_vmc = VMC settings-sidebar-utils = Utilidades settings-sidebar-serial = Console Serial settings-sidebar-appearance = Aparência +settings-sidebar-home = Tela Inicial +settings-sidebar-checklist = Checklist do Rastreamento settings-sidebar-notifications = Notificações settings-sidebar-behavior = Comportamento settings-sidebar-firmware-tool = Ferramenta de firmware DIY @@ -377,8 +532,8 @@ settings-general-steamvr-subtitle = Trackers do SteamVR # The first spaces (not tabs) for indentation will be ignored, just to make the file look nice when writing. # This one is one of this cases that cares about multilines settings-general-steamvr-description = - Ativar ou desativar partes específicas do tracking. - Útil se você quer mais controle do que o SlimeVR faz. + Ativa ou desativa trackers específicos do SteamVR. + Útil para jogos ou aplicativos que suportam apenas determinados trackers. settings-general-steamvr-trackers-waist = Cintura settings-general-steamvr-trackers-chest = Peito settings-general-steamvr-trackers-left_foot = Pé esquerdo @@ -390,7 +545,7 @@ settings-general-steamvr-trackers-right_elbow = Cotovelo direito settings-general-steamvr-trackers-left_hand = Mão esquerda settings-general-steamvr-trackers-right_hand = Mão direita settings-general-steamvr-trackers-tracker_toggling = Atribuição automática de trackers -settings-general-steamvr-trackers-tracker_toggling-description = Liga ou desliga automaticamente os trackers SteamVR dependendo das suas configurações atuais dos trackers +settings-general-steamvr-trackers-tracker_toggling-description = Liga ou desliga automaticamente os trackers do SteamVR com base nas atribuições atuais dos trackers. settings-general-steamvr-trackers-tracker_toggling-label = Atribuição automática de trackers settings-general-steamvr-trackers-hands-warning = Aviso: os trackers de mão substituirão seus controles. @@ -404,22 +559,23 @@ settings-general-tracker_mechanics = Mecânicas do Tracker settings-general-tracker_mechanics-filtering = Filtros # This also cares about multilines settings-general-tracker_mechanics-filtering-description = - Escolha o tipo de filtro para seus trackers. - Predição prediz movimentação enquanto suavização suaviza o movimento. + Escolha o tipo de filtragem para seus trackers. + A predição antecipa o movimento, enquanto a suavização reduz as variações do movimento. settings-general-tracker_mechanics-filtering-type = Tipo de filtro settings-general-tracker_mechanics-filtering-type-none = Sem filtro -settings-general-tracker_mechanics-filtering-type-none-description = Utiliza as rotações como registradas. Sem qualquer tipo de filtro. +settings-general-tracker_mechanics-filtering-type-none-description = Usa as rotações como estão. Não aplicará nenhuma filtragem. settings-general-tracker_mechanics-filtering-type-smoothing = Suavização -settings-general-tracker_mechanics-filtering-type-smoothing-description = Suaviza o movimento porém introduz um pouco de latência. +settings-general-tracker_mechanics-filtering-type-smoothing-description = Suaviza os movimentos, mas adiciona um pouco de latência. settings-general-tracker_mechanics-filtering-type-prediction = Predição -settings-general-tracker_mechanics-filtering-type-prediction-description = Reduz latência e torna os movimentos mais responsivos, porém aumenta tremulação (Jitter). +settings-general-tracker_mechanics-filtering-type-prediction-description = Reduz a latência e torna os movimentos mais responsivos, mas pode aumentar a oscilação. settings-general-tracker_mechanics-filtering-amount = Quantidade -settings-general-tracker_mechanics-yaw-reset-smooth-time = Suavisação do reset de guinada (yaw). (0s desativa a suavização) +settings-general-tracker_mechanics-yaw-reset-smooth-time = Tempo de suavização do "Redefinir Rápido (Guinada)" (0s desativa a suavização) settings-general-tracker_mechanics-drift_compensation = Compensação de drift # This cares about multilines settings-general-tracker_mechanics-drift_compensation-description = - Compensa o drift de guinada (yaw) aplicando uma rotação inversa. - Mudar a quantidade de compensação e até quantos resets vão ser levados em conta. + Compensa o drift de guinada do IMU aplicando uma rotação inversa. + Altere a quantidade de compensação e o número de redefinições consideradas. + Isso só deve ser usado se você precisar executar "Redefinir Tudo" com muita frequência! settings-general-tracker_mechanics-drift_compensation-enabled-label = Compensação de drift settings-general-tracker_mechanics-drift_compensation-prediction = Predição de compensação de drift # This cares about multilines @@ -434,21 +590,39 @@ settings-general-tracker_mechanics-drift_compensation_warning = Algumas IMUs sujeitas a resets mais frequentes incluem: Joy-Cons, owoTrack e MPUs (sem firmware recente). settings-general-tracker_mechanics-drift_compensation_warning-cancel = Cancelar -settings-general-tracker_mechanics-drift_compensation_warning-done = Eu entedi +settings-general-tracker_mechanics-drift_compensation_warning-done = Eu entendi settings-general-tracker_mechanics-drift_compensation-amount-label = Quantidade de compensação settings-general-tracker_mechanics-drift_compensation-max_resets-label = Use até x últimos resets -settings-general-tracker_mechanics-save_mounting_reset = Salvar a calibragem automática de posição -settings-general-tracker_mechanics-save_mounting_reset-description = - Salva as calibrações automáticas de reset de posição para os trackers entre as reinicializações. Útil - ao usar uma roupa em que os trackers não se movem entre as sessões. Não recomendado para usuários normais! -settings-general-tracker_mechanics-save_mounting_reset-enabled-label = Salvar reset de Posição +settings-general-tracker_mechanics-save_mounting_reset = Salvar calibração automática de montagem +settings-general-tracker_mechanics-save_mounting_reset-description = Salva a calibração automática de montagem dos trackers entre reinicializações. Útil ao usar um traje em que os trackers não se movem entre as sessões. Não recomendado para usuários normais! +settings-general-tracker_mechanics-save_mounting_reset-enabled-label = Salvar calibração de montagem settings-general-tracker_mechanics-use_mag_on_all_trackers = Usar o magnetômetro em todos os trackers IMUs compatíveis settings-general-tracker_mechanics-use_mag_on_all_trackers-description = Usa o magnetômetro em todos os trackers com firmware compatível, reduzindo o drift em ambientes magneticamente estáveis. - Essa opção pode ser desativada indivualmente nas configurações de cada tracker. Não desligue nenhum dos trackers enquanto altera esta opção! + Essa opção pode ser desativada individualmente nas configurações de cada tracker. Não desligue nenhum dos trackers enquanto altera esta opção! settings-general-tracker_mechanics-use_mag_on_all_trackers-label = Usar o magnetômetro nos trackers +settings-general-tracker_mechanics-trackers_over_usb = Trackers via USB +settings-general-tracker_mechanics-trackers_over_usb-description = Ativa o recebimento de dados de trackers HID via USB. Certifique-se de que os trackers conectados tenham a opção conexão via HID ativada! +settings-general-tracker_mechanics-trackers_over_usb-enabled-label = Permitir que trackers HID se conectem diretamente via USB +settings-stay_aligned = Manter Alinhado +settings-stay_aligned-description = O Manter Alinhado reduz o drift ao ajustar gradualmente seus trackers para corresponder às suas poses relaxadas +settings-stay_aligned-setup-label = Configurar o Manter Alinhado +settings-stay_aligned-setup-description = É necessário concluir “Configurar o Manter Alinhado” para ativar o Manter Alinhado. +settings-stay_aligned-warnings-drift_compensation = ⚠ Por favor, desative o Compensação de Drift! A Compensação de Drift entrará em conflito com o Manter Alinhado. +settings-stay_aligned-enabled-label = Ajustar trackers +settings-stay_aligned-hide_yaw_correction-label = Ocultar ajuste (para comparar sem o Manter Alinhado) settings-stay_aligned-general-label = Geral +settings-stay_aligned-relaxed_poses-label = Poses Relaxadas +settings-stay_aligned-relaxed_poses-description = O Manter Alinhado usa suas poses relaxadas para manter os trackers alinhados. Use “Configurar o Manter Alinhado” para atualizar essas poses. +settings-stay_aligned-relaxed_poses-standing = Ajustar trackers enquanto estiver em pé +settings-stay_aligned-relaxed_poses-sitting = Ajustar trackers enquanto estiver sentado em uma cadeira +settings-stay_aligned-relaxed_poses-flat = Ajustar trackers sentado no chão ou deitado de costas +settings-stay_aligned-relaxed_poses-save_pose = Salvar Pose +settings-stay_aligned-relaxed_poses-reset_pose = Redefinir Pose settings-stay_aligned-relaxed_poses-close = Fechar +settings-stay_aligned-debug-label = Depuração +settings-stay_aligned-debug-description = Inclua suas configurações ao relatar problemas relacionados ao Manter Alinhado. +settings-stay_aligned-debug-copy-label = Copiar configurações para a área de transferência ## FK/Tracking settings @@ -456,7 +630,7 @@ settings-general-fk_settings = Opções de Tracker # Floor clip: # why the name - came from the idea of noclip in video games, but is the opposite where clipping to the floor is a desired feature # definition - Prevents the foot trackers from going lower than they where when a reset was performed -settings-general-fk_settings-leg_tweak-floor_clip = Clipping de chão +settings-general-fk_settings-leg_tweak-floor_clip = Limite de chão # Skating correction: # why the name - without this enabled the feet will often slide across the ground as if your skating across the ground, # since this largely prevents this it corrects for it hence skating correction (note this may be renamed to sliding correction) @@ -465,59 +639,66 @@ settings-general-fk_settings-leg_tweak-skating_correction = Correção de desliz settings-general-fk_settings-leg_tweak-toe_snap = Encaixar os dedos do pé settings-general-fk_settings-leg_tweak-foot_plant = Pé plantado settings-general-fk_settings-leg_tweak-skating_correction-amount = Força da correção de deslize -settings-general-fk_settings-leg_tweak-skating_correction-description = A correção de deslize corrige o efeito de patinar, mas pode diminuir a precisão em certos padrões de movimento. Ativando essa correção, certifique-se de redefinir totalmente e recalibrar no jogo. -settings-general-fk_settings-leg_tweak-floor_clip-description = Clipping de chão pode reduzir e até eliminar o clipping através do chão. Ao ativar isso, certifique-se de redefinir completamente e recalibrar no jogo. +settings-general-fk_settings-leg_tweak-skating_correction-description = A correção de deslizamento corrige o efeito de patinação, mas pode reduzir a precisão em certos padrões de movimento. Ao ativá-la, certifique-se de executar "Redefinir Tudo" e recalibrar no jogo. +settings-general-fk_settings-leg_tweak-floor_clip-description = Limite de chão pode reduzir ou eliminar o atravessamento do chão. Ao ativá-lo, certifique-se de executar "Redefinir Tudo" e recalibrar no jogo. settings-general-fk_settings-leg_tweak-toe_snap-description = Encaixar os dedos do pé, tenta adivinhar a rotação dos seus pés se os trackers dos pés não estiverem em uso. settings-general-fk_settings-leg_tweak-foot_plant-description = Pé plantado gira os pés para ficarem paralelos ao chão quando em contato. settings-general-fk_settings-leg_fk = Tracking de pernas -settings-general-fk_settings-enforce_joint_constraints = Limites do esqueleto +settings-general-fk_settings-leg_fk-reset_mounting_feet-description-v1 = Forçar a calibração de montagem dos pés durante a calibração de montagem do corpo. +settings-general-fk_settings-leg_fk-reset_mounting_feet-v1 = Forçar a calibração de montagem dos pés +settings-general-fk_settings-enforce_joint_constraints = Limites esqueléticos settings-general-fk_settings-enforce_joint_constraints-enforce_constraints = Impor limites settings-general-fk_settings-enforce_joint_constraints-enforce_constraints-description = Evita que as articulações rotacionem além de seu limite settings-general-fk_settings-enforce_joint_constraints-correct_constraints = Corrigir com limites settings-general-fk_settings-enforce_joint_constraints-correct_constraints-description = Corrija as rotações das articulações quando elas ultrapassarem seus limites -settings-general-fk_settings-arm_fk = Opções do Braço -settings-general-fk_settings-arm_fk-description = Muda o jeito que os braços são rastreados. +settings-general-fk_settings-ik = Dados de posição +settings-general-fk_settings-ik-use_position = Usar dados de posição +settings-general-fk_settings-ik-use_position-description = Ativa o uso de dados de posição dos trackers que os fornecem. Ao ativar isso, certifique-se de executar "Redefinir Tudo" e recalibrar no jogo. +settings-general-fk_settings-velocity_settings = Configurações de Velocidade +settings-general-fk_settings-velocity_settings-description = Envia dados de velocidade derivados para o SteamVR. Necessário para suporte ao Natural Locomotion. Pode causar instabilidade no FBT. +settings-general-fk_settings-arm_fk = Rastreamento dos braços +settings-general-fk_settings-arm_fk-description = Forçar o rastreamento dos braços a partir do headset (HMD), mesmo que dados posicionais das mãos estejam disponíveis. settings-general-fk_settings-arm_fk-force_arms = Forçar braços do HMD settings-general-fk_settings-reset_settings = Redefinir configurações -settings-general-fk_settings-reset_settings-reset_hmd_pitch-description = Redefine a inclinação (rotação vertical) do HMD ao fazer um reset completo. Útil se estiver usando um HMD na testa para VTubing ou captura de movimento. Não ative para VR. +settings-general-fk_settings-reset_settings-reset_hmd_pitch-description = Redefine a inclinação (rotação vertical) do HMD ao executar "Redefinir Tudo". Útil ao usar um HMD na testa para VTubing ou captura de movimento. Não ative para VR. settings-general-fk_settings-reset_settings-reset_hmd_pitch = Redefinir a inclinação do HMD -settings-general-fk_settings-arm_fk-reset_mode-description = Mudar a pose do braço esperada para o reset de posição. +settings-general-fk_settings-arm_fk-reset_mode-description = Altera qual pose de braço é esperada para a calibração de montagem. settings-general-fk_settings-arm_fk-back = Atrás settings-general-fk_settings-arm_fk-back-description = O modo padrão, com os braços voltados para trás e os antebraços para frente. settings-general-fk_settings-arm_fk-tpose_up = T-pose (cima) -settings-general-fk_settings-arm_fk-tpose_up-description = Espera que seus braços fiquem para baixo nas laterais durante o reset completo e 90 graus para os lados durante o reset de posição. +settings-general-fk_settings-arm_fk-tpose_up-description = Espera que seus braços estejam abaixados ao lado do corpo durante "Redefinir Tudo" e levantados a 90 graus para os lados durante a "Calibração de Montagem". settings-general-fk_settings-arm_fk-tpose_down = T-pose (baixo) -settings-general-fk_settings-arm_fk-tpose_down-description = Espera que seus braços fiquem 90 graus para os lados durante o reset completo e para baixo nas laterais durante o reset de posição. -settings-general-fk_settings-arm_fk-forward = Frente -settings-general-fk_settings-arm_fk-forward-description = Espera que seus braços estejam 90 graus à frente. Útil para VTubing. +settings-general-fk_settings-arm_fk-tpose_down-description = Mantenha os braços levantados a 90 graus para os lados durante "Redefinir Tudo" e abaixados ao lado do corpo durante a "Calibração de Montagem". +settings-general-fk_settings-arm_fk-forward = Para frente +settings-general-fk_settings-arm_fk-forward-description = Espera que seus braços estejam levantados para frente a 90 graus. Útil para VTubing. settings-general-fk_settings-skeleton_settings-toggles = Opções do esqueleto -settings-general-fk_settings-skeleton_settings-description = Ligar ou desligar opções do esqueleto. É recomendado deixar eles ligados. +settings-general-fk_settings-skeleton_settings-description = Liga ou desliga as configurações do esqueleto. Recomenda-se deixá-las ligadas. settings-general-fk_settings-skeleton_settings-extended_spine_model = Modelo de coluna estendida settings-general-fk_settings-skeleton_settings-extended_pelvis_model = Modelo de pélvis estendida settings-general-fk_settings-skeleton_settings-extended_knees_model = Modelo de joelho estendido settings-general-fk_settings-skeleton_settings-ratios = Proporções do esqueleto settings-general-fk_settings-skeleton_settings-ratios-description = Mude os valores das configurações do esqueleto. Pode ser necessário ajustar suas proporções depois de alterá-las. settings-general-fk_settings-skeleton_settings-impute_waist_from_chest_hip = Imputar cintura do peito ao quadril -settings-general-fk_settings-skeleton_settings-impute_waist_from_chest_legs = Imputar cintura do peito às pernas -settings-general-fk_settings-skeleton_settings-impute_hip_from_chest_legs = Imputar o quadril do peito às pernas -settings-general-fk_settings-skeleton_settings-impute_hip_from_waist_legs = Imputar quadril da cintura às pernas -settings-general-fk_settings-skeleton_settings-interp_hip_legs = Calcular a média da guinada e rolamento do quadril com as pernas -settings-general-fk_settings-skeleton_settings-interp_knee_tracker_ankle = Calcular a média da guinada e rolamento dos trackers dos joelhos com os tornozelos -settings-general-fk_settings-skeleton_settings-interp_knee_ankle = Calcular a média da guinada e rolamento do joelho com os tornozelos +settings-general-fk_settings-skeleton_settings-impute_waist_from_chest_legs = Estimar a cintura com base no peito e nas pernas +settings-general-fk_settings-skeleton_settings-impute_hip_from_chest_legs = Estimar o quadril com base no peito e nas pernas +settings-general-fk_settings-skeleton_settings-impute_hip_from_waist_legs = Estimar o quadril com base na cintura e nas pernas +settings-general-fk_settings-skeleton_settings-interp_hip_legs = Calcular a média da guinada e da rolagem do quadril com as das pernas +settings-general-fk_settings-skeleton_settings-interp_knee_tracker_ankle = Calcular a média da guinada e da rolagem dos trackers de joelho com as dos tornozelos. +settings-general-fk_settings-skeleton_settings-interp_knee_ankle = Calcular a média da guinada e rolagem do joelho com os tornozelos settings-general-fk_settings-self_localization-title = Modo mocap settings-general-fk_settings-self_localization-description = O modo mocap permite que o esqueleto rastreie aproximadamente sua própria posição sem um headset ou outros trackers. Observe que isso requer trackers de pés e cabeça para funcionar e ainda é experimental. ## Gesture control settings (tracker tapping) -settings-general-gesture_control = Controle de gestos -settings-general-gesture_control-subtitle = Resets baseados em toques -settings-general-gesture_control-description = Faz com oque os resets sejam ativados tocando um tracker. O Tracker mais alto no seu torso é usado para o Reset Rápido, o tracker mais alto na sua perna esquerda é usado para o Reset, e o tracker mais alto na sua perna direita é usado para o Reset de Posição. Os toques devem ocorrer dentro de 0.6 segundos para serem registrados. +settings-general-gesture_control = Controle por Gestos +settings-general-gesture_control-subtitle = Redefinições baseadas em toque +settings-general-gesture_control-description = Permite que redefinições sejam acionadas ao tocar em um tracker. O tracker mais alto no tronco é usado para "Redefinir direção", o tracker mais alto na perna esquerda é usado para executar "Redefinir Tudo", e o tracker mais alto na perna direita é usado para a "Calibração de Montagem". Os toques devem ocorrer dentro do limite de 0,3 segundos multiplicado pelo número de toques para serem reconhecidos. # This is a unit: 3 taps, 2 taps, 1 tap # $amount (Number) - Amount of taps (touches to the tracker's case) settings-general-gesture_control-taps = { $amount -> - [one] 1 tap - *[other] { $amount } taps + [one] 1 toque + *[other] { $amount } toques } # This is a unit: 3 trackers, 2 trackers, 1 tracker # $amount (Number) - Amount of trackers @@ -527,15 +708,15 @@ settings-general-gesture_control-trackers = [many] trackers *[other] trackers } -settings-general-gesture_control-yawResetEnabled = Ativar toque para reset de guinada -settings-general-gesture_control-yawResetDelay = Atraso do reset de guinada -settings-general-gesture_control-yawResetTaps = Toques para reset de guinada -settings-general-gesture_control-fullResetEnabled = Habilitar toque para reset completo -settings-general-gesture_control-fullResetDelay = Atraso no reset completo -settings-general-gesture_control-fullResetTaps = Toques para reset completo -settings-general-gesture_control-mountingResetEnabled = Toques para o reset de posição -settings-general-gesture_control-mountingResetDelay = Delay do reset de posição -settings-general-gesture_control-mountingResetTaps = Toques para o reset de posição +settings-general-gesture_control-yawResetEnabled = Ativar toque para executar "Redefinir Rápido (Guinada)" +settings-general-gesture_control-yawResetDelay = Atraso para executar "Redefinir Rápido (Guinada)" +settings-general-gesture_control-yawResetTaps = Toques para executar "Redefinir Rápido (Guinada)" +settings-general-gesture_control-fullResetEnabled = Ativar toque para executar "Redefinir Tudo" +settings-general-gesture_control-fullResetDelay = Atraso do "Redefinir Tudo" +settings-general-gesture_control-fullResetTaps = Toques para executar "Redefinir Tudo" +settings-general-gesture_control-mountingResetEnabled = Ativar toque para realizar a "Calibração de Montagem" +settings-general-gesture_control-mountingResetDelay = Atraso da calibração da montagem +settings-general-gesture_control-mountingResetTaps = Toques para calibração de montagem # The number of trackers that can have higher acceleration before a tap is rejected settings-general-gesture_control-numberTrackersOverThreshold = Trackers acima do limite settings-general-gesture_control-numberTrackersOverThreshold-description = Aumente esse valor se a detecção de toque não estiver funcionando. Não aumente o valor acima do necessário para que a detecção de toque funcione, pois isso causaria mais falsos positivos. @@ -545,7 +726,7 @@ settings-general-gesture_control-numberTrackersOverThreshold-description = Aumen settings-interface-appearance = Aparência settings-general-interface-dev_mode = Modo de desenvolvedor settings-general-interface-dev_mode-description = Este modo pode ser útil se precisar de dados específicos ou para interagir com trackers conectados a um nível mais avançado -settings-general-interface-dev_mode-label = Modo de desenvolvedor +settings-general-interface-dev_mode-label = Modo de Desenvolvedor settings-general-interface-theme = Cor do tema settings-general-interface-show-navbar-onboarding = Mostrar "{ navbar-onboarding }" na barra de navegação settings-general-interface-show-navbar-onboarding-description = Isso muda se o botão "{ navbar-onboarding }" for exibido na barra de navegação. @@ -562,7 +743,7 @@ settings-interface-appearance-font-slime_font = Fonte padrão settings-interface-appearance-font_size = Escala da fonte settings-interface-appearance-font_size-description = Isso afeta o tamanho da fonte de toda a interface, exceto neste painel de configurações. settings-interface-appearance-decorations = Use as decorações nativas do sistema -settings-interface-appearance-decorations-description = Quando essa opção estiver ativada, a barra de título do SlimeVR não será exibida, mas será substituída pela barra de título nativa do sistema. +settings-interface-appearance-decorations-description = Isso não renderizará a barra superior da interface e usará a do sistema operacional no lugar. settings-interface-appearance-decorations-label = Usar a barra de título nativa do sistema ## Notification settings @@ -596,28 +777,31 @@ settings-general-interface-discord_presence-message = } settings-interface-behavior-error_tracking = Coleta de erros via Sentry.io settings-interface-behavior-error_tracking-description_v2 = -

Você

concorda com a coleta de dados de erro anônimos?

Não coletamos informações pessoais, como seu endereço IP ou credenciais de rede sem fio. A SlimeVR valoriza sua privacidade! +

Você consente com a coleta de dados de erro anonimizados?

- Para oferecer a melhor experiência ao usuário, coletamos relatórios de erros anônimos, métricas de desempenho e informações do sistema operacional. Isso nos ajuda a detectar bugs e problemas com o SlimeVR. Essas métricas são coletadas pelo Sentry.io. + Não coletamos informações pessoais, como seu endereço IP ou credenciais de rede sem fio. O SlimeVR valoriza sua privacidade! - Traduzido com a versão gratuita do tradutor - DeepL.com + Para oferecer a melhor experiência possível, coletamos relatórios de erro anonimizados, métricas de desempenho e informações sobre o sistema operacional. Isso nos ajuda a identificar bugs e problemas no SlimeVR. Essas métricas são coletadas por meio do Sentry.io. settings-interface-behavior-error_tracking-label = Enviar erros para os desenvolvedores +settings-interface-behavior-bvh_directory = Diretório para salvar gravações BVH +settings-interface-behavior-bvh_directory-description = Escolha um diretório para salvar suas gravações BVH, em vez de precisar escolher onde salvá-las a cada vez. +settings-interface-behavior-bvh_directory-label = Diretório para gravações BVH ## Serial settings settings-serial = Console Serial # This cares about multilines settings-serial-description = - Este é um feed de informações ao vivo para comunicação serial. - Pode ser útil se você precisar saber se o firmware está tendo problemas. -settings-serial-connection_lost = Conexão com o serial perdida, Reconectando... + Este é um feed de informações em tempo real para comunicação serial. + Pode ser útil para depurar problemas de firmware ou hardware. +settings-serial-connection_lost = Conexão com o serial perdida, reconectando... settings-serial-reboot = Reiniciar -settings-serial-factory_reset = Restaurar para o padrão de fábrica +settings-serial-factory_reset = Redefinição de Fábrica # This cares about multilines # text means that the text should be bold settings-serial-factory_reset-warning = - Atenção: Isso vai restaurar o tracker para o padrão de fábrica. - Que significa que as opções de Wi-Fi e calibrações vão ser todos perdidos! + Aviso: Isso redefinirá o tracker para as configurações de fábrica. + Isso significa que as configurações de Wi-Fi e de calibração serão todas perdidas! settings-serial-factory_reset-warning-ok = Eu sei o que estou fazendo settings-serial-factory_reset-warning-cancel = Cancelar settings-serial-serial_select = Selecione uma porta serial @@ -625,30 +809,35 @@ settings-serial-auto_dropdown_item = Auto settings-serial-get_wifi_scan = Obter varredura WiFi settings-serial-file_type = Texto simples settings-serial-save_logs = Salvar em arquivo +settings-serial-send_command = Enviar +settings-serial-send_command-placeholder = Comando... +settings-serial-send_command-warning = Aviso: executar comandos seriais pode causar perda de dados ou inutilizar os trackers. +settings-serial-send_command-warning-ok = Eu sei o que estou fazendo +settings-serial-send_command-warning-cancel = Cancelar ## OSC router settings settings-osc-router = Roteador OSC # This cares about multilines settings-osc-router-description = - Encaminhar mensagens OSC de outro programa. - Útil para usar outro programa OSC com VRChat, por exemplo. + Encaminha mensagens OSC de outro programa. + Útil para usar outro programa OSC com o VRChat, por exemplo. settings-osc-router-enable = Ativar -settings-osc-router-enable-description = Ligar ou desligar o encaminhamento de mensagens +settings-osc-router-enable-description = Liga ou desliga o encaminhamento de mensagens. settings-osc-router-enable-label = Ativar settings-osc-router-network = Portas de rede # This cares about multilines settings-osc-router-network-description = - Defina as portas para receber e enviar dados - Esses podem ser as mesmas portas usadas no servidor do SlimeVR + Defina as portas para receber e enviar dados. + Elas podem ser as mesmas portas usadas no servidor do SlimeVR. settings-osc-router-network-port_in = - .label = Porta de entrada + .label = Porta de Entrada .placeholder = Porta de entrada (padrão: 9002) settings-osc-router-network-port_out = - .label = Porta de saída + .label = Porta de Saída .placeholder = Porta de saída (padrão: 9000) settings-osc-router-network-address = Endereço de rede -settings-osc-router-network-address-description = Defina o endereço para mandar dados +settings-osc-router-network-address-description = Defina o endereço para o qual os dados serão enviados. settings-osc-router-network-address-placeholder = Endereço IPV4 ## OSC VRChat settings @@ -673,16 +862,16 @@ settings-osc-vrchat-oscqueryEnabled-label = Ativar OSCQuery settings-osc-vrchat-network = Portas de rede settings-osc-vrchat-network-description-v1 = Define as portas para receber e enviar dados. Pode ser deixado como está para o VRChat. settings-osc-vrchat-network-port_in = - .label = Porta de entrada + .label = Porta de Entrada .placeholder = Porta de entrada (padrão: 9001) settings-osc-vrchat-network-port_out = - .label = Porta de saída + .label = Porta de Saída .placeholder = Porta de saída (padrão: 9000) settings-osc-vrchat-network-address = Endereço de rede settings-osc-vrchat-network-address-description-v1 = Escolha o endereço para enviar os dados. Pode ser deixado como está para o VRChat. settings-osc-vrchat-network-address-placeholder = Endereço de ip do VRChat settings-osc-vrchat-network-trackers = Trackers -settings-osc-vrchat-network-trackers-description = Ligar ou desligar o envio e recepção de dados. +settings-osc-vrchat-network-trackers-description = Liga ou desliga o envio de trackers específicos via OSC. settings-osc-vrchat-network-trackers-chest = Peito settings-osc-vrchat-network-trackers-hip = Quadril settings-osc-vrchat-network-trackers-knees = Joelhos @@ -702,10 +891,10 @@ settings-osc-vmc-enable-label = Ativar settings-osc-vmc-network = Portas de rede settings-osc-vmc-network-description = Defina as portas para escutar e enviar dados via VMC (Virtual Motion Capture). settings-osc-vmc-network-port_in = - .label = Porta de entrada + .label = Porta de Entrada .placeholder = Porta de entrada (padrão: 39540) settings-osc-vmc-network-port_out = - .label = Porta de saída + .label = Porta de Saída .placeholder = Porta de saída (padrão: 39539) settings-osc-vmc-network-address = Endereço de rede settings-osc-vmc-network-address-description = Escolha o endereço para envio de dados via VMC (Virtual Motion Capture). @@ -723,19 +912,21 @@ settings-osc-vmc-mirror_tracking-label = Espelhar rastreamento ## Common OSC settings +settings-osc-common-network-ports_match_error = As portas de entrada e saída do OSC Router não podem ser as mesmas! +settings-osc-common-network-port_banned_error = A porta { $port } não pode ser usada! ## Advanced settings settings-utils-advanced = Avançado -settings-utils-advanced-reset-gui = Resetar configurações da interface -settings-utils-advanced-reset-gui-description = Restaura a interface para as configurações iniciais. -settings-utils-advanced-reset-gui-label = Resetar interface -settings-utils-advanced-reset-server = Resetar configuraçõse de tracking -settings-utils-advanced-reset-server-description = Restaura as configurações de tracking para as configurações iniciais. -settings-utils-advanced-reset-server-label = Resetar tracking +settings-utils-advanced-reset-gui = Redefinir configurações da interface +settings-utils-advanced-reset-gui-description = Restaurar as configurações padrão da interface. +settings-utils-advanced-reset-gui-label = Redefinir interface +settings-utils-advanced-reset-server = Redefinir configurações de rastreamento +settings-utils-advanced-reset-server-description = Restaura as configurações padrão do rastreamento. +settings-utils-advanced-reset-server-label = Redefinir rastreamento settings-utils-advanced-reset-all = Resetar todas as configurações settings-utils-advanced-reset-all-description = Restaura a configuração da interface e de tracking para as configurações iniciais. -settings-utils-advanced-reset-all-label = Resetar todas as configurações +settings-utils-advanced-reset-all-label = Redefinir todas as configurações settings-utils-advanced-reset_warning = { $type -> [gui] @@ -759,15 +950,21 @@ settings-utils-advanced-open_logs-label = Abrir pasta ## Home Screen +settings-home-list-layout = Layout da lista de trackers +settings-home-list-layout-desc = Selecione um dos layouts possíveis da tela inicial. +settings-home-list-layout-grid = Grade +settings-home-list-layout-table = Tabela ## Tracking Checlist +settings-tracking_checklist-active_steps = Etapas Ativas +settings-tracking_checklist-active_steps-desc = Lista de todas as etapas do checklist do rastreamento. Você pode optar por desativar etapas específicas. ## Setup/onboarding menu onboarding-skip = Pular configurações onboarding-continue = Continuar -onboarding-wip = Trabalho em progresso +onboarding-wip = Em desenvolvimento onboarding-previous_step = Passo anterior onboarding-setup_warning = Aviso: A configuração inicial é necessária para um rastreamento adequado, @@ -775,54 +972,109 @@ onboarding-setup_warning = onboarding-setup_warning-skip = Pular configurações onboarding-setup_warning-cancel = Continuar configurações +## Quiz + +onboarding-quiz_continue = Continuar +onboarding-quiz_back = Voltar +onboarding-quiz-more_sets_modal-title = Você conectou todos os seus trackers? +onboarding-quiz-more_sets_modal-desc = Se você tiver conjuntos de modelos diferentes, podemos conectá-los agora mesmo! +onboarding-quiz-more_sets_modal-confirm = Eu conectei todos os meus trackers +onboarding-quiz-more_sets_modal-cancel = Quero conectar mais trackers +onboarding-quiz-slimeset-title = Que tipo de trackers você está conectando? +onboarding-quiz-slimeset-description = Se você tiver vários conjuntos, você será solicitado novamente mais tarde no processo +onboarding-quiz-slimeset-official-sets = Trackers Oficiais do SlimeVR +onboarding-quiz-slimeset-thirdparty-sets = Trackers de Terceiros ou Feitos por Você. +onboarding-quiz-slimeset-answer-regular = SlimeVR V1.0 e V1.2 +onboarding-quiz-slimeset-answer-butterfly = Butterfly +onboarding-quiz-slimeset-answer-wifi = Slime baseado em Wi-Fi +onboarding-quiz-slimeset-answer-dongle = Slime baseado em Dongle +onboarding-quiz-usage-title = Para que você está usando seus trackers? +onboarding-quiz-usage-description = Se você pretende usar o SlimeVR para múltiplas finalidades, você pode alterar as configurações afetadas mais tarde. +onboarding-quiz-usage-answer-VRC = Jogos em VR (ex.: VRChat) +onboarding-quiz-usage-answer-mocap_vtubing = Mocap e VTubing +onboarding-quiz-runtime-title = Você executa jogos via SteamVR ou diretamente no headset (standalone)? +onboarding-quiz-runtime-answer-steamvr = SteamVR +onboarding-quiz-runtime-answer-standalone = Standalone +onboarding-quiz-mocap_preferences-title = Preferências de Mocap +onboarding-quiz-mocap_preferences-desc = Especifique como você pretende usar o SlimeVR para mocap ou VTubing +onboarding-quiz-mocap_preferences-playspace-title = Qual é o seu espaço de jogo? +onboarding-quiz-mocap_preferences-playspace-desc = Se estiver em pé, o SlimeVR tentará rastrear o movimento de caminhada em vez de manter você ancorado em um único lugar. +onboarding-quiz-mocap_preferences-playspace-sitting = Sentado +onboarding-quiz-mocap_preferences-playspace-standing = Em Pé +onboarding-quiz-mocap_preferences-vrm_model-title = Você tem um modelo VRM? (Opcional) +onboarding-quiz-mocap_preferences-vrm_model-desc = Carregar um modelo VRM melhorará a qualidade do rastreamento e a compatibilidade com aplicativos que usam VMC. +onboarding-quiz-mocap_preferences-head_tracker-title = Você está usando um tracker ou um headset VR na cabeça? +onboarding-quiz-mocap_preferences-head_tracker-yes = Sim +onboarding-quiz-mocap_preferences-head_tracker-no = Não +onboarding-quiz-mocap_preferences-head_tracker_location-title = Onde está localizado o seu rastreador de cabeça? +onboarding-quiz-mocap_preferences-head_tracker_location-forehead = Testa +onboarding-quiz-mocap_preferences-head_tracker_location-face = Rosto + ## Wi-Fi setup -onboarding-wifi_creds-back = Voltar para introdução +onboarding-wifi_creds-back-v2 = Voltar +onboarding-wifi_creds-v2 = Trackers conectados via Wi-Fi +# This cares about multilines +onboarding-wifi_creds-description-v2 = + A maioria dos trackers (como os trackers oficiais do SlimeVR) utiliza Wi-Fi para se conectar ao servidor. + Use as credenciais da rede Wi-Fi à qual seu dispositivo está conectado no momento. + + Certifique-se de usar uma conexão Wi-Fi de 2,4 GHz para os seus trackers! onboarding-wifi_creds-skip = Pular as configurações de Wi-Fi onboarding-wifi_creds-submit = Enviar! onboarding-wifi_creds-ssid = - .label = SSID - .placeholder = Enter SSID -onboarding-wifi_creds-ssid-required = Nome do Wi-Fi é obrgiatório + .label = Nome da rede Wi-Fi + .placeholder = Digite o nome da rede Wi-Fi +onboarding-wifi_creds-ssid-required = Nome do Wi-Fi é obrigatório onboarding-wifi_creds-password = - .label = Password - .placeholder = Enter password + .label = Senha + .placeholder = Digite a senha +onboarding-wifi_creds-dongle-title = Trackers conectados via dongle +onboarding-wifi_creds-dongle-description = Se seus trackers vieram com um dongle, conecte-o ao seu dispositivo e você estará pronto para usar! +onboarding-wifi_creds-dongle-wip = Esta seção ainda está em desenvolvimento. Uma página dedicada para gerenciar trackers que se conectam via dongle será criada em breve. +onboarding-wifi_creds-dongle-continue = Continuar com um dongle ## Mounting setup -onboarding-reset_tutorial-back = Voltar para a Calibragem de Posição -onboarding-reset_tutorial = Resetar Tutorial +onboarding-reset_tutorial-back = Voltar para a calibração de montagem +onboarding-reset_tutorial = Reiniciar tutorial onboarding-reset_tutorial-explanation = Enquanto você usa os trackers, eles podem ficar desalinhados devido ao drift de guinada (yaw) da IMU ou porque você pode ter movido os trackers fisicamente. Há várias maneiras de corrigir isso. onboarding-reset_tutorial-skip = Pular passo # Cares about multiline onboarding-reset_tutorial-0 = - Toque { $taps } vezes no tracker destacado para acionar o reset de guinada. + Toque no tracker destacado { $taps } vezes para executar "Redefinir Rápido (Guinada)". - Isso fará com que os trackers fiquem na mesma direção que o seu headset (HMD). + Isso fará com que os trackers fiquem voltados para a mesma direção que o seu headset (HMD). # Cares about multiline onboarding-reset_tutorial-1 = - Toque { $taps } vezes no tracker destacado para acionar o reset completo. + Toque no tracker destacado { $taps } vezes para executar "Redefinir Tudo". - Você precisa estar em pé para isso (pose-i). Há um atraso de 3 segundos (configurável) antes que realmente aconteça. - Isso reseta completamente a posição e rotação de todos os seus trackers. Deve corrigir a maioria dos problemas. + Você precisa estar em pé para isso (pose em I). Há um atraso de 3 segundos (configurável) antes que isso aconteça. Isso redefine completamente a posição e a rotação de todos os seus trackers. Isso deve corrigir a maioria dos problemas. # Cares about multiline onboarding-reset_tutorial-2 = - Toque { $taps } vezes no tracker destacado para acionar o reset de posição. + Toque no tracker destacado { $taps } vezes para acionar a calibração de montagem. - O reset de posição ajuda a ajustar como os trackers estão realmente colocados em você, então, se você os moveu acidentalmente e alterou significativamente a orientação, isso ajudará. + A calibração de montagem ajusta conforme os trackers estão fixados no seu corpo. Se eles tiverem se movido ou girado significativamente, isso ajuda a recalibrar a orientação deles. - Você precisa estar em uma pose como se estivesse esquiando, como é mostrado no Assistente de Posição Automática, e há um atraso de 3 segundos (configurável) antes que seja acionado. + Você precisa estar em uma pose como se estivesse esquiando, conforme mostrado no assistente de montagem automática, e há um atraso de 3 segundos (configurável) antes que ela seja acionada. + +## Install info + +install-info_udev-rules_modal_title = Regras de acesso udev de hardware não encontradas +install-info_udev-rules_warning = Regras de acesso via udev são necessárias para acesso ao console serial e conexão do dongle. Cole o seguinte comando no seu terminal para adicionar as regras do udev. +install-info_udev-rules_modal_button = Fechar +install-info_udev-rules_modal-dont-show-again_checkbox = Não mostrar novamente ## Setup start -onboarding-home = Bem vindo ao SlimeVR +onboarding-home = Bem-vindo ao SlimeVR onboarding-home-start = Vamos configurar! ## Setup done onboarding-done-title = Está tudo pronto! -onboarding-done-description = Aproveite sua experiência com full body -onboarding-done-close = Fechar o guia +onboarding-done-description = Aproveite sua experiência de rastreamento corporal completo +onboarding-done-close = Fechar configuração ## Tracker connection setup @@ -836,11 +1088,22 @@ onboarding-connect_tracker-connection_status-none = Procurando por trackers onboarding-connect_tracker-connection_status-serial_init = Conectando ao dispositivo serial onboarding-connect_tracker-connection_status-obtaining_mac_address = Obtendo o endereço MAC do tracker onboarding-connect_tracker-connection_status-provisioning = Enviando credenciais de Wi-Fi -onboarding-connect_tracker-connection_status-connecting = Enviando credenciais de Wi-Fi +onboarding-connect_tracker-connection_status-connecting = Tentando conectar à rede Wi-Fi onboarding-connect_tracker-connection_status-looking_for_server = Procurando servidor onboarding-connect_tracker-connection_status-connection_error = Não é possível conectar ao Wi-Fi onboarding-connect_tracker-connection_status-could_not_find_server = Não foi possível conectar ao servidor -onboarding-connect_tracker-connection_status-done = Conectado ao servidor +onboarding-connect_tracker-connection_status-done = Conectado ao Servidor +onboarding-connect_tracker-connection_status-no_serial_log = Não foi possível coletar os logs do tracker +onboarding-connect_tracker-connection_status-no_serial_device_found = Não foi possível encontrar um tracker via USB +onboarding-connect_serial-error-modal-no_serial_log = O tracker está ligado? +onboarding-connect_serial-error-modal-no_serial_log-desc = Certifique-se de que o tracker esteja ligado e conectado ao seu computador. +onboarding-connect_serial-error-modal-no_serial_device_found = Nenhum tracker detectado +onboarding-connect_serial-error-modal-no_serial_device_found-desc = + Conecte um tracker ao seu computador usando o cabo USB fornecido e ligue o tracker. + Se isso não funcionar: + — tente usar um cabo USB diferente + — tente usar uma porta USB diferente + — tente reinstalar o servidor do SlimeVR e selecione “Drivers USB” na seção de componentes # $amount (Number) - Amount of trackers connected (this is a number, but you can use CLDR plural rules for your language) # More info on https://www.unicode.org/cldr/cldr-aux/charts/22/supplemental/language_plural_rules.html # English in this case only has 2 plural rules, which are "one" and "other", @@ -848,9 +1111,9 @@ onboarding-connect_tracker-connection_status-done = Conectado ao servidor # if $amount is 0 then we say "No trackers connected" onboarding-connect_tracker-connected_trackers = { $amount -> - [0] No trackers connected - [one] 1 tracker connected - *[other] { $amount } trackers connected + [0] Nenhum tracker conectado + [one] 1 tracker conectado + *[other] { $amount } trackers conectados } onboarding-connect_tracker-next = Eu conectei todos os meus trackers @@ -858,6 +1121,7 @@ onboarding-connect_tracker-next = Eu conectei todos os meus trackers onboarding-calibration_tutorial = Tutorial de Calibração IMU. onboarding-calibration_tutorial-subtitle = Isso ajudará a reduzir o drift dos trackers! +onboarding-calibration_tutorial-description-v1 = Depois de ligar seus trackers, coloque-os sobre uma superfície estável por um momento para permitir a calibração. A calibração pode ser realizada a qualquer momento após os trackers serem ligados — esta página apenas fornece um tutorial. Para começar, clique no botão "{ onboarding-calibration_tutorial-calibrate }" e, em seguida, não mova seus trackers! onboarding-calibration_tutorial-calibrate = Eu coloquei meus trackers na mesa onboarding-calibration_tutorial-status-waiting = Esperando por você onboarding-calibration_tutorial-status-calibrating = Calibrando @@ -879,17 +1143,18 @@ onboarding-assignment_tutorial-done = Eu coloquei os adesivos e as straps! onboarding-assign_trackers-back = Voltar para as credenciais de Wi-Fi onboarding-assign_trackers-title = Atribuir trackers -onboarding-assign_trackers-description = Vamos escolher onde cada tracker vai. Clique no local onde você quer colocar o tracker +onboarding-assign_trackers-description = Vamos escolher qual tracker vai em cada lugar. Clique em um local onde deseja colocar um tracker. +onboarding-assign_trackers-unassign_all = Desatribuir todos os trackers # Look at translation of onboarding-connect_tracker-connected_trackers on how to use plurals # $assigned (Number) - Trackers that have been assigned a body part # $trackers (Number) - Trackers connected to the server onboarding-assign_trackers-assigned = { $trackers -> - [one] { $assigned } of 1 tracker assigned - *[other] { $assigned } of { $trackers } trackers assigned + [one] { $assigned } de 1 tracker atribuído + *[other] { $assigned } de { $trackers } trackers atribuídos } onboarding-assign_trackers-advanced = Mostrar locais de atribuição avançados -onboarding-assign_trackers-next = Atribui todos os trackers +onboarding-assign_trackers-next = Eu atribuí todos os trackers onboarding-assign_trackers-mirror_view = Inverter visão onboarding-assign_trackers-option-amount = { $trackersCount -> @@ -900,9 +1165,9 @@ onboarding-assign_trackers-option-label = { $mode -> [lower-body] Conjunto Lower-Body [core] Conjunto Core - [enhanced-core] Conjunto Enhanced + [enhanced-core] Conjunto Enhanced Core [full-body] Conjunto Full-Body - *[all] Todos os trackers + *[all] Todos os Trackers } onboarding-assign_trackers-option-description = { $mode -> @@ -983,67 +1248,79 @@ onboarding-assign_trackers-warning-WAIST = ## Tracker mounting method choose -onboarding-choose_mounting = Qual método de calibração de posição você deseja usar? +onboarding-choose_mounting = Qual método de calibração de montagem usar? # Multiline text -onboarding-choose_mounting-description = A orientação de posição corrige a colocação dos trackers no seu corpo. -onboarding-choose_mounting-auto_mounting = Posição automática +onboarding-choose_mounting-description = A orientação de montagem corrige a forma como os trackers estão fixados no seu corpo. +onboarding-choose_mounting-auto_mounting = Montagem automática # Italicized text onboarding-choose_mounting-auto_mounting-label-v2 = Recomendado -onboarding-choose_mounting-auto_mounting-description = Isso detectará automaticamente as direções de posição para todos os seus trackers a partir de 2 poses -onboarding-choose_mounting-manual_mounting = Posição manual +onboarding-choose_mounting-auto_mounting-description = Isso detectará automaticamente as orientações de montagem de todos os seus trackers a partir de duas poses. +onboarding-choose_mounting-manual_mounting = Montagem manual # Italicized text onboarding-choose_mounting-manual_mounting-label-v2 = Pode não ser precisa o suficiente -onboarding-choose_mounting-manual_mounting-description = Isso permitirá que você escolha manualmente a direção de posição para cada tracker +onboarding-choose_mounting-manual_mounting-description = Isso permitirá que você escolha manualmente a orientação de montagem de cada tracker. # Multiline text onboarding-choose_mounting-manual_modal-title = - Você tem certeza de que deseja fazer - a calibração automática de posição? -onboarding-choose_mounting-manual_modal-description = A calibração manual de posição é recomendada para novos usuários, pois as poses da calibração automática podem ser difíceis de acertar no início e podem exigir certa prática. + Tem certeza de que deseja realizar + a calibração automática de montagem? +onboarding-choose_mounting-manual_modal-description = A calibração manual de montagem é recomendada para novos usuários, pois as poses da calibração automática de montagem podem ser difíceis de acertar no início e podem exigir um pouco de prática. onboarding-choose_mounting-manual_modal-confirm = Estou certo do que estou fazendo. onboarding-choose_mounting-manual_modal-cancel = Cancelar ## Tracker manual mounting setup -onboarding-manual_mounting-back = Voltar para entrar no VR -onboarding-manual_mounting = Posicionamento Manual -onboarding-manual_mounting-description = Clique em cada tracker e selecione de que maneira estão posicionados -onboarding-manual_mounting-auto_mounting = Posicionamento automática +onboarding-manual_mounting-back = Entrar no VR +onboarding-manual_mounting = Montagem manual +onboarding-manual_mounting-description = Clique em cada tracker e selecione de que forma ele está montado +onboarding-manual_mounting-auto_mounting = Montagem automática onboarding-manual_mounting-next = Próximo passo ## Tracker automatic mounting setup -onboarding-automatic_mounting-back = Voltar para entrar no VR -onboarding-automatic_mounting-title = Calibragem de Posicionamento -onboarding-automatic_mounting-description = Para os trackers do SlimeVR funcionar, nós precisamos atribuir a rotação de posicionamento dos seus trackers para alinhar com a posição física de seus trackers. -onboarding-automatic_mounting-manual_mounting = Definir manualmente a posição +onboarding-automatic_mounting-back = Entrar no VR +onboarding-automatic_mounting-title = Calibração de Montagem +onboarding-automatic_mounting-description = Para que os trackers SlimeVR funcionem, precisamos atribuir uma orientação de montagem aos seus trackers para alinhá-los com a forma como estão fixados fisicamente no seu corpo. +onboarding-automatic_mounting-manual_mounting = Montagem manual onboarding-automatic_mounting-next = Próximo passo onboarding-automatic_mounting-prev_step = Passo anterior -onboarding-automatic_mounting-done-title = Rotações de posição calibradas. -onboarding-automatic_mounting-done-description = Sua calibragem de posicionamento está completa! -onboarding-automatic_mounting-done-restart = Voltar ao início -onboarding-automatic_mounting-mounting_reset-title = Reset de Posição -onboarding-automatic_mounting-mounting_reset-step-0 = 1. Agache-se em uma pose de "esqui" com as pernas dobradas, a parte superior do corpo inclinada para a frente e os braços dobrados. -onboarding-automatic_mounting-mounting_reset-step-1 = 2. Pressione o botão "Resetar Posição" e aguarde 3 segundos antes que as rotações de posição dos trackers sejam redefinidas. +onboarding-automatic_mounting-done-title = Orientações de montagem calibradas. +onboarding-automatic_mounting-done-description = Sua calibração de montagem está concluída! +onboarding-automatic_mounting-done-restart = Tentar Novamente +onboarding-automatic_mounting-mounting_reset-title = Calibração de Montagem +onboarding-automatic_mounting-mounting_reset-step-0 = 1. Agache-se em uma pose de “esqui”, com as pernas dobradas, o tronco inclinado para frente e os braços flexionados. +onboarding-automatic_mounting-mounting_reset-step-1 = 2. Pressione o botão "Calibração de Montagem" e aguarde 3 segundos até que as orientações de montagem dos trackers sejam redefinidas. +onboarding-automatic_mounting-mounting_reset-feet-step-0 = 1. Fique na ponta dos pés, com ambos os pés apontados para a frente. Alternativamente, você pode fazer isso sentado em uma cadeira. +onboarding-automatic_mounting-mounting_reset-feet-step-1 = 2. Pressione o botão “Calibração dos pés” e aguarde 3 segundos até que as orientações de montagem dos trackers sejam redefinidas. onboarding-automatic_mounting-preparation-title = Preparação +onboarding-automatic_mounting-preparation-v2-step-0 = 1. Pressione o botão “Redefinir Tudo" +onboarding-automatic_mounting-preparation-v2-step-1 = 2. Fique em pé, com os braços ao lado do corpo. Certifique-se de olhar para a frente. +onboarding-automatic_mounting-preparation-v2-step-2 = 3. Mantenha a posição até que o temporizador de 3 segundos termine. onboarding-automatic_mounting-put_trackers_on-title = Coloque seus trackers -onboarding-automatic_mounting-put_trackers_on-description = Para calibrar as rotações de posicionamento, usaremos os trackers que você atribuiu. Coloque todos os seus trackers, você pode ver qual é qual na figura na direita. +onboarding-automatic_mounting-put_trackers_on-description = Para calibrar as orientações de montagem, vamos usar os trackers que você acabou de atribuir. Coloque todos os seus trackers; você pode ver qual é qual na figura à direita. onboarding-automatic_mounting-put_trackers_on-next = Coloquei todos os meus trackers +onboarding-automatic_mounting-return-home = Feito ## Tracker manual proportions setupa -onboarding-manual_proportions-title = Proporções de corpo manuais +onboarding-manual_proportions-back-scaled = Voltar para Proporções Escalonadas +onboarding-manual_proportions-title = Ajuste Manual das Proporções Corporais onboarding-manual_proportions-fine_tuning_button = Melhorar automaticamente as proporções -onboarding-manual_proportions-fine_tuning_button-disabled-tooltip = Por favor, conecte seu headset VR para utilizar a melhoria automatica +onboarding-manual_proportions-fine_tuning_button-disabled-tooltip = Por favor, conecte um headset de VR para utilizar o ajuste fino automático onboarding-manual_proportions-export = Exportar proporções onboarding-manual_proportions-import = Importar proporções onboarding-manual_proportions-file_type = Arquivo de proporções do corpo +onboarding-manual_proportions-normal_increment = Incremento normal +onboarding-manual_proportions-precise_increment = Incremento preciso +onboarding-manual_proportions-grouped_proportions = Proporções agrupadas +onboarding-manual_proportions-all_proportions = Todas as proporções +onboarding-manual_proportions-estimated_height = Altura estimada do usuário ## Tracker automatic proportions setup -onboarding-automatic_proportions-back = Voltar para o tutorial de reset +onboarding-automatic_proportions-back = Voltar para Proporções Manuais onboarding-automatic_proportions-title = Meça seu corpo -onboarding-automatic_proportions-description = Para os trackers SlimeVR funcionarem, precisamos saber o tamanho dos seus ossos. Essa curta calibragem vai medir isso para você. -onboarding-automatic_proportions-manual = Calibragem manual +onboarding-automatic_proportions-description = Para que os trackers SlimeVR funcionem corretamente, precisamos do comprimento dos seus ossos. Esta calibração rápida medirá isso automaticamente para você. +onboarding-automatic_proportions-manual = Proporções Manuais onboarding-automatic_proportions-prev_step = Passo anterior onboarding-automatic_proportions-put_trackers_on-title = Coloque seus trackers onboarding-automatic_proportions-put_trackers_on-description = Para calibrar suas proporções, usaremos os trackers que você atribuiu. Coloque todos os seus trackers, você pode ver quais são quais na figura à direita. @@ -1051,11 +1328,11 @@ onboarding-automatic_proportions-put_trackers_on-next = Coloquei todos os meus t onboarding-automatic_proportions-requirements-title = Requisitos # Each line of text is a different list item onboarding-automatic_proportions-requirements-descriptionv2 = - Você possui pelo menos trackers suficientes para rastrear seus pés (geralmente 5 trackers). - Você está com os trackers e o headset ligados, e os está usando. - Seus trackers e o headset estão conectados ao servidor SlimeVR e estão funcionando corretamente (sem travamentos, desconexões, etc.). - Seu headset está enviando dados de posição para o servidor SlimeVR (isso geralmente significa ter o SteamVR em execução e conectado ao SlimeVR usando o driver SteamVR do SlimeVR). - Seu rastreamento está funcionando e representa com precisão seus movimentos (por exemplo, você realizou uma reinicialização completa e eles se movem na direção certa ao chutar, se inclinar, sentar, etc.). + Você tem trackers suficientes para rastrear seus pés (geralmente 5 trackers). + Você está com seus trackers e headset colocados e os vestindo corretamente. + Seus trackers e headset estão conectados ao servidor do SlimeVR e funcionando corretamente (ex.: sem travamentos, desconexões, etc.). + Seu headset está enviando dados de posição para o servidor do SlimeVR (isso geralmente significa que o SteamVR está em execução e conectado ao SlimeVR usando o driver do SteamVR do SlimeVR). + Seu rastreamento está funcionando e representando seus movimentos com precisão (ex.: você já executou "Redefinir Tudo" e eles se movem na direção correta ao chutar, se inclinar, sentar, etc.). onboarding-automatic_proportions-requirements-next = Eu li os requisitos. onboarding-automatic_proportions-check_height-title-v3 = Meça a altura do seu headset onboarding-automatic_proportions-check_height-description-v2 = A altura do seu headset (HMD) deve ser um pouco menor que sua altura total, já que o headset mede a altura do olho. Essa medida será usada como base para as proporções do seu corpo @@ -1082,9 +1359,9 @@ onboarding-automatic_proportions-check_floor_height-full_height = A sua altura t onboarding-automatic_proportions-check_floor_height-measure-start = Comece a medir onboarding-automatic_proportions-check_floor_height-measure-stop = Parar de medir onboarding-automatic_proportions-check_floor_height-measure-reset = Refazer medição -onboarding-automatic_proportions-check_floor_height-skip_step = Pular etapa e salvar +onboarding-automatic_proportions-check_floor_height-skip_step = Pular este passo e salvar onboarding-automatic_proportions-check_floor_height-next_step = Usar a altura do chão e salvar -onboarding-automatic_proportions-start_recording-title = Esteja preparado para se mexer +onboarding-automatic_proportions-start_recording-title = Prepare-se para se mover onboarding-automatic_proportions-start_recording-description = Começaremos a gravar algumas poses e movimentos específicos. Estes serão solicitados na próxima tela. Esteja preparado para começar quando o botão for pressionado! onboarding-automatic_proportions-start_recording-next = Começar Gravação onboarding-automatic_proportions-recording-title = GRAVAR @@ -1092,18 +1369,18 @@ onboarding-automatic_proportions-recording-description-p0 = Gravação em progre onboarding-automatic_proportions-recording-description-p1 = Faça os movimentos apresentados abaixo: # Each line of text is a different list item onboarding-automatic_proportions-recording-steps = - Fique em pé, gire sua cabeça em um círculo. - Incline as costas para a frente e agache. Enquanto agacha, olhe para a esquerda e depois para a direita. - Gire o tronco para a esquerda (sentido anti-horário) e incline-se em direção ao chão. - Gire o tronco para a direita (sentido horário) e incline-se em direção ao chão. - Gire os quadris em um movimento circular como se estivesse usando um bambolê. - Se houver tempo restante na gravação, você pode repetir esses passos até que termine. + Fique em pé com a postura reta e faça um movimento circular com a cabeça. + Incline o tronco para frente e agache-se. Enquanto estiver agachado, olhe para a esquerda e depois para a direita. + Gire a parte superior do corpo para a esquerda (sentido anti-horário) e, em seguida, incline-se em direção ao chão. + Gire a parte superior do corpo para a direita (sentido horário) e, em seguida, incline-se em direção ao chão. + Faça um movimento circular com o quadril, como se estivesse usando um bambolê. + Se ainda houver tempo na gravação, você pode repetir esses passos até que ela termine. onboarding-automatic_proportions-recording-processing = Processando o resultado # $time (Number) - Seconds left for the automatic calibration recording to finish (max 20) onboarding-automatic_proportions-recording-timer = { $time -> - [one] 1 second left - *[other] { $time } seconds left + [one] 1 segundo restante + *[other] { $time } segundos restantes } onboarding-automatic_proportions-verify_results-title = Verificar os resultados onboarding-automatic_proportions-verify_results-description = Verifique os resultados abaixo, parecem corretos? @@ -1111,12 +1388,12 @@ onboarding-automatic_proportions-verify_results-results = Gravando os resultados onboarding-automatic_proportions-verify_results-processing = Processando o resultado onboarding-automatic_proportions-verify_results-redo = Refazer a gravação onboarding-automatic_proportions-verify_results-confirm = Eles estão corretos -onboarding-automatic_proportions-done-title = Corpo medido e salvo. -onboarding-automatic_proportions-done-description = Sua calibragem de proporção de corpo está completa! +onboarding-automatic_proportions-done-title = Medição do corpo concluída e salva. +onboarding-automatic_proportions-done-description = A calibração das proporções do seu corpo está concluída! onboarding-automatic_proportions-error_modal-v2 = - Aviso: Ocorreu um erro ao calcular as proporções! - Isso provavelmente é um problema da calibragem de posição. Verifique se o rastreamento está funcionando corretamente antes de tentar novamente. - Por favor, verifique a documentação ou entre em nosso Discord para obter ajuda ^_^ + Aviso: Ocorreu um erro ao estimar as proporções! + Isso provavelmente é um problema na calibração de montagem. Certifique-se de que o rastreamento está funcionando corretamente antes de tentar novamente. + Por favor, consulte a documentação ou entre no nosso Discord para obter ajuda ^_^ onboarding-automatic_proportions-error_modal-confirm = Entendido! onboarding-automatic_proportions-smol_warning = A sua altura configurada de: { $height } é menor que a altura mínima aceita de: { $minHeight }. @@ -1125,14 +1402,72 @@ onboarding-automatic_proportions-smol_warning-cancel = Voltar ## User height calibration +onboarding-user_height-title = Qual é a sua altura? +onboarding-user_height-description = Precisamos da sua altura para calcular as proporções do seu corpo e representar seus movimentos com precisão. Você pode deixar o SlimeVR calculá-la automaticamente ou inserir sua altura manualmente. +onboarding-user_height-need_head_tracker = É necessário um headset e controles com rastreamento posicional para realizar a calibração. +onboarding-user_height-calculate = Calcular minha altura automaticamente +onboarding-user_height-next_step = Continuar e salvar +onboarding-user_height-prev_step = Voltar +onboarding-user_height-manual-proportions = Proporções Manuais +onboarding-user_height-calibration-title = Progresso da Calibração +onboarding-user_height-calibration-RECORDING_FLOOR = Toque o chão com a ponta do seu controle +onboarding-user_height-calibration-WAITING_FOR_RISE = Levante-se novamente +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK = Levante-se novamente e olhe para a frente +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-ok = Certifique-se de que sua cabeça esteja nivelada +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-low = Não olhe para o chão +onboarding-user_height-calibration-WAITING_FOR_FW_LOOK-high = Não olhe muito para cima +onboarding-user_height-calibration-WAITING_FOR_CONTROLLER_PITCH = Certifique-se de que o controlador esteja apontando para baixo +onboarding-user_height-calibration-RECORDING_HEIGHT = Levante-se novamente e fique parado! +onboarding-user_height-calibration-DONE = Sucesso! +onboarding-user_height-calibration-ERROR_TIMEOUT = Tempo limite da calibração atingido, tente novamente. +onboarding-user_height-calibration-ERROR_TOO_HIGH = A altura detectada do usuário está muito alta, tente novamente. +onboarding-user_height-calibration-ERROR_TOO_SMALL = A altura detectada do usuário está muito baixa. Certifique-se de ficar em pé, com o corpo ereto, e olhar para a frente ao final da calibração. +onboarding-user_height-calibration-error = Calibração Falhou +onboarding-user_height-manual-tip = Ao ajustar sua altura, experimente diferentes poses e veja como o esqueleto se ajusta ao seu corpo. +onboarding-user_height-reset-warning = + Aviso: Isso redefinirá suas proporções com base na sua altura. + Tem certeza de que deseja fazer isso? ## Stay Aligned setup +onboarding-stay_aligned-title = Manter Alinhado +onboarding-stay_aligned-description = Configure o Manter Alinhado para manter seus trackers alinhados. +onboarding-stay_aligned-put_trackers_on-title = Coloque seus trackers +onboarding-stay_aligned-put_trackers_on-description = Para salvar suas poses de descanso, usaremos os trackers que você acabou de atribuir. Coloque todos os seus trackers, você pode ver qual é qual na figura à direita. +onboarding-stay_aligned-put_trackers_on-trackers_warning = Você tem menos de 5 trackers conectados e atribuídos no momento! Essa é a quantidade mínima de trackers necessária para que o Manter Alinhado funcione corretamente. onboarding-stay_aligned-put_trackers_on-next = Todos meus trackers estão ligados +onboarding-stay_aligned-verify_mounting-title = Calibração de Montagem +onboarding-stay_aligned-verify_mounting-step-0 = O Manter Alinhado requer uma boa montagem. Caso contrário, você não terá uma boa experiência com o Manter Alinhado. +onboarding-stay_aligned-verify_mounting-step-1 = 1. Movimente-se enquanto estiver em pé. +onboarding-stay_aligned-verify_mounting-step-2 = 2. Sente-se e movimente as pernas e os pés. +onboarding-stay_aligned-verify_mounting-step-3 = 3. Se seus trackers não estiverem na orientação correta, pressione “Refazer Calibração de Montagem”. +onboarding-stay_aligned-verify_mounting-redo_mounting = Refazer Calibração de Montagem +onboarding-stay_aligned-preparation-title = Preparação +onboarding-stay_aligned-preparation-tip = Certifique-se de ficar em pé, com o corpo ereto. Mantenha o olhar para a frente, com os braços abaixados junto ao corpo. +onboarding-stay_aligned-relaxed_poses-standing-title = Postura em Pé Relaxada +onboarding-stay_aligned-relaxed_poses-standing-step-0 = 1. Fique em uma posição confortável. Relaxe! +onboarding-stay_aligned-relaxed_poses-standing-step-1-v2 = 2. Pressione o botão "Salvar Pose". +onboarding-stay_aligned-relaxed_poses-sitting-title = Pose Sentada e Relaxada na Cadeira +onboarding-stay_aligned-relaxed_poses-sitting-step-0 = 1. Sente-se em uma posição confortável. Relaxe! +onboarding-stay_aligned-relaxed_poses-sitting-step-1-v2 = Pressione o botão "Salvar Pose". +onboarding-stay_aligned-relaxed_poses-flat-title = Pose Sentada e Relaxada no Chão +onboarding-stay_aligned-relaxed_poses-flat-step-0 = 1. Sente-se no chão com as pernas à frente. Relaxe! +onboarding-stay_aligned-relaxed_poses-flat-step-1-v2 = 2. Pressione o botão "Salvar Pose". +onboarding-stay_aligned-relaxed_poses-skip_step = Pular +onboarding-stay_aligned-done-title = Manter Alinhado ativado! +onboarding-stay_aligned-done-description = A configuração do Manter Alinhado foi concluída! +onboarding-stay_aligned-done-description-2 = A configuração foi concluída! Você pode reiniciar o processo se quiser recalibrar as poses. +onboarding-stay_aligned-previous_step = Anterior +onboarding-stay_aligned-next_step = Próximo +onboarding-stay_aligned-restart = Reiniciar +onboarding-stay_aligned-done = Concluído +onboarding-stay_aligned-manual_mounting-done = Concluído ## Home home-no_trackers = Nenhum tracker detectado ou atribuído +home-settings = Configurações da Página Inicial +home-settings-close = Fechar ## Trackers Still On notification @@ -1145,19 +1480,24 @@ trackers_still_on-modal-cancel = Aguarde... ## Status system -status_system-StatusTrackerReset = É recomendado realizar um reset completo, pois um ou mais trackers estão desajustados. +status_system-StatusTrackerReset = Recomenda-se executar "Redefinir Tudo", pois um ou mais trackers não estão ajustados. status_system-StatusSteamVRDisconnected = { $type -> [steamvr_feeder] Atualmente não conectado ao aplicativo SlimeVR Feeder. - *[other] Atualmente não conectado ao SteamVR via driver SlimeVR. + *[other] Atualmente não conectado ao SteamVR via driver do SlimeVR. } status_system-StatusTrackerError = O tracker { $trackerName } tem um erro. status_system-StatusUnassignedHMD = O headset (HMD) deve ser designado como o tracker da cabeça. +status_system-StatusPublicNetwork = + { $count -> + [one] Seu perfil de rede está atualmente definido como Público ({ $adapters }). Isso não é recomendado para que o SlimeVR funcione corretamente. Veja aqui como corrigir. + *[other] Alguns dos seus adaptadores de rede estão definidos como Públicos: { $adapters }. Isso não é recomendado para que o SlimeVR funcione corretamente. Veja aqui como corrigir. + } ## Firmware tool globals -firmware_tool-next_step = Próxima etapa -firmware_tool-previous_step = Etapa anterior +firmware_tool-next_step = Próximo passo +firmware_tool-previous_step = Passo anterior firmware_tool-ok = Parece bom firmware_tool-retry = Tentar novamente firmware_tool-loading = Carregando... @@ -1167,19 +1507,51 @@ firmware_tool-loading = Carregando... firmware_tool = Ferramenta de Firmware DIY firmware_tool-description = Permite você configurar e fazer upload do firmware em seu tracker DIY firmware_tool-not_available = Oops, a ferramenta de firmware não está disponível no momento. Volte novamente mais tarde! -firmware_tool-not_compatible = A ferramenta de firmware não é compativel com essa versão do servidor. Por favor, atualize o seu servidor! +firmware_tool-not_compatible = A ferramenta de firmware não é compatível com essa versão do servidor. Por favor, atualize o seu servidor! +firmware_tool-select_source = Selecione o firmware para gravar +firmware_tool-select_source-description = Selecione o firmware que você deseja gravar na sua placa +firmware_tool-select_source-error = Não foi possível carregar as Fontes +firmware_tool-select_source-board_type = Tipo de Placa +firmware_tool-select_source-firmware = Fonte do Firmware +firmware_tool-select_source-version = Versão do Firmware +firmware_tool-select_source-official = Oficial +firmware_tool-select_source-dev = Dev +firmware_tool-select_source-not_selected = Nenhuma fonte selecionada +firmware_tool-select_source-no_boards = Nenhuma placa disponível para esta fonte +firmware_tool-select_source-no_versions = Nenhuma versão disponível para esta fonte +firmware_tool-board_defaults = Configure sua placa +firmware_tool-board_defaults-description = Defina os pinos ou as configurações de acordo com o seu hardware +firmware_tool-board_defaults-add = Adicionar +firmware_tool-board_defaults-reset = Redefinir para o Padrão +firmware_tool-board_defaults-error-required = Campo obrigatório +firmware_tool-board_defaults-error-format = Formato inválido +firmware_tool-board_defaults-error-format-number = Não é um número firmware_tool-flash_method_step = Método de upload firmware_tool-flash_method_step-description = Por favor, selecione o método de upload que deseja utilizar +firmware_tool-flash_method_step-ota-v2 = + .label = Wi-Fi + .description = Use o método over-the-air. Seu tracker usará Wi-Fi para atualizar o firmware. Funciona apenas em trackers que já foram configurados. +firmware_tool-flash_method_step-ota-info = + Usamos as credenciais do seu Wi-Fi para gravar o firmware no tracker e confirmar que tudo funcionou corretamente. + Nós não armazenamos suas credenciais do Wi-Fi! +firmware_tool-flash_method_step-serial-v2 = + .label = USB + .description = Use um cabo USB para atualizar seu tracker. firmware_tool-flashbtn_step = Pressione o botão de boot firmware_tool-flashbtn_step-description = Antes de ir para o próximo passo, aqui estão algumas etapas que você necessita fazer -firmware_tool-flashbtn_step-board_SLIMEVR = Deslige o tracker, tire de case (se tiver), conecte o cabo USB nesse computador, e tente seguir os seguintes passos de acordo com a revisão de sua placa SlimeVR +firmware_tool-flashbtn_step-board_SLIMEVR = Desligue o tracker, tire de case (se tiver), conecte o cabo USB nesse computador, e tente seguir os seguintes passos de acordo com a revisão de sua placa SlimeVR +firmware_tool-flashbtn_step-board_SLIMEVR-r11-v2 = Ligue o tracker enquanto faz um curto entre o segundo pad retangular FLASH a partir da borda, no lado superior da placa, e o escudo metálico do microcontrolador. O LED do tracker deve piscar rapidamente. +firmware_tool-flashbtn_step-board_SLIMEVR-r12-v2 = Ligue o tracker enquanto faz um curto entre o pad circular FLASH, no lado superior da placa, e o escudo metálico do microcontrolador. O LED do tracker deve piscar rapidamente. +firmware_tool-flashbtn_step-board_SLIMEVR-r14-v2 = Ligue o tracker enquanto pressiona o botão FLASH, no lado superior da placa. O LED do tracker deve piscar rapidamente. firmware_tool-flashbtn_step-board_OTHER = Antes de fazer a atualização, você provavelmente precisará colocar o tracker no modo bootloader. - Na maioria das vezes, isso significa pressionar o botão de boot na placa antes do iniciar o processo de atualização. - Se o processo de atualização expirar no começo da atualização, isso provavelmente significa que o tracker não estava no modo de bootloader - Consulte as instruções de atualização da sua placa para saber como ativar o modo boatloader + Na maioria das vezes, isso significa pressionar o botão de boot na placa antes de iniciar o processo de atualização. + Se o processo de gravação exceder o tempo limite logo no início, provavelmente significa que o tracker não estava no modo bootloader. + Consulte as instruções de atualização da sua placa para saber como ativar o modo bootloader. +firmware_tool-flash_method_ota-title = Gravação via Wi-Fi firmware_tool-flash_method_ota-devices = Dispositivos OTA detectados: firmware_tool-flash_method_ota-no_devices = Não há placas que possam ser atualizadas por meio de OTA. Verifique se você selecionou o tipo correto de placa +firmware_tool-flash_method_serial-title = Gravação via USB firmware_tool-flash_method_serial-wifi = Credenciais de Wi-Fi: firmware_tool-flash_method_serial-devices-label = Dispositivos seriais detectados: firmware_tool-flash_method_serial-devices-placeholder = Selecione um dispositivo serial @@ -1188,12 +1560,17 @@ firmware_tool-build_step = Compilando firmware_tool-build_step-description = O firmware está sendo compilado, aguarde firmware_tool-flashing_step = Atualizando firmware_tool-flashing_step-description = Seus trackers estão atualizando, por favor, siga as instruções na tela +firmware_tool-flashing_step-warning-v2 = Não desconecte nem desligue o tracker durante o processo de envio, a menos que seja instruído a fazê-lo, pois isso pode tornar sua placa inutilizável firmware_tool-flashing_step-flash_more = Atualizar mais trackers firmware_tool-flashing_step-exit = Sair +firmware_tool-flashing_step-onboarding_continue = Continuar ## firmware tool build status +firmware_tool-build-QUEUED = Aguardando a compilação.... firmware_tool-build-CREATING_BUILD_FOLDER = Criando a pasta de compilação +firmware_tool-build-DOWNLOADING_SOURCE = Baixando o código-fonte +firmware_tool-build-EXTRACTING_SOURCE = Extraindo o código-fonte firmware_tool-build-BUILDING = Compilando o firmware firmware_tool-build-SAVING = Salvando a compilação firmware_tool-build-DONE = Compilação concluída @@ -1202,6 +1579,7 @@ firmware_tool-build-ERROR = Não foi possível compilar o firmware ## Firmware update status firmware_update-status-DOWNLOADING = Baixando o firmware +firmware_update-status-NEED_MANUAL_REBOOT-v2 = Por favor, desligue e ligue seu tracker novamente firmware_update-status-AUTHENTICATING = Autenticando com o mcu firmware_update-status-UPLOADING = Fazendo upload do firmware firmware_update-status-SYNCING_WITH_MCU = Sincronizando com o mcu @@ -1209,7 +1587,7 @@ firmware_update-status-REBOOTING = Aplicando a atualização firmware_update-status-PROVISIONING = Configurando as credenciais do Wi-Fi firmware_update-status-DONE = Atualização concluída! firmware_update-status-ERROR_DEVICE_NOT_FOUND = Não foi possível localizar o dispositivo -firmware_update-status-ERROR_TIMEOUT = O processo de atualização expirou o tempo limite +firmware_update-status-ERROR_TIMEOUT = O processo de atualização atingiu o tempo limite firmware_update-status-ERROR_DOWNLOAD_FAILED = Não foi possível baixar o firmware firmware_update-status-ERROR_AUTHENTICATION_FAILED = Não foi possível autenticar com o mcu firmware_update-status-ERROR_UPLOAD_FAILED = Não foi possível fazer o upload do firmware @@ -1256,12 +1634,108 @@ unknown_device-modal-description = Deseja conectá-lo ao SlimeVR? unknown_device-modal-confirm = Claro! unknown_device-modal-forget = Ignore-o +# VRChat config warnings +vrc_config-page-title = Alerta de configuração do VRChat +vrc_config-page-desc = Esta página mostra o estado das suas configurações do VRChat e indica quais configurações são incompatíveis com o SlimeVR. É altamente recomendável corrigir quaisquer avisos exibidos aqui para obter a melhor experiência de uso com o SlimeVR. +vrc_config-page-help = Não consegue encontrar as configurações? +vrc_config-page-help-desc = Confira nossa documentação sobre este assunto! +vrc_config-page-big_menu = Rastreamento e IK (Menu Grande) +vrc_config-page-big_menu-desc = Configurações relacionadas ao IK no menu grande de configurações +vrc_config-page-wrist_menu = Rastreamento e IK (Menu Rápido) +vrc_config-page-wrist_menu-desc = Configurações relacionadas ao IK no menu pequeno de configurações (menu rápido) +vrc_config-on = Ativado +vrc_config-off = Desativado +vrc_config-invalid = As configurações do VRChat estão incorretas! +vrc_config-show_more = Mostrar mais +vrc_config-setting_name = Nome da Configuração do VRChat +vrc_config-recommended_value = Valor Recomendado +vrc_config-current_value = Valor Atual +vrc_config-mute = Silenciar Aviso +vrc_config-mute-btn = Silenciar +vrc_config-unmute-btn = Dessilenciar +vrc_config-legacy_mode = Usar a Resolução de IK Legada +vrc_config-disable_shoulder_tracking = Desativar o Rastreio de Ombro +vrc_config-shoulder_width_compensation = Compensação da Largura dos Ombros +vrc_config-spine_mode = Modo Coluna FBT +vrc_config-tracker_model = Modelo dos Rastreadores(Trackers) FBT +vrc_config-avatar_measurement_type = Medidas do Avatar +vrc_config-calibration_range = Alcance da Calibração +vrc_config-calibration_visuals = Exibir Visuais de Calibração +vrc_config-user_height = Altura Real do Usuário +vrc_config-spine_mode-UNKNOWN = Desconhecido +vrc_config-spine_mode-LOCK_BOTH = Travar Ambos +vrc_config-spine_mode-LOCK_HEAD = Travar Cabeça +vrc_config-spine_mode-LOCK_HIP = Travar Quadril +vrc_config-tracker_model-UNKNOWN = Desconhecido +vrc_config-tracker_model-AXIS = Eixo +vrc_config-tracker_model-BOX = Caixa +vrc_config-tracker_model-SPHERE = Esfera +vrc_config-tracker_model-SYSTEM = Sistema +vrc_config-avatar_measurement_type-UNKNOWN = Desconhecido +vrc_config-avatar_measurement_type-HEIGHT = Altura +vrc_config-avatar_measurement_type-ARM_SPAN = Envergadura dos Braços ## Error collection consent modal error_collection_modal-title = Podemos coletar erros? +error_collection_modal-description_v2 = + { settings-interface-behavior-error_tracking-description_v2 } + + Você pode alterar essa configuração mais tarde na seção Comportamento da página de configurações. error_collection_modal-confirm = Eu concordo error_collection_modal-cancel = Eu não quero ## Tracking checklist section +tracking_checklist = Checklist do Rastreamento +tracking_checklist-settings = Configurações do Checklist do Rastreamento +tracking_checklist-settings-close = Fechar +tracking_checklist-status-incomplete = Você ainda não está pronto para usar o SlimeVR! +tracking_checklist-status-partial = + { $count -> + [one] Você tem 1 aviso! + *[other] Você tem { $count } avisos! + } +tracking_checklist-status-complete = Você está pronto para usar o SlimeVR! +tracking_checklist-MOUNTING_CALIBRATION = Realize uma calibração de montagem +tracking_checklist-FEET_MOUNTING_CALIBRATION = Realize uma calibração de montagem dos pés +tracking_checklist-FULL_RESET = Execute "Redefinir Tudo" +tracking_checklist-FULL_RESET-desc = Alguns trackers precisam que uma redefinição seja realizada. +tracking_checklist-STEAMVR_DISCONNECTED = O SteamVR não está em execução +tracking_checklist-STEAMVR_DISCONNECTED-desc = O SteamVR não está em execução. Você está usando ele para VR? +tracking_checklist-STEAMVR_DISCONNECTED-open = Iniciar o SteamVR +tracking_checklist-STEAMVR_HANDS_ENABLED-desc = Você ativou os Trackers de mãos virtuais do SteamVR. Isso fará com que os comandos feitos por meio de botões não funcionem no SteamVR e nos jogos. +tracking_checklist-STEAMVR_HANDS_ENABLED-go = Desabilitá-los +tracking_checklist-TRACKERS_REST_CALIBRATION = Calibre seus trackers +tracking_checklist-TRACKERS_REST_CALIBRATION-desc = Você não realizou a calibração dos trackers. Por favor, deixe os trackers (destacados em amarelo) repousarem sobre uma superfície estável por alguns segundos. +tracking_checklist-TRACKER_ERROR = Trackers com Erros +tracking_checklist-TRACKER_ERROR-desc = Alguns trackers estão com erro. Reinicie os trackers em amarelo. +tracking_checklist-VRCHAT_SETTINGS = Configurar as configurações do VRChat +tracking_checklist-VRCHAT_SETTINGS-desc = As configurações do VRChat estão configuradas incorretamente! Isso pode afetar negativamente o rastreamento. +tracking_checklist-VRCHAT_SETTINGS-open = Ir para os alertas do VRChat +tracking_checklist-UNASSIGNED_HMD = Headset de VR não atribuído à Cabeça +tracking_checklist-UNASSIGNED_HMD-desc = O headset de VR deve ser atribuído como tracker de cabeça. +tracking_checklist-NETWORK_PROFILE_PUBLIC = Altere seu perfil de rede +tracking_checklist-NETWORK_PROFILE_PUBLIC-desc = + { $count -> + [one] Seu perfil de rede está atualmente definido como Público ({ $adapters }). Isso não é recomendado para que o SlimeVR funcione corretamente. Veja como corrigir isso aqui. + *[other] + Alguns dos seus adaptadores de rede estão definidos como Público: + { $adapters } + Isso não é recomendado para o funcionamento adequado do SlimeVR. Veja como corrigir aqui. + } +tracking_checklist-NETWORK_PROFILE_PUBLIC-open = Abrir o Painel de Controle +tracking_checklist-STAY_ALIGNED_CONFIGURED = Configurar o Manter Alinhado +tracking_checklist-STAY_ALIGNED_CONFIGURED-desc = Grave as poses da função Manter Alinhado para reduzir o drift +tracking_checklist-STAY_ALIGNED_CONFIGURED-open = Abrir o Assistente do Manter Alinhado +tracking_checklist-ignore = Ignorar +preview-mocap_mode_soon = Modo Mocap (Em Breve™) +preview-disable_render = Desativar renderização +preview-disabled_render = Renderização desativada +toolbar-mounting_calibration = Calibração de Montagem +toolbar-mounting_calibration-default = Corpo +toolbar-mounting_calibration-feet = Pés +toolbar-mounting_calibration-fingers = Dedos +toolbar-drift_reset = Redefinição de Drift +toolbar-assigned_trackers = { $count } trackers atribuídos +toolbar-unassigned_trackers = { $count } trackers desatribuídos diff --git a/gui/public/i18n/ru/translation.ftl b/gui/public/i18n/ru/translation.ftl index b4739346a9..79ad835e5c 100644 --- a/gui/public/i18n/ru/translation.ftl +++ b/gui/public/i18n/ru/translation.ftl @@ -917,9 +917,11 @@ onboarding-setup_warning = onboarding-setup_warning-skip = Пропустить настройку onboarding-setup_warning-cancel = Продолжить настройку +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = Вернуться к введению onboarding-wifi_creds-skip = Пропустить настройки Wi-Fi onboarding-wifi_creds-submit = Отправить! onboarding-wifi_creds-ssid = @@ -955,6 +957,9 @@ onboarding-reset_tutorial-2 = Вы должны быть в позе, как будто вы катаетесь на лыжах, как показано в мастере автоматического монтажа крепления, и у вас есть 3-секундная задержка (настраиваемая) перед тем, как она сработает. +## Install info + + ## Setup start onboarding-home = Добро пожаловать в SlimeVR diff --git a/gui/public/i18n/sv-SE/translation.ftl b/gui/public/i18n/sv-SE/translation.ftl index 63d5cef115..173f65c6e8 100644 --- a/gui/public/i18n/sv-SE/translation.ftl +++ b/gui/public/i18n/sv-SE/translation.ftl @@ -839,9 +839,11 @@ onboarding-setup_warning = onboarding-setup_warning-skip = Hoppa över inställning onboarding-setup_warning-cancel = Fortsätt inställning +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = Gå tillbaka till introduktion onboarding-wifi_creds-skip = Hoppa över Wi-Fi inställningar. onboarding-wifi_creds-submit = Överlämna! onboarding-wifi_creds-ssid = @@ -864,6 +866,9 @@ onboarding-reset_tutorial-0 = Detta gör att spårarna vänds i samma riktning som ditt headset (HMD). +## Install info + + ## Setup start onboarding-home = Välkommen till SlimeVR diff --git a/gui/public/i18n/th/translation.ftl b/gui/public/i18n/th/translation.ftl index 49ecf93960..fccd684540 100644 --- a/gui/public/i18n/th/translation.ftl +++ b/gui/public/i18n/th/translation.ftl @@ -115,6 +115,11 @@ board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3 board_type-HARITORA = Haritora board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1 board_type-GLOVE_IMU_SLIMEVR_DEV = บอร์ดพัฒนาถุงมือ IMU SlimeVR +board_type-GESTURES = ท่าทางสัมผัส +board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini +board_type-GENERIC_NRF = บอร์ด NRF ทั่วไป +board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR Dev Butterfly +board_type-SLIMEVR_BUTTERFLY = SlimeVR Butterfly ## Proportions @@ -276,7 +281,7 @@ navbar-home = หน้าหลัก navbar-body_proportions = สัดส่วนร่างกาย navbar-trackers_assign = กำหนดแทร็กเกอร์ navbar-mounting = ตั้งศูนย์การติดตั้ง -navbar-onboarding = ตัวช่วยตั้งค่าโปรแกรม +navbar-onboarding = ตัวช่วยการตั้งค่า navbar-settings = ตั้งค่า navbar-connect_trackers = เชื่อมต่อแทร็กเกอร์ @@ -349,6 +354,7 @@ tracker-table-column-name = ชื่อ tracker-table-column-type = ชนิด tracker-table-column-battery = แบตเตอรี่ tracker-table-column-ping = Ping +tracker-table-column-packet_loss = สูญเสียแพ็คเก็ต tracker-table-column-tps = TPS tracker-table-column-temperature = อุณหภูมิ °C tracker-table-column-linear-acceleration = ความเร่ง X/Y/Z @@ -390,6 +396,9 @@ tracker-infos-magnetometer-status-v1 = [ENABLED] เปิดใช้งาน *[NOT_SUPPORTED] ไม่รองรับ } +tracker-infos-packet_loss = สูญเสียแพ็คเก็ต +tracker-infos-packets_lost = สูญเสียแพ็คเก็ต +tracker-infos-packets_received = ได้รับแพ็คเก็ต ## Tracker settings @@ -428,6 +437,7 @@ tracker-settings-update = อัปเดตทันที tracker-settings-update-title = เวอร์ชันเฟิร์มแวร์ tracker-settings-current-version = ปัจจุบัน tracker-settings-latest-version = ล่าสุด +tracker-settings-build-date = วันที่สร้าง ## Tracker part card info @@ -593,6 +603,9 @@ settings-general-tracker_mechanics-use_mag_on_all_trackers-description = ใช้เซ็นเซอร์สนามแม่เหล็กบนแทร็กเกอร์ทั้งหมดที่มีเฟิร์มแวร์ที่เข้ากันได้ ซึ่งช่วยลดดริฟท์ในสภาพแวดล้อมที่มีสนามแม่เหล็กคงที่ สามารถปิดการใช้งานสำหรับแทร็กเกอร์แต่ละตัวได้ในการตั้งค่าของแทร็กเกอร์ โปรดอย่าปิดแทร็กเกอร์ ในขณะที่กำลังสลับการตั้งค่านี้! settings-general-tracker_mechanics-use_mag_on_all_trackers-label = ใช้เซ็นเซอร์สนามแม่เหล็กกับแทร็กเกอร์ +settings-general-tracker_mechanics-trackers_over_usb = ต่อแทร็กเกอร์ผ่าน USB +settings-general-tracker_mechanics-trackers_over_usb-description = เปิดใช้งานการรับข้อมูลแทร็กเกอร์แบบ HID ผ่านสาย USB ตรวจสอบว่าแทร็กเกอร์ของคุณได้เปิด การเชื่อมต่อผ่าน HID เอาไว้! +settings-general-tracker_mechanics-trackers_over_usb-enabled-label = เปิดให้แทร็กเกอร์แบบ HID ต่อโดยตรงผ่านสาย USB settings-stay_aligned = Stay Aligned settings-stay_aligned-description = Stay Aligned จะลดดริฟท์โดยค่อยๆ ปรับแทร็กเกอร์ให้เข้ากับท่าทางผ่อนคลายของคุณ settings-stay_aligned-setup-label = ตั้งค่า Stay Aligned @@ -896,8 +909,8 @@ settings-utils-advanced = ขั้นสูง settings-utils-advanced-reset-gui = รีเซ็ตตั้งค่า GUI settings-utils-advanced-reset-gui-description = คืนค่าการตั้งค่าเริ่มต้นสำหรับอินเทอร์เฟซ settings-utils-advanced-reset-gui-label = รีเซ็ต GUI -settings-utils-advanced-reset-server = รีเซ็ตการตั้งค่าการติดตาม -settings-utils-advanced-reset-server-description = คืนค่าการจับตำแหน่งทั้งหมดเป็นค่าเริ่มต้น +settings-utils-advanced-reset-server = รีเซ็ตการตั้งค่าแทร็กเกอร์ +settings-utils-advanced-reset-server-description = คืนค่าเกี่ยวกับแทร็กเกอร์เป็นค่าเริ่มต้น settings-utils-advanced-reset-server-label = รีเซ็ตการจับตำแหน่ง settings-utils-advanced-reset-all = รีเซ็ตการตั้งค่าทั้งหมด settings-utils-advanced-reset-all-description = คืนค่าการตั้งค่าเริ่มต้นสำหรับทั้งอินเทอร์เฟซและการจับตำแหน่ง @@ -941,9 +954,11 @@ onboarding-setup_warning = onboarding-setup_warning-skip = ข้ามการตั้งค่า onboarding-setup_warning-cancel = ดำเนินการตั้งค่าต่อ +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = กลับไปที่หน้าบทนำ onboarding-wifi_creds-v2 = แทร็กเกอร์ที่ใช้ Wi-Fi # This cares about multilines onboarding-wifi_creds-description-v2 = @@ -988,6 +1003,9 @@ onboarding-reset_tutorial-2 = คุณต้องอยู่ในท่าเล่นสกีตามที่แสดงในตัวช่วยตั้งค่า โดยจะนับถอยหลังใน 3 วินาที (สามารถกำหนดได้) ก่อนที่การรีเซ็ตจะเกิดขึ้น +## Install info + + ## Setup start onboarding-home = ยินดีต้อนรับสู่ SlimeVR! @@ -1368,6 +1386,7 @@ onboarding-stay_aligned-previous_step = ก่อนหน้า onboarding-stay_aligned-next_step = ต่อไป onboarding-stay_aligned-restart = เริ่มใหม่ onboarding-stay_aligned-done = เสร็จแล้ว +onboarding-stay_aligned-manual_mounting-done = เสร็จแล้ว ## Home @@ -1418,6 +1437,9 @@ firmware_tool-select_source-firmware = แหล่งที่มาของ firmware_tool-select_source-version = เวอร์ชันของเฟิร์มแวร์ firmware_tool-select_source-official = ทางการ firmware_tool-select_source-dev = รุ่นพัฒนา +firmware_tool-select_source-not_selected = ยังไม่ได้กำหนดแหล่งเฟิร์มแวร์ +firmware_tool-select_source-no_boards = ไม่มีเฟิร์มแวร์บอร์ดสำหรับแหล่งนี้ +firmware_tool-select_source-no_versions = ไม่มีเวอร์ชั่นที่ใช้ได้สำหรับแหล่งนี้ firmware_tool-board_defaults = กำหนดค่าบอร์ดของคุณ firmware_tool-board_defaults-description = ตั้งค่า Pin หรือการตั้งค่าที่เกี่ยวข้องกับฮาร์ดแวร์ของคุณ firmware_tool-board_defaults-add = เพิ่ม diff --git a/gui/public/i18n/tr/translation.ftl b/gui/public/i18n/tr/translation.ftl index 589f39736a..587aaff22c 100644 --- a/gui/public/i18n/tr/translation.ftl +++ b/gui/public/i18n/tr/translation.ftl @@ -7,68 +7,194 @@ ## Websocket (server) status -websocket-connecting = Sunucuya bağlanılıyor +websocket-connecting = Sunucuya bağlanılıyor... websocket-connection_lost = Sunucuyla bağlantı kesildi. Tekrar bağlanılmaya çalışılıyor... +websocket-connection_lost-desc = SlimeVR sunucusu çöktü. Kayıtları kontrol edip programı yeniden başlat. +websocket-timedout = Sunucuya bağlanılamadı +websocket-timedout-desc = SlimeVR sunucusu çöktü veya zaman aşımına uğradı. Kayıtları kontrol et ve programı yeniden başlat. +websocket-error-close = SlimeVR'ı kapat +websocket-error-logs = Kayıtlar klasörünü aç ## Update notification version_update-title = Yeni sürüm mevcut: { $version } +version_update-description = "{ version_update-update }" seçeneğine tıklamak SlimeVR kurulum uygulamasını indirir. version_update-update = Güncelle version_update-close = Kapat ## Tips tips-find_tracker = Hangi takipçi hangisi emin değil misin? Takipçilerden birini hareket ettirerek belirleyebilirsin. -tips-do_not_move_heels = Kayıt sırasında ayaklarınızın hareket etmediğinden emin olun! +tips-do_not_move_heels = Kayıt sırasında ayaklarının hareket etmediğinden emin ol! +tips-file_select = Dosyaları sürükleyip bırak veya dosyalarından seç. +tips-tap_setup = Menüden seçmek yerine takipçine 2 kez yavaşça dokunarak da seçebilirsin. +tips-turn_on_tracker = Resmi SlimeVR takipçisi mi kullanıyorsun? Takipçilerini bilgisayara bağladıktan sonra onları açmayı unutma! +tips-failed_webgl = WebGL başlatılamadı. ## Units +unit-meter = Metre +unit-foot = Ayak +unit-inch = Inç +unit-cm = cm ## Body parts body_part-NONE = Atanmamış body_part-HEAD = Kafa body_part-NECK = Boyun -body_part-RIGHT_SHOULDER = Sağ omuz -body_part-RIGHT_UPPER_ARM = Sağ üst kol -body_part-RIGHT_LOWER_ARM = Sağ alt kol -body_part-RIGHT_HAND = Sağ el -body_part-RIGHT_UPPER_LEG = Sağ uyluk -body_part-RIGHT_LOWER_LEG = Sağ ayak bileği -body_part-RIGHT_FOOT = Sağ ayak -body_part-UPPER_CHEST = Üst göğüs +body_part-RIGHT_SHOULDER = Sağ Omuz +body_part-RIGHT_UPPER_ARM = Sağ Üst Kol +body_part-RIGHT_LOWER_ARM = Sağ Alt Kol +body_part-RIGHT_HAND = Sağ El +body_part-RIGHT_UPPER_LEG = Sağ Uyluk +body_part-RIGHT_LOWER_LEG = Sağ Ayak Bileği +body_part-RIGHT_FOOT = Sağ Ayak +body_part-UPPER_CHEST = Üst Göğüs body_part-CHEST = Göğüs body_part-WAIST = Bel body_part-HIP = Kalça -body_part-LEFT_SHOULDER = Sol omuz -body_part-LEFT_UPPER_ARM = Sol üst kol -body_part-LEFT_LOWER_ARM = Sol alt kol -body_part-LEFT_HAND = Sol el -body_part-LEFT_UPPER_LEG = Sol uyluk -body_part-LEFT_LOWER_LEG = Sol ayak bileği -body_part-LEFT_FOOT = Sol ayak +body_part-LEFT_SHOULDER = Sol Omuz +body_part-LEFT_UPPER_ARM = Sol Üst Kol +body_part-LEFT_LOWER_ARM = Sol Alt Kol +body_part-LEFT_HAND = Sol El +body_part-LEFT_UPPER_LEG = Sol Uyluk +body_part-LEFT_LOWER_LEG = Sol Ayak Bileği +body_part-LEFT_FOOT = Sol Ayak +body_part-LEFT_THUMB_METACARPAL = Sol başparmak metakarpal kemiği +body_part-LEFT_THUMB_PROXIMAL = Sol başparmak proksimal kemiği +body_part-LEFT_THUMB_DISTAL = Sol başparmak distal falanks kemiği +body_part-LEFT_INDEX_PROXIMAL = Sol işaret parmağının proksimal falanks kemiği +body_part-LEFT_INDEX_INTERMEDIATE = Sol işaret parmağının orta falanks kemiği +body_part-LEFT_INDEX_DISTAL = Sol işaret parmağının distal falanks kemiği +body_part-LEFT_MIDDLE_PROXIMAL = Sol orta parmağın proksimal falanks kemiği +body_part-LEFT_MIDDLE_INTERMEDIATE = Sol orta parmağın orta falanks kemiği +body_part-LEFT_MIDDLE_DISTAL = Sol orta parmağın distal falanks kemiği +body_part-LEFT_RING_PROXIMAL = Sol yüzük parmağının proksimal falanks kemiği +body_part-LEFT_RING_INTERMEDIATE = Sol yüzük parmağının orta falanks kemiği +body_part-LEFT_RING_DISTAL = Sol yüzük parmağının distal falanks kemiği +body_part-LEFT_LITTLE_PROXIMAL = Sol küçük parmağın proksimal falanks kemiği +body_part-LEFT_LITTLE_INTERMEDIATE = Sol küçük parmağın orta falanks kemiği +body_part-LEFT_LITTLE_DISTAL = Sol küçük parmağın distal falanks kemiği +body_part-RIGHT_THUMB_METACARPAL = Sağ başparmak metakarpal kemiği +body_part-RIGHT_THUMB_PROXIMAL = Sağ başparmağın proksimal falanks kemiği +body_part-RIGHT_THUMB_DISTAL = Sağ başparmağın distal falanks kemiği +body_part-RIGHT_INDEX_PROXIMAL = Sağ işaret parmağının proksimal falanks kemiği +body_part-RIGHT_INDEX_INTERMEDIATE = Sağ işaret parmağının orta falanks kemiği +body_part-RIGHT_INDEX_DISTAL = Sağ işaret parmağının distal falanks kemiği +body_part-RIGHT_MIDDLE_PROXIMAL = Sağ orta parmağın proksimal falanks kemiği +body_part-RIGHT_MIDDLE_INTERMEDIATE = Sağ orta parmağın orta falanks kemiği +body_part-RIGHT_MIDDLE_DISTAL = Sağ orta parmağın distal falanks kemiği +body_part-RIGHT_RING_PROXIMAL = Sağ yüzük parmağının proksimal falanks kemiği +body_part-RIGHT_RING_INTERMEDIATE = Sağ yüzük parmağının orta falanks kemiği +body_part-RIGHT_RING_DISTAL = Sağ yüzük parmağının distal falanks kemiği +body_part-RIGHT_LITTLE_PROXIMAL = Sağ küçük parmağın proksimal falanks kemiği +body_part-RIGHT_LITTLE_INTERMEDIATE = Sağ küçük parmağın orta falanks kemiği +body_part-RIGHT_LITTLE_DISTAL = Sağ küçük parmağın distal falanks kemiği ## BoardType +board_type-UNKNOWN = Bilinmeyen +board_type-NODEMCU = NodeMCU +board_type-WROOM32 = WROOM32 +board_type-WEMOSD1MINI = Wemos D1 Mini +board_type-TTGO_TBASE = TTGO T-Base +board_type-ESP01 = ESP-01 +board_type-SLIMEVR = SlimeVR +board_type-SLIMEVR_DEV = SlimeVR Dev Board +board_type-SLIMEVR_V1_2 = SlimeVR v1.2 +board_type-LOLIN_C3_MINI = Lolin C3 Mini +board_type-BEETLE32C3 = Beetle ESP32-C3 +board_type-ESP32C3DEVKITM1 = Espressif ESP32-C3 DevKitM-1 +board_type-OWOTRACK = owoTrack +board_type-WRANGLER = Wrangler Joycons +board_type-MOCOPI = Sony Mocopi +board_type-WEMOSWROOM02 = Wemos Wroom-02 D1 Mini +board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3 +board_type-HARITORA = Haritora +board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1 +board_type-GLOVE_IMU_SLIMEVR_DEV = SlimeVR Dev IMU Eldiveni +board_type-GESTURES = Hareketler +board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini +board_type-GENERIC_NRF = Jenerik nRF +board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR Dev Butterfly +board_type-SLIMEVR_BUTTERFLY = SlimeVR Butterfly ## Proportions skeleton_bone-NONE = Yok -skeleton_bone-HEAD = Kafa hizası +skeleton_bone-HEAD = Kafa Hizası +skeleton_bone-HEAD-desc = + Bu, kulaklığın ile başının ortası arasındaki mesafedir. + Ayarlamak için, 'hayır' der gibi başını soldan sağa ve sağdan sola salla, + diğer takipçiler yerinden oynamayana kadar bunu tekrarlayıp ayarla. skeleton_bone-NECK = Boyun Uzunluğu -skeleton_bone-torso_group = Gövde uzunluğu +skeleton_bone-NECK-desc = + Bu, başının ortasından boynunun başlangıcına kadar olan mesafedir. + Ayarlamak için başını 'evet' der gibi yukarı-aşağı hareket ettir veya başını hafifçe öne eğip, başını sola ve sağa kaydır, + diğer takipçiler yerinden oynamayana kadar bunu tekrarlayıp ayarla. +skeleton_bone-torso_group = Gövde Uzunluğu +skeleton_bone-torso_group-desc = + Bu, boynunun başlangıcından kalçalarına kadar olan mesafedir. + Ayarlamak için dik dur ve sanal kalçaların, gerçek kalçalarınla hizalanana kadar değeri değiştir. skeleton_bone-UPPER_CHEST = Üst Göğüs Uzunluğu -skeleton_bone-CHEST_OFFSET = Göğüs hizası +skeleton_bone-UPPER_CHEST-desc = + Bu, boynunun başlangıcından göğsünün ortasına kadar olan mesafedir. + Gövde uzunluğunu doğru şekilde ayarlayıp (otururken, eğilirken, uzanırken vb. duruşlarda) sanal omurgan, + gerçek omurganla eşleşene kadar bu değeri değiştir. +skeleton_bone-CHEST_OFFSET = Göğüs Hizası +skeleton_bone-CHEST_OFFSET-desc = + Bu, bazı oyun veya uygulamalarda kalibrasyona yardımcı olmak için, + sanal göğüs takipçisini yukarı ya da aşağı hareket ettirmene izin verir. skeleton_bone-CHEST = Göğüs Uzunluğu +skeleton_bone-CHEST-desc = + Bu, göğsünün ortasından omurganın ortasına kadar olan mesafedir. + Gövde uzunluğunu doğru şekilde ayarlayıp (otururken, eğilirken, uzanırken vb. duruşlarda) sanal omurgan, + gerçek omurganla eşleşene kadar bu değeri değiştir. skeleton_bone-WAIST = Bel Uzunluğu +skeleton_bone-WAIST-desc = + Bu, omurganın ortasından göbek deliğine kadar olan mesafedir. + Gövde uzunluğunu doğru şekilde ayarlayıp (otururken, eğilirken, uzanırken vb. duruşlarda) sanal omurgan, + gerçek omurganla eşleşene kadar bu değeri değiştir. skeleton_bone-HIP = Kalça Uzunluğu -skeleton_bone-HIP_OFFSET = Kalça hizası +skeleton_bone-HIP-desc = + Bu, göbek deliğinden kalçalarına kadar olan mesafedir. + Gövde uzunluğunu doğru şekilde ayarlayıp (otururken, eğilirken, uzanırken vb. duruşlarda) sanal omurgan, + gerçek omurganla eşleşene kadar bu değeri değiştir. +skeleton_bone-HIP_OFFSET = Kalça Hizası +skeleton_bone-HIP_OFFSET-desc = + Bu, bazı oyun veya uygulamalarda kalibrasyona yardımcı olmak için, + sanal kalça takipçisini yukarı ya da aşağı hareket ettirmene izin verir. skeleton_bone-HIPS_WIDTH = Kalça Genişliği +skeleton_bone-HIPS_WIDTH-desc = + Bu, bacaklarının başladığı noktalar arasındaki mesafedir. + Ayarlamak için bacaklarını düz tutarak tam bir sıfırlama yap ve, + sanal bacaklarının aralığı, gerçek bacaklarının aralığıyla hizalanana kadar bu değeri değiştir. skeleton_bone-leg_group = Bacak uzunluğu +skeleton_bone-leg_group-desc = + Bu, kalçandan ayaklarına olan mesafedir. + Ayarlamak için Gövde uzunluğunu doğru şekilde ayarla ve, + sanal ayakların gerçek ayaklarınla aynı seviyeye gelene kadar bu değeri değiştir. skeleton_bone-UPPER_LEG = Üst Bacak Uzunluğu +skeleton_bone-UPPER_LEG-desc = + Bu, kalçandan dizlerine olan mesafedir. + Ayarlamak için Bacak uzunluğunu doğru şekilde ayarla ve, + sanal dizlerin gerçek dizlerinle aynı seviyeye gelene kadar bu değeri değiştir. skeleton_bone-LOWER_LEG = Alt Bacak Uzunluğu +skeleton_bone-LOWER_LEG-desc = + Bu, dizlerinden ayak bileklerine olan mesafedir. + Ayarlamak için Bacak uzunluğunu doğru şekilde ayarla ve, + sanal dizlerin gerçek dizlerinle aynı seviyeye gelene kadar bu değeri değiştir. skeleton_bone-FOOT_LENGTH = Ayak Uzunluğu +skeleton_bone-FOOT_LENGTH-desc = + Bu, ayak bileklerinden ayak parmaklarına olan mesafedir. + Ayarlamak için parmak uçlarına çık ve sanal ayakların sabit kalana kadar bu değeri değiştir. skeleton_bone-FOOT_SHIFT = Ayak hizası +skeleton_bone-FOOT_SHIFT-desc = + Bu değer, dizinden ayak bileğine olan yatay mesafedir. + Bu, dik dururken alt bacaklarının geriye doğru konumlanmasını telafi eder. + Ayarlamak için Ayak uzunluğunu 0 olarak ayarla, tam sıfırlama yap ve, + sanal ayakların ayak bileklerinin ortasıyla hizalanana kadar bu değeri değiştir. skeleton_bone-SKELETON_OFFSET = İskelet hizası skeleton_bone-SHOULDERS_DISTANCE = Omuz Mesafesi skeleton_bone-SHOULDERS_WIDTH = Omuz Genişliği @@ -80,7 +206,10 @@ skeleton_bone-ELBOW_OFFSET = Dirsek hizası ## Tracker reset buttons reset-reset_all = Tüm oranları sıfırla +reset-reset_all_warning-cancel = İptal et reset-full = Sıfırlama +reset-mounting-feet = Ayak Kalibrasyonu +reset-mounting-fingers = Parmak Kalibrasyonu ## Serial detection stuff @@ -128,10 +257,13 @@ widget-developer_mode-more_info = Daha fazla bilgi ## Widget: IMU Visualizer widget-imu_visualizer = Rotasyon +widget-imu_visualizer-preview = Önizle +widget-imu_visualizer-hide = Gizle widget-imu_visualizer-rotation_preview = Önizle ## Widget: Skeleton Visualizer +widget-skeleton_visualizer-hide = Gizle ## Tracker status @@ -140,6 +272,7 @@ tracker-status-busy = Meşgul tracker-status-error = Hata tracker-status-disconnected = Bağlantı kesildi tracker-status-ok = İYİ +tracker-status-timed_out = Zaman aşımı ## Tracker status columns @@ -293,12 +426,18 @@ settings-osc-vrchat-network-address = Ağ adresi ## Setup/onboarding menu +## Quiz + + ## Wi-Fi setup ## Mounting setup +## Install info + + ## Setup start diff --git a/gui/public/i18n/uk/translation.ftl b/gui/public/i18n/uk/translation.ftl index c7527077c4..87f3eb61f8 100644 --- a/gui/public/i18n/uk/translation.ftl +++ b/gui/public/i18n/uk/translation.ftl @@ -612,9 +612,11 @@ onboarding-setup_warning = onboarding-setup_warning-skip = Пропустити налаштування onboarding-setup_warning-cancel = Продовжити налаштування +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = Повернутися до вступу onboarding-wifi_creds-skip = Пропустити налаштування Wi-Fi onboarding-wifi_creds-submit = Підтвердити! onboarding-wifi_creds-ssid = @@ -649,6 +651,9 @@ onboarding-reset_tutorial-2 = Вам потрібно бути в позі, ніби ви катаєтеся на лижах, як показано на майстрі автоматичної прив'язки положення. У вас є затримка у 3 секунди (можна налаштувати), перш ніж спрацює скидання. +## Install info + + ## Setup start onboarding-home = Ласкаво просимо до SlimeVR diff --git a/gui/public/i18n/vi/translation.ftl b/gui/public/i18n/vi/translation.ftl index 398cb64476..f575e1517e 100644 --- a/gui/public/i18n/vi/translation.ftl +++ b/gui/public/i18n/vi/translation.ftl @@ -649,9 +649,11 @@ onboarding-setup_warning = onboarding-setup_warning-skip = Bỏ qua cài đặt onboarding-setup_warning-cancel = Tiếp tục thiết lập +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = Quay lại giới thiệu onboarding-wifi_creds-skip = Bỏ qua cài đặt Wi-Fi onboarding-wifi_creds-submit = Gửi onboarding-wifi_creds-ssid = @@ -686,6 +688,9 @@ onboarding-reset_tutorial-2 = Bạn cần phải ở trên một tư thế như bạn đang trượt tuyết như nó được hiển thị trên trình hướng dẫn gắn tự động và bạn có độ trễ 3 giây (có thể định cấu hình) trước khi nó được kích hoạt. +## Install info + + ## Setup start onboarding-home = Chào mừng bạn đến với SlimeVR! diff --git a/gui/public/i18n/zh-Hans/translation.ftl b/gui/public/i18n/zh-Hans/translation.ftl index e7fa8abbbe..be8a32837e 100644 --- a/gui/public/i18n/zh-Hans/translation.ftl +++ b/gui/public/i18n/zh-Hans/translation.ftl @@ -115,6 +115,11 @@ board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3 board_type-HARITORA = Haritora board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1 board_type-GLOVE_IMU_SLIMEVR_DEV = SlimeVR开发版IMU手套 +board_type-GESTURES = 手势 +board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini +board_type-GENERIC_NRF = nRF系列 +board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR蝴蝶 开发版 +board_type-SLIMEVR_BUTTERFLY = SlimeVR蝴蝶 ## Proportions @@ -262,7 +267,7 @@ serial_detection-close = 关闭 ## Navigation bar -navbar-home = 主页 +navbar-home = 主界面 navbar-body_proportions = 身体比例 navbar-trackers_assign = 追踪器分配 navbar-mounting = 佩戴校准 @@ -275,7 +280,7 @@ navbar-connect_trackers = 连接追踪器 bvh-start_recording = 录制 BVH 文件 bvh-stop_recording = 保存 BVH 记录 bvh-recording = 录制中... -bvh-save_title = 保存BVH记录 +bvh-save_title = 保存 BVH 记录 ## Tracking pause @@ -339,6 +344,7 @@ tracker-table-column-name = 名字 tracker-table-column-type = 类型 tracker-table-column-battery = 电量 tracker-table-column-ping = 延迟 +tracker-table-column-packet_loss = 丢包 tracker-table-column-tps = TPS tracker-table-column-temperature = 温度 °C tracker-table-column-linear-acceleration = 加速度 X/Y/Z @@ -380,6 +386,9 @@ tracker-infos-magnetometer-status-v1 = [ENABLED] 已启用 *[NOT_SUPPORTED] 不支持 } +tracker-infos-packet_loss = 丢包 +tracker-infos-packets_lost = 包丢失 +tracker-infos-packets_received = 包已接收 ## Tracker settings @@ -419,6 +428,7 @@ tracker-settings-update = 立即更新 tracker-settings-update-title = 固件版本 tracker-settings-current-version = 当前版本 tracker-settings-latest-version = 最新版本 +tracker-settings-build-date = 生成日期 ## Tracker part card info @@ -526,7 +536,7 @@ settings-general-steamvr-trackers-right_elbow = 右手肘 settings-general-steamvr-trackers-left_hand = 左手 settings-general-steamvr-trackers-right_hand = 右手 settings-general-steamvr-trackers-tracker_toggling = 自动开关追踪器 -settings-general-steamvr-trackers-tracker_toggling-description = 根据当前已分配的追踪器,自动选择可用的SteamVR虚拟追踪器 +settings-general-steamvr-trackers-tracker_toggling-description = 根据当前已分配的追踪器,自动选择可用的 SteamVR 虚拟追踪器 settings-general-steamvr-trackers-tracker_toggling-label = 自动开关追踪器 settings-general-steamvr-trackers-hands-warning = 警告:开启手部虚拟追踪器将覆盖手柄的追踪信息。 @@ -583,6 +593,9 @@ settings-general-tracker_mechanics-use_mag_on_all_trackers-description = 在所有有固件支持的追踪器上启用磁力计,在磁场稳定的环境中可以减轻飘移。 可以在个别追踪器上禁用本功能。切换此选项时请勿关闭任何一个追踪器的电源! settings-general-tracker_mechanics-use_mag_on_all_trackers-label = 在追踪器上启用磁力计 +settings-general-tracker_mechanics-trackers_over_usb = 通过USB连接的追踪器 +settings-general-tracker_mechanics-trackers_over_usb-description = 通过USB接收HID追踪器数据。清确保连接的追踪器启用了 通过HID连接 功能! +settings-general-tracker_mechanics-trackers_over_usb-enabled-label = 允许HID追踪器通过USB直接连接 settings-stay_aligned = 持续校准 settings-stay_aligned-description = 持续校准会逐渐将追踪器对齐到设置的放松姿势,减少追踪器漂移的影响 settings-stay_aligned-setup-label = 配置持续校准 @@ -753,9 +766,9 @@ settings-interface-behavior-error_tracking-description_v2 = 为了提供最佳用户体验,我们会收集匿名错误报告、性能指标和操作系统信息。这有助于我们检测 SlimeVR 的错误和问题。这些指标将通过 Sentry.io 收集。 settings-interface-behavior-error_tracking-label = 向开发人员发送错误信息 -settings-interface-behavior-bvh_directory = BVH记录保存目录 -settings-interface-behavior-bvh_directory-description = 选择保存BVH记录文件的目录 -settings-interface-behavior-bvh_directory-label = BVH记录保存目录 +settings-interface-behavior-bvh_directory = BVH 记录保存目录 +settings-interface-behavior-bvh_directory-description = 选择保存 BVH 记录文件的目录 +settings-interface-behavior-bvh_directory-label = BVH 记录保存目录 ## Serial settings @@ -942,9 +955,47 @@ onboarding-setup_warning = onboarding-setup_warning-skip = 跳过设置 onboarding-setup_warning-cancel = 继续设置 +## Quiz + +onboarding-quiz_continue = 继续 +onboarding-quiz_back = 返回 +onboarding-quiz-more_sets_modal-title = 已经连接好所有的追踪器了吗? +onboarding-quiz-more_sets_modal-desc = 如有不同类型的追踪器,现在可以继续进行连接 +onboarding-quiz-more_sets_modal-confirm = 所有的追踪器都连接好了 +onboarding-quiz-more_sets_modal-cancel = 还需要连接更多追踪器 +onboarding-quiz-slimeset-title = 将要连接什么类型的追踪器? +onboarding-quiz-slimeset-description = 如果有多组不同类型的追踪器,稍后将会继续连接其余类型 +onboarding-quiz-slimeset-official-sets = 官方SlimeVR追踪器 +onboarding-quiz-slimeset-thirdparty-sets = 第三方或DIY追踪器 +onboarding-quiz-slimeset-answer-regular = SlimeVR V1.0 和 V1.2 +onboarding-quiz-slimeset-answer-butterfly = Butterfly (蝴蝶) +onboarding-quiz-slimeset-answer-wifi = 通过ESP/WiFi连接的追踪器 +onboarding-quiz-slimeset-answer-dongle = 通过USB接收器连接的追踪器 +onboarding-quiz-usage-title = 将使用SlimeVR进行何种应用? +onboarding-quiz-usage-description = 如果打算将SlimeVR用至不同用途,可之后在设置中进行修改。 +onboarding-quiz-usage-answer-VRC = VR游戏(如VRChat) +onboarding-quiz-usage-answer-mocap_vtubing = 动作捕捉与虚拟偶像直播 +onboarding-quiz-runtime-title = 将通过SteamVR运行游戏,还是在直接在头显上运行? +onboarding-quiz-runtime-answer-steamvr = SteamVR +onboarding-quiz-runtime-answer-standalone = 头显独立运行 +onboarding-quiz-mocap_preferences-title = 动作捕捉偏好 +onboarding-quiz-mocap_preferences-desc = 设置使用SlimeVR进行动作捕捉或虚拟偶像直播的方式 +onboarding-quiz-mocap_preferences-playspace-title = 将在怎样的空间内使用? +onboarding-quiz-mocap_preferences-playspace-desc = 如果选择站立使用,SlimeVR会试图追踪你的行走动作,而不是固定在原地。 +onboarding-quiz-mocap_preferences-playspace-sitting = 坐下 +onboarding-quiz-mocap_preferences-playspace-standing = 站立 +onboarding-quiz-mocap_preferences-vrm_model-title = 你有将要使用的VRM模型吗?(可选) +onboarding-quiz-mocap_preferences-vrm_model-desc = 加载VRM模型将提升跟踪质量,并与使用VMC的应用程序兼容。 +onboarding-quiz-mocap_preferences-head_tracker-title = 你头上戴着追踪器或VR头显吗? +onboarding-quiz-mocap_preferences-head_tracker-yes = 是 +onboarding-quiz-mocap_preferences-head_tracker-no = 否 +onboarding-quiz-mocap_preferences-head_tracker_location-title = 你的头部追踪器放在哪里? +onboarding-quiz-mocap_preferences-head_tracker_location-forehead = 额头 +onboarding-quiz-mocap_preferences-head_tracker_location-face = 脸 + ## Wi-Fi setup -onboarding-wifi_creds-back = 返回简介 +onboarding-wifi_creds-back-v2 = 返回 onboarding-wifi_creds-v2 = 通过 Wi-Fi 连接 # This cares about multilines onboarding-wifi_creds-description-v2 = @@ -991,6 +1042,13 @@ onboarding-reset_tutorial-2 = 你需要摆出一个像滑雪那样的姿势,就像在运行自动设置佩戴向导时做的那样,在重置实际发生前有 3 秒延迟(可配置)。 +## Install info + +install-info_udev-rules_modal_title = 未找到硬件udev访问规则 +install-info_udev-rules_warning = 通过udev设置访问规则是串口控制台访问和USB接收器连接的必需条件。将以下命令粘贴到终端并执行,以添加udev规则。 +install-info_udev-rules_modal_button = 关闭 +install-info_udev-rules_modal-dont-show-again_checkbox = 不再显示 + ## Setup start onboarding-home = 欢迎来到 SlimeVR @@ -1318,6 +1376,7 @@ onboarding-user_height-description = 我们需要你的身高来计算躯干比 onboarding-user_height-need_head_tracker = 进行校准需要具备定位功能的头戴显示器与控制器。 onboarding-user_height-calculate = 自动计算我的身高 onboarding-user_height-next_step = 保存并继续 +onboarding-user_height-prev_step = 返回 onboarding-user_height-manual-proportions = 手动调整躯干比例 onboarding-user_height-calibration-title = 校准进度 onboarding-user_height-calibration-RECORDING_FLOOR = 用控制器的前端触碰地面 @@ -1371,6 +1430,7 @@ onboarding-stay_aligned-previous_step = 上一步 onboarding-stay_aligned-next_step = 下一步 onboarding-stay_aligned-restart = 重新开始 onboarding-stay_aligned-done = 完成 +onboarding-stay_aligned-manual_mounting-done = 完成 ## Home @@ -1421,6 +1481,9 @@ firmware_tool-select_source-firmware = 固件来源 firmware_tool-select_source-version = 固件版本 firmware_tool-select_source-official = 官方 firmware_tool-select_source-dev = 开发版 +firmware_tool-select_source-not_selected = 未选择来源 +firmware_tool-select_source-no_boards = 此来源无可用的开发板 +firmware_tool-select_source-no_versions = 此来源无可用的版本 firmware_tool-board_defaults = 配置电路板 firmware_tool-board_defaults-description = 设置引脚与其他和硬件相关的配置 firmware_tool-board_defaults-add = 新增 @@ -1463,6 +1526,7 @@ firmware_tool-flashing_step-description = 正在向追踪器烧录固件,请 firmware_tool-flashing_step-warning-v2 = 除非特别指示,在固件传输过程中请勿断开或关闭追踪器,否则可能会导致您的电路板无法使用 firmware_tool-flashing_step-flash_more = 烧录更多的追踪器 firmware_tool-flashing_step-exit = 退出 +firmware_tool-flashing_step-onboarding_continue = 继续 ## firmware tool build status @@ -1598,6 +1662,9 @@ tracking_checklist-FULL_RESET-desc = 有些追踪器需要进行重置 tracking_checklist-STEAMVR_DISCONNECTED = SteamVR 未在运行 tracking_checklist-STEAMVR_DISCONNECTED-desc = SteamVR 未在运行。你要将追踪器用于 VR 吗? tracking_checklist-STEAMVR_DISCONNECTED-open = 启动 SteamVR +tracking_checklist-STEAMVR_HANDS_ENABLED = 手部追踪器已开启 +tracking_checklist-STEAMVR_HANDS_ENABLED-desc = 你已经启用了SteamVR的虚拟手部追踪器。这会导致SteamVR和游戏中的按键输入无法使用。 +tracking_checklist-STEAMVR_HANDS_ENABLED-go = 关闭它们 tracking_checklist-TRACKERS_REST_CALIBRATION = 校准追踪器 tracking_checklist-TRACKERS_REST_CALIBRATION-desc = 您尚未执行追踪器校准。请将(黄色高亮显示的)追踪器放置在平稳表面上,并静置数秒。 tracking_checklist-TRACKER_ERROR = 追踪器出现错误 diff --git a/gui/public/i18n/zh-Hant/translation.ftl b/gui/public/i18n/zh-Hant/translation.ftl index c46a5c2a38..5ba2e67704 100644 --- a/gui/public/i18n/zh-Hant/translation.ftl +++ b/gui/public/i18n/zh-Hant/translation.ftl @@ -115,6 +115,11 @@ board_type-XIAO_ESP32C3 = Seeed Studio XIAO ESP32C3 board_type-HARITORA = Haritora board_type-ESP32C6DEVKITC1 = Espressif ESP32-C6 DevKitC-1 board_type-GLOVE_IMU_SLIMEVR_DEV = SlimeVR Dev IMU 手套 +board_type-GESTURES = litten Yº by Gestures +board_type-ESP32S3_SUPERMINI = ESP32-S3 Supermini +board_type-GENERIC_NRF = 通用 nRF +board_type-SLIMEVR_BUTTERFLY_DEV = SlimeVR Dev Butterfly +board_type-SLIMEVR_BUTTERFLY = SlimeVR Butterfly ## Proportions @@ -345,6 +350,7 @@ tracker-table-column-name = 名稱 tracker-table-column-type = 類型 tracker-table-column-battery = 電量 tracker-table-column-ping = Ping +tracker-table-column-packet_loss = 封包遺失 tracker-table-column-tps = TPS tracker-table-column-temperature = 溫度 ℃ tracker-table-column-linear-acceleration = 加速度 X/Y/Z @@ -386,6 +392,9 @@ tracker-infos-magnetometer-status-v1 = [ENABLED] 已啟用 *[NOT_SUPPORTED] 不支援 } +tracker-infos-packet_loss = 封包遺失 +tracker-infos-packets_lost = 已遺失封包 +tracker-infos-packets_received = 已接收封包 ## Tracker settings @@ -425,6 +434,7 @@ tracker-settings-update = 立即更新 tracker-settings-update-title = 韌體版本 tracker-settings-current-version = 目前版本 tracker-settings-latest-version = 最新版本 +tracker-settings-build-date = 建置日期 ## Tracker part card info @@ -589,6 +599,9 @@ settings-general-tracker_mechanics-use_mag_on_all_trackers-description = 在所有有韌體支援的追蹤器上使用磁力計,在磁場穩定的環境中可以減緩偏移。 開啟此選項後,可以個別在追蹤器選項內停用磁力計。切換此選項時請勿關閉任何一個追蹤器的電源! settings-general-tracker_mechanics-use_mag_on_all_trackers-label = 在追蹤器上啟用磁力計 +settings-general-tracker_mechanics-trackers_over_usb = 透過 USB 連接的追蹤器 +settings-general-tracker_mechanics-trackers_over_usb-description = 透過 USB 接收 HID 追蹤器的資料,請確保連接的追蹤器已啟用「透過 HID 連接」的功能。 +settings-general-tracker_mechanics-trackers_over_usb-enabled-label = 允許 HID 追蹤器透過 USB 直接連接 settings-stay_aligned = 持續校正 settings-stay_aligned-description = 持續校正功能會逐漸調整追蹤器以對齊到設定的放鬆姿態,進而減少追蹤器偏移的影響。 settings-stay_aligned-setup-label = 設定持續校正 @@ -944,9 +957,18 @@ onboarding-setup_warning = onboarding-setup_warning-skip = 跳過設定 onboarding-setup_warning-cancel = 繼續設定 +## Quiz + + ## Wi-Fi setup -onboarding-wifi_creds-back = 返回簡介 +onboarding-wifi_creds-v2 = 透過 Wi-Fi 連接 +# This cares about multilines +onboarding-wifi_creds-description-v2 = + 大多數的追蹤器(例如官方的 SlimeVR 追蹤器)使用 Wi-Fi 連接伺服器程式。 + 請輸入目前設備連接的網路的 Wi-Fi 憑證。 + + 請確保輸入的是 2.4 GHz 頻道的 Wi-Fi 憑證。 onboarding-wifi_creds-skip = 跳過 Wi-Fi 設定 onboarding-wifi_creds-submit = 送出! onboarding-wifi_creds-ssid = @@ -956,6 +978,10 @@ onboarding-wifi_creds-ssid-required = 必須填寫 Wi-Fi 名稱 onboarding-wifi_creds-password = .label = 密碼 .placeholder = 輸入密碼 +onboarding-wifi_creds-dongle-title = 透過接收器連接 +onboarding-wifi_creds-dongle-description = 如果你的追蹤器有接收器,將其插入你的裝置即可開始使用。 +onboarding-wifi_creds-dongle-wip = 本部分目前仍在開發階段,將來會推出管理接收器連接追蹤器的專屬頁面。 +onboarding-wifi_creds-dongle-continue = 使用接收器繼續 ## Mounting setup @@ -982,6 +1008,9 @@ onboarding-reset_tutorial-2 = 做此校正時需要進行滑雪姿勢,如自動配戴校正的畫面所示。在觸發 3 秒後(可修改)才會真正進行重置。 +## Install info + + ## Setup start onboarding-home = 歡迎來到 SlimeVR @@ -1362,6 +1391,7 @@ onboarding-stay_aligned-previous_step = 上一步 onboarding-stay_aligned-next_step = 下一步 onboarding-stay_aligned-restart = 重新開始 onboarding-stay_aligned-done = 完成 +onboarding-stay_aligned-manual_mounting-done = 完成 ## Home @@ -1412,6 +1442,9 @@ firmware_tool-select_source-firmware = 韌體來源 firmware_tool-select_source-version = 韌體版本 firmware_tool-select_source-official = 正式版 firmware_tool-select_source-dev = 開發版 +firmware_tool-select_source-not_selected = 未選擇來源 +firmware_tool-select_source-no_boards = 此來源沒有可用的開發板 +firmware_tool-select_source-no_versions = 此來源沒有可用的版本 firmware_tool-board_defaults = 設定電路板 firmware_tool-board_defaults-description = 設定與硬體相關的腳位或配置 firmware_tool-board_defaults-add = 新增 diff --git a/gui/public/images/quiz/quiz_mocap-pos_face.webp b/gui/public/images/quiz/quiz_mocap-pos_face.webp new file mode 100644 index 0000000000000000000000000000000000000000..ccd14a455fbf8ce2edcc3e4edb6ef556561ea473 GIT binary patch literal 13558 zcmVc zE3tcPB^^3kOqbjY#SCE_B1dNUe*egJj{d%%-}$c*F#+_q|Nr)X@Et0M6a*vAb=wbR z+}z0@URgXP=m@nWSIe4dr=1WJS_akmpf7#MM}vokymvA$c%W_{ZVw=4 zjeEB-fS?z>dohTJxt@IxM9@yp`U8n7*Q*}`33}P9popkj$PuLKn(Sx?pm71ZU)?SVj~{GUy?EIPk3d{#~DMSZBU(CQ)=Z|oZxXE~L# z$xqws&k#E50e_ca_sHk3Z)s_YSXlL{-cdPc9&D{VgXG^)?bw zBl{x4`)Ary#roe_e9U(~v23+pBH1lhOtqrYc2~@p`}+CjsO1!{N{dO|o{KHUx#xE= zEf3rhwiD;VFIf!C!%r63NBQa`i>Ze%vy8@WS4_^kiY?;OJ{Hp-xy%+C53(r8TO1px zd5=X$-W`Fjpv@IM`=a30e9NL~q8hq8GNSD7Rk%4@SkxUVMXlgX7KQt#U{=&CI`0c3 z7X4O~=Iww@JtS)L0=Nnfh~j@$f)(B)s()JymD454ABZ5embH`j!Z=-#6a9C=G-E^n zt4s>o--h*a4hr6D^?FPIPert<8bwSNYxSf+{;5{In57+^ z3db)Q!=!Ur4zmHJ z?i9>FRjL!T_mp}Mv`(c4KwG2KLD23{stdHZQcq*XQKcFgvJ@#DNAmiMl(@pn@F5st+F8$o}573UO1-JcZ6b`CrqxDs86dmVTfcWN~W2Jp3$lm z26}IT4A&76U7=LI28XM7B5+TXPAztI>L(nXfeA3S)S=UVtqovTMrU1?SBMv2L4_ZA#(neXT!(yOBc%$!pzzD=3GZ#B zbKm9DplhP-gy)ju=a$+ro4mZor~j=r5Z41jr8~yYhErV&3dq}9_J=D5%(I&?t?l?B zCnDpAyIbo+3ixtERe;d_LgQT3W`2Swqlw_|!$AVY4J7*xm2b zAkj{v@B+fV$){|HT8*-wz^`V1xSZ}bi8~9uqP#!SKvx-rpCt61j8E57y+K|k{6~CR zM3roe-Y|a7ai4CXNX`^LOeOpaT;H4#LmaIl3yiT(*Ae|;hD<%#V3YXEwd9;If_nv7 z;Ub^LA`~7s!T2Jw!#jN%A}VGB9KVz-@n)Z1A*zzepO{6q*ys;mMD)+e_eqqjv5bBB z#UZ+QQhadqEKY6;lSL+cd6bCmn-uqiuYci_;hadWD@In??ninm5p9_ichjjw4e?^K z%-QTiI}y>INzr^7E8XK=YWgXO$-tHNGTZg6Jzf!_h&vgQj=j)&&xF}czQ>axpGhTu z=-a>h#pgFJySdadLZ5nO$lRfg@vucu>90l;5gzHjyTTeMcGz&I=6~4ISYQVf{lt8y z{=U!-$Y~kTK=>`k3W$my>pj7_bWfYngrBhlB63P1L=9#WzS$a1TW2^Kt+R?+)5&hD zs5ah1RxzCom#?ylGSl^vRTi7>YP%@PnJwYiMZ`5*3)yApq|wqvmf>WK)=sv0++?|! zZA4s?^^k4OVT197*0ER&{w>*N!RIEs!zv2v=C>SSgB^{acmA&Jo7X=1(CrJaXj=72 zRt!H_joqvs7F9=@fGgYKinilRP>ZZOAqsP67%^wjuqgd`DP9*IVo^Ie2dtSrEShH) zAQkO!MfqGSOa)Jji2nR$1fvDNvOsvK0-%}uSWx`gL8s`MF~O0q2j|8k0_A{%OzkHu zSn{Q~OnuH3I6pApbV1(=Gx1yHbtfdDP^` zGKJm}+y~3_IRKmtm8+9LP7YTnGj{TdF0}%_8&jnTJ=vkk?eJyGHED%U zp3`I-ewnBu|G`f-D$;KO$ET_hPFsMqs&Nt9z_BS>Y!)kQ(PGdJ-c%yScJQp3+TJ?6n$iK?}mOA71-|oMm ze|SI4{HOnS*4J2ha`{i}f93zbe%^nH|NsBP?q~d;_`b})Q-8Sk1pP+;IsME0pZp*4 zU;qAa|6o1j`HlXW{p0>8`Cs2}mv8pJ@&CztwEoBbtM+sJm&gb7kL=&^`w;(heS$xz ze~bUQ?DhXQ|3~}J-~XfE>i_M3_y4%~8vbqm5B~?}|Hudb|46iEE>*B>j|1>*SlxReAsdq@6ANlppwFffJoblm>+Rw_HV1DxpHe#xE zVSg04@>v`A`u9S~Kdt>g-LRB+tD&OS;1Ps)q*tu|Kw9~jgeemMH|zlWkX%34m4d|B_9h; zos^XL*zDbNS`YAQb`^nRVIQaqo}B49Wre|I2C~5I4_6~TVO7Q10Y4~9q8$OH^`mqVT zZkEva69mI5f7XJb{b!)Y?Z19HMFf*WB>feoy zvzW<2MH=fUO9Z&)(|j4{v`O34ZVG1a+sJdipPt4g@(vMmNoc|yHNWA#9p*+kh^H(| zSl^`^IFBbb+sEMKDV@RNXC2k@gqLT!o0#UOt{Ge|3VxUHZX<{PvJ8qHk+~?9Wx$E} z6=}N+CjnMs6k;lKh7TiUAuBCDHCs&|GQg7=ZFG5;n=0el?CGX5+krXlVtf>Hd0nHt zaFA0@(7J38Nz>EjW8Hs5>3oRV{@`{LI?s{__5l~I847>^{{0vn01dSDv6;SrCY;)l zV7x8_;5F`bjo|NcjDPS|rAJccbS-T2bhC0la+Ws6n`t|e(b1B#tY&*#7N6-KeHNSnAaDW-_sIO!l z)`653cT~z=4^`%h2|WKK2Hi+kAZQ_1D`ntJ0Zx-7!`o4O?7iJapfR{1tA#b(=-qGo z@rj4ect0^x{IObpJF`H96+x!{_!ADIG`EwS{F0ry_J6>M!6+w*^o{vQR7RIR*x+_C zmv&8LT4Fy^()54FsA+TH_`d!aj%o93a=ZGxrBIG|6KRcde@-h7(V|Y|l5w!mdv?SP zX1adLQx6FTwmuZfB)iQ`rG3Ux{7Nfzo;6=nrDl5-w5ho3d6A8+o|r?3*=`uc0q9~T zK!=ehk100*CZy*sKa;u=d8H^HpDbz&`GaD1*m+8@+oy!5aqS15hR z!3wipd0%svh9~E{Fw<|y4BrWhap}P*O6)5;(7BXN6my)|r16)(=9O3k9R2A=EkIcs zJE_o!J~NV554rB0@E*6FZXHL1Nx`JePH#sT#n}_Pm^O`Vq1qFSD+-IlgidkuwvG;>5V~JX`^DqTUjm z^2r8PBT?`yN@Z?I)qfSbo4zFY2tx|Is9c5toEaZ|sErL)wDsR1~5-CEs^0ViTx z0+kL+sc)mAgmp0uw|qRtX3C>rbbDdk*D`xY&uGUOP1haYwR^W>2$QQUd(wk(Ux`J4T_*?u~^&G35s-MVyXs7UpuL^<)6_n5R9_v;tW!w|$NH zc69vsqrRVHH$=&eY_<=vjTL;Xb1My6_N|=RmV*rr00aj*fk*0g?L=uRlkpkLMVeAS zyZhOpKjoNY*OSkuoiOa*?eTxf1s&Z8CJjPP-J)_|-fgCv)o(vg<4DYnm2*Cfe`v=2-NJH23N44%n=Li=^*HVA$DcN)gOAszxIwhc{c6( zu32SL`LA<2c98cIgcy@vC*Y;GH&l?<7%cn#Ox<>3z{}hBp1%2PG;SIQd?^>Y=b7e& zKPWH%y;*F}bP;A*<{uCpx@EU*yU;97NJG<3&qWgnP8Unp8W zNOs1aBZ3=EqzZ~GwSVGQFje#WY~Lt-@+5>{^(L;4Uk>^=oPx<=Fn5buLQn4XIq4vw z2hB8VE^Oter!hqG*tc=8L6$buyp=5(CQ;oT%#pbo}P9~ltm!qNUH}CW1m$?SRr^@SF6NCgH0mLOW zIhW}BvU$qRdrWMK*P+OyNEq<=VuolhD4{G8P8v{Lxx@ZbMSTI1ggS9H4KNz3wbWkH zSBb_d_7S0f^D8nB3Uj9JecoIBBSel8=Y0@=>KbQq4>XbdrzyBqv4^e)NV`VGBX zMiW4n?7Lu=tzPlLAp)l_I(oA>b5(218TJiD4->vRo^YP1U9*&1eH*C|%r^y=%1B@v znls+Z<~e>0c5!>_ulJx0!+`jkHXL>2>EZjbL2D#1|>!8#qC4)vGno#Fc~RQJ(dp8R!3F{Q{!R6O)IF=tQB{EKdxwM&h5;Xl57c z+QBgw2em*N{}k5d?PTfmz9LUR) z8>;}Tm(W4UyfnIngmNd{cLHA@x|#bahnO5`^AOC|oaq7jpE9R-KjseP2ctQ7qcqP* zRMqNI`(%kuG>?J4QKX-)4%yE!47#*Q8gJ^3`|GFmT?uv(w)%@{@^PMeZiRm}&`@HuaZjo@gU8&)e*qqCb^Y6VX&<#r%A&I!p4@*nni z$Win3v;0U*w(kbocV2oI9OO&Q{J!0t|C-Q3t!5GG0fv-2ST_%bB;OAIw8T!CAhGoT zyO4%ZRZGp8BI^FMdgA+<7xU563RJ(@TcD5m*VnF41M(6GH^liUXkDb0OH12~8MRJ| za*3$gtlg<#2melO5?`C-9UTa|?IsaM*Yk%EW-{=}(y!JLtb@sDm#=(0Jfz7Pg0&w@vHIbU!-pyw zTYDF)6pb&=Y=#|3+wr9b$g&2FAq;L1cJ@D|rRU9@U$paz+0<=Hd0Rci^R@ode@4WFl9={?l1#aPvqMb0G=ui|OR(R3p$2RX}$q2pADffR?!Z zI4u+GQVs_RW#TReViz7G-?27wtZARGy&#Pm^&)fLp7chPLADIS1K#4A(vd{*3i*9~ zf8c1+HAJr_V@yh^GPaoLa2MdNS1d)S^Mc8Va3WDjANog(ildjn`Y^tPUypvc)jX(H zHfS)`uYqMXazojpzz4CqpEPa@lQ_WJavf<$A6}zE-bkfgVrnTnTn1q(xemkCrr^u0 z_#d$N@SW__Fj}gNV)o&e9qP3TC}2IKw-*QYKt3pC{vJqpKo5X>XNvfwu}c63NPqxx zI(aMogwJ~cKp+m_|G21S&31nng@$ZGXOk=&f#MSrAmR-SoBx8OuIrGHfI>+3ZH2`h zvBRimE1nZEqhc=cJV^JD9$A%qHI~Dks+np5*{p+qt;0chqgY$3v{78S3=T-G%kDp( z4`gmu|FvL|heO_RVm@3c{XI=m$ys~F(b0x-M^l?E8t_1EUC9^saLw{LLIh1{geY2+ zi51fvI428nNc<;*dutI@NG-=urU zkvu!&K+3Pc$5`NA?93}|!gJ~KLn+DpAyLSX9bp)r90R>dI|tL|T$c}MYM8g3Phu>0 z4*$UM3>=AQF8GuAeumv(PmRrJ6_<5^@qOL_s$10%00(WTxa}kOgUT5KO-k?lDl`HM zXS?QY);MhGB?dD3sO~%^lfWM)AHD!Dv6t=Nb{*MJiN%NUg=V9;=AVeo!~XZnK4EgX z_1_ABgu#U%7yR)#mw`85*yN~)&aFbWU=22~evt#i+QYwhTz)wA|~e86LYS9x(U6^c_~t@E#6fbhx!0d5{t&VHy(lWq=`C4pWn@O@n89jkGbA- zB#YSz)Z0V}i%*;lPDj4x5?-r(&EgXGA{P4>rkQ!vvjZiBLxwSM6q>(wzk?$_+$RMN zHaHOgqqHMh00G-%rz&nuT2klRrI%A>OcdZZtp-`6K<v zuf~Hu&tlgLx{_WL8cCF+k+r4U=2-&N2%`AfnC@5yejwHTu=u5=O}FiBRhvvm22Yj0 zRtZ(?Sth`XNS4#1lVaS_Oqjedx(cv~ahtW=e(B6b!K6m3#S|vUKO?_nrM)p z@Tro80Yttnu1H?A>Ve&5lF7|`amg_y-sv2#UmHvy<9|1P8#wkih+&jh_VysJPzt$A zB5D)>oq3tD5!Yp^NAAU zeGY;fk1P;7yH?A5P9~u)we>zH$V`m8FG+Ov)Qt*9p2^f{^Gf%m=fO&*Vx&89A!=CD zYQQ3arP`850HK?gZV@*>;L84R>vw9R5^d&TWFMQNvX;&_VqtPyC$XIInH%E^lOTHl`uD=Ih3Pi|{Q0;v^9LM90A zow<583jKO)0S798;{Q{!mo=jU<@IHR+{9x>CS{vC*p1UuiB*p5sbs{l{czxRNR4S| zq6V~AKhHK05&EnxW53!F*EgcE^YW2``}Kz0q)x2&`1V^5BCYX`7`@o_qnr8c-Zyd5 z9#Mz8e0`-I;;U1tTz?H7cFUvlyag%`qC;_cufJR0`sK=^*$b7cj=63*UrPdu>pk^O zP-=!p^F#pyJ(y->H7;kbFZ|tVEof^_ZXxF83 zR3P2FZtsI?k>u424=TPWE7p>BH1e&0(OAG@}S;ZAX}BiKNp{G5`iH%cEpF=hlTk%8IL|mkj1CKAl&oDC;u=U zlJO4)wXK6vuP+DjjMPuJwOd)=V{x4fHJpH|c7vJ6p=iqNx+IwYD|Bu1byO(eIqSnS z{GYb?&1B9ufh~HCeqd$SXw%;!L-O3do+X&}#`~FoP6n}XWJ5U4KMfyAKPe@cY^Z9? zhN^m}Dn7KmLnHW80UFye0e@@#H#;~3=xU>`XL+kHg{p=k*IrH#rClsI)7R1>${^SooD3? zc0!HVguz6`A z!Ew2qp%5HDcFF1p`W-nf`_gM{Q3LY)TPkxcg`}>aTNWYx=>04^K5o38cb_82IUQ8m z10Ml|%9ArSrck$KwWMoiQYcWetBfk$5E~$u(=((=6!WS&3U~7=C?oT7u}`c1jZMIb zgMwO0GvZf)!PoL9;ageM2)4j?dXTffBW&6%!Gf>)Xi^nD`+WJs=Cp%|{WM?;{~r@J zpT%Yd>;nY0cQl!z^|NV9NEpV(GsDD*JlTpeTN=VfsK7k<5LD5y$U+!f zn&LJq7jQvYnzkz%YsZhW=OuL~5kvD{l-ZV}ocQKV$WGgK9RlB32B9M4_1GyZ(|3GP zCfeH~3V^{*9SI4$_4MlBqJyaml$@slu!yT!AT{gg?2*kFr9Qc+L>Qb6%fKdq%g^hjJ`y1m}+ai)d6XsP1U^ZwJq^t(bu&nW@D__yi4j2|sGf~Z+h zGEQ(F@s&-}hr%fs|2sB1zqJw1^DOF5{%{U76gsfT0P6%^?{^kVEs}6@4Fn9r&{F?yi*j)^xp%w_>$W<%rA88IekADuyX8%q({rTumK@RG(1;H zSa95BQWE-UoyRRZ53N5NUrohh?s=4fT*$&ajffQpZEI-?EL-{(I^GStyT61)(y+nZ7wl; zci~qZETm=)>63#9<6T8WpoJZ|2;%63I^A(PkSiJ-oa-!Q54K+YwqDW)M~nq>ZWy`m z_c4JBWCxqUb1;e!VE@)Ce_$M@ZMZ2-^z(IoudJT}6g6ZNb4?ntLxgDzKt?A!K|YRK7#QgnDjPA zy!f2Cv{FB?Nh%fnv7IG=`U^{oWu-B#@ro*j_xngR2zxR-Hq-aOEk&P_P-8f=o;!l~ z^%k=|z1X*Z?kGO7L%FD2W(Ve0&1L|p+LkBRdL}tZ3w8_D-4=%Al(~ScSe06ggKw}u z-RbAiNK*15b?vT#QMl}=1X9k+0| z9&d-#{H#&0!5>z2o6!1J&(DMN`sawurh19cni>r}>{LA9OZeD&0-yNm?mADq%>6y= zozF$oLR+-%(?2CfTu`k+XI+g&78~($3g~f-(*#$05&^NV#;(O-^Rp%YZE^R!@xb^f zdt6)#9%9(}uIpsqSA5X9Q`J2Nb+}gHUn<|isX|u@?f@%y*%CmDoPpK`jzS+>q>hO6 zVdbUVp2wP-XgW#9M%QizxV8QmPUcKaHelJW)st_!W6gk-i?R>~gC!kUul)t`xRAK` z&IIb+ixSpM-6vqrZ1zhiU{w(+(%R;U~x;TyU29)094 zUOxImM(7>EdHq^oA@kKn_n~AmU)9|=#{y;=ARJoy-Qb3!9XKP1$Y`q{xj<O4nNDktYy{sK>0q zIuQc8)7KIcIW;61^4S8PLC6+^=zrYJny`ek!!7XbJYeY+*94#=^vzm{`?#(`Vk400 zBdQ$bixC2#DOsP}@2bxYB7uu@TLT{if=i~`)TKj$faO(Lhf0q%VW<1LAS{qbAzIv36$H|=1wM}KwZ}5elH1v&)Rct)kZ!V0xAw`mnQ)D(PD~1B*0+FU zBxG0@xXeVS)ML$SpprW|kDIl>oH6r_o8gOhRj!gXCZi(k_~bDo;Z=E6v*q~P!R+TJ zk<_py1MAOMiaUsRnBJ$YH-oNVM$5&V%NA!(_Qju8OI zZS>;KMLKDWCPIzU8_v`(B26Um z&uJMMQCE`Rhvu2N4*38UJ6>hvK!$YwIW69R8q4)WN{o6u1-vNX90oOpk!xZ!{mIkt zCa-9eyxd--qK9G@Z?Z&YcQd#$gR2ojE^|ROBi&Kt2#Fbe*;_89ve%qG&gy28uZ#&83Q**{JBC!&vK1y{^lIgeYNPgu!@ zqHt*7dR~Uk>~VLupi>?etCv)z<2X=(GRueqQ14I)H#|d-K+G}Y3n@6@y8chN7Dz7h zwko z;b6@$0YnHY&aHvV1m!r3h~3^j0H#2$_;&*)8x!jGR$=x>mqS}SrLP9IkJiN0rXBAQ ziWA()AMl{%LN+L+LOLZ((WN%wCHC#ttFMZL;dXPZ_VN`HbGi>0!F$drAEi_^k;c?5 zC~pL_jztgIW$h2%Bk!C&hv3W3kIzL(MSvNbh1T~a8Evaeez(<-OtuY(^uhNTh_2G{e9<3yU{QWCM&xzsW&gfcRCs=pGCMs z{Syihtl^R5Hb)v?+6#VGR~7s6v0fYu6%4Vb{vdoWOHi~y4F}0v=>au@H0JKl*>g3F zBg$fn&rhApEu^4ID(?qxPriGdXeMx+y`9*J(@Mas_Uk0!FJhVJf1hBX&cn6o9KuRX zn!@>i!jTA$?Y%pxuU3=&ONVP}J0d3h=UoUopEu?N+q7Y^`B8x~SIvMx&0~DjFd{w?tE1$jom}BwT zx5oaHe18-Hy+aN;cs#~>1s6{|f6^Hd)n?_ioI1sp-4%vo07HB?AoHfsLIET1ho|GyA%0A>*;!3DGV~_j^F}t* zbMTMNRP`_OxlvaZ>ksm9-{5{qldhQ(}ootaA>x zYqDD6Hv-q)1Up+Q`qN#^yCv9d3K*^0(omn}e3O)ZSaU}IJ zx9QB)`2TbHW|tW!YxV#obMZm7=>HlF`?SG6MrJ0|mHfs{#pG}4DpHE1M!$hT)Gu@p zERzjrO0iNij$w~K?Cv{xEd3(}Qw)Fm5ETkNM+V#N=PbhHz+Q~L%7^6g5Kq3}b1ACj zxs2#@Hw88*@&~N^SI!f$?~d^oCzp-9x}4Xj;hp&li|>2S@3RKIp%gbi3aH5|P+H({ zk0vIOXySfA%LmLUb8U53dX|zL0h_MtbEbJ|(YkRpmGvRuZD*-Cd{w@e5Q*0*qUJM7 z213sWfIC{=QxYfj06Fw^<9J)>S}bIEUNI+B%((+6vHxzEzwK7jF(a!=0 zr|KHjNyD9AKbY7jo1tTnMb93Fuv3kU&_L}b+Y+L;`W!=oiiR6*`ZTpuoC^-y+0aEV(KJ5 z8w#MB-hXlrB20iU#7@2T%tNxud&5_dIR)*M*cwN_EojNriWud$WmRg@xKm6_5j_*t zERCDy{!)2m`Vj5&TpV-<=~a8S0>`H%#0H*1SBHF;#p(yTIwDJbrSvzCkHAwvXCL_0 z0_^e}xHZpPV%N2IkNzFdt`(b)x2ZMPViIj>jt`yH#d_b0C%MO~#4a^L*E=tYxC#(n z^ZfMiv{wiNp>f&+yJbq~iLtTMACMe32P|XDS|_acuC-?`dJ86D=-5qg>iFEGZbPZy z+4!`Sr9b@?$o17K+qXCH|l;}5#N)UZ8v}2r7q4`wd9=QFD{8pOGcVUx$!%0P{)f3 zLEw+|uQ}x|Ya2)?FUcw?=WB|J&ogTLBpQUZx7-nlA*|&FjWyA#lBBQva-`^pKTQMl zvLSHcQVu+^N}+5@cK4ycReKs%kY2XNNNpAx2}zRp&L&sJM!s~MqwTz_7dF9NNoSU$ zUiLhr6_qiFcMN$XejbZ|sZ4*%s*FfN>AqqwMT+~U5QV8e9eB^ct+{eg<4d_L5VoZ? zS^Vx7Ko~67I0=ADL`YFupI9p$45#(b@2a*4%SliG0K@K|77$vRr6|NQ%mcK=EDtj58kw}C}?-toqnWr=*M@e%0^fl-ND1_f`ca?o))yT zU_1@Z?|EZI*urQfr-uml)<*4%PNa*7ct>N_Rhmp65q`JGvglwsi44`}+0}%hv=aGy zhhtOpLYfITjkBp)=_b;>%$@>m=_koY1GZGf1`vL4gdIf(#Ek8utmq{%W&BF(2|6*? z81j3O-E8^P>t``&SFy$99%M1?Uq#juZWRNc8EYxdJ7VOoa_ppn%_NQX5ia=mqe(F| zSJ*^&*2AscLo5dGkJ`f3yPNvN{D-X|xbk>XROBW2jd}2Q7A@mp@bYS0(ey6pvUc~1 zuI<1%JAM~!y-}z+GfuGRyAiX3JwLH%T!UEIL06P+z-wMBi`s9XRd$j^@#nCL9%50w z0;y27E4uSEprW6$C?9ul+MW>gD+p!hNfz}x!YGB_U{Rl!5y;t10$@FX9QjoA?|C@{ zQcj!b&r=BGtQG#FB5)o9#_7O=qqvkY9`YbAFA1P=2bFLyB3z2fT1G_nq4JVI+8M;e>A-~NV-jUd zWG^PS3aG7^yo<^}2$J7Xkr*QR49ne!%n?`*AySD;rvu2FxNtro?YPKfKste03rC=a zfvH4e%1J@C4u?SD{GIjRu|RtmgpzxkdRXvnMIf>#&Vq5fe$K7E0`phujXc7Fb6mT^ z?*!>H%8mU=pgyacb5gKAty_hF-LBgBKD3%t+a+ipP^}5Bdd;#1;d)fFn83YVvrTwS z((E?4yd2Fc;p!mGE8se+S~*-#skRNS)vC3k<>hLY@1ga7()?OM`$5&}(c+kDhtTQ{ zs@9IytEBo#u((vW2C&*l_eZhfZOWA~RGlH^0zX0ZC~04bRCk_wxg9|5B>iLkFnt~X zfM)lDl$=Ho&g{bIRRXc_6heO&pg_(If5GD|Bpl~|f(}1IFwSmeLHC0I3jaRF0`J*T zgkf&IC-}Ov2*Z_s2*BPtf-qRi0}B>3`yT*A<47l8Xi1mbK)=qp08Q4nrGVcZZz_oA>-AYO?=6-L*fP(7SD&RX>c zP}nt$xF%~*tIr9=7!Mn-il|kqH(&_&50}JBB6?h_Dj4X{FzF~EB3h_aGK#}u9uC|X zq4RTHolX&sPWvzzyw#x#f1=YZK+t5@w^PM*@fwu|BS_F5_NV`@0A1orl^O|3=ZGK4 zhC;gJ{G>v>91F@io-%HoLYMoUPi&;F5EvAEiBrNy%INZ+^XVJcMEeQPojsS=RaZ=@ z+~LzN_Zf&M0YZ6oLzlu`4a-B6+9LLc$F!U00AX5G`=uu$Lq8m>niQavJH7tM;-Uf5 z^p69Dh$0iBS7(vWEk4~xm28-M3Hn-}8Ys|VmY>4d-RRRdM9&(9mlO7LeCiKSl~Iln z_*b((TtMX}aVMaco%BaaX|X|g7oqPYd|FGB4Du4;-|f>T8q3CL3gYMV__UG2o+-Y} zA^bzGZ%(%%&W#3Zu8IpQxDk#o)n?n1J{O+F2VDL7<;p-p6m*ZTAw zQ7#*xXC7JN2A^Ig8ZYS|pFp-)<`2&#`gi&@N5~rU*_R(3qFHJ2-q}kScqB*`8TaK5 zBDx_hb_Cb{uerk$@fr%rDr@}c%^{*E)1sWt&n}JSl4TaN4>d$Yhtp!^1+4UN>bl0A z7f%NsYcIua%4%X`0u*-B;U~vl=)sf;v-{H_PKRWo>!&YUj~#g7>4z&fgcFP|(6*#SkG&3FFqtL=cC+qyLne$%l6qTGt6 zbL{mtRvS(DaZ4Z~XJnYD)NH~Vtl@$shLgS$t0*#^9I%S3jdzPxjAO$k$6H0d>6*wY zb4|CzF0wtd#T~l{yJowM>@qN5wBBsXa1utVA=~URnOA5VVb^4hWSbM&U_8S*wur$$ zCEE;rXtM2AkySGLwli$7vtjh6J-2`FeYRUeuNiGDYX3A5tZN!sG*2u?DpKo;@?I58p}V?8e|{s3 z(el$Q5Vnm0=$fM}D1Pgpll}Oh;K*yiS$9UDY#Jx?KE(p8J06OW&w7IGJ|v=XL3f}8hq7+L_ooO7 z<*p!nJOF{Smj&Z$1e{k{K<2do)Uv>wC|2)27NCF3Rc)bBZ}ZS(|den7K{uArS< zrkB$qc=txtx|RWxZ$>H==@Q64jp+28fc|-&MzJJn@>RYP; zVT>})Ip`!lMweX1PF~ifNWgasRhfgH)Ty!!zWxGDs^F8SG}(t=BBIDU_{qbHoU(wP z95sTy79dq>%w!wr9HGTtvBKN5_{I+2P@<6S;87)ZiXHAy;;<#m)*-9U4){tP7K09H^qAUH1o0I;Y4odGH=0W1MNF&2qKq9Gv^ zSnU`f0|ch-7f9d%n%x`z1FUM=cTeqKv45H$D>a|&zu3P-e|!JG{)^?OUp+hJ)#ZP% z|C9d#`+5Es|Ns79xPS8fQ~s;}Y3>jDd;UxO5BQ(=zvMsv_s;Vb{WJV0{2%n6y}vA< z>A&j#v-fTNU;PjK&)Hv)59puQKi+i_nC zw)hwRWBv#J_vgRJ2mhZ?5Bxk)pVB`Jzg2!?c!Bu8$qz$+A^N}Xzmq?ey+gXE#XsCH z!+&@E(fr-{`@~Wf9QXP z{Hyy2{zv@}{eSnb^FR4N%K!iW_xE+{xAy1QtM#Y-_vdkvVu$-b+5XS=f3y9c?D>My zIpZ5Pw?wa~*+$AXQL%lMn&)JYA2V(QC)2Lw`%QY$Y}~GES`8z|XE$_%zlF3aVE z`4{?ei+EtQT$RXB;Gt|7ARhE94grf;0>T7$BdKZymd;22{=Ggska`D}G9rszx@OnhNxuTOL|6I(}-2XY#7U$tgEC zu@5g2J&*tWhE8Zu+;ba!^$A--=9ct8O~X|`;lG`KhP?#o|;YC0khcV) z`3!^g50XIn!*CenYnx|<_RazBBbM3)-2@M`^mloBVKPdDwCG;?6mLGFmt*OXs^m2~ z7Yf(3Vb~$t%mQ;(s!lhy$2@F@>RX0+F*Wub$nHl| zOCW{WQ(+-;@hk&o!lQ^-(8-lSo6j9TlZ>6XMYEWtqoZF;aY+!?ejMm;Yf{G-Wgyer zSsJgFrI_oB5n=>CQge!NxANH=2@Isgb(~}riYvCA{Jb)_Lk2=7x6f|#q|pCWw6T1_ z1ZxB4J7G%BK-kFCKiJMfotH1b45@S-BGaN5P~!_0@toxijV*W@Ivob4Yf}sPrghXm zUL50I8Hg^@^es8@OHgB=M?*-W&SD7F2rl+0&$g^$C2sBm?x(@yK%sLwF@T(Ec3GXC z#SiP4MMuYLC(MU@)vu+4|3BdrW^AasSV{v?9V=kFlS*g_VB`P*{{Ba*00LPq&BOfw zk|4_eY79Q#apzR_q%r=WBG=%l@2}!_{-IK9U2etE5%m302jpO1M}KFrAqmx?gc>s> zaGmgL9-rhk%jpN%LB~TS?1@gs_Hh)P4G3N+2%Q!+Fg80j=Vx#DD<3N{jK+!Mq^=u* z$J5K2Vvc3g<`h{mqV8GUgBJ^+Cbi`D@9pszYDoN7K1|b_^(^~(JN{iTP^P6d!n-+o ziXs$@GJx>Hf5e@BIyBNF>A=PefY5GjqiUKt<)-ykM!4QWVFsYQq*eU4fbSfL|8szG zNSq399<5inGo}qm!&G8KP-V<`!wIn2e>N=r2yY9PQAT#=)%dGxcUEGOYN{v5vPdue z|Hq0?=RQM{Y0=00BV_Y6cbEX&0m@))I6cb>epijJO`87&VXk5P)6OX|okj?5=zzxt zo$mds1*u&W#V;mFuOLj~Li#N~H}-FYQyR6C9G0`Vf+wj+jc1M6{GYAR7Rv4O$7E>_ z$y>G>mW1ky<;gUpZJ#=Aeck|bM@+@X-_;jH9t0fHt*y6B=y&e_Y{iH@SCq~<(D>TY zo1gajM8N08%LK2n?cEnnv$;q!fwhv3dZ)|T?D*Fi52%K~o%p_QirCs3ggyEU5`Oq^ z=VcTWrTtQ?4;l2z4-`inNvOu`(oM@eeZRL93V~YEV4nQkBUDoQexKmk_R(V4`RlwZ zljaN&$Y{Z_ClNAMdHe!819Yp#&)6=mz?m0UoWX8U96HFA%V@f@HF3cUt`I}{iKcOw zS{8}eO^4eCDoBR^w|-BP%bct@KN*kJ7}l=Lz1?KFrE+%ZW0<$+p*_`aq39jPp%49h zxj|MR2{Un+wNRaRB%G?F7&Z9{Um*{Q0l85~#MsFRu_wYC^awR0Be1dSwP_z^N{tu_ zTO;*z>C6iQIbi($njnt?pSyO?aOk?lkGwts85K3@@`HksB^zmfASN z5eu>(FjHvt@^X_)5!haf%%sHT>*PrFf_lODzv( z7uy>Ni#(;>k$yOi?I6{3D%;tGUNqx*d`?x+C`DYZo+@+-eioQGN$k5TwcvbGJ@51y zOw4B-$i}KG^hXWr-nC0PPLQQv{8wB#G~6TwqN|sLUJ<<_C%x8MP%+*6lEG-H>tjCF z0M)o?%hdsVWbGm%XvDy+*gP!w&#`ELMm3{S(#31HCmX>yO#cia&sN&B(FfMB8%c-5 z)p+MG1AM_i*eI51JvO`i5j^=y~nW>XML)FVkW@#uU$sKbNY-*XZhn6jGIkH zzf*usw9Nt|t|(Y+8VVt5MGF3Y>E!j`KQT;(eka1T%wpHZB>u zL4WfvLzIQkFTMo3DwXAOIVc2z!rCx9n)JNE6y$fhcog0|9<9~!C!F7+aE%(GM}d4( z!q|VSUaf;?FpUH-7Wy<6#&?3wPl*+>nUY5=rYi5@HK_(z5bgrFzd z_0uv08$LmIDebQR{zj`W;-$afNWgbrQ(4Wb&SeLl4Sw;lS7POdCylWZRDD7+74m*B zF)r~x{N}+wY{-@9+wi_wb7f%vVAB`{OOGdQBT*3O{>Z6_mc$cuD7__eSCpj#4hzz8 zTaG2S|(!^pIGaAg4h4%-%`qX zan)(?^+&sB=l;Yccm#2Eo}s#|2pKph$p34E^`njVa?R#aa+Dk+w^6XUZxT_Ao3Aiv zJ^=Bt+v%5a-12@M-XhhX(ST8&oum!KPR?bB4v7jWkMB79a&+%i4;B!A(nsq%4fa*9 zTb%WdY)h{FLQF{xJVfDO3@gLrcVc8rN=dr%tj|^{Rt|B~&--E3uEC#?PVT?3dMKPP zqu9_~cBv3e?GwMm1#U?O>LDUo3=9IJ62h8peFIIuB2pJul<*y_! zXkJBSvF2JDRV)*nn@}o5>MwvZwX@17S1eP-lM(9i^q-$gN!gY2E!yTe?E+8DLR>VbC|jMO^k+&!lwX2w4r(fdIO34%%hjf){(JWCoOEe*S4x7tv|-O3Qp z*Hv|1>36Q#!zM@m+WNW>`75+DyTy=yRE>Z&6D{74|=ve7Y~rrx!20BVcK9BIs?fPPsa7M1vtHx@zhX)%L045rbRu;NzdelKK+> z)F`Y0_lv~zv;S+W8NM`@@|ka+1)p7@wVERibLOrq;`( zNkyMjI)-`}1ctqZNA|g<1A@^jBy>b#3@>%DWV=Ita6^BU?0G*=i6^u>6qNv&Xv7>^ z4=Aq+-Xs(J2kc<$lEv=LYwKtv?B3~eogs>yJZHlb2fcvsV$_fNcj-03xIoW_T z<9tPBvE)sY?G1gJJX6o#>8YQ(|B4f56K+z|5QR7gRFcF}<@^UiG(5n*@A*JMimQJ_ zM^+CqaP-sOl0kvl$-d@VLy+a$UX@gSlG-T;|5HtNO+R8Oi^C;XCpl<|B&)8aGT$FT zxn!!Hd(4@f^`6ena7zpD3dK*}bN#&!5w&?k);*(uB;^+U0@(L>irfbEzI zn3ckG?LKpMhcg0;TlwzMM)CaN}fMwZ}Wfo6ZBc47s+p=Vdzy72K1eW4|wd9 z>H>kY;%&(Zo3p-V+0aw=2-R-xe2AbfXz21wV}`kz68DPHGhxTyztCwXH496KO4&E> zO$?YXZF1dd#V~ix7PhZsIk*HLI(HJUTpAL3U3OYAdkVCmw07H2_%PlFq31n9fu?$z z7BEjGY@Z%g%A7KZXrZH5iPcx}C%Kk_FY-dXm#lTNN*e+12cYK(Hll+CWsB>w5;+}- z$Zj@%<4$Z3mWPmYJLIzmDGd5jB1qiO6xbM-}imSeTvC^rTtLS}aYhyZAnEujg*Qq4xn7GQXIgrOA-5=`;6P~zUmy_)jC z1Q0ci(7D!;h;Y%8s*(t|+!+MsiGn(n3)~kKlgSmShtB zWC#U3YLWYn2D)thitfDUEQS&K&9lPXWpI?gI<8C}0kh~7;!0A}erUH7l%_Z)?^~=> zc}Vc!$L{sBExxr*fPVDcxyo_?wLKtDD&Y&M@KDkk!#-N{jF+(@-0z3bx}Dg*^Xbck zTZ%N_&L>OuR)8HoTt&=Gy(^O6;ehf(Oo9;A)_(f3O>6#hYaoT8#-!*W>Rn!Y+Z?>C zHKiSK&8gUui#SSYnD#er-BZt>yL7>pgR>y-Wxi11U#dEgZjRkVPyQkRT(bh9J1j>? zOl~sibr<&utHHZ0UN z(7i2%w4{`u7_mMW#Z3CQ&cdz(d{zVV(XpVdF6Ht87}PXIc3_oLYDvIXStWV7JpTbP zPrDyu;mkg%w2SuA6`q<%o~Yd13RqyUoITu+Jt{`}>@Y=G?!VDtVeI@s8G$b zj8>aN%kxZgh;hx&lzi!gCoTqGJcny^t3`{kRqd}k^&3*882i!g6}Mi6ujja%LbSpx zhB-6$fH}IB#Sb$oS~=SGSLF|U0+|=vV5i6)O3t7Ai-b$A;;g-6d6fpKlBGzBUZ8w5 zRAHLzG0BBJGfu{_!QU=DG|@zyzi`UCw0QnU$^_9;VC0{zcs|M+?Jh~+ludJ9t}KEO zAC;;AeL=-qx5&zhJ+%iS=?yc|_WM8}1-6*wd#=S|G$7QH!V5Il2*UHkV&(q@+S0Lq zGQ6LI@ETLet%S2O~!$L*Q^Rg>Te<*mw2kh0>r|z;yw5&61+&**wLEG_qVE)nui6sjPJF z4_K=tn!F1y9eD!5Fws6{>7kLlSXz7YFm{AF4I_e?e0J@KX{MrtXvKGPQJP%S50ZT1 ztZxZyW))6uAI8$1WCZB-n)22z3#$Ufxj+z#S05gy8R8pk7(*h9=24?O%w~ zFv2G-P15K;MCfzpYn#R^sM2=jQ5+xE?X4jNAKXLFOCpU-g&881yPgN)ob{Bd1#7zb zvMBLy_pmzyAi4vK#8oPiAZ(osE*11irtE=(fQ@AYcuBS;u|eaj_{blF#2oVA`48ov z$#!ccyT>F@t-q%<5wt*hUreyJuS(djdYIRN2WH?7IgaH4fDYgRvhYqT=3(LzomRY> z3&BkcwJ%uS;OD?<*{d?XJGtMyE887TRLI&GbB*LV=+%tQU8)PhAUE@BA|TvP;<#Xk zbEv2>m|o!8fZnUIgA4)UTty|QPrd+!@P#A(+7-Vhwu~|;^}?t=8m@_7ry35ETeAKc7|EE+xEOK-3TygQfkglYdD28gK|LS zN_B_oy;>3tY8565T{RS2@|)BrkVdL?IF3asE+`HL;l?Nc_(I3`Nvb`LL=tR!?i~py zgWaRBvo7N}mO8_m7XRdVWq`CAEmk-_Q{xC0GA?YAs0zg4*tD$FDwUhr-#Mr4I_3y} z-=PbNoxdM_9tFiz`s$nW3G}6W7#kF9Ylo3(TP`qS2@+<*#84ZBfQ*`8a92y1UW{s>b&S!d`UvJaTZ{ zgw_j#jPtOj<`A$fN+b)r!=IKx5zgd5CVo5Q$12iIn<(Tb#^H;zx9SR-0M<5jyW<KW_3%=CX@q9W|jI0XJ;pPg;~eoVCvw zB;2kgS=|3QLHtrwYMT6ahf8&jvnR%l7$xYW??Pg!*Q{-;Pw!LO4#b3a^@H<)e6Wq{ zNNejhgh}tDm}Kp;NvgQ!p+XJw_&NfDSX#v41mOah zlep#GO-fqjLE+2UKiu&xpLv3il{R2#5iW=>2wjtWXojpm&VU3)?t%42TNT@8Jz_=~ z!+lDj`(*9#2gytbZx#pz^}b*E<@;!9zQLqBj?#}j<209s9j%VxbEMLm9NIY>A~eaZIU>rRE2fj9z~Ij< z0Z2LfxV=~TP$P`JOVpOb=5him2#-O18%jbsQjIkm@tTvG1GghpVT&|y{;Y$ZT=yOt z#1E)0;CbKbPxZs*BR*?b^EacjH!czI@!vf|;h!p(A8J|CsvJ+}vPOpCtwe8qu<_cZ za8Z`3ic&~V_!BIiW3c&T3~e3_-SNwf)L)dx@XF6PH?&6FujAv@e`!Stx~QoKNz5YR zFd}mJ07exQCt)#Us5Ij1HlW<1#$)41O}0Gp#G1@4Nd)`73byHFyfE#gTQ@2&-UM^F zS|mVp7eo^~R~))NuWPOl(aQ=>78j82c)Mz!v?CNgh+?R(DdzD%Y^%tt^@HC*VtBOV zB>;k$7o1Z$C+u_uC$-x4CR8+1%5yOV;JkA@Y#ZsH2K3`9}Ctau5lr6!S05INl9NPJN^^f3m z4!}ozr=VV)Sa7(VW_VMLXHI~oHX7Ab~2=+1`$C=Tr=~`j`oeF&oG#YWBukR}2BfVeF4Xzr@Y1nDyv5~xo$(lHu z(4G$b`9~Mps5LD;JuTy>IShwk%*Uj4*tM0Jqw`QN%^*i zzI9;PHt(%G&lw2Gux6QTZ@PQW*fOZsF3CkE~b->n9djzeIEa zpz944OEQVQdJ4enQn{ybEo)oj_)EN?ZT1zump9%5+YZl_pj(k=I_`~F*$rSTDb+68 za6OkcRWY)d1?O1L8VN0=KeQcPe4Yc7pdvX-m%}s@4vMYRxtj7QUN1^4m0>&H?t+mF z0V1H2-_3yA^a=vDCWh(8O}}3rg9%j^JPQ^FxB?y0{MAfJT|TiVSNCy;+x^8<3@qZr zWlft`l!hl|TeA#B^h4&JmD?@Q=XG_Qy8qt7_%Rolcx}g@2y_sl;6vFhg!Mhn2EXDz z=}Ij>A){5b=kzw4RX!fR7F5r1I5Ug|9?C2epH~^qFHy@DG#DK~c=cYOo+gp(>tUFH z&oXI=%QH{lztCH#sw_fA(t)gi~S1N*tm|I@pZ&rb+n(R{vwwU zhcBV~t6o4Fr3wPStZWkcgV)(F0G+0qx7r&Dy0L(!Y4+`V{Gb8Yqa@j902OphL|o6p z++!MkbU=b43))g9?)Vc>TCBq(i{<}oN=%=`k1xZqDyqf(z~H82P>7?_Oku@;`HRMu zcMD9^@P)m($YTJJwSnuP%VXU#G6rXpMFaxKA*2$H1v+j`R)4gp?O-@h7mZWjz6mBe zamNcO2Y#EX3MPrr9o-AlX->MxPaA#SJiBC6H@IJIj@RFYO1#bbNlJi&OG_JdBbGqS z7+yYqgN(Q4N@b< zta8VCpPA%lz|<;!D;rt_bq!f*6*m9D04&B#auW_Tk*BWXJ>6Tn=n#M0jT_(-kZIG` zM&nZ?0|vnYq5#vC7^cxZQNxgzZ^+(NwrC0@zni7{a;4X5^gyg4tM9^tARaLLl|t#% zt)@ZQj3r_jUOEscwOKS;5k>0Y=9=_;`~P?R(1615>)cm}{yQL#RS_qy!fEWvKG04p zK&b*#cW^XsS8Zo%Rg;g%p&x6;2uh0#a4wlMv@2-kSbnRj5ykfICjeqjrvUrV>8UQ0 zX3F?aDV3r^%JP8`q$wM=;4K~vA1j*Ke5~9 zn-FV#w;3Yk5-~L-S2iVN2yFeMCb9zn{C8=-A?ZSw!w;t$owSNsTA9>ZT4;72!Y6ZV zvG=ekX>1-PjyH|~=_}aJ1MA;-15%gY_g|5W`2OIVvNSjc;;Ip=Kg?!WcCUMOLMxRb zr^3`x_(S!)wU#cB^9z>)Wl?h88>tEK0K z;P6Fx^4AX#SxC_fKnpB6a{pf5zdp~)G#z*HVv7LkbafzrD#f>El*ZD>F<;xOeT3RJ$}2s#yxpkF28y zI(6X5>6y~H357n#;zl}ke4POelc9$xYaQm+{{XYZ<$;ZKNcH}d{k0#ool+=av*3+y zB&FJm!7SHcNDg-g)M&9;5TE1oC@vDrCe=VR{7h2mIO#=UrB&Y7CP-U2V*~arn&wtD zG|5%^zh)p{Z~$5jPdzfJ(^Q5^T|`IXKALg%f$I1;I_eE z44@p>0&;ntvcJ2^>l!3z2Z(Awc*{6^MM2j{DKdV}(}`WZ=oHZZ2;ZvmI}#@RC)QkA z_Ox+v*%;Ha3x)i_&BYK86Ac5rmGqyvB63eR0E4>D!~HX;KUJyU)moniOcsdHh^@wnSdUVg9wT?%6zfBQw#y12@!VdO#vk)T!05_zZFol5xtK>yaD!L z`nPV^ynn4Q1^BN|ZjeDb^48+oklYAdj;!uo+5uaYBb`b4F~IX&_N6rolAS9#k+9BrUnB*PHZKEJJGFoLu2C~p5 zvxW<+Gj6rKx+Xrqt$F4de2fpMJ4E8e;5r<)e1|rmvsvXhd)%yHTZDS>iax@)a}x;D z-*z2F5hUGgtM@z$@^uoK+C99DwO3%l332fgY$~Rf5W3JIs8e2~2$A>}N%CJXB;r(U znY7OmKN5~i;BU!qCKeuY?GRQR90UA5#tn({mXyJVvPBTpsPG+Z_ArJNM|T1FTM{Oz z!$C9PU?szYK>t8GCyb-jH*jvX+pi<(TYpJ{iNJiSfr z@B%E2Kdg_9l-%VkO58OlSbA*=&?$QtM+ngIs}P8`jdwkmt5mdj3d0*vKO9VW>&Bg+ zPHaEie7LMk8}65qB$(GYuzPH1r5nM|?2>F2t$Lp3Q1{A*A7?Hn!$@pr*?KUH1+Kh@~1XhS?91z*Wi z`h|GF2DphYZRx=tOU#i`YsPYBy|PEl34jA6`SSmedw!^oUe3|w>(fGnrJ>hQNv7uS z{6HKWWUmIuIf>D1LSl&Qx=n6%1t+1UAsdgdA87(6?k^sAvV_`aAJ?Qhsisrv{5(Mg zo~(>u4Zcus@mO9kgJmToMH)mbQFK6xv1*b_%AKO4cm#9-M{poeipEmb73hP>6NlUL z#$QK{GK-QPr&|Po@K=z8>Bt~uMv#cJHv$LhliD^%D;@TKUC-19p-c&=Y+6dX^4iQi zyanu=Z{3q_zHHR`d!*wh*&F)fQR4C;{Z?O{4=dNAlXQ8|IbIp-D@^4O`E}TW^9b8t zPaCrkS^Ea?5`=@Ov4*;yeI{I#H-LfLlIOaT=V)+aVO5J0Lx5R$lG0e}rHAMT3=Uky zg3=^SL}FklZi*Wsmi_ov~I6VTN}6FVY3Im9oQQAGR#V5A6zU zbABV5+h?P(TRVr;T|P>9G&bV|@bnD0+TNmrh&jUoQ;aQ(@OPtf7=)26Hw;BQ)1u>H@ zZ17sJ2B`*uhSI8w#Y?jBZ<(rG(+(%@q5TD)ac0qKdiJseWyMx&DA}R|V2!*`B)Z9*$`B+5Qh?l1INZ zD(TaKEpt0jmexiGmS!pyyU1C>lo0m`-9b6dhH^{-Y?F1jBYh!3XG%;az?ds9qN`oC^E4~Jo~ z;ey_2LH?}aY}39UH?KT8;J7F0&X|dT5c)i;oeX_@pG>D~qjCiIFD)xGa^q5Lo>_l zS&<>0Faefq_lT1<@?d_~aotFrci7*x_B)*@h4t%Co}N*N0o`2u@5WqNyFNK}ui)Bb z3r;De{1k62(EZjKL`q^UT*fQTGz1$|cvatPqjm|vf&;F;lMZXdQn_gw7qOPxZ5>Vr_ZhzbV z$qq66E59N;_=%rP0?+^BK;Y|285*i_rrRc6!c8FfA9VwXeg`aR%KZ)d|8W#O9u2ki z8;y3ijy}FJ#CR}%5@__?s0K?qs zC;j>W91XLR%a!LZ`!Bj|(NM1W8Nq^~JXw+WD@>8#n^>($uI>Ptf`anqMZuJ)Ddhc; zRqdmV5b{K`d};o*nNGbtb4BXg#EqPz>de2MX_-qURk5~dnSb~1~saUM4+=pAG zBx8yC?7m8AfSH$fW+g=!+n4q3XJ;}L@S>yfRzcw)PlxoIMsQk1Af&_kb>-GCc-s|U z_SO4bQ>$KM8i!2rf%oCX$N$?IUfmom9{j_$H&hj;ShbR{=i02$xS%x`ihAh&A@#F5 zhWm<=B|-l>ntMp@Vke~u+>Uf%gPvGJ0cgh74YV=~8i{*yA9Jj&c&U7#z_Yg&(h-S| z$Z*1aCob^wm3~FYjKB*3afUUYWw%NN8@R0SRUU%N$Qm>C!6juxZ? zJ5CTpqxi%Ws)y8J#HrcWcx!zWji;GLs7;ijrl>tls$zVO2yEPkDB@YpxT|zB|B!hM z6ArKfV`^XdsDCU0MfJ3_;B1T`LVhZCbQ0V)u#45c(RhB+PjIN<6@lS^P@3Yk;umlx z|3f)tuD|Wh)9Rs!gp=C~Qag^N63D+n%(Q7MrI8kfz$d4qt;z{9B|&gC<38@}@#b(7 z4RQl($l2&8GO-#|#~2mvlkLOMUo+wtYvy|JbzhpF``%53HE+8Mm&J zEalY7I8VbsnH8Rx=m;ip!R0x;r+h`+5MhmN&gQJu2MtAs96P^uRi#BgE3vhymR)Fh IRsaA10Ibj?kXro6-0xasNgAn+XV`T9ntZ+UujXY0068} zW~8DKwv6JA8RwH+_xG~C+*=Dd`=%b$5JYq}GA<|qbao%mc5RtWon(l?|M&td3Olbe z(_|X3s!!=p(1%%L=de5__FiHaC55N6&iHxs+1tg;~fXE*03}4 zLnn00oi4O8_s1dfV}lwW`t9@Q_y!~|81P{RWu=l zJ<{tss^@}GdN>Zu{q?qPUWBJeaQp}5hkaYmZIC}JGaVsklkNKrf`Hr!@bbM7HZK+m z-GZW%G_r1KuDY;Ue-8Za)HL5)UGJTpEEX-1fS`EYX8PDXfjR%;|Jn4ql4Z46=9Hn| zCfhR!1F4sLUPZ6ahQpdUespdch3*!?@$>!}FAL7KBo!0r1go(fcf|6xfXwB{Wj7hY z>b%$5@P~!8dNUIlLF-&aF|&aoj77vI?n|`6F%3YHces|$n#Qa6>iic*KQi@V!Fk{l zt(p6zv!%1kP%>k|g2MSTD?%Upy3>|noN|i<1C>76&i!fReJGt!@+Fd=V|1{gL;RN~66|DFYXSus zwaqfOc{Sy7$caA`-1?eo`$49u5sEw9MDlj6SP>_1%9)K-hP&RfYHo4*ixrgkH$~R*-Ay)K!3932_EwU;d>!Xj6Whp^N|n% zy<1$J+6%GnLcKl|okvTdte}zRFmfgnCG%*6JI`vf)uXY*nN1n+=9oLd)7Y_IB;4 zXBvQH6HU`pXsf>)F<}h^;0!7S+!*6oczc|v8fSrlIHE*$on~xPL>d32HAjYs>pU^| zWKTMyc)B+s$;0-#<&jQFnnVfsoQTK)0ikC;N8>*lXNAoLcofXYO;gFYKg61F=h6rE zm+3C6dRprD`Rofw_WUipN3E?5WE`_}JKgvxkNLR3h?kNLO<`Pj=I4ou(OFTK)_-aC zjN&L!%+)AT^tMe;h;e}VbOx9T!0 zZGp7Jeq#a_1~gv`(vBvDB!$${o;wRvNGw|(pBKR2v|kcA>KSN{{;ro~lG(x&*5b}4 zOW+Iylpc#4iQ$Mmz=F*U4AW-c*^`B+3IR-XiEp|UyEP&`Qrv^Vhmk8Jxi5HoUhK=hu9*FA$a z4GQ8{+OpB`zRQ3ASCGtMH49fnhuSRMFVfQs=~>|pc}!9_U|ZE=dlGm+2IS*)3DIDE zUE7bAI5e$66DTk<_cI8b(2AWCNb;e2ks=U3g+MDf${N#Yeyipv#2(~e?@t7S-}UHk zu}x(Sbq;I;K1L;b6eu8c>B&^6ARME5ibK^3xiHX^XD~COfg3?dZn8ih@9+!+nx2<& zEWgS^4jY3&`(N>C3ed^OFlBH@w3>6QIi5aV*IjhMC?5 z;sTN2J=Fy28^fr$xGh|(nQKHDhQ+WZPOv0A!(GA@{X ze7ge%lG-8q**p3VW%6iMG6fb#ru6lWUMDgsm0;x8AOO+FA^D{0afAClT_d!QELkJb z*aMxIaj++W{9$tRIxA>q$=Bf&7 zr*QjvCOD5mp~t!h82@X3r6RvrlM?HuOrdCc-4uo(fRLdaG65T+dR>{&F*Kj7B zz%QX5UMMV%Qk4EhIg(*4IHViWonS5`JXPpB@aia$>qDc!-pu=RG%zdV72v;$@U_h` zvp~?HsI!6%jr?hjOEVzdmhecea?H>4n89sEX|4-1WZjlfK!j52FvJ9^FU*5qjOzyZJkKe%O#oiP1q7N6kj<=m&?QQ z923{YXak!Eq}zs2FPBb%_MS;v%Yr44DSbSl6f2rX=VQla=)#xyvi=rO_|*#tsom4m z^BlFoYa$&r81;NDPf2Q(CU#JI)IstfH}Oq9F2rz^q8y42gi}1|LxtRtQqL|qC+J|~ zz3LJ6g?lx$43w400gcoMPiU_3CmSIR&&Fz^I~0Y6kW(`7rUm2HkrN8u*th0~z;kY&DYSuWW-j~|)asgN7;4P*GD>MGZ#piqcM724TD)UAa zxK|gvHzPOc_4`UBqGu)-}K^7dU7gzW_Yt{{jjJ^Iiw@=DJO^e1_o*C_Frnr}{TcQpoT94LOYcb97 ze)NBfQD%Uo%GPS6)hArgV_GBdt=3hS+A9s2m0Lsdv)0w7K97Y20IHFg$287V+1&~# zkU$9ft0Rt$_}&zFAa`vGEbD&>`;Ca^6j}M2ux_Q~&Zs3mtzbcW&8<)GL)AtNm+;o6 z?rAK7y)Mncw{l6lYAiy!4*iOS7tM<9-QJV4x>Fq>B^t=Hf zyL;@Mv80ze4&kmvBW%G)(I<^0MC9))^&=J@ck5b}SBOZ;EBEj2zG7LgaYRz%LG0_H z{oE}U&SNWP?j_+17WOp`-1~O5(_n|;lJW1t_~qAW8$9%aw%lUw-nT0>HXLtmdCInd z=hIAfkRd)5)5-!@pAlSUIe37|W@onpyK6skc4F)#=bItHg;&Tj@qf`=Zx40tlIKop zL2pgy^!?P~8GU!DGTS-Y`qbt-k<3m|^-!?8GDl}6!`4}Te^055Kvzs53pm`2G3%wt zssHX=F3u_0d!b2{H%QL?@qLTcC}>j5CM(d}61ZE+@#US78v9mWyA$SKq-YonO&+tb zktEbM21}ke(K8WvE=cR~bl-QF?D{@85Lz3=^25L3EHE-Tae zq)BYyvPbw!f-iwG!!gnTI$Kh&5N%_}|{#SAG(L{X@U98Ty_n^C?a>69X@a#PK?v_N-Gows>mA2No z7r7|bqfMpFwkjpYJ*N%7^TLK!uWP|fJp$8rCJVFjimyMUf>W>JFLO45zFs4Ms zSWOWm!Mm{>co!j>Xxa0w0H=8TA!^vQOcMQw$E6|*;6GE|BQeI8H|z(Aa>lPKrcI6_ zuluVDcjScz>YPF~wLo@g)k#)YHk+}RIl_tv=NTvJ6^|?Zk;5{TO9(c$FAssiD^ffb zOpiQAO@VFp*N?S-7)}wJ@jwYhEw77mzifwif)LOfFgp&CI}q-WRJpqF1mEqRbljI* zqtN$xZXakOYJn7Vi~ibizqUisxwr5#CTC~hX*iOu1;TdBs|}ADK;KwwcL^C5wJ{L; z#QyJS+N&*kZAd)1*}zpE+I0oydfgQrx#HryS>b?U!a|KN7t_*edOFc?4$O%=*+9W0 z2q<|$V2v*i75%}l5M)i?a6$>5e{q|WlQj%k0%{`k+eSgdRHs`I)R>>qjQ$7|26L}B zH96fV15PUCNRueKwDtY-z6&peX$ox~q&BO0?Rh53h^TKD5okbbDmOvHcd&)Bw_vC} z=%4Q8g^mM9NRAYQ&Qb9yE)x^w#Cos|LtoKOt0zeCPKrxi6HdHr@+s*V zw?$HF4G@rg6waI(czB+!x5;|5&U}Sz;(~Wap&KnKZKL4#d2V%|7P84Q3^vH>YbB6o z`-S3Y9%8U_K99RRLTen??y`W%)i4eKp}~p2xy?ad)wq{w9H-2Gu~UxJzVtv`HddDT zU_Fg;N|L}~YeAEx0{#0f!z0eYs`*9K@b)VjfTe{vp@%;e^vb$bL-0dtO~*vI-t*^U zZM6~pI3je^1An4W?_=mQD8LV=8e5Y;L-}<)1rssHrfIs+?G23;&>PRX05$&1>hW0h z%0W=~y=}2Aiq5zw#c=+l znVN9+Q_>X|u6OmYO$pJ6VtR}~>Z>eY0fdSDHAraPx%lLR8@dPFkn9wNrmMVts1`hS z+}CMos`cJ$3OkWZW$tA<@}R*OoYcit|L8p2!2^23&{w{?ho!sOr#?t%)A@Q*X06_H z6uXSf?j+Bl9bLOE_#k_(XZcAh)s7DvZZrKmya=8wgc1^N=WFUN)()&a1#-+G+h&fr2%+d+5pBG(eKIuzkBlP~L++&wbSK0X1ZDUo{2)Hw)SQGZ7nJ_w0)}5=ei**@e)i@7MFwmA z8w@T5pFh3KIXQH3M=f~He-%dcpo57~fGAQ78 z#N+X9lyE5^F<;|%?{WM&WVL^u|LE7?w12GN+VknX;Ng4Y%P|k}&gbF7uk!A+e;Kgn zts^pAMVJ7{S@zgAXb0>9K(7Tp`ak$+e|eTsU!LLw*8D;OiUPs`-M=oo48MdwzoEaw zKTYo_zcxS3zY!*Y-M_{OYb3~h(o3b5)BnF^YS50}BOGqMGrt{_aP;(vmt z2b+wt-J6g{5n|mYXNzTT(foQS1ooGRcdp++Jp(jwNOJf)bHLk*=H=mG{OleXET)d?F)Q2aNUnuWA%LKgc(jw4@ z6}BHS3bi;~2gNjTzV0vD=X_79G&x4UVvHr?DEdZ&kP3ig^bkVuzMbVadgYx z#fh7FQ0njE2Aw46laLMB2|F-#qr_RS+n_SNF`=Lv>M&9v!;-L&@8Z{771;PmB%BRN zmZRB`>lhNy7fu!QV%oc)_thSOvpSGq1@WU=E*gEn0P} zvzMf2`fVUQ6|-h$v6o?>Z3U)AJC0sV=r2Bj|A~7~@j0#5fR^i^RTUKfCSnR`5Owfd ze7wNdgu_@4uNG{k++c=AJI2B z-VnL}X2iWXaiX($j~2+n?bg~WZlKDSorRZBZqjvfdcNY5I8Yi{lTOy2iQ^YcDAVfb7Os94Q)DQg zuiJyvMg8yDnHse3rW1fB-i{L|=@e@UA%uq05{;ThyU)>wak97{dVPj3P;$S#$+bYl zwfl!W++sD7u_ft16x?HM{b&ehHC3M{W~JzKp_4`<-_Jc3MwMhqV%5<0ux;RDg|n0{ zY5&zx%k^V^Y{6m|4ZQ&>AFJ#{ZsVv%C3!-9rQ;hR^i7!b5z23OU%yTzucy3)(LAs3 z)hF-z4Pjx6eXF9sRW73&o*Ac(B+UBV!#c!41FS#UyR)+r5$ez8Sv_wTa7@JKTT=W^ z&#p=@b(@ipIOXZhNo`t8c}Wi;a8^NGXmU3nA$m!X!1^}906yhyh^-TMsQy9(x$JF< zS?Fn-ZGAWSV}iZATeZ0|E0%={X|f4=_~+Xle>fzsm(YNkt4uhYw>4&Lu5h*TZ;I2d z<)96ynC`LX_uUC)S}8Vk*2gEqGhctHQpb2dd#Ji@0v6L!-`i?RmRHj~Vlow*UUFM! zehrXY$cma83=lL7<^>NH%>zxC`fn9_aQXZ{*^%D7pf+_YSC}Qmn5$q|c@qM!QD3`d zJ{B8A1eyZOD6hXiF$CJM7fi*_=B8=% zGR=|=>IOIUCp!pTp_6{KAK<#1Dz)1MZ!yl_K**j>U`BcTfXev7QWZQIhO{yEIiCS( z3IfLpj&bmUb-Px@zWg0?dQG(6YADw#pV;$xlD#5O*i&^uPU-lMG5G&To}-K%!ygZA zN=^X*`zu`0kdJ(S_`jMd6khn3%y1^a)=AicyECvg3^ zxG!yz7y?lqO-YuuyR`7tgVWYdL9nrSZGNO#IysGt5{q0oDzgot)*W4|@bmlK-Zh>I z-&Qo18Bp!fuDDED{{}6~}`F z5h+APV9^Pq_IDRHw102odjIJ~tGMR58E$?hD+~?^%+G0;8aw~k1j-p$E-ia-i~D1o z>#+5>)lYm!XxKs_L1&jhxynV@j}n$35;{T4hwH z{14SLbuU7e;*l+oHE|BOve1g91O(ls5Wu$4UVk8>^rTb=dgab{L25(qM5!2 zsWmkH0)b$4l)NWPm79x+O zg;9`EWYa%r5dj-%M~o1iUED_Fjpu$et4;61kyteKprk1hQK@&%3{*qWu?;e~eP zjSPT}9)c|UA4d37|Epz_2A2_l3VWYw$c~);r6*&5kX75E0{dkNNKp5vLGh*X5@1hM zVMNNo?cAs7oRu@j%YR^V?#ey=qaW+ra_0^5ylD39$HmO}fXx^n;!9aj1m5yWYoWCJ z50tZbD^iu{w%8z_B;%3S_ZXQ}+cUZbwCxwXMG^iVbjM;jsfOMzODtWGnG)rKIi)hC zcKt#5J%%iyB7SGj^XqoESs7I86-T@Z(5BN2Gma(%c~2Ss)mo&hoC7>*vwrAp*+{5I zeScmnN!}2N=Unt2iHSoJxzMbLe)5l!=x;v1o*BY$i%lO;=f}VLE++Fr6AolU1@xXn zr4-+a1pLGms_I{#5#sN|NI9o#l8UJQtX={(G+5F*NRL7&LV)oERqGZSsv}RMnrt!0~lIInx-a+Sk zOC+!|f`D4ae2!@E1aZKS`*W+$wF*jae47W-hc8|~2&!nS?ry`1Uqj_O_nKvV<~ zgcqgX1C=dp5#_fAnkrlCZ{WuATyMY5Tot5Honbu1Pr8P^t%_5$nk$JTr{s>TUqvwmX;e5dS|=OUrI$EUKtJ*%%p4fl07bd(Kg z54n5OmHX`8Ddc}D0Ee7U2ze_vu+^?$ee&c=0t3v`AXH?{SCK!v)=mR~?#tLB@9=Ng z$snTQ1J9v@T*!a}x>({i{YPt~&a<98Rr1zD1@9AiCmW==8%%1X3~}t0~(uaM9aT3~P z49g~9Jj}?8>-7aauwnwR}s}mcpG5f#Roj| z%BwjjA~^;yJ~x%Avjr^Kw>Yxp#`brQ`^Vm%BllQqw?`dC8IFmI8VuaZE{qD1G-^mm z`?I3f2Mc-)g%0sultgG+JLj*qRM#S9OwQ9WH7h#@#}2fO;f(h8E0c-zu-+@D@gppP zwkdoWN&ayjb?ZSkg}K*MT<*bZNgXGD7JsjsUI20SzvzJdD~bd3t>xcM0l7b(de*?B zGlgjUq@8|Ocpz0&CQS@-HICe)Gp_^fCJ{W>ORv+FqTfR}O&%MP!RvJ=c*65yuq|Qn8X!xuS$5h;8I#@R+gqr- zs?qI*77qr79~Z&wT;OZl%IzX#p0W2N)S%q76W0aH+6wSB{XTABc(&+ zt%O6z>v5E@kn||u4nTH&DTw3UJEfsbBlI}RvPZ^W_8A*!iqb7UUF^#(XY7WMJ=vB7 zg*ZeRug+)?KD_GDjMxj&f_w;xF=`zZ-UH+v)PB`ZiZ@l~0BT4`=VRB@SFeDjQtMo+ zgP9uC+L7~}`FD&F+(lBsemnEl8`DZMqFTZ2h6S7dQz~;gNUFYTL3+3~%ptUj@=8sE z0A2OSrD#<+(i@KfIj?;&nP>Cr6RZ+I96hAY6<=-o%$J^#w{j1wxMLSd>FFkW8YLCR z7s!oDlrEN%7_{Ue_5MJ>Ahx-jL|-xhXLqZI$e)ai*>`$s1=d zj-r;;R3;1Z5nxVtWmwns8miV_&p>2JZlFq+mX6^Ep53N3o^HVkfva~ppvppk#6a>C z7}6>=eTK;?$lWZef}mkekMnFqLq`LHSTwYNvS6Bw;WmjKe~K@D(ASxC)_;ID!xZ{Q zD+*9uO@$STz=srfNeAC{s0B1&D8Jmj3Mw%nO05m6=fLZ~@Ym9W#idE4r##;CN{dld zBw51et7R;>uCvAlB5?KJhMa_^LWDn;RsNY5r*aTWbXsZDg)>RcRO7fu3XBXp;o>Nm z`@Glfiqv*A=sqM)CKjFN>Qiz1#b#YGspG|5&9vo`2Ilk$9AHcI%cz#@B2SRC8d$Qn z{CyUmu{G{nx|5NhdL!kN#!(`XtJbEjVoJv-R{QNW7J?+CkM4&g?{9j-%NuNmal1sZ zL7Dbw80eI-MiL(sQqDdX4Jg^a3*h3pRdd^c`(WiEi>Se^lWnpY!cNNn)X=AKXN+Rz zDv9lRpG|tkGQ&JZ<`UHaGsIxu13|b-P66?N=fKNinORD8xY}9BhH)K*~@bRBh2&X z*WU886=MEu1T$p~^j$Py4j8?kAtf7=TWBNWs|vknW<(c1Ue*d)7YTwES2QEdHHTKghA1=u%a6x%b&-7T)b}0HI&FvDZ zl@yX#@rjB#GbFgM?l02WHF81N+F{z#K&|?G|MzJiQg0SMB%ODZvc#E;Yu!s8#Yca0 zhcuVRHfn!zO|5^981zmWi_J*adcUbvyl42DkEEKC-I|kJ3>XjtvGu5eBYfDm% zqYXd!ZT!yqc4>k%TQo@+-0zI0cSTlzW!>5~7(R#~=D$UDx}$4g@m3HiJ9af3^Im$f zaSw|<=1L^c{_dJJCENt`hVbmMx6iQTfOnp_O~J-EDXmJK2xK-ogH=TMtVdkqRA{1b z@t%2SW&;R?>BVFY>4S9ogc*>O^1 zn;fYeF@azld7SKm7ce_yaiOALyEm481qL_-mZFCfyIEP>03uXP(ua;xi9 zpMo8Za6xfD7A1*K>hpfsN^`-gV&9pt@wgn+NlWN}--S+DvQ2h(ZfW>CArB-on&lZS zlLA>RCKz{M8oB@h(Tr#v517s+YGp3E>fj20Za1Xb>m#n7y0=Q5`NTmZa|NP|O(<4J zsteP0Lc6aO+M|1N$xNpy1=P6FSTZcb#42-mJk2__lFFRg3&^7U-Mflb{DG2MdZrnQ zt3KytUoJA$F0lZKKizb{{jZv7`%REL(-zpM8a2NAKbSh{9q4*97U(K8I0X zUNf3Q)CYy4e?esF*vWZ!^g`t-o#uJz&D?a-nvAmxzxn+rB%C$6|2i#Kqfq{{**JPD z5h7UB#5zJn{ItED3tn3vT`C2|>&}5&i*o{Yf>42GC22-|ei|L4ah>T2?JbG*x&o9o zbroJ35i+{6TLSRgjNz3v9%cpvrr5NJ-aj^!=kr^O>vORxc1oeI_&>|3#C!JHVGftO zW*ujryw-3A&@%vWB&ziV2fVTmppqz6e==is0hNg}-~+A?Q23yMNyh{Kt&q+mS?1d3 zyeh=O&24Wb$>d*bgdiOjW%0c5V03&;(T`KW+ND-P^`Kamj5p}nLN<()Z7P(HWD+ye zTqmlBu-<~e*A^vV{arRR7KmIJlhY)@wZeNR9QhC5$0nHQ4N2s)40J;-Nm!^tVY%uF z`g64_#_AuAKMVf~S|hlzkn*v*-dB)&<^PSkq z9lpZqB_A`sD7OHCcc;iQC!pUmxLGOxgu+8bfX__Z!F0@XVkByAobjRx<>LfEXkzHd z8d_9}20&)7E0aOe5c51#1~L(#c&P_7T_RJwl3c;mWjEp#V1UiP4|6}Uw~>G#EL{A$ zJ9PvqaZ~u!uWNU@3uJx!8;7z<-6qB%d#Fq0T%}Uh4wwY*wg|l7Yp-@K(}Q7`=6e{E zUCiGjvII|?xB5}^b{xMHm_VjIb(SHbL)Oeg*G=9@C#NW3?QESih+aG|o(M+=gFEyh z%>itbhPRw2W*XTySxghR!Jm$`16UptgL?_p17QX*3YTN$xL7$>Pk;S)cOHI|@;#T{e zl;*v?;xYW+C%nPk1Hq1ZWOZ!fwYfWFMHqldB!*$^gMehLewtB%=v|i%@w~-yI4K|w zor``R6~Q=9Z{w=~bmG~;w6Rw{;(i4wq^|CIu%W`jO0lxssj_)#{g1etj~y^N(4p@9 zp)S^Kk!#H-gKnjKg5e^(nu&aY)IYL$HZLBF^55XX& zw&Z9G>C@-i;>0aIWyB_l)(2Zy!Zd#&Z=uKL*+Ib?r|At=I4t!WOSxmgFj}@E6dOi^ zJ#2AkgUrt3-lK(>bR-?NG zN69A}KH=AT(pfb6JhF!22vT%XdUG&2AtZ+8T)F1xJK)Bi17LC46av*;ydXFC(kL&`sK;034cLw+hBoK zjp?c>LI~}mss;oG>QXKnp@2Y(y~N07nn#!H_Uk&8o-;fcnbofNu8*mKfX+IX;kvsY8JQ9wm+-RfbNa0^#xVN9)pK zasP?7F!0tm!SA7UI*WAR_Sk3-*jPZ8|~KNKNi@IG#^ko z5JtKOGs7=pbbG8Y%W56e^~BR=-Pw#c^=KW6C%m%kb*nzdAlp$>h1Ni7MWU?WNC2Ax zHS1!=ipEvyTTJYWQd`^zlT==%_|YU5K9U)}Lln5Wh11PRB#UDYr=F~%AlC?V!KLh- zqpwGyOOA1pvONoV*9|aJ4wqI2G?jY$*-|lCkPAjFP64(lqlQEt) zMZ>@?@@H0q`af@>uec1~_rIGUO%t02mk!xRYSu0Em-*J(FbZPT=-lG>d&yv0)k%`0 z#oQTRLb*E7Pc%9o>mwaM-~X=lJ38kOE|15NnV{;56;wVYdvrZ0?()coX<)&hk(}uu zz)8!At*^)spXg6Sp7xB=9dXp}Ai>YhnsyIK_V*tioYsG`wNpUcWFxNfjPcKMqHYxX zJYm5*9YTBQI?5P+1jkR37lSs<_RjlkUf!&+vBdQUlWaYOu^;3!4`|Eq`8j9|tIk-z z^B#7cqEWcdhz$C7bq3F#%c90v5@GPK;dMjs3dhX8(HWEJKF2epN{XSp#jzAshL>jL zeogh{K#XW>u&OsP#_W<$jr?9(DGal=FVkRMvoFOv1K_HY(uVzX8a&*q|0ykdONg~{ zdAeLfxqP`808JEdhvDgm$pjD_MHkcKg`3CjbRoAzp$? z9{?5RoHk#3Dg7dzKIdeMCleQRUNU&CYo%jZc%75_w)Hz<#%vX8*$={Qe6X9JhIE{h z*VBNSW|B6jN7CQK6Op$s!G^B=P3we>0NIZ{M@k9mN;<+mUq&7w6REVVi~9p2gS=R! zli?;)MBVcb%YZ-r!490XK}HSo&7i4Dl%3M!!FXy_WllkYObHh&_kk9t?+-byD~U24 zHX0Ry0(xmuvqkn?qieGAtFfB};%2%zt}=b&bsMZv2~1~~$LPdofs(3V zg4E>3J2lbdAflEoDaFkE%DUxfUK+v88`3~iaHSc(Zxd6yL}p{zN|A03$N6B< zhmoKWiR~Aww~4Lx&G$+iLRP!h{8{(%FCn6PJ>C3g_C6W={b#+PG;EFTo?Ku?Hm(f^m~I-@srIwLLOwkeL`+uaC#VbP?Bxq_&}Fi_W%yEHx1Ot=AW z{Mwc1$r;G<{0+6IcNo$Fdtm38_H+uW>%O)}$czhs-C3=DRZzaOo`uI#(&RlDP^f-YzLlSffW|-&vs|+PGF`^g?Hhpm?rn=3hsvEh_8# z#f9+K%Vxpw(y6K3e6H^6yQYnS*RK?tfbw0^i}v`}AJiX0&8Sa8b6_e4YYqcdNzQx_ zB{bJGA?qW2SZ=n-bfrx<4$IA_$>h)7TuaZjLJ}si2Kqt<8n~TBfsE3- zynitRu z{b%2tEuu`_-i>ajsTpi$m&to{;&l{&(W|dKE!GY5Y_dn}LXPruB_59+L}xVZhF2U^ zyA*azZ!mavvK_`Xk zo8p@JykdIBEWITDQfBo>eZS8T_Ol&Rq{F`{@fjoXiN=oIG`X*kKf1Zy-HKaF%avDr zKLOi;Mnh7TZ{r+S6#`*)KSYhg*BR89Cgw0|tTiaR(9^zUiNh~d!5+EiI_Bz>xs!9x z@*)C4Y;8(EKkJg8xPl2e%q0UcjW?2IYrZj-^W~L!_@e@$c<1sLx3>RAi0jk<0{%G@ zSNIn!S1i@KxclUx#(tK~iC~f(b->vgZ$cIA&a(Pzo8DmAC2eJpTn{^JnMwK z2+~+QEk({(xuOKr9u3p1TY@dN-M^@9fF*qTNr!}oV4}Vyg`GA}iMHJaU7GzjK{N&6 zdU*@%3gYHD6EuSDnV=WxGO#5l)2dDF?cTt3neLm#Wzp(dk!bq>aGMBw5Dd%nEcLl% zQhVl_J3V|Vr~NDvW3WmBgOZgv&8c&P<}QJ{2b7LQ8$=J7?P*%2XzwP&MRcO z)Ulw$HcKuJ5?w@)w)6E!GQXam4M%PeY^{)~lS{m=`g>{zgLRq$1vg)N&?+E+v1q1U zMWsH6VR#oXb5xMt?~Baw;&g3T;y0>HkivR=EiELMA4BE<${>v8({Wm}KPS)0qJrsx zd8|>BGrPg=+(7rFlY~9Ka(od}Mu68j1BtvKWdSfn>*H?@xStEiTiUW#Rp>cM~i zvsBhzsvWA>c?mN&f9uKyUQrGz_(1zX+ya>WnG}%mE65A8BCv0pHuYqM6tR*;CP_{w z=ha)SockiRvB<%>cx3ur2h--8+!NUHXX{#?Um6dlS}d!2h5ApSOA{)^@I&IWM@tYr zD^7;tQbJ=E&Q`&KcUm7Yqd#sFye=B~7hE^*X5cWq9mMpwAsP)2^}sGq&S8XQ`*m$o zeGbzha2>s8RwK)Vc-Pi;JLLxs^JTPc7aW&gGnTrUA8l|(J2oqg&)pEvpKkfmmZy*m z!ilx4>*!Xuiv=yO>Ve$OO>drL(ax$|uXLhTB&bll{jy-C7hxmYqE^>3qJxK@9heg) z)@Z&9D?XTJ&saXk*+>*g6slTe{P&XLr$ybpDFsibs(<5X89cjDvOP>#D1)?R8v>SQ&9@glA+p3i_ePlgIvG>ouw&<~e}5X_g7o zy9sH*x+S=DsvXzVaZ|;Q*uEEN(&66pBU&v*8xM!MTJ967DIOtDx9ggBoDX%Hpa`k4 z^s4Ay(oEo`ddzEf6wP5gN`|ST)*j?$CF<;A3LkKAUe2l#4j7~OPTC;oS!?Mls>1Q4 z@C-t?Wm_ru1c7fwkU_7ftq}}m%NZ%S(mZMJZ^CRbD8gocTFkmz)xB>l*aTvcY5?Nf zclL`PCuzJR7mrR6Dv_I_)@M(Gob^5?eqfeP7A|!{0#%fupzw2kJ$T6egl}IbW`8=) z$xw-Sl3QMFNgF08Z%abLOG@X7e))t7xv}tQ7LcvIWu{+xAN>ZT$x+B|@LWol!K)KM^Cgj(rJk z8%+{)l?5M@Z$Y8Q!|`oU+!-cG6bJ}vrd%kRB){Ym3x@QE{qxWMc6oBH0c z__Sy5hL(|U0K$Kh0>7-KdKJ1heGlz)#{o%Ic-wMQ=)_v(L+B+sQC}x7;v&}umGU`vYH|4&eg7l)?Ha>PxHQ|1+&_U}Vh1f#s`x^D;!9{=FEm}X^5 z;E)%f8!9RevujI9&JQ7!sLI%Olr0tnk#GN&glRJ#`l9E16p?QBvAR^EZq-Cby0^%t zvx|(2uc6m;CKW3Bgi$+52Q0&Q1bh58d!W)BN6AHLX4Jt$_fj!w%?%Zdtx%j*{1*5O zD3f!Z*amU@>grMQ8#nwheea7i^1!$E76&J7vj)$D;cW)52Btx~yqc27N-yB)7~_{j z^Gbz%;xgIYhhuU=Ol`t>!Xw`4$4O?gYiyMtPx~K zrv!>OHSWad}YJrg>9}Rj5_|t5TLQ1L02-ZJg%x1C3RZEnVv7uo!AsDwI%fL9fm5 z!#jQ-n{3n$!ph6K<0<}_NJ((&)-gQYFHv8e~|Ao#%-vHO?`G_Jb)#tdM~bnkhU2AmSfgPH~2S2ckBv4V?RD zsez`FOjx0ZD)wk6F7z+d<56-rgVyJKCTc-&VknE^GIWPj0;5$6N8QX~pJvHybllQ@Fi` zOX96Xt#NC1idwee#N*1%xxc5{h4H@RU9eyHE$ofgC5IpXm`Byxsc`Qii{6l`B89xEM!ogO(eP9{7D z%B^&%Ly~o}m;E-k!l00UIH+}9NZDez_iXBjyYh(Oc}SrL{Lr$&2S68+lusA!5Yxm@XZoc3NyYBBT4ZPyJvTAD8MQqOpPsMs#! zHHPruc9+^$83>Q-jsh^k*?9Uikau7r|lJ0ohnm3B%eqw zZv!^4Eo7ZW1H^}d95)?&i()N4?|XSeBtkX;=L|M7DANK=p>!CF)RFeW!P(&W|F9Ub zqN^G{GYOKg;_R#Jo{!5I{{m#m6Uz~^`Rx?$gERKZphw=@?K|;DA^4*ZQKNqB-Ut!1 zI^^Ls63|h6xk!Bv-Nm*tsOZt#OF>EZ@%7(oSz#NgT2ah~V}fLh#v$cJKW5GM6u4KP z1KG$y!WnjS<_{=#sn2-IhNY+3Nu|`gMB#zM+ExB{YjTnUZ60*btk|H;wbXqbVv1c1 zkC25N!1mTDD|G}UCxcj!&Q{)H|0CKBb%Qtq*}~kh3ZbuPscrS2Z9r5Ap>;2n;qoMd zLe}-FYmy&qHnz=!OQ`dkJQ*2lMK9e-@hE|L5l#44%cmf_OY$R)pNqm=6=Gy*Hk7#q z=%jgPJrNcMhLRg4tjIhDVtS0JdMfzsafx>>*9GJ0dGzX)+8wG>tGUuc(Ng+t;UUgs z8$G&%-JznxlU}{*_l4h%I`sSh-q@`P-?bs$RvxK3e!nJ$3ehrFCW1>2piuxtf&o1* z2cALup6C2Tc-^~M%5LXKC2k{q%7S<;-cqQfMIqgvDToqOrddzrarWGQF$HMT*^ zNlZkP*yl6*JhW*x6O)ARck}Lq`YMV3WtcxCrrPIQ*&29`;j`nfX=(9VX2@vLlZa5NmNY6ZGHx0d$T`q*#;{&+;Lc=JSNv`lp#uo(cpB1r9-S`(UVF(wX6xSr zu17x2u~pR?Ysc3k_gO8UE$JR;l8x}>j_U0twx#*YOBF{mehtuvs;=p#NTmO#$e@M-J!_h2Dg{Il86pBh zs7?3GfC|tx8;s;52xn6kYQc|H1f!Q+$o|=!3n3lV(g|+g?Nnr`9bvCKH2YwHx^Bc* zTwAV}$@{gzmd7WdXiE5=U0$#UZm21wPrhT^bMt`T>~KrYe-h!#!)EvToEzbJ8mQB{g71qj!0r!{hEQ#*)>5~&WoT;|oW>zWX8a~t*IY`6-{!Rc@jUWW zcK0t#d_V8BCxRjcgtQwk3f(KwnO@HdIEL<7#IE-pq{m?%%-K=84RK7BE0zL9Zv7>t zwYfvyF{BuAt`*CNH6CkY^0d0D9#&}TQUx{_mIA>}j(0&${;vF}BaprozT?_m$mB}H`P99qB&tu`BplH zXKGCV8ctza!F8y{J;F=ORx!sTihU)MOaYF2?sjg#d!ClqkX**H#xx+q_?HD>C?y3E^IOW;ZL3zUobck z=E?2?G#k#{XW4Ot!z2Eh%Arp7XBo5pIL*_w^JZvNPipty9%jWk<5j{EL?vnhDd5!O zk`gZ|MttJjpHCf_q^`EcP+S{OQlq+-0a(x-rUn;QI|jW=g_UHwos@r4s3rPdHg8zY zh#OMI4b!%7{FudghKBPktle+0hz`q1$Ez^OQyPrYUV2G zIAN>@pev1_#XL$>+5QQwdQAS6hwoSU!+p4v>v92zGffE;QoP6l`G*MALU8+3idw9?R8`qflo9eh-4>Mjr4sJHX58;hh#=nlqywF zAU#cj{^FxDA0jmn;#pQvjbZK*tL(FmJEh37!HdwKzO=|+_z3STrd}~! zUdbr8BpzVR@2OdA@UKd12~#~uMRhc&&O_AIJzG(0>jc_JWzlyl_DL3pqfCONKqJxP zcfrTp?tKJ`64#J_1&$V7vZ$eGiZvoL>K9F3<`u!Rj&sSUtmk}JII0RL)`8=vbQn$4+2hqdB>h? z@;d)<#bR><25QBXOvoc-L16*#>{JAEUIKI{NAu4J8|$%=MMXwwY_Ad~Yfv1UOUQEY z?8&@_f_t#Cm<{jPTnO5yPr@L;pA0Hg&2fiCr^6=`PzoP(eL;#GXrXd)9DWk2`x6hPvvHIZZfK} zRuy6YPCDqU67w$k%oF8eBmmxUhnrD8e>3+3kigFDF0dzE^ghZbCwMnl4 zyzqLbxWddz6mE?#Gt$Q(euhy*`!hvM{5MCN;wFDFWvGXO*$6tn^?tBp zEK)z1e9!P)RZJUe+hoEC1!e)y6Uyrkk1?u16E%N4m?PxoLZaynvvxSIMf-QFdqGc~&c49u}4#jDJPT=qRApH-6KytScTTUJAjQ0bz5BnK}# zt)Sg+O4P$Vb>V^Hu3iJue~TYy)XOIMf=#jAsufc@0$jIXj;3tDq^2}JOTa80`4R(! z<$d9p;Z+y4RGe+kXs=io$xEp}Fpv4{W#yeT!$Lq{_O+FSCNNT&nI0b85qa5>&9&4A zYh(0=J20(yG>1*H;0Wr_UR$cb?=gqJ5`;R?ZbVI5+R!#8$t>zcM6N&0WH2|i1KL;O zLCSOB5)z@>UA3>0BxPEI{H^|{?;cE>nDC?09XoHWK!7lm_}j&-Z3wlRMrNrU?_z;F zTMGc8fOfFQU{XgHE^B;>;ZX#gR8$f@1+LuWL&1=G>2Pf!5co6m^3Fr8BN1U6J?N$; z8H0&^K}vBy|DrvNlD*6ldDj7ulmH9K05Xs5On+AHBqL6E_qzZ*Bl9KR94y4vRk(LEig3NPM&dD zU2{z%it=5tAAt6|C>Q?HSP@_GFg!xY#fjL#tz}GN8}N2JyVB+tjGGAWeU^5)VxMD5 zXOiu(o6&FoRuPWggSVh&P?wyKW1icv(W~n!jpnwS5TKw z*M$c+-2~Y>8N+kjcr)h0f1Bgr>n23ek&NoGPVkSOa@$gE5eZTcXhJkv(YxTO zr2=X7r_h@5E-X`obww2h8nH$rj3J+Bj{cnr``C?uz)V7YdI^>S)I3lRqE%oHq>RgA zxypg=9iJc4`%RaGG-cVpO2t)tP8h`*>?VnI{F|dBUh;t9MHU}=3bcSrK1v{$;!`Kt zmdx#RX|a zU=uh49ROVOHVE830U+*twqa*$lo3%ui|5JG>&PD^Xt76E>(e9Ix%KYdOAHEY?Q#U@ z0j0j=i~z?C03O*OBlGM3?zeB>qiFjHLQu)}%x!o3%Grm6TfCNC=P)mG$$|^7POwR* zaba;)FzH9wif!4d&U#yRLUsNLk!{u<9wfj0Ws}hm0DZ5z@<+c6I8c5k{>I8lUabNj zyE72Y;;Wl>;&!donE9h=6!MoC!~{*souNYH%?SpC6Igy%9dN(X4fI-We7ReHF8APf zT#Z`X;`rn8t}phY)cuQWM1j@kVYZFv_kbY<*ZzD5Lu};eX=HM+?Xi@5G0DlU$&_ZD z0ekDa$)c@WCOzrB(y`2tOz_vf#c2!Fgn3!8TRQ#-DPkhzNHGGzwl&tV9&QDLMRcS`Ro@Eg#Qc{w$G8v~c1p^~6|^ z3pVnovO_xKDvXYfr2v7p3k&IH#c}39y72y3GNnZ`0)l z@g`NrN?nf}?*BDwz}3^8-YgE}Eg@yN3jbsZr_R$cp)$~lm@juy{SEl%%B4>;dJupk z0qBlUQDc&(H#W?us1lxnk0X&ttgmzd5-U`4vYQ!tzIX&ehpKm*>)Rc+`dM zx!O*>Iir5FvNbn#`0v7eIE}6NsN)M+Fr-1`s0l`7eXH13al0`>En#s*Um3WL-hD zmOIf>=AhZ6(_V<)qGmi>z>*IGTy?B2$`r>)+s&o^iHl#^kN2rSP90D39Q2jOBd}&r zKc;ui0#iPJ6ADkhkO;Lz1$V)k5-JXt90Zd4hv+c2$A#?v7ffcwXPUsR>9sx?!=X(T3!ru1iS>K{CvoWdV+1K)y7ZLl+C#y7FD++UnIlh2!)3gfIsC z)-+h?Dx>``ClNXvQ+q-x=bsoq^Sifyf9~gd^u@er3&!9s5Ah8$fe*HgI1M>GD;AI8 z&t5}cu`wIqePq0+XrV;V+llCtt|n36(CpjFGUo2jbW36d;av?y8hWCG$ni4`>F|*O zfJ!W>qA?ajnkRWC{z7I4+KhO!Epj+U4LnO@bmhJ!2GCFfmI+=b;m$FrxQ^Yc3a@t4 zLaoiHCt0P@);>a>jlJB-bO0i~9=T{xjmr?4#S!HO*aD0|Rv~IJaWSdT1j;WHsDUDM zFf8k9t+vIlZ@K$Gk_A0?NbmWzom%SL1PElkj;V$c(Oh%6-n%=-aA)lRfoh8=>nnVX zWEi>rwlRu5iCh*SGU9Zb(Tgv+ANW__%5lnx7J-+$l%s zB)(V>D@TSvY;-!hFFD_0;(i{$hn|?==hvb>0+>4da(|u61tu&kv?F$C>1<0v!SE9p z!(tG>uWr=NyCpg74yHr87Y?~=ZZT(L9BvYQNw?Y5))`%@Y4E>xfR!cXat1=}8cD91 zT6TfUf>8#ERNHlYwuI(iH67$XejZBMw*-yB>HCZ$!#DvM2{8}PW?w*L$+EER4JI#R z39#Ciw2jJ<*y2+!q_4-naf=XoEbfFJv=S#I@vT%;RRt*k?xH*!Iwj?T-ed_b6fY0F zg9#O(E7aXU$~wO}uTat7`P6?pwU%0GY!8(^o=ScTHmTPPXNDIz0Mp4sEP)es} zsEtXrtVUt^c>p+taKSjHG@WnEqM5eNFoWjsfWSD418HPqm!TLDTwGen&+F9LO40a% z_vsX~2Xv4wUuonWQ3zMPLgi}+y|f|U2`Yxa8_nKi{Gnw7{=!mDuu_)wu6F47I-kxQ z^qkOsl}AKEU=xguXBBg^RDaYSUIf2?O(*)|Tx9C>8rd>tlr_bA_o(bAQ!rZX$b+W1C)UWV^>7$ zxa$0d$GDR%z46Jv>Viv5lIVf_w8_re!UIZI#1u)rpqx#@5oH)2+JNjrkSqFSy z)UL0?`3o;O(?t@GC`aeyaC&FwrT9>al7|x%(XW}?`LPS_lTOwohV|&7)?QA`Bu1aJ z%D8Dc==rcE`e%bmV^Ukx+1~XaPf|g9o+6!MZ$6^&$w^B!Uxgb>5+jY6ZunV=7oz|d z`&sQrdgprQEW|LfF=z|oQ?QYw6l3EL(D6B&xtG)cTzc?_9)+k2(xPb0QC6=p z7fk-*v6B)?V3gb0i4z0KmnrM~KxIg4coN6I#RZX`bgelHm&jEVo{1vUCR~ri#q{x;*D7^G>zItrk<5PZh`@P^IFqAoclo!hyyS3wZhsXbU{3`MvGxNS ze8K1KJB;1cU**HtC3j4zrf8o>b_S?yl06It0&6d8^}e$83Tc70eg%CchJ8;-6$63(*$X1Rf3(wjKEOJD0~q{FV!fF5VM^Tq_92h!y&?)Dc%@hJfprY^H)E?{TC}=7fz4oC zx!hR5H`Y9xCe{JVLPb2%sWK^4Ac&^hG%OGmsvg`cSe_-sBS)?E3c6(c3x zIx$cZ)C6RHUg+xnS0o-_#blQUU^R^?-tFwGCs>EjR+i$hS$-~K&<&-z2-Oteg z`6HReAZc(LPoFfO3Szu5I_gvwwXuLw{C?lrm$OObVm@Krn@|%TqC>oh&y0vSg1IE~ zp2^9A~V0`5YmJ$_-AF3fKek=vU*ah4St@!Bbf%%+o`M=(&dqUXLn4ksMNRoD{> z9Qh44SuG^ILc5&nv;U-Oy(GlEgzlA4))x})X)JJC42~ujzTE0QpXk4+E}&7f@EhER zV7Y#NEdqN2@uC9t)8&~h>!;qwp=Kk+HSyxn>iKzuLY$HuA@$@WqNr2Y;R{hqp_^n~ zl-G|IR@_PnqlP20*p9jlAqe^4|czL!S2JNY@p{E z$&)BZZdVH>9-W(sm$b;mGqB7r2M4#Iikl7o_B*uirJS_jXN2_W9syXQ#A_!utd zPb*hc6bw1r9k?9|<4>aXQ^ot0#QSh5LOVI8$hecC>`TabJea6O`EZYX0q*k7F+R&} z+OcHFPwRM;FdnY5m>(4;^tziT!({M(3?EFXi99x)!F}rOUE+oz^PYkF47xG^tZOME zox=45QzTzNIS|H0SOL=sNYjgsO;MJoJ+2R?0v4;VR_V+(9#RAPqJG+aa>bn-W@^0~ zZnVE$c#=)kE`1fnys6*v5u6I~z5B7b0u!L1U=nmc+-Sy3xp{ zX}Kb@-+ME&c@Ma#st=ZpdL*>MjAPOxz-3}~Q{&i;2xJWwI55>Q&Nb+yL1he;3<#Jx z^Cu|6dSgz8c<5uzuRtK_t4Whm>~>VVYbD=y_Xs)`D_bv24-(_}q;eT|Fl%DtQ9aM` zh4K{V zmvFtduDcbWI>`T$6^khdCk;x6d07PGZ?Qbjj#_eelV1_MSiZZ|e}=GB4p&WSW>S z@xBJzN?`6&`g)`?{2x8``sjm+(C9|FqWAz|_2=U4$k$~E!dHO}hv6%KGN4A6og@pI zg?W5B;SE0QI|nDCX1h0ScHsVhAi&sYB9 zEIsc)V^0YZjY_hby=all$GBm zaZKUq6cXx~u+0v4Gu2YGvv-$e zmWTzT$fo65zIS(Y0CE~fd|yPjfI7x0WL#CNsojQp=Z}xS^2s;mtu?cM1X!?NHJCP1 zppE$&Hr2?EzE>PEky$w#l0J>})~jVp+Y{tE(h1aRI=esIADvlmG-7)7%tkHc5fjdc z8X8(f2iDe5#o~iS`9noQC-cr5KG*kJw>$x0i(g4p*7VJBfGr0E_-{{Lh(J-BcP^kH z)H1lYR^sU0$x|8P$5$&zoZJD(>JLRZ3#`aQ*l4E_|4_QvhLlKD-*R=Og!aIlRyvd9 zm}m)w289yb9al3g66CvhHRy0n`Df22qZ8Q39h-`srxL#nXsOd}U;mth!v3Gz;+ml0Xp2Omy;(o?63(p$Izw06Wt1RwOFmq;>G1=>Ka>M1TY`-Wv>& znAJKTwz6{l9cXWoi>Vy88h6D~78AI*iG4_QJlEw_O`Rwub1_l6@>B!y&^QByePTURjN)~$8>1zF>;%UsdOmxv~u0M+UnI+DA+ypWt^5v zFrg-&61%uWS2q)8^u|@A2mqpbf36p~UW)uG2w_D?D<@tPcEU3Dmw9aK)#l}#){w zj6rR6>w$a2r&?Klv%XJE|6!AOSO@Biuk`JR6UwzHjWeOX= zQ~@T+$o@gUr1WC`Vwid_kAyUDZbQkbZ9DYkDIFOBw87F+L!cK)&+H8}yefN%Cx#o4 zRpKkU*@ytIV19HRG5-V-3#{KZng}QK#CeEI!8AB%ls4uG>OFod?8~c%_{KVQDUT#_ zM7HXw_3vdbq1`4@?8tMNA z);GFNr{K$e(hJ<|4og4X9+DDulnHuNTi(Wb z&_#ajfZm+wPVy)?g?)y%bO+07e*MTp;WMgl+`sAP-xu)CxqaiT{YF+O?S}lZF1}dF zPEPR(NL`b|OvFGcHUAIKaR80?`}Q0U3?=rZ%<^mL zzkfnv%rY%th*f(?tDJc!Lo*ZA8Qe~N@UCTS;f)qT=ZxZc&OZ*7F-ER)whU3a&N>e0(a~}C&YQ&ap%14@`sAHx z7aVke5TX-fdn`ArgP#U>_~X0x-oNfj|H5=cs6S{VJj3;W@&OKqH3z1VoUEkDfB*mh z0DZs*dM?-4tVcCglK!iUyU@KNBiWw4a057joR5w$8WzGOG+V4EDVK@ zTf;beMzpimSjnS0>hb_l0LNC~SQEwOMY% z#tlG@tncvtT3&tqj{m;@Iy?+q96QAA>i??jG25;t000{PCV&6{0000000000088xH AKmY&$ literal 0 HcmV?d00001 diff --git a/gui/public/images/trackers/v1_2_slime.webp b/gui/public/images/trackers/v1_2_slime.webp new file mode 100644 index 0000000000000000000000000000000000000000..b8c1c229a618ced9ce458f621cf70a9af9663833 GIT binary patch literal 92960 zcmV)pK%2i(Nk&E(YXJaPMM6+kP&il$0000G0001+0s!9u06|PpNck)P01vZS%aDx0&sYx6N$Z=54&$=6SZwV|zAlwhi>RZQGBfcHI(pv3f*)g8L?yiBU6@!vHKK19<72Yft zRCjzo~0c;$XI%kn6w@0r%gv7@)Pda^e>8`_6gkQ{yK zQc0GE`thG3a(;Y$JU9D-t!p3EDLoqZwzDjY%4_c^jlMnU&!X^byB4AJY7B2x&36#Tg zB$oJ(KYO64S0~}TQ;;oCJ&Gir*sEp*urHQKNU_J81)z4sNKp0e%wM3-AYs)Hu?%0? zV9frxRNmvW&DX{-0}xdm?&DGX=7JOq$mBEHFiQ5D!jllYW2$`)>>00&Hsc%d%PfX zrgWol;i=47eU!vIT$w2gj*^JqRx)F(2Zb#0QD$swjKusuB@?DTB0I`V<8(TAImX zcS!Vq$YcfI3g4tZgVmr5A9k_K)fp21$QLYA#U4ulg{m@Dfn^z>f7J|CgCPY*TZXC) zrGREiGE-+s4)Ir(k-9Dk6!{<%#r~EBdX$%e@{URawr8LM^U^>I>oZT45qZGhSjMSQ zlLv}^ka0R>NCZ7pGfm&4Oc<@1CC}zQlFG?~Oj6+7=#pIE$;?q+>o_9H1-%Yri1H7Q zA;~9pWrXruMsb?@P`>5xvVN5hNn7DMl*os=vMa4k?Kf<30O=sj*Av!cgD70$|tW zgxcL9UrpPs$v9Hds!pwx{%`DP2&H^vV4p9k?wQ5DX0U4DDM||Nb8%SR=@Ffr5hprIl4!^T ze`2uuju6S}HMfRsJ~^5o2gUHEa+V~_EKvcb^-*b=RNWXBJn|?`j=OTToF#=aVzN+Z zwmT**(GvG1r}@t?k|RfYg2|vxnoO%^)hp71AGj_4V=s}Md+7(?+WF^AMijHskhIV# zSA|veN0A&Ir$6+98^2lMjJ(`q?nzGbV4R#^yH~bGrAsEWg=TrrVEi+O(UgS{jArv;sujf8OK*BEk?83k<&VhNa+{>x+ZBY8RNTI7EhqI9ocRBeD!vu=+W{4Os)xfHB%6e<1VpI#e=P3TU5 z<41Yva3|K?)JY5n1J(x3Cbn_3pse6mZiKRbo1-Oc= z`4y6o;#Cg^{BA~1c9_9=DY>P%3ZCN$5>@PE9PMZp{*q&NOhU9>;3nqZ)=6BoW{!ra znf@~~%Nvmo98lbZbq|n4_FauxaLNn^a?Id7O2=XixQL|_B)H>26VJb9^vut(J0TlO zpMZ;4F^!@_gL$jLZyd9w9NrPhSUCtTV%mt-IkO;>P zt+pX#rhUv)wJZ&&&Az!>1nyz|A_@_`*|>`5e>41^ad-!$05SgbYJ2CA5^xW9ERrbS zRo{d2W^_~KfPK2~;quFS*N)Vb6!=we4{H}ln6aI`*N`xq4D$F!M9x^5f7sPrlH<=~ z;4YTUkwAy5-V4r|X-zzE5=9NePrF*GRB$7UpQA|8zbXK*YiL3j8PA3pF*6o^-WdRw zGUo}2HMRl(G$hPk1$aCcP{d+`I~cf?-kT^`v{VKF>adx{*zIMGXcHG4T@e=y{rPDC+|kn4B=$OT;A20R{|{YE^o4t0 zmnqnfBOipj$Vh0uk1GUi$yZzv%dj2A*#A{w2 z$mtG?#PZ06!|TCity+-_()MvdpidVBOicA2C}QBc-XhtYDdmD~Lns3HuJ3421sAp~ zDjj%P<$}5iOmnEO41K#z0eAKkN(aS0;$ug{=?sm;@b^_-aBEB6k%Ye8&IRiCBbcr* zF?W5V&jar55tI>p6ySnDXEZ(G(mzKkec`EkEAfi5hzqP^(F0ki5N$b;1d{EdMNe|lc z>{#dS0tT*d@SVKilR`cyxs*VsL+$O<;7>bCz%{NnHkk+a2LGa`o zbSRuUxdB|~;ELps*6!g0Z^tY;6X|2;YJG0gW=(cT?^O68|L3?v5$Q9hH@HlDQF=)0 z^l^FfTHiVrQS|oyfUDF&5^P*hIh3u}7Rb@@VZGua{VqeKUn^LRmH)he;d;z4g4-5IvIFG^C`0|0dq@t~E1 zE{sz0(<~xNMK=Jbw@^GN`Wm579Y-X74EMf8Nua?%PW>Y^kVN)nk*m>##E-_KZQG`$VQ9m)F+>tuyra4pxue97 zZ$4Al&R4R4qsJytcnHtA7$p%dj9o1OP!Env!^#SYu)fc|C|lvd2h{)o>${)Hf-r}+ z3m;?NKxs{V)BXcr9QpjKBS%|X_SbI>DvYJvMxK?EIq-hPUvFJDdg&Lo++gK_(>Lxt zc`*}?BU#kLlh225eD`sAk&;4XQ8WD@gZEj*NX%`+D~f=r6-UktOhj~AYt$AWU2WZ@ zq)OZb|MkFX#p4FK?fN%@<^Sm9wU;_2vp)9A@luuo8_e`ZFj4+T*Nq}O#7WuMcJHlD zDTpg0UE7poTW=OE1z_(1^pguenlDDa54)bl5h-Pjk?+e`Qk9xTf*e=eI65mntZ?7; zDFcyG8YyzC!J9-IG?V23n0LE!u|~nksqbAThs0+szN9IOnM8sdU%PVjJXc^myRI$h zNRENV+i(0yvPmEyIH)mfZeMjb~bx^fh)5fb0tyHO-3#@QBM z5|o=!Ge=(7oufxN;!$>f8p){<$D7%jm2n*AP^MivJSrHzrr%e{+4X-a)~Y0G*3Q8O zTsoQ!h(vzd1d{VFwyA5gZrSV{2b^*1uv;7gU2{lj5eGN1wJ2INyUNpmE*&kG3qr}+ zFr`gn;i$3(dE;gSJi(}2N52U{;SXV?G>Uj5U?1x@d&a}2UAt&y`tx_q8V03Te6!s) zIAgYu%Ke5H$kU>JJ|G+H3RWqZB2hO^BG?JeteC=TpP2-4_kI@8wS9r=|t{$FaX<^G+T{6>vRRzN4idKyb$0!~3d( zu}|knXfbjw*Nj~=qyKYx;552=WPIsd>h~)oy4pYyu+R32MXPt1x>*u!?OX|i{F`VN zt>t2e=EcOAy;S7^{uW80LB&z(7f8Idp9_K2Yi4+h@j%Wc6p+M3dkI+XlokCWC)xRi zL_E=*!+a0TC|b(}6+^mcyzTqEoPp(>o3cW8JIWVJ&`G8-v(2K63^yneEIngiSU;Da|P0EjEk4|~DRl0J>( z7SB3@@&^hX>AerojKW*^*vGT!4_A6Nfvu%C5+uDdc~0=1L4t2A=p6JghYNB$X44xQ z%RM{2V5h(REt33Vvjgmnq3Bt}6O0S;e~70uW9FAiur=qv1j+FomU3ltB$-Q9eBeJD zOCQt^h$2e7#x(*(XgDxIy+g6!X&z0a0mPR_ z{jip8B*7yL0RG{LSEdZ>M0p|YGYjC|{Tn(SP9MpKHC4xDs1E?36*=&D%4}XFnO*$A z0%e8VnbMl2#3q90QkG3PNH>}Gf3XER`AW4%Hoy? z$>l{@l|AMe@T5W~N)h3U3T6d1otQM7Vx_N^K{8+9!^zM6!18y;dEdc;fDmA$ITcIywZs^B8vAEaO51i zyFyNiQ^%C#IK|V;aaJx)0(ckzsJD`%IFamk1jv4hdIB79Qkc~9MZX@;ElO@}`j z+L7ZH$1$m(Aq4W7?tk2G@T*X`=s9p>rh_zvlGN- zra2aUW^hMxM!d=Qp&=cu002!o*UWA>u!w1g@V|<+fI*HWKgTJ#!K0A;`$;;Fz2_U{ zinTJf<5~J4ik|SSK_5q?n4?i{Ci7C@xO{x~9vswp@b4UeEkl@o$oRL+zKa}s4TnD} zGk6qI@Pd5&xJvE_56y370NX~>5YcRtZO1t9AP4*}GY{2N=#`J(R)Kvwk8yKdAgAV8 zTB7KWicOWsagwJ~Uhr`cQgKi|20W|y{^z04*FW{XqjY^MV2>|sMpt=uBxJ?7T>vRL zEFVLw!Dl)ThT}+S8=sfi0xviQ8AtAvoM7l$A*AN0d^~2W<&7dox}X2hmVSEK-2N-+w-8wI0km3cS^6{qJtZr5$2=DkTzzL3p za-RHY@k7L=o^7fEDb5(|9+;PpP_v2uQcNg_ZmSkH>t=elmjk-RPk(T1Jvd>YeF1g( z&`+)6YaT)pOuXgeV^e1IdIL{oSn%*c4Fd;c!KQm!LPz<&ld10Fb% z5Vh#9C6M||sp|{tXdp@JtWE+OZRCJS8?JGn!c8=r?|FM$ZYIETQX$9n56oG{=;m2R6MyaYF5BOD;I&mJqprq#aULD>#p4 zMk|+k*$}4XYgHa_&dX@caN#J_;%D!vwGlL-v3wq=m_#AMg-ct&F-ri{5LrYV{l`G} zjl$pOzo+=_aNs^J<(MWwIM$j64!Rzo1})%xYv3HZaTvOm&j3K#6E~wFg$GK8biqOG zR(&1=C-njBCw}dEnacLXH5ZPI@RfW9;3<2+p*M4}ee)zO+@hWC=0m-dHtz7}79XX;~xFGNs z3Pxi7OfduJ&I$3%s$kmf))68lS^+kVbI_2N3wFNH1OoNhAIiYdJ+FA4GqbPA#m9KF zr{<{6frt5&o%c}~@a3UA1`huZ&j`m}bn%EnA8q(>n1kMxaDlRS2894)!@KhtIR0gO zs$;Gmz1?4NGR}dw6)sS_G)#Mlv4KV(q{CD8l!x3snmts12h9i%^FiT&p6)RETP1_^ z_#N6(Hs|u;Y|BS89273+gQ9_0IzxTwat(uY*-4!Dz&!ZIXhKWRO08=DEe8jJtk#wPpZ0@Qj80=1Dz0p)vpKHb|?#+5^L`A8E&%BOIhP@j>qX z7nqhNI|7hqPb0?w&n?6CqtNGn8631w#s_Tg6Fu$V{H1(IyERdcXN;o*-9MVSI>SRF zK0Z(mOki5U_}I5WNW<(Gj>kNEjSK)y^+$N{riTxFokMyW(I=1QLV7;R@sOuX&jjGa zBytdTSOEX`6PPX%?>hVnr0KmJ<2<>Om;oRRX*^^cvj7DxPmOe7;QUuAr0-fZ3l(yx zf9sh5Lgb?8CyWo&BhL)SLu2CWA_i%^bj8eQ` z`c=1tn{S*XYELk>-ZslQD5*Z4budx8I;bv4r2hpQFWgZYmWz-D6 z=YfWJrh-BZRv>VCUenPbbnWMj41}`RjQ-)ueW__n;MkK>Mtb3TV7+34*w8)i@pod6aLAjKO@vV;cyc z@3aJLQ|ELFjJ5eeWPKAH@tr(SwqS@8N)CQrb|0+~MZ0pX06=lq6ebnUbrgf(p5wqC zg(v5NCQ8x%A<3bq>iQ>k5DqshmH+}r9~w!6lNUFF@Kz_xXug~WN@j%!=T4Uw-jW^T zBd8id8;BCugVq38?N1YWGU#)Em8u}Vo~Il}6Bqm_Ksa^M51I_Cpf4-mkU1AL?t$`QvuFxB%$}%jtCe1!T`X3F=7)DzBrc4;cb~Fo1<@r&Nml< zXqSdL^v!(v_fSM|<(Fm^xa(!4AJ`SX*>wN_zSaq}i3lT|JG{UEl-ggTXbVOs|8A}J zGZ1d256!Sw;ex#(@xaj41_l=Q%E&E%ng|!Rya({%3$%-fwEsI>zWn*|GLns`hsORs zQy%~!Z_vzWX*-{{%Mgvl%R9Va`H-Y2H;{+=Jgcy+1BQJ>gh;kx&2e9tPrmz~G2(=VL7^ zC=U9=+I+C}h=gE|7a;dG7qysxa%du{5F>r9;&S>}`{g z<6Z!^X9bbtni;T-KgW}3ZshbV{E9K4AdhIg;Sft9d}F{3D7< zpwE0$na@B5JCFlE=L7ZVO1i_hx0`%mv-g8OBpKD4;r(@U2AWYT7XWJ243+l&t&cxoEgB+*yy0eS334(eol zuU#Lwq=7QbIms18wYP{L=9;;hIG<#QQ1)#DwW*xuVYK?4_f_nys{-HJSe*C{~)8aQikW2O*a?o%dA9xQfq$hlPX&umLDH)?m~ELwv&0Vj0MzE5@wbsTBbRCz$g1x; z@)&JaSj}}z7y8pSAFN@p-yr$LdNa=unH!C30iXoRwgd}Q2GYvv#*l*|H5P#F7}3*$ zKG)@kwPY8NWT?&zE}$76SQ`n>ITqoenPLk7B?IVu`ttQ+Slh}dNzo3Drmz`(BY9B# z5P9f+t`*ii?s$oNTNtdZcYvf=ui_{j=eQ($aBzVOFEIpVmo?|YXU$$%`xcZVqnQIPaXgkj)-Z%Tr0ul=u=VsChr+pw z0Z2mDLvoCt@w6CbqtXYt7nZnax@$If>hhu2aIhA`1pfp# zuzWbmN8v_Gpq93bqeEciwhxlIKEj9E*&qOHj>v~rX_o)Z3YoRK02*3_U&Dy&%{99(Y$fbU@c+XNye4TioCGDz9NQJ(*p30y-7!ZvUh*$)x4 zLbUK1vjIS@>>6J-3>}j~>nn$p{)=aT7wJ!18RLq*>2mABDeJt!pOGM#seRco<^Y6V8*4GH%UEB(({vFR1)vPQbH@Y!* z%mZ$u@49Y{@Oh)h764GHdyTcgP=8Y%r2H|S%U(0}oy4G#m_8B&mr^;YTO-m3>TF_r zrpbP7df@XSaKJ;z(c>{=4^UQcIePDd0tH-4cEPZLC~?1**|4d18SO!1Dg64+mW?Vn z;U1n|;Ovx?=(`s^I}~s=)$3>nk@nyd#e&-Um~4*yyRR@%5zGN+tkgOBJsfp1f{PQE z8UhNqo!-uvEkrnSw19Es9SM`Y+Vx5gIB8CZW5~;qJ1-p=ow-~Nt|)7X+CxMOz4gi} ztaKR3R%5K0fy2JycDTCW-1xDw$*p_WQm>+fMc`!9P?a-^HMNV18y$c^TbF7eXt6g{4|;^Z{|^Y#Sfz+ z8R z7eZVG6aD3oK6}v&e`Gv?2?0ZV*AWEQI`6|@pUu3P9{Ht~K^py-gI)x9;E*m{#$3mG zaJLnoFUJGvHDBY1?BU8E7c6|&Q3M(2K@Pm8@IVWS6i%Edf{gTjG^3FKSMh{68N;9Z zAVa-znIp85@vtMhAZd^He|%{tJGMtxk$w{h{&hYxrl zv%O)7Lp#g``TZz3@fY8%^FW51-9N>27ZVbE*Y^p7jCuJR4!W&yLDAnLqWx2= zdLVPo&LGE19iON1nP_16;a(qP)@#EY^tXo#)HXvXjKsu#A7t9S*Ln0NK3F$^0)eB4 zRLH>ZS>d2zl@Dr0g#jmiSO*#T;4pIF0SlgE5g|Z*?&knx=+%xW2hA7rL4L0x02scJ z2bue_As+hO!^gG{>gg`F*i*_NgU?!{JlbA9@YFm>Zy3K>#UQg^(r<9kOd%h@p(#vb ziGkf-$n-0FW_jorW+CrrC`}>iH}>U17NEZuc@ldpK*=pVJtfAzhwMPsxWGeiiY>6M zgIXHG<<0`g7RmjSK;Izg= z+J{!utwWebVtv&NvJ9)nkcZw@SpfFQIHrrlqa$9(I%FrpT=cBS0;u~&wX~p3f9r#6 z#ElPQT-aM+0a(@F=zKWey#cZlJ?)doLwKH909JD|>TnqTV-sX81_onXq<_mS0F+Y>yxpMH(%^hSqs>CL?mb7_uGn?#KD) zZ8ft3`7INUv~r;wvLKtn2|k*yV^%<^zOFlt{^v#p*^umqIP%d#BeMeDrt9<0qCIF) zAxqMCKZ1O;P|vIY+t@aaj)BHvcMfDvR=kQ^poQJc3PAq8fw)tIFO(~gP1zVh3pD?U zHDJ}3#|5_-u8bq<+O)KRLHtiFT`~V6z|Tn1Q2MrJgKFW z_UdFIWMlgNK`Vqe^Q{4tc0ZiQl);6o6%4X7%U&W{AbeSG1LS{l>xG7?5&f-_LAEBV zoUud{I-*(wDC-)1yMW1ucfRmL7ALD)wnm9Rf;Ir#+C3gK^yJcK{wVQ4Rwp|gwMI13 zz^nnVs#6c0-%4rGg}-$(5rn*7{j z4*>XDLu-Pw9o2b|J?i~BYzyJ=Uez8}a0S;OK6UeO8G~$6rR6Q!L6msZsMz4yd`~C4 zG5VtKr@cYQGG+BMXbX`(*yyzqY@5`{Zf)Y`H}ydeWSz36F|>zBAO13z$M@k2gY3q) z_fAw5Fvvr zKsKxY#5~$YM2YY_jqFdnI$i)-u58b1T!S@5V{oquS+8tIpOzBghkF=g!}?AyB_Gb5 z-0X$yShar8NHSyWa4uxe7Pn6(6Ndh(_CYo+YZ^9^#(4Ej6=d16qV7nNgl^aSAp17w zz@U}{#?vDu9>~fqXd6$g5yqbHQXp$Lcsa5r`oy4`?de8<-hvh{!iVHfzR5H|Nrqlz(4=`fPeSu=jl)I&%s~PpO8J?eLL}U_b2?9 zvA>Rdv-aQZ-{XJQe{KEA`uqB){Ll0K{rxZZkNLmmzu0>h{DbXR<-fWAod3K2N83;J zU*G>EeGUBE_J8+(;lF!7*8GD0IsDuFKleZLU-Lh~{`CDWe8>I2^FQ+Y?zUg?-}67r zdjk6(^#Aq$-ha)1?Eg3KzwrO`f698B`q%h>`XA%}EB?TLoBvk-d;S0X*X^(W|Ed4i z{?GX${yY7r{(ttLn15hj(Eqpp)c+CwulG0Ui~1k@Kj**L{^5H{{U7{a{m=4W?0;`Q z!GD&2Q~!Pa>-^vUAN0Tf|N4F4|M2$%`)U8f-4pfG{=e-%{}1-4f2bYV{9iuXL#`+w zyFZKP+h^Ln@S<{Ws3VE@)mlvQMVY@l#CsJaDcUQLk{*Z3b4OwdNBX)l70)A%BOK_1 z@6UOvP#BeyP&>2uzJ0cbTu?!Fe;3cT(CYzydJU*FRx4b(HXNNl-s41Ket5GCxFhe@ z!K8>PbP0*y0XE;f0v`uR`noL^mRm1v$@axRYn3+Lk<(dnWZsGJR?zE;2rkd!`S#i! zaX|&y{9ARkU5vm(GhnuO2Kj%DAH=j0UbiXYqfFQjv9HL|mAZ!eXt!QhR_3@aS8)~F zPqy!UJb#y-_{C4JqwTV8JM`L3V!^&$iI(iU=;x z;`rBc2kQj%9bV0UI%Dq>2T%F@B>V#(LbAhR&CVW2;VleBd{@4yX%3bzaHy2dlM#gw zD?O*R$-p6955u#euXW*<8%Fs$bcP{t*MfwjkTRl%ld}fH7rg$UcW3c@`)v-mpn~lE z{;f@|w|!3M3(Sb?f7kb9ORa2@lQqD0lPCv0NR#{wTBDcRALXn);KF$$O**%Cx*F(_ zfUDoqAe5B<2fh3-t%ps;L9V(JH_K7RBENt;z9|47YS|KJ#GJQMctHi({9iuXL#`+o zkuSxU@f9@sNtp;wk5Oe0xKU=2NcFg2%m7jwHvdM$h8lk8^60i}-<3#aI4d4lX6jBKc@&-fwdDHL z1F7JQ0taXND%7)!s_dq!a|EKn1=;*xJ!v@5+iZFm1FAQ4ch6QqG;?TDu_=QKmQdmS zdmMRp(h4ooiS&R!aC^{&p4KpXhQioI*{saAsiAm6>&*}$ue;3bR_(MV}{CJDP)427i z29*=vIqCdv>5Ct~>N^o|=Y*}ane9XdJMJI6?<^c}Y*P!oLNm|ogI2@qT$}d0k_zma zVd3!4ex1h59;ioJ#A)UOKV0*e_l-f@#09~If8L+G_#iOEf*K8KSD5p%lbzcCmFQ4YVTu0Ym`pyxf(cG`zy$r)5`S%{64;7BU7=q- z+e5A>AiE-w`HD^cjBWV{xIbMvjYSFy8QfW>wf#d=xni7& z@NK!Ir{5mVaUa*igc^an=HRA1J7Y@e>#T|zfB*mf6eiT1dhBVEx#QyB`6su@y(Q%- z2#m{zs1s2?(26ujmH}`3V&}fr^ZJ3^pT+a+Wgy^}G*}!L0IF6ru(gHXc*CkV?=_;7 z4YTkn8y0ALki#@vI!X&cqKXTxiA8s+ku1NgEL@?G;S%o;Edj!*4CKy zIcRHfv``nFLyYtLWWV{9>or=XLCOoW_`ZF%hg>OYtw2=Jmt)T^HY{$~v1Y`~+cWXO zI**!)e>}_#Wdoi!Q04Qp$cIu!YrS`B&~S36|I`9BABkREAD6d3dwbSUW1i)ns{P3z zO260(e$A&VXA;ES8&w5u8(L?4o(xmh%L?kDZYb$_Y~ES_wmCyhWg}*nw3~X;?z)J2 zMh!{WDK9qrtCBfWm?ahnF3;ll-oeB0I&4$QxAFfv$JB9CT)D*va^Zd96Q{`ZehT6U z;uiO;q4zR)0F2ccx>zWWF*@SZvp#z?ud}j;`)|o(-z21T)*IbbsgdiIZ;@Uv7yf>b z8Kw=0h2bi+35^#vvEgS12WXa#yd4rtef zfIRpb62!4#+sup%)F2tli#9CpKv0+DY`}~w9X}RgVl#A=ez2g|J`F>E6THCb z$7hSuQo?iQapS}PS?!20^0E|on9Zqf$fC0&TcxOxP8`ObC32G$HLh;Fc~$IO&-O}! z&YkdXz8@Ou(Vf{}r4=I5ULJq!8J;OS5{fpsCBQ4OZ?G;bVssm5X}h4kjHM@^x|T`d z0!KB`qa9(zQINR{fC4;%OD1KV+Ht?dRCX8E@9T>aED$w5aPkfaZ}tB!w<%v%0inoc_<0~BfaV(;mKn&EcRWsgM0FVCRm7Gd;(kn zEJkh@1>5uMpPK*DH%f6snA3_WiuWK=&sg7-8oot*Xav5S-kgbVA7nRf8iOo}k;UnE znNxO^?b}T6|0dz-f@Bepq6-QujpHl6L5bgsID>FfnZE~9a9&RzUN7R>!SgSL zDLIPT6+U%uS2NcX?~`FlLUpN?=Q2h|qrJkh6sT3|Q1m5OX6*Ca&;RHgT=Iav`UzN1 zAb;#ITu8dx*_XQ}nRuIMBq17uzQwH5- z5gm<-rqG)j=F%Jv@07^%;&tzT0_o3`$;Qye#iBpXO;e$>vA-Vf5|oy^*d(u>jLPqilBi1Sv9?g z+_1gdJC57t(}>0^q+>FzLjF~m18C4&Ib#lI7Lvc(U{kE8y1E>H-cR#4oabIWg@eV~ zxe)h@HXv$M96;SgnC0V-`&M!PEBktLfv`cOa4T~L#pqOY-L3!6%%lHL+MQ-Lf@gVb zB4hK&1POvRY!A-v`uRb^;G3qhwC%mRkG3d@SgYP?R=uB7;k!n2IOId&|I3}yMJMY^ zfY(VvtFV7IPI(peAcao@fa~Lo0k}hHhuhnU8-cHbzXJ(U@Tj8i?U5Y#Un;BH2)SQY zZYEb`0CCqVYd=f63C!Um!Hq5^?Gyl9p+>4|9pmJ4?#vFLFPOaX=`0PYbm!7n`pk>>3zi-S( z8}1S&iHCqg9~S;L;36Y72Z-bR$o{%^`TwwRi)#vPZ=rdt2!1Gw6W`WZ^{0!?xOcjj ziM%=Ph{s9GU4qXdBfvLgC7dM0$RxFykHsFhQ5$tX<@AmMiKghoy|dDv{(Z#O?tPL= zA;Yu9qvWx&CQRl~Awt19LS3v@apP(7387}?H9<}JJE?OW`1!YBC{#{&Q@Ym&HCErC{a(@?< zm>~LC*j(nlq>Enu@Q~k?-!Vt=xA;vqtyy{1Vz2#2${ghyH<{65|LQXNpgDnFcXv_> z_g5@p%Q=&cx!Ggw4_=ZMJ4zzu@)5tumxj?YJp^)My&tB$c;U)`{uw4{Muu->Z8RM= zt=Z0W2Y8Jg?n{*S)J%Xn*9!sVPR>R5@8$aOHZONlt^Q5qu8T%yh^dhgq8uc0A*+F? z&pCui+xvi33biyPp`p|oCx3hR+`fdKRg!h-?3*||I8?=OT5?FFOZBV>zFCVs88?qQ zGcrAeHM_e^5Yy7JKv5>^4>DjA@=n^V?r^)rjaI~02$lBf)+m2!51A8b zYDHX-pDsWDpK-C!j8t^g;W+pW%*-3(`Ot5#Pxt(~FS}pSPlUyMAC_Nzj+mQYu>_ac zM@n&9m(tG(gl^^uBk!`uiy(e4Re2P4GjSdd(+6INcmG%9c`r+}uHqts_kgnV@yaaU z%=Wp1O)g0UC0u5_Cu+>X?d^;z3OCFO|L{y7qPpyFLFbW>?&iQ-P)h_KaK%&W&Vb+o@TA4^g!cQbJ;sA=l%>KgsQkO*Pzzt8e%QiLx#G5K$jQUu?NQrl#v za5Itfh)BILFX(^Nk=^*=^CHnz`f>6r{)g?%QV!m+Po~4M$LH~L8KB}}@$EKHRa(Ft z=jQ#%;=d>9i|7C07wUo;Zt^^(pw)iQ%PyO34!E6dd>^ri#A%DlL%V1q1)WehHy_O57u+DTr!Zw^Z}_V#ZakfwL)BRc`C+H*yfES>5m9 z)$fZ?!JGV=M$z`MuQ5JdWIMHEz|QtS*M~eJ!rt9F_pDpg&qoFG5Ace5|bB5CGFTQMWrtOP5vh@82!+dxXzs84g)@>q1>Z9lkn|P7b z+enjNRkfX# zc0720@8$j;)tCP)LttL~c}eNs%fQ5R);vjYYYx5!2py)|&xJs^PK=tFz<^6TF#KHv-eEJEVv`$H+h7! zkBWrebNc`nbZ%_tM8pYG%c9Y#X!gf%m5Ixu&b?)q@~}tO2pHk1v1p|I-nc zpT+a-v^wGdRY0o0f(x_wzJ0cQX#&Qf(l@^U6Fg&-M!dronw4jeA>TwIvz7QEIy2V* zYI@_aDp61ULio?nMgRZe-zoxu|2&*J&^+8uF01=;*xJ&-Zz1*^6kLXk&w$D+z{ouyEBN(&E> zYIpZ2oWUrtL3V!^&$iI(iI4#P|2i}PK>C9#g`Hd7GYqG<*ThdDB~tBlQ=L(=kJ2(9 zvh)^RK-k~PdjC~@06lyy5I>qz{{#y$ZyhoWkYQr} zfuf6Ms+X}YN6xrn3&=X{?;5G3Q+n@vul}nROJ$LlO&*oQy$PwxWM;$#@(KE~20o?Z z&6WzL`ay9<&S`Z?$t+$E#0o`Qe0tBOL9ke*?}Z~eVem&tfXs!iP@=3HH5?ap>H27X zHW0_kYYOqHQgO%&K1^42n1ZjjJxcJ#iCYfLS#VBxsjD&+^jt`7bd!K|u{jd!MwS7!?g^`tR*Q|e$YS$wN=iq?mMc46)e-oekl;*EIM&JDUIh+^_ zSVQ2sdVCF>l9h=-u%=a>1ee&E>1YKEv%n$u|cvr7F*O&DJ8FYOD9mcRiiU4ERm4^l-J zCtXsp)Y^2RcU~oqxz)4f&Ln)HbXL0(so|@mSeHbyTA3^)0R^WwY!!g;>~q2SdCP|c zRAZ;#xI_Do;Ru0)+@x!d1E75Fr?<60Z|_(ZMu^2E^{nkD@mr~SyWN#!%u;)gn zDhD$x=EisP_-Dki!C#-br9^g&KK?UyB;PWknNO+gqlM-{*unh{dB3XT&*kN>9Zphi zeeg_L)0)yjjT>ei@ti=wH~N}Q^IfT!SjLQMXA}L^onk)Of1tf2z3J?N+nnvo7DK!} z%=l3-^(1c#DpAp*VRhmX7W|a8uo`r~K0|MRX^`Ky5t}VWE)}P6y-IQZyde>n&%01O zMk|Krxynna*BjE(FTxeGfDmSU=Eb03_0EwGIxG#ZE54zvI`xQ-A$6};v`;lh&Xr;T zs19$GAtx-<(TjNpL6_7QKp;diMCLb0%Xm<@e`XNlc`O6xtJ$=2^v=}YwG($;khgim zsn7Du+)zD>+LluPp|M8Sl?Q>_$xw?#bO~-)6AgA@kKH_|P$p($%jFO-1rl>vyi6Dy z{?`>G*$4ZdYgoRWsr_H^t{zR1a(isAh%U-dEvba*Ry(g@0sq!PO4(BcY^>|H&2QS` z3>kC(`GiJI`zkXrTOQyL1i87_T2CpD86Tsi{OBy&)RsrJykcSKrYm~PQ)A{a696S)j8E3vLoRk`TeN4r@{XeXXWTPC`lfZYoy^8q_3MUs zC~KV`WhI(06;@7`qw=hdDl@}eqdAbi?kEQPNlNM`h&MT1M9pZB}OU*gk45G#!NhYe({j?a2$?95hE0yFo+djL)aF+ zfqJMx-syAG=K^gW*ZN=Z|5t?D^_VKB8n-{Bw6R-EB*GBxw-G`IaDk6yX?^%OPca4| zH+CUg&9bGA@TDvzqr4^W`Ig?2G7`!!E;Sv}EK02QH1RZS7oo(ak5~x5IsKifAX5FC z9c5aby8>8|Z@e#0BjYa5DFWC}=21%|YZZ*W)-Ky1G0ZvIp=BQQSF0Lw4r^uW-zPY1sTUF*i&pJEn~;`y(~S0hp8}20iilPf@I2?HUha*^68$GE z*rL}V;FkP5W9mxj49xuax$uy({LS@_J)>Ma-2cW54hji2yXLySd)|2W=9n*az2-m~ zx8ZVr0)*_z@BD$AS2haxne`9=Yw^W8L;$Qky(5;WDB?ewc%fBf2e0DqQB?QiLCyO3 zSF=?u=rX_Cc1vf_luE}gM;x9^x_bn0^!5t6R1H`8I8Q9%rN!tvY z9lV)#VpYJmO{p zgKF=^FC-o)Z=_OoMW;Wz2M~qJI+nLgtLGp)~V8TEE<#trqz!7!hIf0OUPn(j@WHaFFm66u1=%%A( znFi%NT;##y#<(F|KUaf-7Gp8A2wkyrP8cM<3xGod-)igb|AEy10uLy{`TC_1tXfYt zG;J>9*ZgC~;#)V=XLScRR5U2B`sQ#kRwBMzw3O2QJEV%eMDqtjMB@fYPp8baTH?Yo zwZyajYAzk-onrDfWRh#Af+_O^cVPECm+KISwmIbFAmtCm+hhk zgrE;vSbM3ZlNqsw)==C`z5pE#Lu<1O68r+43W5qY(gynV;2UB5C|>_R@oUI=R9bCD zyF_&y#xFc{xvgcUICl;m(zln= zv6xYo0d1|rh;hXR7-#~$bf9XA?ZGv!<~eNFPuiWkePsi+1;%sauSd$aN`ctRXz&1H znu#GB@KlTBrEXhYj~tu*P2zD#^tAgl)kDQ{eQ8Ys`+8nc{&8SL{1cO85%tfXkcF#N z$QWSwqCaPk$p&^^g@ND5CcwQ>*aV><*YdrUs@1ZQRKtx1bPpa#y^bbUR;;rFhuqkW zW9cE4kvG2$vJnue?PL!F(D5v#V&z7e8nyqQ&p_XA{n17Bdm=6__4ZT8HK40kg~!4B z>X7`O+Fe!W`AW*r?_yw2W~w8O&~BGI{W>8WQGM@@u&AOqRDLck>d;ir7qcoEq%ib2$-Y}J zWRe6P>~b{|`wg3a%ix5_rtMlW3AP~uYzqWWg;9l7OxIU3ix19bJh73@E-T9aUbwRb zz$v!H0UTTWukSw5bCKj4z<(Bzcrfh9ord>0{|MhQO*UW1(bTCN0+!2LY>W(aCzlLc zpmIKzHP^%qBayy;lZ6eNey25T!|RKox@_-Z&{WvDP4Slzj7@U9lWT$I-2E}Pv+}#M zpds4OeXEqA$7BnFK_sbhV&wSmIg2Lj&A*I<^7EqP>Nvmh0}uSp(>8tpwxIRHMZ#`- zX%P7A(d0BB_ayNUbT7DoLu5d_YOj~|b53ut(1x79;f6u|M7|l(YE(UEq3Tb;T1T8; zcv~I}-EGXGLvy(Zp(pSQECJ@p$y8#Q_QL*}Y)$DuIRju2NJRYJv{&atJ%yW)|TtUWm{Xf2-xfc>u4ffV6e+N5K zwDDZlp89s)Xa2QaVs`awLvwxL>NIL5R(xb*!_)~^kHuW<`cSzC1}`hwc@pZN2PIU; zW)yFnNYypYO(<1B7Ukcie!zQ3^e6XmwDHUEMwN0P1RjG1&$V=ETcf-Wbu3&nZXSyu z2Q~aXC~1dh3A`x$$Prn5hQ3eTN~h{idsb(okY81FS|p#nmc=n0bhZbr?R=_sTk>_7 zxg@m1D2w=HY{DIPm&fIFW-+ey@#gK#?I((dmqZ0=nMfd~3=jn+e6#WZ? z7O|ttt{gPe%|f zMR}lHS)7C#7Y~Nl+>jl*`IW^5Jx1p%x0SU#sENlWN=wHP%)oSr_q#eUS zNCU;`N0vuYcF+L1<|tlZ@C?iRQ`fOg`=%tZC56(PG4hVi$c>te~lOaI;o0RHxO zMUW5smg-wfzX~4@jGVt;#!>J|h;bU0_X*5BO5p}TSh5`^)Hw&_hl6~o87@$y4eU`I zBd!UjC$Zx*M-%O z@57t>DU0%Y4$-4z}P^q^(HH@4k!UnS^RHxX)T z>3X{bmeV?x*!Y{U@}wE!kb~hx(6*mXc1rw^P49~R)VijWER+PMxEgroy7*Os^ZoLx z_^cCnG7I3>VSs|Pew>cyQydmo!ZCQn7Y{3G7J=C!RP+7tMnUg8#Jx5yU&R2ht+;5+ zA+fWR*u4de_1__o9zPBDx;yfvq9Auu7WcLlqNU87<`_c$`Ga!4sPf%%G$y#VvS<#( z-#wnrl9cJ{>D6dla}*+z^DHbAe+>+omlvitlX9gB9L7dW>5pva_=9VOGw{O`Yffkd z{yeP+&Yc&}53)Vf1SlqptWm!g;?!pNM-04b_jt(+*dt?uB|oEqTb8eSK-SE0fY!nh z=`tCPs$_>bE+-~W(Coc#sZ_g1J(VHI>sTd7n50>5e8Zr+jXyL~YrZo}=5a58wlG@q zRNt!jv~>a*YBo{%k^|_0k=c#dnyEjuTMw&eQo|U2>GEV2#5k|7p*fr>W%-{zlB`W` zN~OSr=mkbM#>5mj4n)?MHE8dDm6IykSbY~;hq+A0)xH!D^GUc<$PC>&FCgW8<~DFu zJUaJMTKn?r@?iaNX%;=Q#a*qN<5idj6l^-vfrZB6!;;v;peU!me7oU|Prq}7#dviB`3EcRwR&(ZD(4NO)E>_71y3wmB-vP&v6%`PfzzC(4p*l~D~ zrQyk0EaA;w@2g%Vy~DmL6++f-j#wSnGcSr5y@w;=3o3GUEhVYCEu<>pJXbmiR0Fft z6gGBx*yxwDF?lD!rRk9{0ZM0p^@ElyVbN_QJvN+J2@^$!j%i6O-;-ig&$rXsY=|jM zGcS$E&h{+QC^hFEH_6Q3TpX9RDtZ#18SS!cXfD0Q>I5YU6|l--B|OtTHSouh`^gaY zouDc~+eNymNn4!&V%tS3?ZfRr3WdD*@YgTdf8aeA@qHKtv}iLu?xPQ1R8)dGo=Dav zi)uVwa(J=Zd#>pGiwh6wDH~jqsfNslj~`2%pCC%HPy?hJgfMV2=uu>6=m<(~+!A z@=Of0C1Y2G=Y_gmb2{SJd(4F}N7QIy+y-@y5+ji8b@oy$KUF7WuJaafHo=HSJ44!% zej$2j#>p>lF5TAyn6l-R8gAL4d~cG^JI+vDh$X80$qLADA{W%dFEb7$z0*9QwAE<8 zjAqukpYth|gU2is__@VddUSo~ep}1~u%ijoxf+6)rgVq`(A>O7fD3C~42AVr7 z_9sgI`(t&tM)S!thnEV8m>JO&JZ}19#=dDaJwQN*bE_>A0 z2SU#?6;ogGpYYm~?9cwVc5UK&v>Vd9IlkDZKy0G3%+CeUKLcW4Q!d-SGvpGsr3(KN z(*wbgvor)e2yXwO-XR<)+`ACaRbD-h64i&ak|JWP!gZE>*w8V3F-Eqo6rY_6cq?vzfo;aOEn8c5g+zdw15=efB*JN}*E9cOiL=^|xBTBc^DJK}cv?GPBB_y6t^pWu&_T_26I8kMtF`q-Nv~@b1}Tp` z8KF{JQ@Ixcr;Fy6ktqqh+0)L^{EQdsvMeS&n*B9J7I;y*{|T@0&HsFcY%!i%H$u00 zC+ze+qVFPK$wVx`uH2_1w%+sn1PBn+!TI`WRBk)BiQuQ*B(CpAPOz0mrSJZ$8H`z| z{p~&?ZzpC-px-aV>J>@Ddy>%i#JBLvzx$r(5w`S^kg9;BIXht(O=GjY-NU>D4x$>x z_qnCKfn3Chb#7I`(@=A*W2~`)sNY79_q^})8Ji#H*bZtW<##(LZIG2CTwIXPgW{&_ z=?dU>8c5>2zkMIfFl+){4q*Mf*aob|UhLF{?)I`<*|9juuIJ$74KWbhR5DX6NDy{! zj_+F{-wJOFf8z3ii}kEtipPst(}v>1$WA|me3bQcW_DMQr|GJ@w`1CU#pU$g27MjI zSJhf`ThSOC>V{p7x#XEeHyg8nf8SzU;+Ro7$Tx=3C)*!8{Uxi_&S^66DM6rubi8Of zYHU4tei=a3Du`W(yme8lkkpnJ46%5%WH-@C6(DxsM9_AG8$t5Wu_)rifVeM zRotE&7kkdNk8*etKMDGYCEMOk<0rE!=8Rf&3(MWV^52+-1o;D01zx_#f&tTLas_j` z$=Z(BRDm-RDLtDMN{W1R%iwQ1UDS4j&qYJ zu5T0Ozd4gQuvYXen97C^bMz%;B^OlTR@MYw2_>x&6$V9Ba-E~sEHaI|)QEIutcuK% zSX6aj30cJ>KLA*WgISe03sS(Criln2jwU_sU-qol@5)+$^Mf%gXKBW%C*j;i?@?7-FeXn{mF|%cz6C zJi$1laG{QYv_|CK{W1+s^YQxa-C?_>j(X2V36lkki{+~n|L6W9b(8hZRpgvhSS0K@ z&|vDyE9k8ubT1ot!nEHrJQtE|uwlJ{KN{mB7=&qvbpdTXgwG4&U)NY##(aB!CI#8n zX}8vKIp#IQvl})wZ_XHjWK-2RZ=@zbfV)-8O&bXa5c1;_ms9Ff-(_81KPu z!IYa6*v8T3frgF?!bShvqhKrO+=arX3fU5dBb8CW;(1m|;M9Lwh#YdZ!PZynC)Tnv z_Q$=_`cgLZd7IjP>3am=^iv@p(FEY(q`8l51D4Rc#L8~Gk^?H^VwX9Bd=*}KGMQ#_ z8oFc9IJBIYB_ppS=_ae1|l}&k+VyrTVsw!qA;>G>Q36_VcRqLR~qi9W7_GwZw zKB-`CCMWRja|O_72=oYKS4JqN!ok>cd)ZLqYc>J>+x3IRBR=Y^WvFH#vgw$mJ9DW! z7vDcP1pB4+Q!9RlBWd62e9!ZJ#w4yyYfIgziU%n*C$ys~`K0QYrDQuL} z;(jeMuc9cRKm<(yn8La|4aR|m`bepr?uc(o^<`vo{ikDB!3Gu`Oq`KuV!Q7fI2;V; zpZPA5FN+$8wL20sSUI2wd$}E9l1nODl`Imi4W(gz5+t-^pmog!S(n$3OM(Kvcata2lXhcZ7D)jYGvEH=` zLbGO7b5fzR+(@>OCflW^e3M*Hfp}6CGm!4e!b|#RNp*Z|R{Ufna;@kUueY4T)74Oo zV3P#3%l#@dG5De%;QX38C1NhAiK&R({ML;T40)b-sG``bnL7Yr0tDX(nyd$r-}4F( z=%l(XAgW3jcWhp%hzTu%I`_dIiZkt?A2fsMmr4R^dBP1+2eiM~6P*|4X}i^xawY*Q zM0L;dTloUv$iWn3JX2tOkl!=-wY>7VAisP=Z1ioIuYJS8E#Rtx*5YM!B2C~`J+%Ko z8fOXCkZI*d@%f_A3 zC=X+b6nnZDS+RjbGEwK;ijS`j_Lg14kGLbGN;O{PBP#BaQ+^)5L7iqygLw7{tI({O zhy_&}Xj`0+>xM-VtvSOu;=W!q4ZwTRGh7MdrAew}M6kLS@WD)9CM?J6@jSI`B|TcZ z?zg*z1zZ7jpNPK2lh!?_=2>SFv+RHueC4V!w42$Dd0N!?!37>>? zFn4*to>J9yKU#N6am}NiO2i&M4U()KAa+!!taY+G3mx++f-CZO%}@4zgLmu#rsp~t zsMio0@RDsmD;dJL7IFZE=ZR30AcwoyIeBF65bn>2JI6^h$Xf&S#2d1PS9jXDIsb1c1Ku@2*KOqA^_ zPzg<>2uIokFk=f24)kmftWzKKL&h(d^UCwz5D#BFUnO+?M|ub(_^{GiMES_#QJsI7 z5797bt*AlPDLx?~-kVdZf>K^5DWu2zlndV}&fe@)g48#a+_Ax!35j`WKe)%`?%BWWUF^0X~<{i#xtr97N zA0K_8rdC_pGC?D+n)5UgzSs#dc{lIyyz-d~RnSIblKY`It~6v0X1qI@YS9XMP*Ur$ zBNK(ss2g!d)S+@SYTFAsoa@;^uwcLWoZ>Aj9p=`I4DuVJ zZCe-L*{^uDUM!7Kci^S{*ZMd8)9$ybX2m?*RmTd|NM)U31B5=a+vz;@UNkQ9yb(P_ z*ZMt@MT2B(K^m6h%6(G~nux&HIWH5>IA$vGpw6RK_C6VO2G^^xYQCJFNH#|ns@jaY zsVWRj*dv(1(hy%z*nQR&{68yfDioy3R6JpFd2F^UZ-oKs)UnAgen1BJNcci8P8Qsj z8$hr~$M-MFyVS|V$j2?W800^PmGw@`&;A_Xel4r+%9L84hudA9c>e-V7Tyhi*H6t* z-x&&l3?8nK=wqUDddq+DW&uQ-vg{W@2@yHpXvy|Q{p6opx!M3bC1l*HKCgce?)wox z-alv?=$|h9Wc#==A>#zXXVpQ56TV3OxkdGAwq6ztYgex7#-u?L8=G-Q6entuS(o-9 z=bM|QVtq0!F6UiH$sf=Xda3~7=-wFjT)UK`{K769F?JO>hj)Ds@Y(v~f-4^4EwgbG zs*hBDP^*{xGpHx@cQh?FnAMdb7ylF2)am3fw%;Sh1Ti3CyI(Bu?|V*e9a?u@Lo;UJ zyGaL;#NG4cSC6_|P`9Q{$vNbS@H zEBp?(-GL+GmDJ};i#vEcHa0 z8==MB0%M%W@9NxOZWDQU*UlDS+X$KeW7lpSaI@NMPJy6rhemmUT*I_|+MB89l(zkX zuFjNvtm9&|vc8adGOTX_xo|m%#Mbb%0e{mZ3df=%E*;JGcr$W~je{%aMFvL!knV@| z?;r=s`bC!)aPL#nkUiD7UzVeBsMEg7C#c|7AY;ef_l1IJN5kcyKV;gh-aAv_rZm~Y z1pM6G|Gkw!S*C;sicgc#Zk0Pg`Dw;7OQv2EPrp2fNYvU`%u zqG6))aec>?Ym-yfl0JmmQ{A!`xak~&6IWjHY8Yp&EF9K~JR9Vx#Uc7uy8RC|M2ZFw zAdDv)nm0<~DDjrCE+!01Q{vNfs%aG?rv~<0mWVGksWIxl7pXv~IG2MQDz`8YaN<6V zQ?AThGBUFmG6fSmE1@bDfJ% zxQt(&I`k;V9jI;M;9p!7(mX2)1O*3s4e!r82=H41AfCBk?xC-Ud(BTOU`H*$q@_bJ zP|7r>&hmq-5W!3(`4VpV1`lc8qz#gQg%HH56t=}~$!cPr)M9mS3{{IwY>zYB(?WMx zVV1m5_jOUxE1pXF&oh>MI@3`++hCrY=9sxg#p=yL_su6bynlimAz=Mjm>pMk63x=F zb0kOEKwJxuM16gW#L=^v`3EpamGEYlKh_fAF1oK5C$d3|@Q|aE!^j^%T{EPfnc=_z z{{f3=X7z0?;tF$yep<#APfJHtUvpORh5LqNmF{X(wrjr_YUllg1#>K^eZ{*aLU8or z6F8Wze>WWsRv<}YbwJbIM$oY_F??P(#4NS0wJd^9dHC>RIWCIyyzwNi=75l5E!(R)eUw1lE|k0kUi$oo9b*fX_pU&CC@z+o99gHh5Cjv-()Dqt@%wwZ`04@ zrfgNy2cFj#w!9ySCt20tdt4xc`cHX$=4J&om&wE43UjU1H!PNz27Lr%M>n~$KU24WU$_(9?-E;&kkJ)sxmu0jqy-Dfj=#Js%R@qTu@_Ie)iNMo`YA0h=Y6g?>zwqyAyU+2qB3{mJR%QIb&R(YEhZEb0T2t&3u{m%+p_v?rTJ1iQFthns~m zo?i8V3SO#%Hxa^M`wCs01~!8>XgrsFh@kGvN1GB*O2^(?JZoU@;!P}e^5%k z(`_n`GuS+K<9hE8moIL4Y?y9f9Q3Y+Qmp{d^|`Ioykv<0r*sn!P3$OF765?rZV75l zKLJLKFbK&&&@v;(Jz0#B#d62-j)gIJ7+CV&{91Ql#YB7uROcYK zM^aH7`ih6Th7}3%%wrty&XYCvo@m}|M=1n~*cyrjn#vlJF7P6k3bT%0eioKzDEXp# z*cKzcQvq@JQVd^wH;haY6jH`(yivM@9oy;CP&x0_hhM&fSV=gv$9V~g#=3xH0Vn@4 z!IjV~rS+fHSAqy)-ttx&>9vM{9~>;ZEeD1Z#B3!sGkA8wT`G0?L?4(yd_XwULa(kS z9dc1mn&aS272d@mLnRmq*F-(PLF-rCn&jiWY_WW+zlip2l{0w2`cX3vy9gVj?uNJ$ zB4TMP+=?i4^4QZdsk1%NU%}PjNgPvp!#!4q{Iy$22%^S6&c#A^`|rr2fB~G;8k6|0 zVbwOpcCklZZpdN=R64tl$@8u9q6~Q1+}^_j80Pd->X8JE%3iZPY&CmmD&18cD#596 zoz|Jz5390wOWtrkw8mT@Z3|L?B0{8$R`8VrrKZp8}DSplwX3C z#MIt7A9`D>b9ld1lmETRG+uwChetM_Zo_WkE zr%a&yT`mE#?(1Wc0G3A>NFPc+mWzH(k5kW^7NlBzd!#Ovd;A{};x>b5mlu=HMqvi? z;7tO0MspXn+6bgD*pCy-xLS%qz?y1>q0k}MxV8E7Mg;6OpdzL-hg^-P@vNEhm_I3` z^dwwHozWcCR!;S39^T03bpu&KJgUHO;~neHo=uOEM)f00n>$GXt}EzVQ#olhlfCT+ z{VER4VyuTLlJ{Oz9ru;-gLHb+%39}CbeL~0-|}C|aLNcwm`gu`gEx_Fh{dCP24#T= zRYglBNOEW=IOQ&CJKonPL#%F)SK;=rR6&kl4M>&NY%9Yx#zd#9u%nR^Wna(B(BxzRrO=` z>;r#&M0?B?;nrHPZN_Be8Q<15S^8jTCiOnH9Z%U?ei=XgzJugxI+Q;|15 zPaRZ?z>;dB6c$sa{RfOd%3pRwg~aEVD~%D2#akQTI*+lA>7CJYv!9|*yXLDKQaBCa zAR~(p`zLA99m{7{BILNX#vXelrQnEp?bZ3^c`MnQahK07nNJW1*W#jNMF>mn#&ZJa zD6d!@HPpjRC_Hv%mVwW!%*Gf!<(^zeo%~CR1IJ~B;U;9qQLjl)st~-Ilr%y*YmV+9 z3GHT$eSt4l-f%eaeG=A<>jy3Hu*Wv)=cP!JKTWCxxB41piAqVRuvmNxz$!z?qAqZc zo#r*&TGLS?VQ`tO{7n&hscU+3gH#G6nf>o|~Rd@~)E2cPVg#i);hXvm6PFccB8RN~EqQLbvr!wban>?pKIlh>@S( zr>TQ(miLoIWBAo5o!p7AXX0kBq3MWB^vya7{7(^tlN2Cn+W;>=n{X!un${R9Bawt@ zO5(ad8Jhvqj~_@xGJ{?W&6WFJTYA6S-=dAO(%E9$NleeRr%3zu{del|S|r9T`#Z8v z8(Ii1)a$oE2!A((x8b(Y=3A*>B$(iOBCp=5ZkM(mx7i-Gl355_>Ul?lRXPA^pe4n@ zlOU5hHtG>Mem3a1aPa7u6A+}q+w-A~i(z=q>16`d(^bh>eE=;nmTTMtgF%P>mrTB7 z#S%7(7_u=^qJf@AqTPea{I+@%%G|#+#q%G{Aw2d$UqIJNrj-^NesKFyw9uRX8*J)H z#unjE6>uWxu3r z*YyQKQY`u!^3q@w2(IKcg*7SDK@*`En1pPa-2`*H2-`MdXv+J356vO?BkAq@U7-?E zoU;kA$ResO_oRv!y%>4p@DXxXo)-%?NW-x5=OVRq&yp=oJSaz~3y9z5ZqFLY$)BlJ zvG69rO|je-$;$Gwq28a}bb~#jS)=x*mR#_AVw3R%!0HY@g^M@phWnOqh#qC9G>a5J z5v`3fYkszcHg0cjlQr%PcQzgv*D_=@PYt|++3@p!gNxQ79TI*p-=<|r91)L~KpSop zhqKFzZ{&{|PRZC0k62MII!Eouosa{&;>ecM;rPqNxJ^bm(N@7Wjve=?NDJ&eMG*jv zj70;ib_T-;MtaQ`!vgy_kk0EFrX75wE$yuUB2ei}X$Vpa3FJbF$$89)42+be*>V{g59QEUd&FpHZoT9GNw#u7m+-TFn!lZUUVJEvXZCSa2Gfrf zQ43XG;D5#+=cYOCDtAHT$r#p{x+agWe+yGTKMvoii#+68nm?A zHT%g+KbgPD_0P`(N;H-1K*~XQGYRRCJcBZ;;c75|33iT96_@kPUBD1NJEJU*KnXZ; z{}{yVjX94=iz)dk!o=$!pV87ev@e|{{DXICPkO5-mx&*#PkdfJ2HD1MJxt$J8zN<8 z)~%b+G`cS?IMqg*{0?P7iT~v4J{FBYU2!%7N@kc*mEXh?5KsJ?z_+B!ct1zQ5a1sq zzHf!&^~D;-5%O=X!NE&^)tXJ{GwW&LQ@^3WATY;UGvzBbpEMp5*luBEGbrn=u-h7LO2Mo#NoyMMH1shen^xZ#|3ublU6e{ek`gbs$CWb7YLpU{)QBS) zp5y}y{e$#dr5nbDWiKC-!txN${L8|P{-?ucW!sO*e|11UGdHm3^})aHQT4i*^k)td zhyfqQlH!b}RjJ8-1dR%;t)TvmUuVmdxjgWdrQR_p$K2L6M{7?uBG z_wZRW{K6^(*cBlGO4_BD9Z5X4X=|m_ISo}x`dk8Bz6C25WQhmsw=N3T=X42ac0aVC zn4zGLDlR?pLgoOniiPU;fkNf9XRqqPS0G8U;diJGkYx8^|Ng0B?p!*(i=r@gTmDZw z7>06Rk@mGXw1Xx?zTBUSTF}}!91fR*lkJ*eo*2pQdeHaX2Dj-aDuIqzJ#F%SVhZfl!VM@ZJ8taMC6_uz9kywU%Y*HX z*GlUj1#W70WIlcQd8!;+`~YkPlQMXRNw(vNaSXST5b+7e%0-^vH09Z}2tLvLZ>or~ zROs74WZuHeuyZx6{*7a!eS}dsP*rV6&wU~fO>T_XFA>N>^@AK|=g$fN_7#W2EF=qW zzv^!JVz%A+*6mmHzYDr9Mw``Mte-nn7$FIC+`)qD1#%=Cq24XwMY&J+c%xpi)ychF z{|X!mc;AD?(oq%_kXe~apmZ~fbu*6oa_)+v5d(MCuOx-ej#COLeG9dq?4N)+U=;^X zLMo~j(rGPG&5!x$6+7D&REaOLT0<`6`Rg$Ea}HGzZAPmCg*3x61R zhmQ_sHEYjmvzPl-h3<*lEpj||KtEWed#H?^sI?#!>=0%l@?{?-RxFnHTru#r zDZO=DuFywHAi5Xry?osQq5@0~l&mF3REnbY@oBS!*GoCR*(KzyN~f`~70MX3t#HI$ z0!z6$N(EAsCjErYk2&l(YI7<@Cv75i@{lwx0z#oVFDmi2smG?Gfm>n5IJ&RCJ3g+L`$AHaR93;CJ9 z2HWZOpeepTv}yNM+H9=)mC6{dGp}-QL8C>VW>bhQmB+tVj5m&)G#g5T_QM+C{pb>Z zu+bJ}gf~$h+~YnY%thO^`5@FA%^)^y*kV;gh#iTndhH=Ba5pB+%FDdL_H zh6K=77dY>;OrC>Pbl0T%Z@I3+XGHC@GnjSpSE)0N&yL;!Uc8ajnPEwWF`%F#u8kv; z1b=}{(lHU_NOyLZXFbhA3wvePcq}BC>by#mClRPb1TWl+Q%pghpIIm< zo*HBngffLdq6G45I7tVTWTzs9W~>_Vq`0(hgSD&5#@IfzvH3%FgnKi*C^uCF4}&>v za4-I=p?SU)Rb{; z*3;VG0zRuf#>lQZZhoud|G7mdiSd{a^|+Dk$>C0dnCdF*fDW2ZE-vg1G-oDP)F(E) zO|40)q%iA|K_bF3LF~5l4fDVqRBwwA4^1(f?zA=k4D8{NtLF>Aai6X@!^;~pVyz~e z{-qufN4Uq5y z&8&!CX3e;ef})DI#cm+#Q@z0bAx_jgVU&U+c)M`h;Q~&L8?LlGIu5NFw4-JbD?ENQ zBKow#A&80KW@!`QPMKF@d+8+i+VsFJ>MlZrt6flIGPxl-EMh07!SLr7dZh-EA28%=h{z(Zqu^~w|Wvg{k#gaD}3 zo5GC2&xzgmQMHjCiSF8WWa>OO0Q!@f6B-2vhSh19@!i>B`?lBY5@-?gE=#*L?dz$+ z=#=LUr}cjT({yFpv)O<={Ncv{BMrW|DZQifOpigy56(#ZS2^6 z0g3#W`dM2!Qxp#WF%bX+Sd=6TPIkEY7M^>TSu) z#&=FLt+TCDhJ+U|so%l%!S2M8q+>B$-S}BL2PU1Hy)21-XeTa*6dUQXJ17CVtVs79 zU+T?1M}6`8ys-iO>UgpPfhqG_VuD?>W6!XTWh;mf;@s75cz;VmNnx+mecl2M#zQU) zwaO%v8YfYYBE95v&NOi6pQ9IhdY2Xo3lzm%hmLcV67%TcjnMU84T+ku3~Fd&;D0pV z@t3Iyl@xyXOUG0g0=rc^hE4#M&i-$5&fZkE~7{0(w<8A6@9EM#@Y9_EEZNjpeZ(G?V_-K#a z-V5vYt4&*SFR5y90`s`L51Y+{x@r^s$_>Q0$9?d#yzGvaSqX0RlqC#(QLJX1y$-U_ zbIy0@6-0q*MFF13Pz`CW0d6N(I_*6tnnz>>o3`xm5#0AJalAsRx?s8gc=GXs{zZGG zFnFPr7wszPMPqRY!Z`m^yFK+fv(>Q#apy`VNk)P4Aw-Zm?tob8|Gu)z9^s8r6@m^y zcixLug->Tq4smq|<|aNd`;L#zY|x)hlGUIA(b-3D#J>AkVo~Qk^el9e@EQ1@oG)J0(}QaO%S7l5U1MjHrj_J0UKR$ zi`2~|77UogDbHm|++75aJ?ZW@SQny1+tVj*1dToB^!^9Vp50$g&6HGHf>Z1d*NiK_ zHX95CRYCLKA#ihXfH0WJdX3Oh0PQ>7a;t!I>81PLtgl(%81Ye|#G6S=?9#=vHI=GB z#07Q7K`9W0(-;H@JWjqyx+xkJpY7xkX_bZJR1}ahku1$_n9VN-{MF`T+O4ctdK+&! zD4o_%j{ZuM;@n9z2woh;yD?lSGHA;=Yay%e&g^Ywfw z6ousxg3qFrW5%p0P3eH%9~~KYUe`w%j;Y6~o?$os^wogEG8oLzN0v&N^LhM!k7XEoQK7A++b&nNySA1C~wbNm&pM_%rz8llTWzY87n#{fMZ zAOjqm0U>Pjzf*NI*0I#4uRs3nTV!rjl)+hfqZvWl;=Tixfzf5Z#-KGgY-hHhp?l{j z=N!q-hQn}< zforEL&;6sOKxjqCcI#+kU(;T$qMUm@BPPz#|6E^|9B#gkIe#~9NWkq(-oxa@z{q88 zzA=)@kO6h0OPD*kIMe*Y!G_xU?BFZtj8(O?9F2Sf^#fSiO~X8o%DP4ab|tn!w>1U! zc^Lo#b4(Y6ojz<3{*CL+H__UT7)>*MqN0=?mzmTsPt@^f#{`dT_?IkmlUT%D zTlSf_o6mrwvL0mSK_5af;SJPqxrvMnl;Ai>btGP}7wKKrO{Nty%d)6}P5N3RFX2@r z3(Dy33-(t1spO3j>JWU7&vD0Afrc-ZG&DA^aPV33Vv7i(gtIGL3z}J>U>lV@0k2LqziGYCKj?$ z-g-qGe8*Fdh%Adt934ER{130**JWhi50cq7KPIkFD0p@VJ||)LJwD#BPuX@S1{PrG zo}`?8{l(9tQkGz%j;K6II3mW*=j#n|{G|~>2dFrX>qT0t(R;uzIwsy2=F3XsZ{<4@ z@1<1dl$DjWROEDmbkWK}C?8wpudO8%gUa&t2 z72umU$%-}&zxP(jj-L$0h>^;N>Fi+Rs8`QF6dgj%AWeVo<=App?qyI>8U4GL9Qbusf@DA}pIFY4g}Mu#e<-*J5SuMHI9AL*H{f@-Qn^}a|U{wKW7nQ$3&w@ zqL8KQSE&492TmK;^=~bV^`@0_qqI3(3oR?D;4tqzsR3042p1mz%)MD^b|tVCPf%r^ z*^P^2Us;qu3iJhd1gJ6c)h4{vXruZcDR>St10PmX$cU4^Rb9St2tILUqF9=Cemzkf zQt8Xc-6s7&)6P~3X$rZeV}h9~ct&#UaW0QKD)*CkLaR06#pS;tSMmtN;cwrxLPU+( z6K`s(noIq_%HYV%;U`t{DZ*$0ChwT#eX4vo-3_ir#Zzp}s=O~{ zruv&{6I2%4TDzCA%GzLDY%p9c3q(CTve4?avLYDf;-|KV0Qc1Lo(TEXZ-@gS1i&Ft zkaI*ERE(QSD&4{;?)LO6qxwPRY=TRy&qX;vp8AY72YWKYcRGmM7JQI z__Is$I%wOuj9mqXs=WnFrQUk-GRTe?!mYrYdA~1W;gr7f><*zQrx4Q1rOi?@1$TFL z;~r?&hLYgy+b>31nb2ZQ?V_1!zD)uYjzdRTTvO=)39Kl`)&hX5NKxUmHK|#&+kky{ z>IW))5eYcjvphA)6068Q*;+r6b&ObjTO$lV2&-Ygtv%_YLc(=c#579f*bRaO=m+6u zQEsdqMyMrv3s4B}zNgGE=}N$MOMSm<4N?>HhnV{CT}6ds`@pa~aX8Dz1M%RiMJ|;9 zg(v!KPk}w3!8ywl{W)VFkSBx6m!P*_GwOk6Y+ER?O9cF)X=&sjf|PX=80O}QmqC0& zJ#lCJL=IW*%31u4){hXrGAv=oPO_o?e@#%Q%l?rV{V!RPnCVb92ihi9E3Kb|dKn{2 z(c6mB)w1O9K=44CiR6u{m@?1hZ?jplG%t6vs8D$%R>SUf|9>kzVq@P;DO{eB5x;&_ z7_gQtW?d)(4G0wfJvF$4Dm72VI=OLO4uA2v-Ofng;7)oq@oM-&jKK@*-nf zNBO=i%zRB2$M|0I!*mYLJ3s>wGXMu}0xqqwWg6=By`g{Q*rgPRs<6j@`&XXIk;B57 z4#7OT8)`O3{BcQ5EC@Vw2LtM6pn1JT`U|Rtuh(c($1mK*K!z!d1Y4Jqb@2!RK!4nP z24`+WZjy0vC+d)R_E^Iw9hmSHoxGi)9`ysPvgz6cx>G`i^T|xyo zJ3sK|3<2OF<&Qc45PIS= zk6!#H7(b@dK4ETCE33il$+^i{2`wtGc$>kJ5N1dW8&rLhv^5Kgt<^Fn>G!y`)pKhW ziiTXhw1p$RldpOswKra;C4hm?f9LB^nKFn}%@@>nm1#sNmkOE}Gmt1rk^rdmQGK3Q z1VN%UPsBiGGE@_g@c6$`aSRBom4*(xzKbD+%rGpvm8@A3dfD zlo|D^$^S7N@KGwl+u#`@6&Z4JcLtIwv5(>f+Kh9Q4R3IKg9yC^N!4A}@UJl&9o0N$ z#;{6Dbde&=L`A~s-W-Kdq?k3QLZ#g2ASGGA`{u8adb|GaRDwp!--V{oNvUy6`J2K_ zei^h+REf|BqFkkq&MP{@dNDDS_K!p2$-|D~#4b3CcxH5BV8KCyl^rO9+7$#h!#F}4 zjnw0HN7|6ungcf(`Cs#yurzvx)bE;P%zzC&yi@@zz)d-3vW1{>1wUr@n0H*$ZKykS z&br3I5~C#OP^=;x_W{zMK(g9VgxXbk01&m4FaTBI-OS<57HZlQf8rKby~;|V!xQOg z7D+~GC0lZ3S#nX3I~az(0L)osKVc{wY2*g8>cjL(xfO%De;)^1=xGx7Z?hn_vwwH9-_<`F`tl}z)r`S7o| zOcBM9IBGeJRa6eF(Yfn8p~Ji{qZ$4}0OIZyMM~U6=*v-aQW}tYT%w%3xA*x3o|EiA zv!$(z)&0+^Xyx;;UzFi~!7^>h)1KhFWsXpOX5h_tkXhaQ<~c6;vKVdx4-`CNb(bml za2@+aZ{teJj1RGv8hKfSTWm2V&ghSZA3}So_!U z##zu6T*%d)7kj6ztwKR97x&!x{vgzH}0cpn>AEcP?~yi4HQO`m6n@+ zml-@GM3s*hyqtfN0t5&9eZ&aH!BOqBI5Tr3rmH`Pf+FV{TH|-R;HfGsJD3ZASmv&N z*Zooh^^!TregR7tiDFjIJk57MP~wJQkJs&|M2ZeKtX@2hTDfQ*RRW^bSBQ%3oIT6{ z?@lt?U(U6=UW`-oJ!Zy}xaE(_T|z^!Uj#G#)5)xtXkf~QiPD*Upy{AjG(#m0XvlRn z@_?2$I0q)tHoP=3+MZ**n8}*M8^{2QVt(dp(9(GY$UZL0nqUz&|+H%c3 z=+y7ZYC816A0v|>Mv}nfGE~MIMi3|F1UlIe8U4SNkWsj9G%>+2|Fs{{N8qbm?#ACo z{&LZ19^hQHXpgMEQj{-{LjwIi&ecxXX zZmO1oj~%Q))OeO;Nzm1`#+NJmr^l(Hxcxx!q$rN27%i%MOyHg*kJ%c^U8%x|r_9gA z;|*OIFM7kbu~o99!sh6)S4GTtAHgZ@sVKeCEGW2HA~sl6gsCLxd#3Zg=7`m$-65}c zH<+J}-{DM3@`9;b^D!$k05(DVuKW&toG>B8(no7x<07ThA{}WiGCjK9O}~C8+d^$T zb4T_tzrzKYVYVlfaZYZ}`yaY*DgrE;@xAMdCZ8<6Zm_6$hLv&h>)Au%QnKlOc(0_| zz;|n-j(OBk&!o8ED8Nu>ivN*EP>W!-^^#2I;Msh=V*W@+3K zDHQ2%r{7H9gD=4bX6zd{!~t7sJ|5TSDYE|2O%rL|`Q)n8pXO{0f9J;t91LK&VA<`a($RDZEQA?uQx^%fs(TVcfHN>(%#eS{x%`h8m{0wW)0)D6Nr)A3 zpOHxO6PHlJ5~!)xd<$PUi!N%ea9n=R7;2*z3z`i?tCAF|gup+l`eue-Bwl$LG6q(& zK6G!dq5x?SH)tw0a@~ zOU<q*SW;{ z`jZk;S9P$4PFDUp3(hG&GZ9qUTMWKqoel6{^ST*;O>g{bG#a1aXcyh*OO4*can}|h zT(|Plu*f&x)>_)Oge840@wfh$YM)8*K(s!KeQe2Vdph$%dUb3tLmZ?15a}HL<+ie>1@~ z;F@id(vyk#EDqa!eJ@4DY9ZG#Cx+)Bdy>RIx=#G6x73cl1QjiiX_7mj<28(YFz?iEdi=Oa^>Zp2LHh5+<^xqF{gGr|wR^m8TtRwI;& zOk)mTyv;iRr3gz9IxW$^f`8x)w(MngrwvMmDIJtYo73Y3Uqk0768r z1R-~MyZHS9B_8w91#C5t*|5Lc-)_;7v{z#rp}CeOWVNRElcV$0a4$0|eu(%KA_lDv z*{?bOpsl28dCLl61O?`*C|X%GlIl~(=!z!-CX~$ZQr+5x;4PSo(Q3D<--@8_9R`x1 ztJtGmw%z*XG4c#c^#>+{)=gesk|NtCT*D@WsMsQ_Vd9&1Vlo}V3#q>tEMBHO(KQna zgYTImdu-)Bu)SMol(F4aXg6;_QZ%S5L<(7G`%c|MzIY>j?@#94sH)_%oW(v0Kv7MdX}8COM#4%gbW6_P36JE1Kagm8Su~QH2$bV_j}{zk`}}9G)uAJ7toX#_$PccL~?x>%h1CjP08nW7#ayYeo`U^9b&rv3~!vc3SoZ+3~%jD9$tG85uqRl>9a}c%DavMddlD7x&*JISodqT6m5>WP z^(F2qS`{Yq!|UN0av!Cbn%Wtf+&?@AVZqpd84H!`y^Z>5js_)dh%%wnT&MDgeFwm+s#v7ub#V+c^#vrndD95u`0RZB00&K+rHpHkKECBUI#iEfz zz2|mgPLa(=Cxk=GX42|1Z+Wut`3PoIR(Ilp}BD@q(hK~EH*d?u3wZ8xY96FaL=+rig^poNPKv{ls0c}ztbnd|V_*Y4IF zyX%MTfih=y=`J~sODapyusY$Pny=u{RrQNc8Y1EtNuuz{X6Y48VPUzV3|OZoFgyvk@9db`vKWxjp|!W3sr?Sz(rVwF{;yVv#@1zHdAcPNm@# z!qCsat4|1JI)b78qRQA-Mb6y}oKwNxdj){`eQCImb;T-dDiR=6Zv|inz1AGQ64X?$~LiY(g=Xc8x z$@$B_aXz1}A`?ZE8T^NlokM?)7syU-+v>Lw!WIz>;j_X=`o#mvH65iyef-Z&Obh*! z);*_?h)rL5>~kGBebgP7CKDhr3K-g(yf5Om7q}U%r1rEjT~mjl778j(DrDrC`?68J zp8YYtzisN{*8%lSD_@!l{<~s)zGB9ipIFW+<|zpdMK1t1%c7@9^fG)gHK3TDrNtRw zbIIBnVt(Q$iT9Sb0CYj}aC#%_ID#A-W5N8WOKB|B`U~U?QE=BX<^rc)rV1)jnzEJ7 zp0#lFj1cML(yS95iy$mM1;~z3wWC7}qfq8RC}))48}|wqg#vqgfpdR#PM^6WMAWAQ z(8tamfNz-?NXHI8o2oN?;+_=ZT8%8@%#n{0Y{Tzm2F1I9{212 zm?RG0P$&D=~W?= zf)Qx&`L!VOHYg8)K=a)y=*@{Ty8Of~ntHB7XNb>kBMIzxcDl{o_;y{HPnS2UXd4jC zZO-gInaFa^5x?@Xax)K`5;2y{J^UN!Wm!p1W{Is~+;{kUlJmKjqOQTl$Zc6rU?_}W)BpgAnq!`iBSl>fEA zCpsr56ZYG-ST^|9J0AE#)X_hUUlO+O)stw$of({4if*9&-)dP%U1k0#n(=qiakbh) zIv+)b1IIv(l+PIC6s0}INp*A(A%rUuI|crsf8?R>v<(xp9}up^MTY@SnbAp14FaNH za5IKxN$LrMS=BJA8hqjZJhj{43qu_+Diqe?r`ytO>>P6I*{onx=XM=?`ow}WL^>eh zM>MUK3mIZ4WwwdrP6sCsR&>Y&8-?T+=W=srWlV)1NvhB8D$%K$stz`jzHXpZmX^Dw z0r|4SiZX>~aFBOx&O^yVoe*l1ga*mWesquvK#y$|rl$U7SKb6MtmT(}kGK6~injn1 za*^zkuA-#?vDcS?0KBPCwhf@niy8Q{5bth@dE0>ta|Gf>?6rQh=5?c62~B*;B)_i` zMSBlx{ZCvjn*A24y`i1}BJ`ieWen9^31VZs`5N$3z<-pK~^J#keAu7#y*{?{@k;NI)rDpsnJ*sKtbltTE8MT^F~h3#%edPM$gPngl>l}Ki6u( z)b`zaV|IHHoArY&1{Xur8);yrX7pDHfu;T-8X_|8*@=cbMha$6Jq2{p{Cj6NYIHu4 zL%MKOjblfn!ViY7LWUy(k;2#TSvy!AN*o}LMu3O`X{N>e3q|^?6;06sQ5tNHyyu$R zQBV2m$)+A5JJV>O6Y9Y#CX&uUmXf%n>18y?RWjo{QuIgJCTa-Zx8Ydc*%N(sl9KN$ zdTC9sjrHw^IfQ#V%5*=oc3jY4JcVcmPSb$zr~Dr&tudl+PVS8t8JDh0xmV9m3te#C z7tFE4ieH+^vhR`JwO8&PZw>Kl8e_w-9(7XOBgPE)dnu3{f_nypR;JxLanpMn! z8IE;_@^HYu^;4HlNa#L$TN^u|7Qg)t+wd-A(u-CTO6my4r(h`<6bX0}h^dgAhkOZOMbT{u8&O z@7&$Z@LU!=n@}}d!E3kt$U2%J$s{P1vBK`{1QFSC&XDr)eW>_j-Ssqfnu@E)nfQ2t zy6paaH}PW@(BYhNlW>(39S~a}?z;p9AeC+==#w0QxYkT(j?mu9$(|=sFIY(Vq*Wqkgj-RZV6-!p zDG$dyFH{h5f=Yn$&7oN|e#)A^qHhgIp1#mL_1NzNy-x-->=TyQCaf0Jg7;&OtQvS0 zW~55=jRvE>7O7ul4kp;u26tK>aRzlZD+AoXwQc^1H3J=aAE~yuUYM)K^U^022vR*J zKsdT+%%cNyDZ3HXi15MEn_>$O0VL(cbpTsfATVn;vP&`Jv>QxuJYf%}_6a<_PA6a>>5QGVUCZ=b-CyTKitf$S{K;lYbJ{eOd zzGwVTVY`ORsTJ^Yb1xxq+KHl)EGuksBt?dT%D#@t&<_-SezVceEWpV6d51laUjsiC z*9zwTU%{TFy*Pd_#*4jSqQ+qqo%QjF6kB2ZWw+nMj#m21rET9`!`#&AvobZhpc#6I zN(a09%>m5+;4J+d1I`)wNXOYhDB6D!fS@t{ppJ_y`qL8Cn#c0kS7KF2Q5_GqVi z6vW!Y%yC9P@=n*5W`%y|XW2%`!UuFa|VBE7+&nvs6Lb>u<~ie&D0UtT!TYk#hlD{&M6wH|x4R9K$IXNw=D z{V*<4QqLsU*MRe~nzP`6$C*g!**w0`b1!n-oT-x<7KMrXONk0Zl~AAxKhkWzkO}_) zVhr=(?XkHAruM7$pQ(r_tT&m$sI^0UwJ5zZwbD{%Gk5e_)FD{qjtC`a8=H+IN0ZHhRE!6;Xl>mlI_W2-%v`<3is-?eps+iW#B@vFUj8i$Md%VJ2 z{wqHhOcu^?3r$&pv4ZQaV=PnpZ~~xuqSE|_m|Lu0=8FJsUCj{VNRu4SjW1Lq)Dryp zcT*~UUCyCzDAFGnax;}yj?i!5z;c#saaAti!3?3io7cnKJ!)C();;aRgt_ctf#F$f za2#2$S4=#NiL7F>ssh+U2JC%%*J&priFP7nD^H9)RQlMb_#g3&E!xp-Yo}W-Yfrw) zObNjB`B6odvrItL4kQ9O*#|p!?gzj0&;RAB4l2%C{&?jZd7u3iryOXNhKQ9S;4~k( z&sZHfqy$M$kLm*+PTg68IrkMs)F{H^mdFL<*1dNQfI6@Lv+V-$G#0*?1#xiNmDQ)Q z)gmDGGmJ;DiIuUjf1+qYZ1LOH-uz{kDtt#g^w3VeJCrf{fva!le^p2NjyE%GQ`+b+q{n z-Eq2PK&qnti{ogZ>FRX>%HK}DAF~Qyc+EyImE;U_XjMb^kUCwTkW#>@cA{PPpisdg_?L*&HK z&q8&t3yKZ-cx>qB_}mOOGsT~{M)R1e%>!L_+-n%}IZ8KoO~@B!ce#^Syu5S6Y7zQq z5Zl%N!cpwYh!K>KaFs4ND1v!48l)k2{Zc{kFavF>#51DaZ=aoo^7%2OY?$W+?DL1eF6b<`~s7eLZ7Tg`!#ES60a-sMY#W5#JCMqKK9&Ou|fKnAPxCk zGYhq`lwX?u`O9T_sdRan5O2YZaP?jw+Uxy9fQT>byXefx>BWe{4k#7Wd#r9f_U#0U zq%ZpmWq7bk70vRDwq6b#|z8=R?R@tEvl5mL1_2k}d$ zXMpOgHuI2!W6{&xUjIj8s>;OStA5-8PVJN-P^~cAjJ?*33v>kATTM;{K8d2v%9pHK z&LG4^v%;ZQ{{&~B1Ip~XnZ%%)ag~9@Dm}G!AAB`;`kcN0iq-hMp?(OSgW=&dO^=o? z^9Fm67U6#y5gzTa9L9!c@mu~l=X9e))5)!~?q8YD*LP-nK8d^{#@M5t)%N?_vr%h) zP)_`!SQT7b0~;Zy!-eE`2Y{rO%dr^DOFi~?Nn)U$qpH4Tp;oJyDJCpu@4v+HFqR;; z(Z>ZlO^C2G1dMvh^aPHlQTR*VQPthW4gd*#G;^?q1`Zi{z~L4!@4pto7VNvXRp3cd zmtTa3e-@#%!Uj^3HV-to9RvZ&`u?I@AUA(AH9_R zMsKvc7`rz_BymwZ9UQx?pA zaOf*RZC`blZFhe;vWrw7C#%d^iJ>;%Y7qU9p4=dN0Z==?V*$FEg!#nDu}P*mpVkAS zsyH_pro_FFe+4vfYH~aV=51UR-n_Hb@YvN_UkHdS{59d?0*R)hKtB$Q@8|y0;rzQ# zV!HwVCf(~g+``@eGcS=-%=3vKwL#uqt)OtQ!e_^FAFW>04(0!%L{JWbCP-cmvt#m{FY@+J`E~3n1IXn zHAA2pVaElgKGv#@LH}{>&36=A0c{-qsE`OxebqC4edmE67(^PIz-?h!bo*gZlb0(_U}aXZ+c-dGEB~KIrC&ji%jOsm%Dh*aywWl2rhh7c6^{>by;bUF`aldt{(n_$7jTz^~d-`bM|nWkDZM4t<|; z%$ojGpsMcH#^=1MAZQf~iq1D^Zi}xKH|{Qn$-@{m-v>NJx*oldhA{q8;U4Ve`Jx`P z{Nnzw6_Kg7V-eELZ4b%zZuYs!=1vR`R;SoKzTXL%p|oyIZ1{yN*M&pB(=0#-Jfqt8 zc2Tvd(h?`I)E5}ia&SN{iSAFeC+R^Rk)<3CQ+b-uQ&^8eedMNV6ssSnP<32I5IjF9 zjbdUw*HIc8zXB9$z{Dxhp-6o=w+1wdlz)gMIs*$dr`B=y)TOKh>Q@@IEiwVI6XYNZ zz{)TvNF#gHc*C`?2409x4*)sO?qrfV#Un&6~#t1nYr4`burwq2i@SmD)Rhhr{>__Q`QNoDZ@&7B)jmT)|-pPUbo8o zOxJ{d^^T~v!NBHs_UxtuilE5w(wK_DQgEZ2pzZa?B zYO^f&VcG|?dhUf9W%Dvbua@7i22AKiQsH&!IS_4V=BW^~q$mH;cta8>pB(V=zMGZd z{sTKeno>n(H)|A~57jwlD1&O^9hJ-#cjen@@waN=FG*IIEJ$R+I$8Obgqg${z$%(c zqT0TH;zfNA3g20a>ErAl!TxsU{EL@g-)V{U2peqrq&ZYZV@mfSZOAu|8z^-re@wOg zVzBj7?x%n63P;c**n`7LLEtrXb2|W%g!Elc*XRpTQjSHgf9nl=%W$oDD&xrbnofDq zveK}?bmBc3lz%*vp(DTtRfpst5DAvT zCgyAGk51bi{XxJRF8q@wYpiqP-7C zFo`d;-X*x0#lP-?l7+yY_DJSxjXL0B_kKwwLI~U`O5bOfzX~LEX)!VO9+h$s8UK({ zjy?wx(s*Npc3kTSqDaqdsUx-!stheF5~lFYyhwZmy)r(s&C)OwF2WQUx&B3~5hMpI z^*(XsKOlqdJ90 zNQq-4#z#Jls=~2Nb19JJ)q@sy!pH&yOamt=hcT>Xu_Dr~&0nZA96A*5M`sas&V#Y{ zVWWqY*Q|lQ$3NN2f1z&1n}~Fi1XqF8R&%muGlTv!#?6fUa1H<-;W8xl?7G-o^^dGsFq2?b z1DK5Q^+S3X&8vQYqlXV|;ObIB-+>+3#*PBuAzb6N;uRggtrtLv&?-~LaI9v!TheX|Jm4dg z4SIUn^Psv&rSz-|dJfH}hwA~@((1&;T*<8gsCAxx{AfEEv5Ui`kLDYUc=}Q*E0SY# zRMP_7s$w*$-X=>1{H1-5^9RrPR}k9^4z3-fb0R@UN?V}H7%_;_FC`fRFKi^OpqVrY zOfq}D6=a|eU+ijjoWb1cgGzC`%FUIO9p5_}!Qo-ezuO{1QJ|@=3OVAS!qu8~tb+HM zZNIp&NdqXbW^2z)uZ5(hUjsP6CNa5)is<-eAaBEB5iqQw*c9>nr}LQh#ZDsUj2vAF z|AE6Gp~3y#JI%61B)LLkq*W35yogvhKqcOXAkg}&FGnh)^e1C-c|;7}!k=0J$JSRZ zT>kOp>dyJhBJAFz`ZP*i>qzeRN84;>sak(DF*~s3Z?hbGG(Jdt16sY6qOGh#FS~fx ze7kuUj480nMk>pS^xCBwp*fWY;%h^h<>sOd>#GNT97&ZXL-9x%c?_29|GWdKk&FHd zaervq4HZFSSyi=5l{q=*2Tuj{nY(W1)x=B}ePl<#d(;GV9+^;1)UnN~(!KRTr#rI|4j4c>*?Fg2e8_A132i8OQ9{-7MHCxhTj__v6J2#)c<zl?N&`DuPA7Gl=2)-}+6)X#(wZHwJ6nlB1e=7^s5Kc<)ceM*`_{HZX~#*sSviN(1p5{6h+Uqg5pKs8TeV9p>y-9V_+ap2dRRh zT+&SQg7olWk5D%xCs)B-LtB=&E$P^WlRl#3*U7G$N_idGz_&H{Z98U=D{yG8y*lnB6-+UD_tz1#K%8#uR?WP=!AhAj9I~Kj zMwx605qP)2KF00se`g63gFPa6tqOMHlg2$~OPoHf@T}8m*Y3rM^Coc&qPW&`z)_VN zxc&OJ0FT9XGVF}vSuv$l>DrVOeyU{BQESDtgjuU(2vj*0C)LplOO)Kc=-p!lW0Fb5 zG4gps6S$b}XvtEgN1m_6txcl5=ZR%FHgn8r;@)e~uQ#8V`Tan^DdY);ohC3b*LBKd z6P#T;w4_{h;|13A+%a`1YQSa<^3FaYL0BTG1q|%hK53^UBdyh2`o5Ljl4mhQp?6$~ z+53!c363xOS6kvv8RXbG0GCiLY6DH&_s}Z?>F~+{WS*-4-1}23VV8~ z#bMDXNZew{%f84|JJ*SHhOJ;hQWo21?YKS}UG{)4&xMVMEB3+zH?#Xqo>>vXA}R8H zGWAISe?Fle*ufvPH(dD7?F$)TSOk(?+;roii)rE15j1VgN&U_qA6utPynSjRja#xy zR&o!~V1}4q;fkNC8UCD%b(G$;5FZUw(=X*eF?Wbb6$90FI2i_r^*(Ma{j<;Qxa;;B zoHKqRAf%mWLh*c{osiat_g%sP92c+Z{?yUW zXp-4@XK9~!a2MN}H=sw$E=sb;vLlks`)*NSaIv{M36Wwx2+*zKU$UtJ-^sQC&n#VX zP$Irv`2BfxB1ptRZpW}K!#o8Il_t}nncw!WDRx&C@d??7uZ>sisR-r$MVWC(x{Efw z{Y~)a=709rl&k520!1ATo*8mbglBJJOZTiyV3pB`Kc#ffaO3dJN5J7hdSCQ%Jb7z} z3&1E!ge!n$%nUuR2aHQ)6Xmak0^o0L_LjY35$X>S%B-gFpW&a0)+P55D}n1FhDv<0 zko;|ki8vd|9m6sZObVp%~H3Om{^?9D>N4znI zCaT-uiLYL&@vJpGM8VEbRtDO$hg)&RK(^~i+I63aKn{4-iY^R^0O7evsi6I?RH69P zV_SU#ppKuxtpZ^-(#A?_u%=XHB06P$>aRWSfZoI(1TI@8AbV~L$#a1I|dp(a_ zB7hjf$JuZ^1idQ=mwMuthTn(j`3HHhdbl@k6Efhi?g$lbvbjfvO#*-o5ro_7+@_)d zAr@29pZ!VG&mg z7v~|2U{`jAU0VziWvjay(q%%Pl%XJ)p4j!?ZzXFrf1FLVnAMv64-FgD;I?m38!N=X zt%3V&wc|QmQ5#18>3SxfrGjCKzoLCG2GanqJ-rUDq&4y_w%0wVpl^+p@CrS-%Wn{q zvQNa-3^agLV@|#3zcCpqMqeO?n8%Hwno^T|PSSS)_Q)+oSh`Lw`ziYOr3j%IA@f0= z2K_;osU3bYS*8l3LKnRh%ejjbUa$NQe>(jBQ5WujTG&#R>Syu{R^RVFpBgQEUBC0X z)}k}8fW8Azk(zcP&YMG>VziPKnxm@Eu3NWxa|ZEg?yd!b8{JQUc&bmkiYz`nrzUAT&D8$&rJqyG`@3a4jt}htR4p0C z>O8pz!k{!;5QmXf**}s)HMDPAesjc2Dy0|c0WW=^|CCum@4V`|5T*YqRJf@^+X8d| z%lsN)wdP2v%YlXwe80U=D;0HKlUYff_pjIPtJFrhlMiv_0Bs46JGTW|)hx~G(EVj& zQrs*?08=-=%ZHXIEx;vge&F=nKZ){}UjSI8Inb0t>5_yp@E;#XAq>B80#8ex>n@_9 z>jH?7Ps0TIMp^B5Vr;r0`4vrT$y_H>7ej|y!l;mbFTO>zJCiTo5t*0o!o;b}hJ_c2?PNT#@Se5Q_G9Mt3sz3+`QMd~6Wa8*aojg?4l8 zE>xSQ3RFdMi1jQ=p)jn7hd&zyywyI2_ouoWHYf{8Ix;%G@;adj0WaR(z-UlD$2UgQ zRr(Ss$8?6J2fJn2VA@XV1yGL0MB98N3RIDh_zHQIQp{>h{2H27t!b^`J<1(Mf^~u%OIt@wP|| zi^_Yi8I_SzvhW5PEQu6-$AiA}E^_PqKxvSD+|Z@m;DTzWWKUv7=9;(itsk+D$je>@ z61>V3%Vh*ur8SUiFA`O+S{l86Xe=7#Lei;iVjm1|df zc)c$ivxWI2yDZ<1vkW@WJxT8(d30-XPa2v&rONq`I{Wqv-~$${xzWa<_>*tkKwn@! z!c@%HRx>vw1-ohHCR#PXk+5@k{Kl#)XE=NWoA@(T2cAR8+(*|;i&hXOfqfPWv0d4r z8Z-|h87YUsG4~7@UucW}Aay770mQX2Y^qeX=$$fxZT!n@C<_<0-Qg0GFHCmCENL2a zwrbi%9gRdB)!I&%I|t|O*>Ef7cg)6^U|*Hp57!Yb>W^og_#DWF^TQcUap67Zj`nJ_ zThR8+bfpNnF1TCOb*Te-GIg{EdgV`DjHSfw`Cg(#+Ow*iIR5Jk;yZyouY4b z&ep73ewbF{fifLer}`C!xMhzxBU4}=2--^o>Cg3slT6$Ru*g%Z-{^B)e8Or=PAgE{ zeQT03NY`a|H)|UPY6^RiT`OFvO~)cFay#ZD#PAl@Q9G9e_x15Xljznp!TMFgB0!WT zLKjd?ya;S>=xU-%gDzYJaqPgFLDiBg7`TzL4cfb`k6ef#d=>=*`0WDU+g}E9U(WO0 z6fkW&grNu~6M=TuHGZ|^c+eiEZ`RXob&hEI-}pgrC|ECGm*LJcjqmM~uwE1we{*~~ zi}fywescL2G`>Xm1XdHamM|hJMhSb{uIcoY)gtGP;O;nT2sU3sPSN(qxR(R7{Eo6| zC4^NBDD@DujindkSU71naI{*m?}QFBM=&jcNdW+$qjHwVu`C)i2JMiKStccY?W{ZXdG8aRh3jE zBP5H-f>XmaqVRo)w^{hgbAva`M(e7Z*f|73{YWBG$t@(%7xQZjH)FOy;IeKM4f#1i z88nrt$@kUO{iL*1@iqVhSrg1O=13gr>7C-Op^7jl-b-tnLRXrZ7bpfD2E2&NowF6 zKc}vgY3U$GSjzVx zxaZ9e+^I`E#l9b1^Byg&zpl9d;(brMUt56sO5haTXkBS{pVmZzUtXC%NWJ7LXQ&EB(rcBwPpgG`c1di$2G>DGYSr)pXgL z(Mgn1fgv-tiVD@AbI-X1bxYx+h_GvH0H)PIxOaj07WYSgl(n>DT~{nEH39b4qXjmJ zD9M#+@QEe%7xT5aj#E9c;ll&x8QOw(Dn!Iu&HTqa-t}4!G1F-D5ef{4kx072NXt6d zu&-_>Q6WS+SQrC~-}*JEjg2vvsfPL-07*c$zgcCN&qW=GY;=PWJJef?Os(+74-Ey? zpTRbZuw5wvb#5sy*+-hh_A{b|q1d$0gCevLu1=pGIS7(35yDg>N zjf09Y%`J#-<_C??{Rws#CZ~_n4KFO-d_L3>NTrAfq(b+-_#U1Ivt=)8fTP25Mm1E;|I(zzv5>;At~@3wZ6XZ|dfp z9sLHycZ*O`2S7A`)wxtyclv35I(=QZw@Ns$2Z)<8zEy@OMAO?{OpZaIKGWBcGWe^A z3~|dpV1;RI4sIW8^c4W>TuHrQqt0AO_hmlIBUh_g+$F7MgO}Ll=R3p19HhR0kaonw zDO#?gWc^fZAVpDvk7b@^n5#xS@`moL9x5M)lT$@$3Wq+`1INcWt1rXIzpRzAef~G6 z?jFuT!(YQ4^c|9c`l`i}Wm$0vg!d5k}@(5DA({sFyBFSaD*`mix zVb2graQ?Hy+=EO2=FM?*y%2=^Z--yt|1TRlVj+3|1)B!Sl-I*f!D#Ggv|zm(X@t&~ z8gMwLNOn;vVL5})&yK^y>+dwk^)1_ZqdjhG5MC}b8kgl6l6te=3MRC~=V6^IWIWd# zOhUOzG+2~4ZYY5f;G z=|1iZ_N0e(F&yrwTP#P;MHns^8>Oqb=U7A@w(I;3D&=;K5zBhp7`9?Df$^%05`w2M zXwse4ZBs5b0?eS`POdEKfkQm51zak1gdlU(p<$9viu3TXAUJKM>Mk$eE$|H$*d$B+EZOkE$hE#xtP zwHWD*{9jNgq^j#-6J%z=<`_AElD(Ek{O2}n@J`O$ptROFFTgtd&!eD4VN3qJ_5``+ zNLl?x9M)f}w2^n@iR@R#{(_Pu;N9Dh*c|(R$$B(;FU(P1|50Js3`GXrM&uLg5Ym($ zCt0$K#RjsSUy%12Sy9B^KpJqU_X5y{9EPU}E-eH~5y=(O>eX3ZR&RMWK+`F!1Jn1ZNfLEx z7J#>fYg3Z-0m!S7Dg6XFx;4T_HOY)&qJtLd7|JZ1W6XH4Zi*bDV`69tHpLMCM8cRu zx17@i515AhCrA&NOW9R@lT6PSJX0(!q7RIuaGz_w*Z(f?lwDI46?SDwqL`162cK!&8R&_)H-m>(z_-VT zd=x^4!O(Zr*h0j4R(~5Bzc;2VSmLMB?n3Qbd*-JPE?52Hz3)XMZ=pRWV|Mq(|D9~naV8lEugqbC!r8vt1)VT ze>c4&NOM*$K+jaW@_|FX**M?Ih}TNb4HuYn8g0-du%*^p`ODjB5dzeE` zZkC|ir1hB+Q^FyW9V~Ls+tOa{ke>Jkdlt0G^z85jP#b)hE)wuEeJTN^aSYAfQ zMp$Lst9ir__MMYN21bn#nm06})ST zBQ`g09sP+`0XP@F(8pUJU2V?ZdN#pJwI5g9)3wKZP6&+6RbB>R?q-3U+db$xHg$e1 z{DTwVEg}~O3|74|1esSf4$N@DOm~umX%U$XV2P$Ck;}X2aU4C{E5PjX zfP;p0&WU_vjp;kB^?BEc$wbkqLsQM64pT=Eu#P_Bi51~WO4QUJ9{;AM9Gzb(4Ev&dp% z3Voll%;HP;SHokWS%~bgUZUd-P7YN{l*j)HlpGj4*)v1FX!+FZ3EfJ;>o8<6(&b~6 zTRG-&NKUcNN4=JgAVMhYBzhvuVgH1y72nJAE~`Sa?vAJZq*8)Z`4VHCXfaDQQ<|Yv z?L^L27KfZ3`|P66?dld}+eDEnqra}QgqVEG<*|}GWT zfLBKdgTxFgSOL~ApG|XeO_k=uO7KeE>cM?C;nHh zi6Flfj0n9}D3Db=)aaeUmF!*czF_Lj}mZ}(;5t)=olvOSIj z9mfjq{K6tKj>w5~Il(7tFo>%hSb-O>!B`5V7|*0|<)s{ckxP%=SCM)M69zBMsxO+k(c3G>q z59c_@C$)>kxn7BscGk(gL7xhxd*ekp>d;jj9BGr`=5T}`Lqop>FeQU+DBUH>GN~Lg zPs11oE#;YSzh%R)r6CiIL=JvzdP#079R)Cq=oMul>|Hon(jqRX6 zjOz{s6gFSG$Uab77y1?XMhXkV8x?c`8O%dBtr7jj&t`@@pkh*1bzoEe63)JgzG7` z3H`p)f70}iN4~&=0SUO}!!IjJs93rxp@uiBWRlgtjvPsH>JCE02l&GtL~F(JymI?< z=hk#;WIhytM!ULZBiH;(ey@Qb561s4H`2DDDC_vrLepmGkF>$+{=u);;$cqHU@w*8 zaqoH(ZJ)j1l0IIpTORlhD2h!#3Vj$5duwz|Jf$G_twVQA`a!l|e|KECpLr^K~zO_z?B5+h*((j5ZU}ao&bD>@MNw^ybj22gj(F#Uzs}m^_7Q zLzt(VOfWq7Np}A~KD84cPZ|s=my&KT&-F%IU<{f>W7y-VXV!V5XaB(tJ9z?h@saTZO8GW%1hvrobTUsh7SU zA+Wk0rSuLK$5w$t_IRwO-Q-C1`LZR*jIxGc(~SuGIyXb3jp5{62iFCbGbNEBg|aZZ zRsu#iyDqWw)&Y2XLx-0^QviZest)&CaT#HqrA=hu)9jqL8vwv9z&`0}O=jNc$t+Hb zX0Z+3uKTU}`;l7SQGAynCB4>*IYSvV2D7M=|6l83;|>V=Ch8Q+oru~gzR||2r=-@q z!;aD3L{}T0jWxy~_gl2sf!P^MLvB17;SW7r833!yo*YnYTU4`lKJd9nsgUwF)IS^# zlmNGpkeKar!T;ddmhO$c#hWysDk4&Yaadwe@QK@FtVof0GWoJz#PE!KlCX}VE(&eY zZ&6s|et^@N9sLsM2JsI7-ja4sz_8bF4?5K}8uv=>`rmrPpjM^~>BCd)z|~T0)WLvp zCZ41G;MzvyPD2l5{SVyXhiURKv^HV4ySS1edYpk(WjTpnvTwJe3^>tB)8a!xe?w-P z+TsezHQ>%KD4k8K1%S0_S61?T76{`8SdcQ(S5cTJGf_aNp%h}KQxy1f7R>QWmIlK8 z^|qc@1Cs@g!kXG;ylTf^k5MAk7w_tUD=UO6G-}C#Cc5LDOMzf^X`WWqd}v(dt&~>u zCqh=n3&rs|51L7g?}eyY{F!GiGcRAvP4qyjwQY$W^ok*k@?~ss(u-A`F{Vo26}Sts z!Cr*FXS)G=s69{j6)Ko2{&3d9hsRUAu_OTV!R_o^)`_0UE@{$x)kluk7{V`CR2AJ1 zLhYZd{@Rmz?8s&DFblc@y3}i&Q@3Gq@Zf@dTH}#RBMPqsHfO+mC>*2eoi=oNuY2@J zWdoQrU1?g_!>vf-^dB?1Ty-HGjh-dBTSE$Rebz>}v^8ijzdt4!LGQ4beH{w_wdq_K z5Nc9ku*72dDSi+|u@= zhERm=a>FuWjQ;Xl5nuv5y5WqI5{f~%pWiIHxgXAXRL=`UX+^^u}o z_ksc3f`VQL6o5Vnxtb{nQcTUwvoc$YV`P(zl!w8i|pGzo!I_ULkT44>D zDsLA+ZZ}X^;Y$`NK5Lx;X8bHk0PIW{-G@%#OZB&tEpJqst>Ol)7~t5_6?Kn)BPh%+ z%p@620OUVqs0RtDy9+Fk3sE_UD30&)G+CB=trX9BbX$66Kh4_3@ADy%DC9|Z;qPyq zn?8SJs`ZjR=HG3|@}Gk^WLn%7!#>7c5_Ce*JVbpaB-`JvjLpsr;PmiYvK*MR8?XVw z2LMxKkhogHK9=>Yd_yjj3p2E2qBhnLTd}GrAq=&0n?v)W#FX|rb5lc9Q&xeB6;khVo6Nv-*Vu9%wP{law(qXgTk3xjRO zdM^Muufc2E{@q^h#l)=;54>Bttu5>m*)PxL<43ERT!jGf3I{bVDZ}i~gGBy{$N&At zbQ`BbP|jkg?oaZ}=7@uhhkKTWE7@ekxm3CVWLYptH{hoWup)>ZFW&$42$Gj2AR8>J zO21dT!fyBWo;N@w&J9GS>ZWF24wRqeioi{hgy96k8Pd>SrVH}|8A!%Rb?kL~y-}xx zw%7SqP8l&{xDF`FRL~6M&Oh4FUqo9O;ZgZ3{3)~5N-=<(!B^1kHrXRw;{+EI zcc})9%LIsXRQ`z(j>4IIdVU3%G)@9}AWFJcu{h>zdH)Frq95NKlf4R2hCPyM$0P%14>QY%Djd7^8)ByBo z=_EqTXW5H3Y)&!C0r^QcwfpD!w2z?FQZs3@T^>?xKHDRae$F=?S{YD!#9B=JV)J<` z%QTHZU&d&4C0M_Nm=`%&kaak7FU`9nCCg3DZoEaxmNBE599Gihk4EpfCo7=fq*N4=0yy-A-522vKnOHF>;V zVpQb=0h7mhY9Yl>^m*YM$hcADeH}}A@VNu-0!DKQeXWPhG{h=Zjx@)~H|+rsUG!)d zq8}}%b&Vf79X|v(-!U^le5pG+iqudOUw9 z-rOytMev{m)UIOMuuRL*syh;lFkK5=Jcn&iHoQc=QG)mb?`H8rZoPp{y=v`jDHMs#%08fM+S+X4)q10<2ms%dnhYx2&m!Po7z#R zCw7Jc(DwkpX^78xMCX#W!(7;k?gYVEgQ7DTZv}s%jP8ffuo!voR<|s?bX84As%>8H zo;UYh^KKQTG10M?2D2ut-$!4y-Z&IP=q(}=Xqpdh%@+b~E$kixX8E(douMR=Cp=@k z=)VpQJV$R+_DU|DizfSpi;!NXG>qdY&HJTapdul9B#zF0Pm5xvmV}){k)(JxTB6x_8sCl7sRBI#JpuA6iqNm0C+u1tBueu_r~)VnW(w zcEKVf-LEU@$WhSl*qPAYcLs8pV7CaEWAG8TgHgQA_;*eZgTmg+%|rSZ0@UlhuPahG z)3EQOtZt4uCyZ^Y!pfqqAY${AFi;mEt6bIq+rdzj$bE(M&U<+_i*C(P`iLIqCD|v5r&lNKBnOAYwB9(z^)vGjcu{U!jUG4G`^{K={Q?~?M%uEr| z;{44l;VW6|K29mL=8E+K{OyObB1VT3cV2(rw*zEgM5+=v!dD{t37wpe7>{8&O^r?0 zEJ)uzI+$89N>#74G1bdVg|B+~x&4k{9oS_tG*7c&q&=*CG3xQR`7BItM(Mhhwi`t< zYSnrapB}$Pz>es-vPDBQ(Gg2U1Um4Kky92O?`})sk52K} z72W$MH;SQ~J}B8eoWQBuagocl5)Z_^626g%Osfj3v|B+SzZuqwLqzJC>J2|k;3RALGIUxc$+>BJ>3s%XtDk@L7zEvF{}K6_0>ln-|tl^X&)~P z|27^PUAPe)aQ}a%7z%rx!_i8xILeE*(4hnMtG~%dMW(4j(1n?nv9Sy92*i(ogyO)i z3EP~~kf0z{&om+X9S*aM*s3Sylt+F(y?%>;!li;+H`=_1sS1|fB5Q2cN9cHD1Ny=U zVs2=*DO=fgJ0zh~Y>;I3(_`jv4aaUW!taJIJgWe~W(UCxat52kLc+(;aZNB{>wU;H zYa7~+Zm1(JV<9QcO*!BUHry#x4EGlfoIGx2%&Y6#%yXh613*WDEVr-l0y7QI+1BXP{mEP!4#aPN-bhLEYtm^fzRPkL3DDx)#;gd9 zVX#hF^fx#hxiM6WFQa$uwL4$WN>reWSK4RJQ~)(PipD5RpycnIm+>J|L97}Oq*5ef z7KewbYOK#tu7@CYg%mqF%L$Qur_>?#3|7X&mE^HV(ozC~V`O=m_3(lK`{}3cvC{Ne zQ242ZL_f{skr&oUEIBB6&z~S*Dj#LQWOLUxzE>|iy6UA_6Fj2M>}?68H)wlTqIbcK zPG@6_YrI!0rRg!Kg2fx855f0ROB6}17WFZ5MT+Ye&p#gVet49n3`Y&)!^h!Z$8#*G z6o_Haj<_kmt~SMD0&!Z0+c=F_;Oyix$v}L^f-7b{ZAZ*^xA1e^Wa-S?4*V_EU^R_4 zKrL}U5Qw5+7mcB>o+tonwrMIV-rHYFKLeB5>0vZG1CAWjUVzjPhnM+y##J*%=rm0f z^;8g+=+%c#N9RLBBAEE?6!FXYe)Gj0)3EvuJi6r6YrHUwi_C`|@AJW9BSbp(36568 z9pT3OWuSbA=Z|v@sDodrRzsx#NY}C#u5yM)6t5kC%tX{Z{J~?HL z27|vS4yg)|=>Sn+4)d3m(g;P1se8k9^y5s!Uum4(db-V*hd_sc;$u20b7%=P7WU&r5tk8rLa7qOt-ZPH@c+ploUuP}&@B8zzW-Q5 zV~rhiE0kYtHzQN}o3*Iw!#vG(t^dNtWFLyg*p85bWvR~Z_=Y*)8%4L#%vG>0D=*Cr zn-^pquGCsmYIgm=RH`P{x(=QfLb9nZBitE^F!CXN-I24`SI=Y}3hLlrw9x%e&IM7a zFPXoswk^QH1QQZy0eOm#AVB)yg_5R&>}b%*QZcqFSeeazGHQ;F=>w2K!ulOE#NQ~) zc}ePvq0hcjGIPl3BW#p^hI``xCVNO*-Z8R^6auNA%an`FWb58WWgYl1WV0;#{I6(a z>5P_kDUgyX6iIiOCV~>)sT8OphF!L=PE)s2E6e8cB^~ti*_g@D_;NHUyJTZ!QzVuo zK`W&I6BOUUjS@ON$5%x4!$0;Om4e9{ri zGeGRQ#O+rmA2OiUoqoub_?~%hmeB7oK<@o7S_})5?61koTh2rf+u3K27=J%M-?WC8 zoA8y3giO@sFtt(1GzQ&FxHqvPj3U2%2_@t3LJ5lzH|T>?qsazl57t`^Iv>0V1ui~j zqx8#DcQhPzakdvpcS&AK_Ha(U82G2%8y?dzlE1Fw>R940W?KM~+qQI}FJ; zBJDAV=fbOWrMD1Xanpc{O=D|w$2tSHDXW_Tl;aTm?2J!|f>68@#lrwtNzx}yUV>Iv ziJ+myOUHbI0Pw)8ToTj4H}-`EtCa5JWuxm#I(YCvUuSbq^_d zVEnCJU}W7G=yrXjez$1ZYw^|aRXW2LjyNk2VBLcEG{bZPbRRW54duOJeUren_41J zanTV(W$ckNl~OAb8oXGZp2Jk}XevwKd*4Np!6_Y-uhz=2O!EKVMpAd-pkBMiGNP$m z2rxKdmZ~|(d&Zpe?eBcDHPNhf>~;;aiI=bgppQDL8?QQR^!uMnbeO~S(YY7-KMH-a zQ$wN^o5h`~wX3gV@78+WWr3zn*QIOV;7na^{7&j=3f^!)%*DL`JSx9g=(ZTv?FZc= zHsVl=G?6d#Hl5ES)1orW$R)RJt+}s9^SPu1Dejv^Va_9rFiDpWABV9 zXhdJzAU_pgwc~)r1rE*kAQe+Kc?w~k`;+D!aXjKrWiMU23dwg$zyNfU$q4qB@1MuV z-rMniYM2{(cxT6?k|)rgaphLsUt!yNCz}JF??D3k$3H?nw20hT%7?i#S2CF1)S#DP z&61^Pr9}=9#)*2$;Q5K6ruUg` zEPyof42~R&qprb`FqrM1A+*mgS^xNp9)^r5%7S4w6-z2S!y?G%@-FK1=F0p;2{XXv(1dfk^fuaWOyXX({n1?s1kVwe}aq{2@&+ zMSZW#eCDPRFlJ!zGN9@8FZ_NRqLI9U?3L8CjBVu$qL4l$C7traGEkW;`@Y61XrV}w zFSNCjR>Y38_MvXy4Qy2UYGUn;58P~F-*oi4V+t54IbiLtkEj`S*=R<4Aiqw`)fB6> z>r%SeJ=D`@+-Z{}p!g%_h|JdV{Hxg~pqGxH2+tG~R26rMSlf`g)jnJ(_q6`Ac?YF! z%i58B4Ki)g!{8|gL9yJjJv=w2^xxn((4zE>f2Z_G>@RQ!@*v$aT{Wz7Ix&`s>8QBi za==Sa%RP*NOhXFYQBySHT1BsU*J6e&p{qbt!Gim78Z6pYs0x}Atc=lsj7~ZQxk|aY z`f;@qVp%V7z65K6u44p|108&iOPp9c2*D&W3=N^`y)A1mAARD-^#}pWVTO#veWg$Q zuL)a;fvrL03Jh%upM6TeUez!4Z$}n|IDMK(LDR52*FnM)qx|*7Ye0q_0%>t>Y`Cf9 z6!mp2oWJE)$e4qUT9Q2Xn;^d-`_ECU%!=I##L^^~L3>y{Hg_O;_Tgv3y4#cp`V32M z6rSA^{sBv%N6_3h9s2_`(1%6Zqx=!oBqCcRS^1q+q>n1~k{(uq<1aa)&!jwo@D-qS zh#>9sa4G2l;gDC8mnxJCx?$T+?-BhQm!ADSJ?H9m^xp=poWI=!D65`>V0;;tR$-QQ z#}Uh;xla<#i4^bN)S=xJB9+9~?LBd&rm{RixoYnPDaFco}7XWu=e-FQYmag`497Xlp5tcXiw=Wvw;F?J9;^s}u)`Ru$(EfiPO5V(U2YIZV)OOW0jhCd zJmq;8&)b%}yUf|$;Ap>luc{J#xZ8^Avu0?fT#(O3coThKixNn1#8^B^tttd!jpWOV zvbQFse%mg2d=dfT9famyfa*SI5|hmoeZ8hY%xB0C>$0~Jx%qJQZ#}+2DK0bjcT<`S ziMl{83i?*MQ>j_D_6*b{i=Yj1(&W8UzbcnFJSQ1HFg(1!9_4sc>!=JF7Ip97p zY-ZVsuwpM+`EBFcEGt6VbdAyk_oQ-`2;382Q=wnXSu;*sJ}3ys(W^4YpkW?RLK8Bl z5`eebf-)~tDv)HGAs*Wuw5K0%pBalpO!$(S?s@2ro2 z5A?qX3F$q#rr+3NUPIw%na-1c@22O_!(kExcmcQT*gnzQlAuarj~KTz!iKfN4k$9c zc8ajOAb7q}ubZ?RYDdXhjvkE<7+_CfHC8=4$=IP^H%Ks8U!=RQNgu9PyZKP)JwM`7yZOQp+iWMDubLR=? zx@k1k)D0O9qcih0_xvz&=TGqxhe=uE%EZr5?XN)~@+Ix!04I$G?!NH}mJGZ?z^aSK z0C3B?)acWYNVLHw1BB%MQmxy`pgsD~Wx>cwSW}y>e$<_nrg0O%D5SfIaISWf_i4I1 zBOQajn^B{PQWjj943itFyLte}<8}mu&=A~7_=nax%~7qAEgLskFBV4HYw~__;Gq78 z`~p?ZSgQ1uI16F(tfJoP6v5oy*6MnrD<{(p@t1=GrzPc>O?N#zBSFWNR-=+fNQ=zF zrZI&{!oXq^dB`#TDWZq5o#Q+0HCFpX>q7gTpceN1EXWA7h4b3A37OD1E{5v~Q5(V< zCriZ1Zh+lW@I4$w0>1gj%eHmq@%Y3CbYKA$+Yk*CEkuN%dPf(mePG^=?1mP0D&kRa zIR*s=M;0C7}I zheF#fL2j$4M6*p1vh7V5A$fF5woe{IqYMRdD^Ni+&J5}6#y83QGvh7b($wp|fcL?l zm$)D}2N4I}ZMbNR;djTtczsHBZ87H?eDvF46}2{T0S>r1ghwc;_V{*)$-_#`H3kGb ztDeiNx$W5YA3F*hiQ-)gUAk`hx@Th5oBNCEg%p>Bp(n5;@7n(`;G~D-)oOi{(|AOm ze8@s`9!oH|J0|E4r9+fhQZ}TpW1q|mJ<4-Wx=QwIlJV!FXV9X3&FLtcP#( z+73Z4arvTE0**QfZY5q)RqXp)M@5P z*Q01-M2Ct8A!=7xNYF%Sy&YG;PZ($wGQCV(P8wuuX89rZ_7*yp$r570sc>$sYxE4YKUc_S!v_Ea1Nm%iW%3NEi}nCw%wp;YGYlq>9WRDl@F zU31bMFUWmGO#uv_c6f@^2f?865y88n79amdcGMp{j}gQHx!FCg3;tBp?Qwk?=}pG& ze4E>ox*D}Ak(SO+^r|QQU=t8=!&G3hZf-}EaP)7~ z7s}tkvkw@4XKe%oPkn4orwB*;yBLMM^U6R;POJXa477f{2vY~wONc!{PhBSAhE;>$ zlx|tVX546kqn`|N>}lqZR{OQ5m2B>OkK=`2$URtEMERw#&V?psJo!_rYiaS!5AA`B zO59v+WA^i$KP3)A3yf2Kbo#Z>h`$=H`*ArdY083+;6fr#inwKhaSdI{c#aWVM<;2;LruN3XOTAA?oF2megS20!9l+`801t-1xue{vnJS z8tJoXpFDn=r^Q&wSH`kI^&Z5|u)o1Ha^f7ndvnFtcLrkc12`hgUHWcXbqp^T5_Uxg zMq9pN!OCtFl~EOf;b6uhuFgL46>%AJ<7?Lkr4NhTnyL9M9L-`1ty^+pn14pGWV7&e7@{ZIQ?0?+8QDWSTIF}${$vY{~ zZcpZD8)({C&jF_o)~`D^)5+tARy$Pq1+D`hX^S7}Z*~(|aYPcD*Uu+IL44LIxdNG; zH_Hqk$sBQwzwJ<=dP-sJxMj?R)PPbCsLU`ePV~aFx&(hsk18El|`^*#zmt@+k0Tn%o;~X4Yf%|dR)F-WfuMC_gzv= zZ>@XZM2nySnnGe2rsIA8NiZ7Mw>kz?}6 zCs&>3dB0T&^?Eou~!>ZxGCP zsR~CGeC)AzNUC^%Pz<&@1TL1SODi7P6lm(IC53q{!_53RzC}Zqk_?|V-8t!j6rOjF zWpif0T!z^4$Hx2moJAzzBHW3u`dq{cYa77kop)oGr&FD~??yPqVDZ zOHk_#1iHkseL+-Shy;Y-p<^0HVDQC4sM^5*)PXE$YZd0mg2{~yMpn8aEZ7>tG9PdN zOb@}q@OYxpsV%fhIKnO67oEVv`9&VINWS;&jk<1$`>&%I7qDnS21*BZGKJDr_r??< zO|E)(UT|O7bBI0qmR2@gV=;Qd@0xqeBK-Q9B^~{KxRHP6Za>0iu9nEXH!NJ2$Zly9 zSc$%*3%lvRVdYf5(!Kx;w^A#YI7NziS_`!xC2qtCJx2Gs5N(+0HddBbbixfHI|n~7 zVN<@gY1gC2N+Tm@!Y3e@s>{T56NOX+a+B19@joJt%{9u-7$2A$&Y;=;% zTJu(nRIGh~lt;B8$whAm!8EN3)g)pr{K;e&Ts|xXE+;$ZMvOwUPkN2cN_rtC{9Ci&Vg@ZS4=R_BgkomNQo9x&5+%ajq3g~tbpS0yZBwTPz!KX$FF|!7myTjB_5Rq%{Sqr#JGe5X7rm8TP-} zRrGX1f(S+5U7A!57k#?Wr*efZ%@UQH%f&cH&fd@t&zM@#{Zjx5RFyjFQ%1M}Ht?&W z>QHxd=Omy6e>|LTcQW_5w+*0Kly^rz$6s=3y8pt3>*hK-?)~Z`OAdO0LaF@}(<2?i zAJV~qU|ysEg6HvnFTPP`^5_2!{%w0P&tM*^5D!>*3g5UtDbE{Q0=s(!?1Tmp!Gm5NgtxI-A zEm!nXSwjMDCeDltX;2?JIma{{yGu^S@Ap+Lr*3WT4x!C$u;?1<(}zXbvWxuTBy@9p zePD)Fo^9e#&us3xO1AYxeTabHI;cRkx`eyICEekLp7Uf9o(iCHPAAgVvb1(haezv-PySYg>VT?JPa8i_y!4$jYujMm{s=)v z+Hx&km0MTG0xQ4?ri&Dyj8aDeb?O1k1Xz|rYOPd}vPhZ?j5 z9g{y7IaQY&l^$S4xfGP+ZK}kmp7uC-6T+6CL+qY!#w`^UfHk>aQtKt)7b_PnOTlsJ0Dn3`Jp@8y{tyTZ3*VM)~_+qgD9IAKZ zv9ElbrIQ)MlK{3rhAVH!;qjZQJpfSylw1%$^bGGpdrrjXUAL7dpe8(hkhH9g7=IqzS=2XFdf4|ph}rN1kOGjB3U65=c7d5X|oVN4E4Wnc)cWD z;|yocOe+fkA55B(5bPfoLgY9Z5-FhfT9e$$VB!qcF&^7-y`D5JQtaV<@^1rKV)jO^ zj7?e=NP71h>B@dDQ^J^vyKyE$4G^%Rj$*=o)FXq^`;Tw0HK0pbSPN_+2CsR~@J55+ z2f>lN3J$ghrv!FkB;_$B#*g9A7;_S8y`e#=cL1eQHki&C5g!pbk z1+p2WZy(_lCHaEqms}QKdP5O)f>hXzQ#s<(1Zth=PWczl7KQ6#Q1(O)Ue&=lvC|i^ z&saINk4itzUA9s9IHYXnDS^-BKm=_H6k#*XX=oCjFXk8$2GzGry;r)msp&umv7QW& z=BvOu7UkO&luv6FS$IKXuz~8}iGZ{Ad#3mLt)%a(%KkNRfWy6doWh+neeHwbY!l@^ zjT}cO&%G>b{@-R)+UL7UB^wkX#}?|qo?-0^VQcHTbv5mckF_gVm1;1wmyFJB>cHx{zcY9WekcrTFo@GjK#^LBim)gG6l*Vb z+92|FUJ{!sj1j;_i@sU$)1@SvTztkZ2;}?!z2m|`q2WlBfh@f_kyv1y1)5msyuH$O zGls;C(&!gA^wV=l{aRd-^OKAUP$KJeb!M2LxdCJnd&6lf=%?b}wc8aUMp=d^ny`YB zKFxy!rIzW#WELqhj;UyyJONi3>MJSOafcthIX2QeGLN#%nIInm8G5*q}bE zuVCqd)Gho;NK1{2DJV=6dMj(pBrzlG0)d99;gk_nNqHve+5qh0NoMuipz0rVluKwh z3Unb!QAWMk)X2w|S!=Z;!SG%$mwXS6nU+V0t$Sp8p1ckMvmM70>&63oV*L?xy`2 zcO1wq6j10xqmf%|29+Ut|?e~MD=3odd>)(>$8eKGvU+x7LeK}VGRvd znm;aB2Q@T2z4O;91zu3qGk^!SJ>uv(!QYFi_mCI*iC^-l6v{sKjNMGiOw;#p3M*_O z6EV#=UX{%cLKt~0y_+2)3(*gXN!DY*_Osn<&y?xGd2Xct7a=x2^Yp0zfFIH(Ss)Jm}G@k&XEHyYe51wfl&`t=7(*BQBW^z^H-1eD2lw>RrO-c zwlYkb{u_yf(2<-al+<&O=PZ$zI#UpW)6!338(=>NiaN;i0UZ}wN+74aQYs;oWykz; z;`gIzI4`;OrW9~~0-hUvPQF{rU_AedX?rm={g7~Th?fRp{xIMiN~E;6Sk%VfWy*X#?umKYz z<2CSQ>n?|tozOK)O-^HQ0xB{wh8AuKTSW-fUMFY$A6@B3r{_EsHHqVT8fyp?-(Oq} z!7ULfDaMCtWl+_?Pxv+k>)*~9o=E~b(5`K)!d31xApU5=tV8)dXgqoP;g97eIZ14m zu{`4wqXAlO(3uHg^H>gTrtu8Ts_Q=jcHrAs-(Dujau1&v-IWfz7X+P3(Tf#23Z>E0 zNViL@F*OC+P%mGY$qyX@Yyi<-yJobl=B+Tylq{;IHBs z#6~XM>zgt$Odj|r>V_Z-1|+)lhM+O+9TMcO-?kUay2#k6^*^hQY!RtrWuw6r7cz7_!+7 zHYRci(Wp=R-lr{Heiu@nWkSAglLe`qI5tTkwtd7jhODYb`VUF{rGo-{K_$PV1<=qz6Pie!P-jKuYpHM#RKo^#zFIp$sel6h{ZOsECSRG`Fx{h=R&>sNys3?BKYR z>{(c>;(l&trnL(z5rsu0RYjdjdK)bE|E*LF^U(q>(`=6b720)aXuLgN9D>W!{)Z<6 zZ75|L|5rT>IIR1>X&jD7G0mxM-WJP1S_swxV@jhaWP0bge`W)y2G)U!&-3n-W?fm2 zEcHv_Iqms9SAx1{Kk=A;JlY2qr~Fa{c1JOQdetOoGV(i<LF?o)+}B!dR4a~~6vn?TWoG@(tAmf7=>J6mYik=CeWc|C z9yQM_%_`<52AGag1Hus%OLx=gByM!b#Mb)-3uTcU6TX$Zg$siuaE*lCF!WN6=>I%W zv{PR^jH-MgPq`Fr;fPt!@H%)YlChr}Vx5KeZ7O7_x4xS&?)H?S&1m&L;WCxw$N9lb zj3S2XvLf=@G~RiEd*=Tw=79*vo&>>65=8VBa3Jr9m*FMY4ROu4cqD7&#Gk^#xqs3k z0(>V}_S^af8yC>k!J;CkzW>fF~Wq0T9BZa;O)7TEuldQ z?q+K)darCqA0a3NMsRCt?t;dcIwzB4RiB@@X@KXNgSUa)iJf@QCzJLaVEBWWSuiwU zjdatn&%Rs$eX$SOjHXlZV7Mc{-`vVmg^o(E8E7kz6r2+{QqEZBRW4iZbD4ZvMe{T#9#5<7D)xaRbRt}+Dji^$OZw_l{1S(7`162LcjC`@c@URv@e5%L zSMnQ%cJ~Dg3aybT;H&f6|H+_14o>T0Xrq@-=fFHgT#e84BT67pX+_cG$^B8#HFgT4 zB#zY?CuCZZp$VZPQUeI~L!B7m2Sj5d4LFrNC-Nxz2~OVjMNLa=5YcMkEy|xAwgt(8 z7EA_$H=CS|Xx>{-VUfvzi`unki>0VmKN4d;(Hyw+X4@I*fQJL z9o?&bvRJ1jr|HrNf{4^9qJ-BBFXwvpP&mLmNDNB=Fct?$_lO^$Z#8;MD*sHlg)3vW z1?Em4FK9^JT>e;isO7l*&dt)+@YlIU)Hb6z_^khr<3ASlG>=!yudNmm*o5$!(EPo9 z{Neglg~EPrd0D#tlj^E`|L5(l-DLGcW}ln)W3g*zV{Sbv9LNfWg|=|Fim)~K{~);n zMc7wJDOwAY-4=I%UxI0AKYTRlbc*^Ph%hX5Z555)plx3n<02V+(;W2_;cVES#~^W_ zuO{Twq!yFZ1(H#<@~}K`@%+=^55pF)j32q3#pk%LUE1bR;X{IivLJ1T-}y{&dEc(R zN8hi}UHzXTf5KAsH4j};7hhpzj1hXenlkEm#)g)RS&P}ln50k@1zl*}mp^5X>3G^e?VL#oaRQncEr^3m zh@P_=|2NAA*TGN8?2$W>w6%){rIt1Z?XBt^zqCY^8W6uEZ#Qm(jffEP__Qx-SN~gO z4Ls}%0ogbjn|f`}+66nT_QV}IL~wkgtq^${v3e#y1n!}ax9)gp5#9vHIkMtw>IA*=ciQld$a6~aoOE+sHxJc_iglC2ZL6TB+L+p>x`Cd+A^WoqNWiq zBA-xxV;1RZKCZbfV%o9-xosI@G|p0C0%*Bs#a{8+);GvdpfEt9K&alcH}N9I5WtwG zX5bv4!>VnyG*hyC#biWwI6EVW-?TEAVV@$jwfRRNGTo!n^V{mF8Z%{G+M;*8d_mo| zIWc)>j60@3!{+6*Mkgs-PUIz((xq?ruTuh`aMx>p>w`3Iv#<`Aww)q^!a)brLsm%q zdA`Yin$4iFDF(Se0)#X5biOGq=WsvOZ@J?QmIKm(13$zhQk7f0cL>$iX3vhKsk$jh zVIBeEV~%y)s%;Yc4jc&7JX@7)JlyA}3=S{`jiRI81P)LswEG-mo^+|yy>QF8n{n_5 z-)a00Bp^j!xq=W0Z~#)r=JELXs;p5R-~a)Ie?~?h`hM-n7r6DYMKFpZ(Q&z9Yik@0 z-uH}2Sx6n!hl^=`OdXI?DYVonUe;q-0;rbBxy9!Cs_nI~w=u0KkK(e1(Mq@11X`F_~IGxmTk}V?q#q$E=qo0j`9=#A>Y`{*lcg%+xCVljFUA^zUEN708$1xQ94bP~ zM#9e7t3{NP-9&d?l>e9%az>cUKp$6xBi5)vH&Gu1!hhBY}=m=l>f(e3Rf#z4yx}ghl#LiN~QirfozIc9*^jC{WSQm+LGPQ zw862s5Ds|GOvT8^h6-2(zy%^?j+iMAVUJHU9?(xa>?erczb3qzB-CjWn<-iBgeX!Z zC{^L*P*%dkMQs*x+lA@zcfq(20Fw(m5$piq(8?;CYERdIJH@Kr66qyTT-bywx_Xp+ z)q$jT*Y_0W>8#>!L%@Ir{Hv+Z-;TNg)*tk&y#W&GHDlB%6-8tyR0c`VLERNN(+V$Y zdV7Tzdi9Fn*~9&Kg3b`!eyFq}}=Z0oIC$t%}V; zZh{$`&H&nL@04ucC2is>0klQ8?g4GYkbyU}E%>k-nM#Jl$=tS7}VOLzrz4Ev4qEX5a&_q~UMxT|8LYDM1bIPk z*B)L=O2VV~-=Wq{I5PY}tQuW<5za#iw$UOCsN*xIouU$SnY=H5`4pl0CSaf$8u8k| znpzDD!nF%&FNEQ#F~#u4(Y4WUGwa_(i2m9O{e4bOEy?HFH!(djv4IbWuY=t&)VH49 zgy?C@fx|&tLjw}!Bzkd?Vzu%smxttbz<~Rw41T74Ju%rQIH#5|#;~W7P5_6EQ9lq; zB^Xw?H@n?&YWlJU-CKZZAT6m!YwfkQW(SH6vDq(;V;-^P-`Er%0L(w{VzJ=N+;?)hEFK-lA4?;!$O`#7c9HE(n;+eny zc;;cOImy@!|DRz=LabwWLrRHdSUJ_wQ1rIg5#5m{@w0z|$eE~YZ`1l=m#Fw|wifhe zTnPuhzxc=i_)UJ+4!!0cG^*Y-xoBN=V(0nB#~iX(XIh?%)_XUdQWhe9O!S!QeDFWbVApv1B`>GF{ zeTzL+PqfPeCyOW_qA*C4ll|f$X;&Hii}@=f{ru!5mIv+B1-$T*Pz0V)67sb#p5|eg zApuaGUd*?pJMv*>sQ39knoY!49F#bGJthagAhWSh1za!go$EdkmUk^kiGu#ks)4c* zaP1ZL0#$M3#%uGip^7mXOs4%k&iz0?L2whpkYTBqC#mx72+w>=v$FWisV3AY>M@;H zD##;Li(KvIl#)^nfFhlH&knGu!b8q%^;bA5exxx82V#0Wu zRbva@oIqaqUTj^PTBT}RhmlBr-mwV*8J*v(@%I3mvH6ET@Sa-dOFJUv1C8dd-LKVK zNF=eylsOswUNT-=2R3~o_-025atlFrJg35U>Tl<&tMtFlzlHYe z#PZQb+1OX=Zl7w3#XrG0l}~Es3H#Pg8xYF3HB03;mYk7pH9j2;m+`5Kg4!N}Q>K)o zquFL7J3ORxuM?vSdsmir8kP&kq=v57GGtWD5{)JC1ucdh{yyr$8HQi3moafyI~^w{ zQ@J*pAVKQ1>N)Grj>1H4K^bSuqV9m@PkTyY9;SP`VyztUnR=qzfB}7f10#$c`jeVX zxPfewj93(||G)Fbx=-UFj{#neW&IKZAn)@Te2(T1Ub&J4b=v^V3r>uJpEV?0xoOVR z5~&)ZCFY`@2I2@DsH_ekOEkDIlR;`2S#CMc+;M07`a3#kcB}_7>Zn44OWgc}B8l}Up!H^98zA0OrwH3{=DJgD3O7*SYeTdOzyyW!T^yh5w`d$E+jD5W}8G436vaI zCg%-?{IMDrK@3Q9tTkmu*6sJDjg1Fujda<8Uv9T7KEl7Q|LRELLzL0S9Yq$O<;_V^ zW8nYB@nL~|B;V2*cw}wkJ<-C`3CavQz79r1=?rgaA}X<1aD@z*e_7G>z7FM+!I+M& zhm23lSMyFx+g21egaYKQMu{jMJ&W?N7P@~wlghAPK0GTe#6e>K1yh&R*1&_I2ycv} zd7!M`Omk#04`Q4&xiwc|4CzRwU(%wz&M93gVo`X(%ah7M-U|>I5Y6BW>}rZ6EPa+)L$_~A(Rp|-q{HZdK-GfL`=Gb1Q1BqM=@Df zSN-do2i9P}Y1zTXiNAx;q>18?opAoCM0(K?w=QHTdyXVQLFuy%!5@BS*{hct3kcie z-%U=UPp^0IYv@;ldI2mi(5;ja%pQPsK0722(M@i_X_%T^y2ZH-UinSRRoFrC`tTW( zjM|&3_il=lB-yubumNNj;DFg@l8u=&sLwWyVDeOOn3VB`{QoaRRO`*R zl=`ZV4i2LYlePhGt(2c%@NsK;%+a5ff!CPr^K>OR~|ujpw2 zRn&l3_P09b&w{;lWK4gzEEy;wcYf%M%JK10ptzD_b_1BuOt$`*iS{WFsL%%3V_|x? z2~nEyauWIxPbP3R*aQjJ4Yc-knQFnf_ibC-NyuZ{?e;c|o~EC`6cEnwR_Qn8wmta@ z(707j*Q!sGNcQXGq4;JdzL;lnE&Ur^aY5VH>Uwe2$KkWafSUZ@QhpQ;g_Nt*Bx z@*QKQ{aBbcEzuW)Xm@SEw5|%gS!l&?OQ6)hYF}STO~_>U_>vsRbtrYzch1lHc+I*X zZ1Mm-jc7&&h7Jf)hl`VK-mq#fT(uM7_Vpk7wzrp*41Rwi8bKf;m*E@eRn1NCo+-6xDY;WQXQzfFPRP%+rctDM`A+m2yKELSJ+e8!OoF?m_t# zG#%>2{t*9xPpq2dxGA$3&Aj|m4VUs<%v8_Wu`0R>&o=FJ*^i9lbnG#&+-mfs)TSIgxL_kdQYM^YY^sbro^%s z5)2<;%5?0W=M#_Z1N7(B8tL&7WrlY6&vqo($EgINrwFdJ7c;Rh0^vyyv6)xJ*uwwJ z=dzlZbd@it8uU!FFUT$8$SdEY86)`B;Cby*6D{Y^WG5~}*`a&zF3QAI2hyVu6~W`E z5?~RgA}#F}n3kP@yG;@+WC<^XzRPpMM0m8x?e>~o_I|7e@1yR*j^v8%-f`)tkDwjf z?~|d7A#t0zbIi&US+qXr1YXbX(6XK4%JYtTsPp)JVk4|V5>{Ot5_~AG34MZzh=L$# z6c@lXWdNItNWNv?0OSA)5^8YLS)IySa>GMICC^@zF=w4V_G^WqM(>a4ckO>^#)ubX*uW&fSBYY3abbCx`D!{`8zyZ1qmEtd8H@O%2a!fYksw#~!P!qy< z2(Zs$n*r@OPfscvk->&k4J07uNL{HB7TOFTFjFzJckdXYrq`JZL&O8`ZY6pc} z!tN?UZVUn5ePD%m_Of_kD~_th zr?5+Z^y_>zsv_~eNa#f!OufSvBIb|@efX3l3`gK$Ubik-+&pba-|nC@1QERrg+WI_ zuO}3p1b(Mrpb)~QI%?O)Pl$fqb$+`>&bG)RSNN?}m&iYQ=f0u2%s}&FN7kr)7lPdi zXU5|AB6~fem3F@N6c5G&1iadVnV1*nH$37Qa4;=Hn@mj71Ond5o`FpI)9*1+p2@t} zHK|jL2vZjn2eP1)cNPF3=RSM~0wmjnI>&Vu*?9lDAw${`hXbLd>T>u@_nWoJ)rt3F zKPsS^s+^!PUBXlOxXQc2GVE!VclfMu2hJjn972)o{kbHax}a|lRJ*lQnYy~=^(#_g zUrBT(eRS>QnL@H#3=&~WJ67RMe8pHrDObfmj8|AWj^~)y@QV;8iu5aRB$nINte#Dy zx?Jme#rG3hL0O%E>8{@q8mZQ-bP->kfKoZ2C^n}mdce)r^t6unqf%Nda0$)#DOSzR zZ~O@Z=|Ya^`71M5-JAyUfWsqde+@u&>P8G4PdylGEGNw^bKI@3Cd~oUa;nJ^>U-ag z7=cWFaOmCgK69M&%D&{m+17{OLx3(|v9BuxtM94v3HDw=ODr-qaSDS1i{q|H(c+qW z!X~a!6B;9EA+i_vNJJ@>#tle4S!pOWtc&a@kFvyH$fhLxXHt$060}ff=gON7lbfp> zjkWZDA|c&58bc!drVYj3FZ!P@(B%BtrN=GwlsJD^m+aL9Oz0+|k9rS{hxjN(~7HQLwqUaGmZnd4E3(i zK4R~=!iC&~lo{3==KtrcVn8opC?K!chtMHqtU;}1?;s+^bs2Xhxdw=Fa|-^*@o>IN zQYAme>en-Tj$QR?*~xTbV;fOY@O&c^_&zKzO1V0|b^kDqamtQv{gInDnu!J=AC9p- zn62Q^_K~)%(8nX&2()GTsg!n;+NW}!dOVB zH`;EVLMm`<-Z5>~+tTUabRG{jD{3epX+1_p2aJKubLU6`N$s;-z)q1h+OO;geg9^Y zPu4i2Y)d6eh>f#nmMr|wCX^y&H3?H>BS%g#^yjcm7J6~mo}%~s3!8zniew!M)s9xz z^8@ytG+-+N4Vs=X`(Ve|QBtXGP4Ut$;*>BDO)V5*u>v&cg4t&$i}u-_68i~W$hAy5 zY5n18111^PAQ9U4%^^oUwZxNYPN?ZGNU0WQ*}%I;#czBg@eh@hyq>>fvdWQ-mqS#y7M#tMN?1Kw3DzF#|wO8Ay40_D* znlmE?9tQ>{qPmojOlyhyJQ)6mUwVN|cl96}8E=P?&xgsDeqXCPb^#-E7yk-ML~3d8 zXVz9&6hgJ=W}sTwD7McRn&!c?p4Y!%)lety(|@>6X!9ivx?2`F7W|KcRFn)@kl)bW zc(hk{ZF!6V6^NoeY4L`r^b@=|{ZmwuC&6p5HYqT)F(d>C$MPm{%!@4ekEcWOo+^%O zFg8<#`<9@jj(kzAW!q*W=oW?toS^i*z6Frs+brD8<-7Ct#K`0Y*2bPc>##d?^iH>b ze>@UwDWKz{XI#@mfcuaZFKvMa&nd>IV|UD_L>A!S;7z?>ours<1eTg3t%jyzsNnkS;x88QYtci{~<$FA)r_GUmR~FOHORF>9xUWMl(DZAypU2D4dXR z?ubAYxf-|-=S8z!Ue&~J&ubbF+;9nw>p`4`VEQnwUGO|A6QkZZ|I{ZBS>&Ip znhYKsW*bu(OyQo%%r-yDoi860DL9kZedcdxQMo2L+!EMUakKD{ryKV0m10>*@rwl$vIY)da7Wvw8GeEhFjadFct&I7)k*J4GN0 zP={}C8wZFcIvslIT*T{)B(N}cB4UsS=xe98EXnGG#>KW#*Oe`o*JT!_21u1iA;Ngy z1F?Y-kEdGL+y|;>oB>xpvQYwgGdUc5I-&8X*ihOI2p^DP~ft77}X9`j~1s+ z1H$SRB9+_7!&xg7(Jmy_3Wr)QfeNoPiOFbO3!vi{FOf&G&Yhk&6wJf8Vpb578{zV= zaLEpGS7_XdWA0|ydyjH@Hg;#dw~+)&YiLHm{*Y(7g5&|j#aicMrytFq11x7L76rie z!Kj?L`v)P|w?wgM1J}%zS#pWn)|8?19oS#UO!MHSU5HewmOI6M`0TrwntFU4W((mlaxBo<+GXZA+r|Rm$3h5O2G&!GVHM3$ zDQ!uk3SkrvRG6bQUJ8$mS2Brf7aT)8;RGsxUlg&tVR#DjWc^@igo-srUI6Y!Mmm*0 zh`T|IcIPH-+vsuM@SRD_3%qXUKyN-TvtP=n&L^5s!2!Uo8=hRC3GfbEErC1|9>PC; zvOzn-gDo!?0yVw7^ufkdNinJ#>b6p~<9c~?B+vy(3hs6^tqL=wz^-TJY8&w#`r@v0 zmF}<|s)f{_p{{z_S-q$AEC?WuvS+5E@}TTtf@B#+IO?A65 zB?JT}Akuikg8XqFy!TUsTk&1vNr3u;%!eOr#$a@4z2C|-0Wgb|lo*fOUBFVL8qc)-I3Gw$xpq);`%A!QZK?Hf^#fLEHR zn?@MarvoZzY>*j>d!;d=H~?qr`VV#J_|p48Ac3C3ebh&WMK$p!wTGjC01UZg!2#Jc zg9TuQeQ{L@{u^dcA@+xjXoF_NmV@Kit2DoF){)TkQ%+SkY^+Xlx0G$<4gKM!5iWC% z=QkWy4i3XgY)#o;hi+&RTU`sXDatK_@ELuDT#26lxpK4iOh+POLC8DI%V%1}POUV# zrjK`gUBX*@L*NB#7Y(9sfz(?my=EXejaX=jkRrzm#*Sjf`o=Yq5!Y`6>Vv%`-_70SM#( z041ISn1;@vzC@ox9%^q5RzbHTSrRk27M6@!T!legQQJJ`dD$5jKwGxhR~ zyWP8@+gR`@>6%jd@~<~WQW((QJsp>Y$BeFRLiC*TcF5`{k11v5Y#C?fgrK{7{MX&6 zT)QlB;PmzTxSL?MPDqX28&K3Rz5fE<^^?jVF% zu1s-l!uX5sv7T$TiQiE6wGlnNX#?n$tA-Q^S6{y%3kGXw4(XRZb4EXehD#N>3{kiW za}h>K%@dNNzZC(5)J9fnU)YBLuI(Q6_uE7;;Xa;Zh=u%3kvaYj40lepq)&0=J2%z4 z375>Wm3U4X5}@20D;#gc_u`$`!g|G&JzNWYDcANiI3!{?-`DK1?EGU~2-1(;L%*zR z5CD&9{vZ@9OPPB^SDMcZuR9@{$jK zdrC>UgSf=tLWW5QJBs6Zx0-rT!M<`cHL2(3t~}Ck zDk6-I8lE~+ zQMJix!_(hiv1JY{*!I9>~%IBm$$X_?~^JYL4z9X#<+h7(7Ia!(pfXU9|CXG~h;eP~- z7V2?UZ2X?yk$-wq1L1N&-V_Vm$`K`CbZT?i!3NuHQ9!yf;$u4n%>Lfzp%Ol*hip$w z^r(GB9^YDS>prmzRe9e_KhS8M*0#McY*vPM(e5GP!3P8BQ7-p;WrY^ zlquv4f`+8wtO`B3;2IzSNd^DKDb)rb<(Ho&iUWKS{1BtJ;d(KGS8M~TaU^B`BMe`z zY^wMODKOl!4&PK+>fbr!|KM$f=tlcb2KWpf_nxLT`tP_}F%9`*RLKLh`coGZsA!5L zg6F{o<}r%nP?x84shY9_)B^!5WGiDhV>|I(+Oi|Bp2(|5!);e(2~icna%Q0@V96ts zTz|=~u`b27P=~orq1x$@LwwDr%Ct)?Y>q9ZJbV^`ZB5aHTmBuN3YcdP9-z1`6ECHh zgm@2eG(ku_Wq-;lZYng>rocjL{Oc4P@e85?7JwDs4Mhf?ktKc27G(usZFH z0sP`5>Yy7#&C@!|U#Oy^`KqngdvqP%1Y8SlThNBJu9r(STiadwwvP+b4nlRGVrXF( zRYhh)62X;q1{LgK`CmqrJwDO>q5&#Up^NrLp%Erzj?l)KJlq62Mk`;ecfTdGik??- z8J_=$xkwTn@|uwXp}LoQ7zSA|Z|y;!&$zMLsLD5es|F7_^GW#;k)M0>1{IzBI)YY$ z(Vvw-l+3((WoU_ZLoF8byyBDiRx!E&Kjj%!xv_p$Bx-|YycyvwqgoX)9)r4|M6+Wj zHlyt#U-ii)yR+==988&?A&C1WQ~7)U=bBMjf`={$dBkyCl~W+MAfO?@_V4=>3ZOb& zo2UBCIo^1h_QGJR)kckRwE-6oj~bajm;KqQ@{c-^cbNg}u!uulq(wt;gCrHIZEr^9 zx$frJ6mr0bEBg^6PgwnP_q$x*4CPKU4vi_6h^>7c8|U03yep6sX$M4`lhTX744NV( zMTG7+yG{({H_1&C$k=~h<{<`W8$o93!guffs((uoWoEyx|*6NJT8O?RfDj7E1Nzt5*U}(|R7z%>uemR%HKgq(3 zMShzRg4niXKdeRTyQ+0KC!%!lhLSI`{zse`Y!ta|YEjOv{AgExEQnwcd5Ba)dyr;S zJxnxpq**Nxgr#cTi=eN)8JYW)1WILyyI`rZmZ$V-pD6- zfzvYxUYKyMx|p1uMQ&t3%Rg1KA0^cgNC!1k;~T<dn7j`F?#+S?M;ouVBw=&I;=BL=>Zh9A|&xD=u97ejMX8f-UW}>epL_&=^+N~VbK8cavmA|qzJB&n{=I7@)72h+fx&t_tf*&5% z*+k6#GImINr}1+Q&4w+;+~xwt?Q*0aFhgON#%=>d7*;RxI3Jy(#~MekXLl%e7hhlH zBxorF@5S7Mi3?rjZr=FuIyzJ)Whl6N*qzt*clt5OLr2xBlGNCq!wY^%cHY>i`hh)P zONxl$l(X}(L0jjOl&@A-Grqp);C3{8@i-Fr82^z0m{IVL^2aM2KjiK|f+)q|%SbS$ zDj@00I8e#RY|R+WK{*Q45gm)x3CLoyv4C|#@DsHMWO$CVcXCZmt6#jaF2(6$-;>j6esnG9L{{^GL6awnkBzCyY`vnuv~$qW=tt|b#8`p zwRq$KT)sVhqE{Up-|@iA3Kc%l^bU+c^yT(If$O%L;9=vRoh!#QJw&-PTu6S-mU5sp z2$IcG;GsgBCcg6LSE#_;cbSp7m~3qv^g5K_X2FJ-HqXQ(80b<;@GJyxG(FI8@YQ2* z12ao>4s(vNjl!bYt{3p=SEt=H`Xf>D%?-VN5vdne;k)uTl;)eZ5L=vSs$&4sHnLhb z4MCR9SZW+<7tOBfO<8`j&s8>x&D_M?WjwIVlpJ)6J=R4>lK{y7BwL0R-l-b&i7kCB z5?i{?pz5UI*I=9^o+V^80yDxDLGRsT-QLI`E^&(Z=-J2&%Rx7AfK+RcnA_hk_&XnKy&Y#q0tc6j-+?95qN|5*SA1Z^$ub!@VhZxvk=alrTIFeFjN5>qg><5{H8w_-64!>7a@!aE+3m#EQR(Jxd?zl z)2Uku=g|36d%drgM>9&KgRML!soFyGkL~0`g6wUv0sp%1B4`Vjraq%&HOIWuQ!{O0 zYrzTiiXz=B-E)DQ8KOrXecZ{7{UYA9;H&vFpj;UUihVz>%;>mW1f3GNl$XEESMIhL zo<}Fl?mx;1iGki-g$Wkz!~B_0HSCTC0vaq9d*jtUxVAvlo($#eO7p#Hqe_4&YxBbz zW!4fG_2%Sf_VMe-8j3}<18#A3Jl2Fhw3;_{7^m{G*m2;3B5- zr6dKfT+UPjq*b7CnjAUDpAZPRK1Wd$LhjzfnflXU>-r0>*ui2$$k{`%>F%*`(=)Zz zp6_I|I}ML6Z^`qB0tsp;lSY2v`mcwR@0$V$pv@?a#tWNKIm|jUY@C!A2%-}Z;5f-S zi~M8mpDp07vkwRDDEi4G&?lf&GLKdRrl{qRcEoTtNSj-g<^9t5PrYZh7j%i^$bt$`HOV42Axx3@G{mcR&@E}O!qB%tSM1`O z{rQ*_V!@Z0VPmkfe)K4TUX5Au57aK$g7vTH?yYk1biTB`BJSW__sP(@m9O5zN^tQp zvcN}W;bK$>T(vFuU#Ix_|9k@!m_)dPkCXsd8u6dZ-Y&4V7xicLg3s%#XWMlo2Q7Vk zrgOpqE`#kUA%^W8CBx{r&*Ch3PAswMv+$QObOgS=oEsK<1`(g6_9mKH3cy3Vj-z>L zHEdj(o4Ve}d+RTiz{nrGW>F45GEY_oRxd_kf_QvKBGi*M85IuF5wy2Xz9svNgMmct zCLX(TSIxj$kOg%$jbKl^U+^>RX~r+6v@V**mHh1Qrg#)d!P7+TgHO5_VBdNOeWTF+ zr_0d2;WGHi8I7Qm;*bc#(E|Dqa_(LLx_}^J<-ipk8_Lkb1=0_Tjb2^83Ib(H)HHuO zR{b+$vcGr3q>sJgt&(>Q);h}J1X3#*e@LjdJGwPv-OJK6XDj=ypb2%|8H)KVrHKWl z2rM^G#S2+nuUpd)GKigkMB|U4w0?=D2I4GDU@4;m=*T5!y&o$|EPEnWsw?oUcE74I z8WVjuEsD9Dg9IH(y^DL_(LDkWxL22{9ep9X_UX*Jr>Id^u~t}tVW~_L{~atGAZ6A2 zD$y!$T0Ye7OzYpMP2m1a@vudL&B$rLM#OzdUEs1pg{gA!A6EV{o!CSG4c#lao#kAF zrdg&cjQGLt_HpXggra>ET#8RWt5%a_lR9P*H7JfdeG@iR{Im=#-mcD6LplSz5TYMc z4?z^{^E1zzAzV_@YV=atCWLQxCUg^^7QVX6M6Kpb0-7>m@I9tjrVjE&VCKyR`Xoi~ zzgZqS_d%w$OdO}QsUX=){U^Z%&)oht7y-x>EbnwAUvV4BqH?{9v(D^i=1pxMdZm~D zP<2=zY&4;Wzt3iby-Hpy&dLjzBbt}9s9ym`t~i=q(DJ&>8K)Ct8-0a7eacgtA9-B^F+np{QM+xeDNt_J)+Re1dCTUCOqVH(t9&McZpV*Jt8 zi<8)4*;M!yQp526ki=mKWH7*U%koLr)f{}u%rICTC@90zCRR@sui}OW=*Q8zorApl ztJc^wlAaE6k5C~YJjR$7ix?U&QKIVOaLc(hKgMG_g~~T!h8}RR+nFYczCJ{eM;$Tg zNw>zTfKoB+s!yQPYHs|>wwg}vhjS8yH9r0~V8~t$vsSr704t_*$I1=;6Hf6b(e!qO z?1UU4YD~Zzr6L<&IjDS^w z&)&;&nAdy-%lc5k#wbwIyDNRiE6-u|o|t@IWoU|hTC|i%r6DEV^GCM0Q$rOX%g#_L zJ^Irrw$6joO%w}i*~G|l%&~g12h$FR{2|Z0Zavci2_vo`G?sV#RjPLyf5$OqLW|QP zjFSlWozFzA3M-3+PAXz3`)=$#(*^t@sHX=)BluTD`}C+gDj8|WlWeNES6BxeKUMtQ zk}c3){SNLj7u}$t`HAc-0l`&jm@)|sqI%5^OkF=Eg(Fg0@>vGsv+>$Gk{&z!&+eje zRAOZDu*@BHgG5`skaX$RXM8)I<4Zvb4!>C~Z&lT??R72aaqTjvk15t@RBIP+5Gh!}GA^;8QBOwqcvx|+*1PM#{0L=Rql9{VlX{18 zc29)1wL*!aLp%Ky^=U3pm)tjcoSO9WY>47sGdFt+t`I2VQsg(j&%5&9yzCk`RfO#@ z{4?@LNHozHw5{s&aTSj)J({4mD}jhjbo(v4b5*X|uc1Z#TL|em2TP=FavL3T43rH{ zTh2Mu|28II_2ka}xqvHT4j5H3wAS?vi6W|9Hai5jusd$$Qm1z<@#pKIi^fbi+^pkT&hU(aKXW#H%+S*uAns)d7|%Y1Xeh_NsJ4e$6nNluuA--j7ru4$l@t zbMu}K!KhJY3%5AnF@wnSya$q{=qz2sv)~QmjyE}F=R|$xxuWdsW7n`z;n5&Op&+tq z;TypPDhTOa1`jCfe1X1fc$o|ff;+sYgPe8A?h*^Si{6(5Fw|1RL1NML_I@`F3>}olTusuMdK5pX7~>0+TAT0BbT)e$O>m39z&a? zlpspBk-%r;cAU%0c8|zhC7UbtR;}Q@|EHh_Qi(X!J_Zp~Wp19QcM9l(p`T73cAqeP ztC5Z15C|FDlxnsGvf>jg$9>6CnL}{{OtcWFObSQ(d@_(+I@-I@R~v-<9lzrw09At- zY_^5MlSkrDP-ud^3`SN6=bolAB<$*}wVI{AN;&q8M^>v`9K>9hPLRiGtL4zi@8yAm$Djn9sm#SAT_o(AK&yHmFY1f>2Q~q4zFzZ(lP<-W^<9 zYIob`JPFIA*=m~1Sqx1!^M1`hOhOC;W4RRZCy5GO3QtD59%%TPk# z6+Q>YlRJ{A>MQ1Yvmb^`c6W^!E~M2R9C1*n@~-zIzhID^_MN3>+WV|v(n>CdrB4?@ zz@@3kUJuotY?I-k4ay~T3LqUDuH_8-)*)0s4pX9C0N_J>e4qr1JrDKJ?JFRgywuxN zU?7T=564$aXL!ML2;Emk#DnUMlmua1DdXt@jSD^va)d_?dhaL&QN{GF{TW!%glxXL;6#)xlGG&EPfZ$;I>~(3Dc%9I zHJ3yS&=D+01lqQ=v5E&abQ+dBHd(-Z?{f|^>YdI!>*_GDdWm)AM+B%Pnv>2_g~w27 zYNq!v=YEb2-CJ?fCNL>aglF2f1y{iUPN2Xa?liV2yEH$W+`>|~m5@0kM1laxx!wG7((%q|$wQubhsmN)e6HMf@F9xI z)4x^yt81brp=Zoi5c1~|z&xtMix*H9&{bmZH7DmlEgMEeV9F=)z~(jePyFUXhulI! z?w3v!h!+@y&7e+RXv3tjpPUHCuFsEAdk@D>HuYW}KB=l7XAdHptU&RMB3XJ%x}3&x zdBnq=jz$_YueG|H!R6eHpXw0I&q|kAZqI69GJgwit}ml; zX8~rqDPIhmE>L#`mA-qVxH{3>lN|6kGrdXIqYvj7A18^e*dx=kD8(X9=H(0BJWVA8 z_{<8AsOd(Y=hh~-VqSxpcE=onZDDal-_8_qzRrkoPNXCBfU_tRuuEr?!Pu8#?XT=_1>^Zi!)G$?Op!rI%5cetJp#Pu zsI=Hm1aW{|P{0TB0+?y3gYjP=4xaPC{i3c&)m>8f+Uibshu4;o`VorB;}e`2k`4cq z2RKaaTzeyGtmXn?2;SnuBW)T>Tg0IHJMl-enaLC7kCrIVn5@m}`@g*BuVlcPB3^{} zTKj>0lI-_xTHOc&_mSN)bWetvg+FSyBwy@60002H-*fJP1k%VusD}Yl~R5aF=O*)a4AIXsKE^ukAd#!t(rypL8HWh7@XIT3PcRa5eRkJ zt(8OuUw75DppfZ$-8)iz2$JIQWJJM_m;dCJS5ko=rtiLdfas(O9w^=;{38Tc`T{*6 zH%7lPdD>2`k8zp<_{kr2*1b{PH4ht&%nBn!0)Xg;EFGfQ>wZhzXt~gWcg02iRrrdX z8VAIcOE`)8hb?a~E5O)^F_en?aaf8Wq^a?fpQ!CH17rs%=!bc0-5WMTW*S8P$u{c0 zat&lzA3EC*x(!bk7a~T|VfVASkrDeilXOU(4?)l9Wuq^iGx86u&r9JOv%38_u++o} zR!7w&GsL5Z^fLaTr@a*#heHLx*D_WO(yCr~x0k24&@S-i#mm$g z*rnCw+G_3EAvo)fTxmk{$=G=4XnKUGsDL{Y>99dVd`d3(2#LF%K=lv?dr#fs=9|{Ojh?YMF?Wz;w=8kYC$(ANsAp8 zIGTKNS*FLF9$C@0UfH6He9QXr;yVXiqXl-}F>>#yx@woY%0 zl>;oT6^)QMeTvT(Y2xp3@L7jOHBZmggJTRLfsMz&hpl)?>83oXo$@jK4!!*;` z4^yfii8))P5Z8aeFep#nH^BPqya%k?FWw<5)%Obve~ z6*wJ8t+P<|HmDNw<@FNvYhqqEe|1!(_$q*zO0hORNd0QxkX>KJJPX>|`S9eS(f_Qs z!OaYutftEgVYhq-7*n$bUqOmqx^er;*9vr&sZJIOggC@hG2AHQ;fH{r(vK8T}*&>Hc2 z<#sx85N=J8mENz)t28o++*CCT=SjJN(XYvXrPu#XZpC)vi|(79kMgohsefZAci13D z((YqADk+3Jt|d|{??*S{dh{0|Aq!0dOEFG0;DHG@Sf4HG)?HHll^)i zZ;D`Z7S23QU45xMC?5_Ti^gKLR2Xwvlmtdr-eQf4qP@~K1l~pCYC+5r)Juj`^y$lz zkTV&8WH@9MEWB_!!!Y_t*m8zHy|{|kOWniSU4KpwDpx=NtR=ZP(xvLB&cWgGDJ%;z#PP(%z`kJc;ED`8x7&wyPpDNyAOx^f4uDMS?JokW#;#; zwxRcN6L;njQE^ZHr_-P?U*4$quiWJNNSPD~Era1qpQ%AD`=jsSrWvrgJ)Sy8WK?Wi zjap_S?xVn5!PVkGQ+Q3(T!UNrn} zMHcf8{P^BNd21_}W?ShuAVHQ|*L-^3`{%v*h^lMTL|HBP!i8Pi?v5W=j_n42q|jAVWp* z0~y=?_AO3|QR?~xMIlR$Rc5Tu5mYf?mNy$5n((YZ2bV+ZzdBaC(-Axb z4nL8f>ZX$Ojo~rDK&zkdor;@c9TMCE^S~VxG{laQ&(1L$1QvvGH&Fq3kl*JdG2I#G zF=bNPPxU^w(@~Axj)9u^Y$p%eY$%`SAT`DO7uDzr@O&nDEF=>eB(dUl;+6}fty5fJ zR`cLRSo_@EmSJjYD%|9C8x`I^YFv`3*}Z)XjbDKvm_`FkssGTm#%)E*!H`O(XM9Tq z^$iNVP-53Nt}5Qiy<$}_WKgOsOYSJQqUBUVX-&Q*;k*(7<8~L5Nj$3N{pdSi?_gX0 zs`t2jo6^w(tD&sarZ!jltTm01CZJ1npPUzz=isHyoN8JpM(E#9*#e?V2X^0k9TqZm zw*Wpre6GjOOYkSsz^o_pIyvKQK`bWMRiX{rTgiXik=z7w?kOE;3QQ$(6bUu8s5JF% zPb&huvchT>U(c$Z5pC5ZI8EhxS_F6zTXnKN2SAZ!jj0C+9JhJK&R>j=Hm_HUm13f3 zdM#}G!U&gK!u^^5*E7uv4L?KSU2Z5?7LtmQX^s9TlLEybXKfC}N2Ldc?Y#yr+PAkC|4I2gOtK|3oZ1Z{qp z#B0Z~{i4lUzvB?5$j8YVA>NFtmbwWY08BHURW50%NB*%pIjaWzXC9R6aWJ$Jnl2ry zs4Anuymltqya-N&Jj=R*ZlALL zBL9fW&v^Z|@HMn&$GPnUS+Mn``(x^9so}3h*+mR}H9s!I{F-q(J9ssed|!y{r~(7-MBi6o-O_K+nhTBP>lG_{i;R< zRu>IS(kM1a5PqoE0gz$ctnmAo8bMC*R<@1Utlt?J`r4+<%ffm1oU>czbe{ z?y4a6g6jC#`h{F>DHbMwhZ7ZmnYd*M2#S)>$}L4B!^Fq*2%50yz@WejNtU~t&dst{ z)#Q{k=~CrrB-5Z`(MinCdja?QZ@fTX*(V^%Ap_bvdA)b9g=$+zve@QysdH9=w)sr$ z&Y$P=ARi52dwgA=L;KpT|7J(Np(b`2hRJ3`S)nw=E3X4zyQApl*EY zk&vPQI@nltlZm55M?q5%#5B&`E%Un(vC*(AlP!;#-E*VF`HnvuM8?k2hXFXYO1 zH~SghM+aEtdW!^ME23PJIslzIo%rWm{g^axX1`8q_f2NG`fIW;5ZO|Rw{C8X_pzu# z?v5n9KOkaMbLTGIMxRN%6{FY!RyoPZya6|givEC`vT8P`s zs5_#SJkwIw+P_csoyOfBpyF|Tkr#B%knpK2?^OB5s_s9xLtZ8ES}+DOL(1iq)@39_ zd>y$a__h2%fu00AC7pcEUUkdY4@v<3N^EM;-}xNG-p%=fK>ZRz>-2IH4piDKng<&^ zfzg)KQZRvD-wyO<#yD@155mrESTa$9sH~3qnn5#7`^XC|wV#u~XPK$XVEGp}_ zfV4#8N-A?jQAD5f-x~C6LTf*%gVhntva?}yAM$zBRxm(UL%i_I;LHo$ld##{poLd_ z8#IQ3BKL5$N+@p3RLug*@j5U}5w2!XNE`6ysIwKHo2$d3aLhAL{O{Uw6@N%x*H0e$ zyr<}n%-ZuOvK-#XM6rr`VIo^UGRmNfFN0QX87Kk;`Kf?bj_+;zZ~ zM0CNgVXf${8ZU{?Vv#1f)cALb;kSHyDns?Zk3 z=QKHuglHl>47PK%JBHZ0THcH3ON^!8uNLzUe(J>hN)aY|EfY5TvP>?>5}%}pnn+`- zI`iIHN-~`!F#lb?iL;;@D61Lw}=@r_Db@v_qS?DKi2(I z+lPnlrf`+GO>D&wzliIAIQf9sN>F?j9_Lvb$tR@3(+6fM2d6=fUUAB^ z^23omK5V~qnT%a2+jgN4_iNdbSK0lDdli)-*0hX+CsM(%=28=9--b8tCQX$3&p?8{@L~pXBcm_=8se8-Z`A;cWBJR_KbA?rV@V z=Y!+luaT;p#((OF{4^WJisrLFr9%91V$4P;`1LkJgBS1*^(WLP=)l4uA@?_%C#Sm> zcTciZMI|gWBfu)P5Thsr9t_U+rpSJ3>+b`=ZD?M$|0m%RPe(9Wpct{|<0tg%X1i?r zS!b*YVU0x^4lz31b!+HSOEzJQ)tR^mE&P~1M<8QWcygve7Xaqd>$j80+{K{tM-JK!jYCP;1cC;$OJD4^e_xrxkukTmxnW}qy*k$I>l)p)5|EAD3 zseT@o1pofq#u46GBJ|wK7}H56#awt*IOSQb(NmE_5-vRAX|=93o@;q_%`pUF0|~__ z@i@I0l$5E^UHgt^4h;`*$Fr92_=_@x_}83Mjydqe`-wv|Go{k{-ugThL-##h4u9Y- zc-BqzHVEq1Mb*{2sat<;#XP*hAj$K!-zh9)JjSa5mt^Gx#+wD<0(TEE>=yYCv3xEq&@GHJ2(W~N{Y;l^vO zBb?^`55>sp7m5Lhi{@R(QUTP;nteCx4N^Jqy(`lPS8rjB+fSOE+OF`ngGABqGP9CgURzQ z0hT2W=ULkj8z}_Q1h5Bpnr$$7xpB&#&o51ME1+m=D4Nnxb(vVdF@Zh=7>+w0TmE}8id!R*)yL-zk$&eX3Qhn7z%Q3vlGD*Kv&HmCBC7|)AkcPaYca*8fE=})a~rIW7M1*I13Yxh2SA|uNB7dj z#DXywDx~;v5YJzm(e=}FUN65?j=b4zwY(i9y~*5W_~s#9Y)4+z`MH>87BUY5$R5>U6D=! z{4P>y3t}?`az_|`L8pT4m2!W>7NR-B-ffobSAtxN-gkSKIILT-szino6SJU5*p7|| z61ndLr_eKp)kD4r2+a#y#x^RULgHPin_Uf6s#8HEKDVuyy?4cb<|?lv2peC8dfehV zW&yFRW>SM6rEV)N{DQ5}OHw;WkhD&o*hnbHXddz@4HC^8wQYLpKaJnm?YrbQi7nBF z1URJrMBaWlX`3kgb8VD%N!Ey-#?gyeZ^YFlteOLz@pj75hVJD#vkS1+#)w*@x-Gi_ zkv{&j_5(xvL}tCj7if5sQR~1^V-tWXe-^&ST))r#ZhXd&LCfK&pp9X*Z{Vx7tPULj zNkdic4A+!g!bvUwGgyY1fdX0DVNwB=?uVxeze=1pjI9^Pi3$S^=3wOx^kToU>jh40HK=&`iT>m362fU>SuTrl4XJOQB zCL~!4^lyLs;unWmz?nJ6h;u-mbP`?=D?FE5&AS=!U77w8*OX>7m&Tnm;u^x za_RkhX+?F2LBvk<^37omegzDxR8(Fi?bO`gZmy@D0(kk$VA~wp?uCdl4Gz15>mOdO zOC^fkmPkVi`fpTsZXdmt34sZ!*kJbqv549~z--?zyr{$S!mC;Aeg(tfBtmcmh(no> z3CV9?xWi{vIf>dPzz0;7PwZl{C|5tOXB0n)OJuB8UIKloc?hwD_#TWWSnij;`kuQffk#!!je^ebImOYG#5t_I+dbl|tb8DURbZs^ltE z-~mL2f_EM)h4$Aw_)9aU!F?tT%ArD>`f%ei`XW^fmUol#HbpARTY?rA$U3M4?A3Z> z*>=+U5?aiW6?r((S`so}|kOBtYZ!9;_bFLf`@(T9!xy)qrhC^wEDg z?8`8e`|R)Ul6LzfDK<8eR|or@>-YkC1bbpm@uQ7idr^Zne6q#Q@9t_9oMKI)I7;J0 z-D%Z`QFpmIe8cY}@4_^YK@4EWvFfrDgQ3J~-42woi7L zpXcn8s+~B!sGEPUTxP~40@%PsACJGx20P4 z0}m(i4-Lh-sB_P2S)AupuKeFtwODctvlRdITCTf5gh$Zy1?Zvv;(%!Yoc}1IeTyv& z(41fYFS&6uO7S%S02Tl=80`X5V{nrwyRLgu%PF>y5UXR} zT#6KF&1s)!kF>?g<)r!uY-b&6IUK?|?k<>uLxiP1sTAe@kh6G&c>91ag7DjLa{q!m zg8@{W#tdMD|463(GU0wAi&(XPY8fc{Cwog0rW4I>fq{9%+>Nc-dR;G*E}#Lz@f`Kv z;V^$&?>*!E9lu%7yZK9R`PyH(&S~XsQA6P=N|OmWN}lU}MV+AJZgoYcZ>|WzJ7prw~aYr39Si}c-0c^akJE1 zpLPTGQR#gIKFHZKBlg6=;pCk?o`Ia(9Fkl_y5mC`z1p1VN1rsTymJf~Cv=fk*Hg*M z#_Ie4z$cwU?46G6!u#KCtg&SZqzdP#F|O590PK|P9$iXDFQv_=%aD~iOfjosKiI9Xt5mSpw2BHWd(h6>pG=W?&1PyQA0MDttjR z6msKi`*4U`JCyFQ-J^ zmtZ#|$Q@OFemKx-rVfRnk47j1b(u8PYjeZNneKN`5iKQVVgmL@_ii7*bSzr$M&z{? zKZ^)G@!=B_w0PfxEo(?ive)3qk^b-ly*$K>;^xC7rkKM9$qAhl10#}?d^}A}ia!e` zq!!flWGoLLY+SL;h0L-JVB~?uHuS^d{4HzoLirr~KGt|ST{1m5oeL0R5d=c%mA4Gu z@>w7NMVMt}r$WUF+;JI|&*l}W<-V)V`!1-Mr=+D)J8|p+xrTJ4`)M($kf=EHhG6@! zxNNwVt8?~eKOPR@mA)&;NH@t#(!}ALWv>WwU})g1@*T27>6e=R@c#%2e%CZ9?XDF% zRGD^&fG%oSMRL=9QHEucDdPJ5zBdHQuqLafJ3f&B9Wo3CR?_WiWu+*3co?Y&FV`q@ z7M&^pjKJxRO;7*;0v^Wx6~IXYYGCFl-Np1g%sE+r2a-yl8K5#WpBVoZN1B5pJjjap z*oi4>)PKmytKnnzA>#GZ%94_ zU~Zg2Jl#eP$Fg=8O$;t8OGLYk5;cfK7x2V8!Kvu?t zp?<+!?xt}doL(o$EjE54f=F6~Tc%B5P|QOC6_NU~Re7hZoTHGkna`!>LM_iKLkMTP z#UKpevVig{g2!ebsJ=*RU#T}4YgMVeRI#zoj>6u{aaL9KnYivsh+f*|H+i(*hh;vO zE}B8A5QAehH^w$)^9HaFil3iu^Y~R>pLzAF@YPe?XJ?@7aA&+t9_J^$J#Ue#W4`S? zZ9X6)Swt4A`;nM*9;D?jsr_|BPr@#*{rFUP(v9){1z8guk9B)E=|uB~uICun)*PcL z2<~wDA02gg2P`wfho8)mSN1@*uD)sEJ z+GLPx)$bQjbgUU-1&0QCQwxeE0{d$05G$I+8oZzW2l@_Kp;UGaS8s%bCEXA{fQa9q zS!m7V6avQU_O~0kcX;=q=_)jagos88D8p$bK0N53D9kSqqj=I3&icqQG1?1xX_<4m zo(96FSrQUebz)c%(8Jh{Y5`5ki2q}0)F8Mi7bxaviFV9U$M`V|)$Y2hKl-flq*9|a zoa&q&)y2>7h`rso_`9*@9k7ojf@KvVB~kZmQw?{YZdDm1*f$01VH<4*BrujfLS*D; z*cq{+B(^wF1dDguKyA(ociBeAtJw;fc%C9gle%194mWR(;N6FdoIbH#dqf$gyf_9m z)Jvi>qE-L^0WUBB#(>;bug%ZvpZFYY|IEF=fju_E0XQBBz-p_NBa_ z9=)ALYU{>IGea{;{s^NiF^Em5KM4{l3J#AzfKmuAxb)Qx$w*zy?4k{WU6lIg zB)6*SO8v5{wtMrfT>_y)xi409XjY3olJ^((P<8}I$zdBbE$pBmgWMlu^s~BXyt~VBKrOQbZ(yPaQJ`r TPsiL+9%6t102#qV&;S4cR2Ujm literal 0 HcmV?d00001 diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 5b55a90617..9674789d43 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -11,16 +11,18 @@ import { WebSocketApiContext, } from './hooks/websocket-api'; -import { OnboardingContextProvider } from './components/onboarding/OnboardingContextProvicer'; +import { OnboardingContextProvider } from './components/onboarding/OnboardingContextProvider'; import { OnboardingLayout } from './components/onboarding/OnboardingLayout'; import { AutomaticProportionsPage } from './components/onboarding/pages/body-proportions/AutomaticProportions'; import { ManualProportionsPage } from './components/onboarding/pages/body-proportions/ManualProportions'; import { ConnectTrackersPage } from './components/onboarding/pages/ConnectTracker'; import { HomePage } from './components/onboarding/pages/Home'; +import { ErrorCollectingConsentPage } from './components/onboarding/pages/ErrorCollectingConsent'; import { AutomaticMountingPage } from './components/onboarding/pages/mounting/AutomaticMounting'; import { ManualMountingPage } from './components/onboarding/pages/mounting/ManualMounting'; import { TrackersAssignPage } from './components/onboarding/pages/trackers-assign/TrackerAssignment'; import { WifiCredsPage } from './components/onboarding/pages/WifiCreds'; +import { DonglePage } from './components/onboarding/pages/Dongle'; import { ConfigContextProvider } from './components/providers/ConfigContext'; import { SerialDetectionModal } from './components/SerialDetectionModal'; import { VRCOSCSettings } from './components/settings/pages/VRCOSCSettings'; @@ -50,9 +52,14 @@ import { StayAlignedSetup } from './components/onboarding/pages/stay-aligned/Sta import { TrackingChecklistProvider } from './components/tracking-checklist/TrackingChecklistProvider'; import { HomeScreenSettings } from './components/settings/pages/HomeScreenSettings'; import { ChecklistPage } from './components/tracking-checklist/TrackingChecklist'; +import { QuizSlimeSetQuestion } from './components/onboarding/pages/quiz/SlimeSetQuestion'; +import { QuizUsageQuestion } from './components/onboarding/pages/quiz/UsageQuestion'; +import { QuizRuntimeQuestion } from './components/onboarding/pages/quiz/RuntimeQuestion'; +import { QuizMocapPosQuestion } from './components/onboarding/pages/quiz/MocapPreferencesQuestions'; import { ElectronContextC, provideElectron } from './hooks/electron'; import { AppLocalizationProvider } from './i18n/config'; import { openUrl } from './hooks/crossplatform'; +import { UdevRulesModal } from './components/onboarding/UdevRulesModal'; export const GH_REPO = 'SlimeVR/SlimeVR-Server'; export const VersionContext = createContext(''); @@ -70,6 +77,7 @@ function Layout() { + }> } /> + } + /> } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/gui/src/AppLayout.tsx b/gui/src/AppLayout.tsx index 6e0aee1c2f..ab067c3a97 100644 --- a/gui/src/AppLayout.tsx +++ b/gui/src/AppLayout.tsx @@ -1,9 +1,10 @@ import { useLayoutEffect } from 'react'; import { useConfig } from './hooks/config'; -import { Outlet, useNavigate } from 'react-router-dom'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; export function AppLayout() { const { config } = useConfig(); + const { pathname } = useLocation(); const navigate = useNavigate(); useLayoutEffect(() => { @@ -28,10 +29,14 @@ export function AppLayout() { }, [config]); useLayoutEffect(() => { - if (config && !config.doneOnboarding) { + if ( + config && + !config.doneOnboarding && + !pathname.startsWith('/onboarding/') + ) { navigate('/onboarding/home'); } - }, [config?.doneOnboarding]); + }, [config]); return ( <> diff --git a/gui/src/components/MainLayout.scss b/gui/src/components/MainLayout.scss index 216a618bd8..ba5070afb0 100644 --- a/gui/src/components/MainLayout.scss +++ b/gui/src/components/MainLayout.scss @@ -14,7 +14,8 @@ 't t t' var(--topbar-h) 'n b s' var(--toolbar-h) 'n c s' calc(100% - var(--topbar-h) - var(--toolbar-h)) - / var(--navbar-w) calc(100% - var(--navbar-w) - var(--right-section-w)) var(--right-section-w); + / var(--navbar-w) calc(100% - var(--navbar-w) - var(--right-section-w)) + var(--right-section-w); } @screen nsm { diff --git a/gui/src/components/TopBar.tsx b/gui/src/components/TopBar.tsx index e150064851..c87b500c10 100644 --- a/gui/src/components/TopBar.tsx +++ b/gui/src/components/TopBar.tsx @@ -22,7 +22,6 @@ import { GearIcon } from './commons/icon/GearIcon'; import { TrackersStillOnModal } from './TrackersStillOnModal'; import { useConfig } from '@/hooks/config'; import { TrayOrExitModal } from './TrayOrExitModal'; -import { ErrorConsentModal } from './ErrorConsentModal'; import { useAtomValue } from 'jotai'; import { connectedIMUTrackersAtom } from '@/store/app-store'; import { useElectron } from '@/hooks/electron'; @@ -72,6 +71,7 @@ export function TopBar({ await saveConfig(); electron.api.close(); }; + const tryCloseApp = async (dontTray = false) => { if (!electron.isElectron) throw 'no electron'; @@ -81,7 +81,7 @@ export function TopBar({ } if (config?.useTray && !dontTray) { - electron.api.minimize(); + electron.api.hide(); } else if ( config?.connectedTrackersWarning && connectedIMUTrackers.filter( @@ -234,7 +234,7 @@ export function TopBar({
electron.api.maximize()} + onClick={() => electron.api.toggleMaximize()} >
@@ -286,11 +286,6 @@ export function TopBar({ setConnectedTrackerWarning(false); }} /> - setConfig({ errorTracking: true })} - cancel={() => setConfig({ errorTracking: false })} - /> ); } diff --git a/gui/src/components/commons/ButtonConfirmModal.tsx b/gui/src/components/commons/ButtonConfirmModal.tsx new file mode 100644 index 0000000000..59f430566f --- /dev/null +++ b/gui/src/components/commons/ButtonConfirmModal.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { BaseModal } from './BaseModal'; +import { Button, ButtonProps } from './Button'; + +export type ButtonConfirmModal = Omit & { + onConfirm: () => void; + onCancel: (reason: 'cancel' | 'backdrop') => void; + + confirmLabel: string; + cancelLabel: string; +}; + +export function ButtonConfirmModal(props: ButtonConfirmModal) { + const { + onConfirm, + onCancel, + cancelLabel, + confirmLabel, + children, + ...buttonProps + } = props; + + const [isOpen, setOpen] = useState(false); + + return ( + <> + { + setOpen(false); + onCancel('backdrop'); + }} + appendClasses="w-full max-w-2xl" + > +
+ {children} +
+
+
+
+
+ +
+
{udevContent}
+
+ + +
+ setDontShowAgain(e.currentTarget.checked)} + /> +
+ + + ); +} diff --git a/gui/src/components/onboarding/pages/ConnectTracker.scss b/gui/src/components/onboarding/pages/ConnectTracker.scss index c44a973331..36acf82199 100644 --- a/gui/src/components/onboarding/pages/ConnectTracker.scss +++ b/gui/src/components/onboarding/pages/ConnectTracker.scss @@ -12,9 +12,9 @@ grid-template: 's t' var(--connect-tracker-layout-top) 's c' calc(100% - var(--connect-tracker-layout-top)) - / calc(var(--connect-tracker-layout-sidebar)) calc(100% - var( - --connect-tracker-layout-sidebar - )); + / calc(var(--connect-tracker-layout-sidebar)) calc( + 100% - var(--connect-tracker-layout-sidebar) + ); @screen mobile { grid-template: diff --git a/gui/src/components/onboarding/pages/ConnectTracker.tsx b/gui/src/components/onboarding/pages/ConnectTracker.tsx index b688619125..7202eede4d 100644 --- a/gui/src/components/onboarding/pages/ConnectTracker.tsx +++ b/gui/src/components/onboarding/pages/ConnectTracker.tsx @@ -26,6 +26,7 @@ import { connectedIMUTrackersAtom } from '@/store/app-store'; import { BaseModal } from '@/components/commons/BaseModal'; import { A } from '@/components/commons/A'; import { useTrackingChecklist } from '@/hooks/tracking-checklist'; +import { MoreSetsConfirm } from './quiz/MoreSetsConfirm'; const statusLabelMap = { [WifiProvisioningStatus.NONE]: @@ -317,13 +318,15 @@ export function ConnectTrackersPage() { ? l10n.getString('onboarding-connect_tracker-back') : l10n.getString('onboarding-previous_step')} - + {state.alonePage && ( + diff --git a/gui/src/components/onboarding/pages/WifiCreds.tsx b/gui/src/components/onboarding/pages/WifiCreds.tsx index 992ec77df7..85493d45d6 100644 --- a/gui/src/components/onboarding/pages/WifiCreds.tsx +++ b/gui/src/components/onboarding/pages/WifiCreds.tsx @@ -1,16 +1,14 @@ -import { Localized, useLocalization } from '@fluent/react'; +import { Localized } from '@fluent/react'; import { useOnboarding } from '@/hooks/onboarding'; import { useWifiForm } from '@/hooks/wifi-form'; import { Button } from '@/components/commons/Button'; import { Input } from '@/components/commons/Input'; import { Typography } from '@/components/commons/Typography'; import classNames from 'classnames'; -import { USBIcon } from '@/components/commons/icon/UsbIcon'; import { WifiIcon } from '@/components/commons/icon/WifiIcon'; -import { WarningBox } from '@/components/commons/TipBox'; +import { DongleSectionContent } from './Dongle'; export function WifiCredsPage() { - const { l10n } = useLocalization(); const { applyProgress, state } = useOnboarding(); const { control, handleSubmit, submitWifiCreds, formState } = useWifiForm(); @@ -18,34 +16,12 @@ export function WifiCredsPage() { return (
-
-
-
-
- -
- -
-
- - - WARNING - -
-
-
-
+
+ {state.alonePage && }
- + {!state.alonePage ? ( + + id="onboarding-wifi_creds-submit" + />
diff --git a/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx b/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx index 0d28b30266..a80b2a6373 100644 --- a/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx +++ b/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx @@ -185,6 +185,10 @@ function ImportExportButtons() { parseConfigImport(config).forEach((req) => sendRPCPacket(RpcMessage.ChangeSkeletonConfigRequest, req) ); + sendRPCPacket( + RpcMessage.SkeletonConfigRequest, + new SkeletonConfigRequestT() + ); setImportState(ImportStatus.SUCCESS); setTimeout(() => { setImportState(ImportStatus.OK); diff --git a/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx b/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx index bd9bf33fc7..78ec3b1a1b 100644 --- a/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx +++ b/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx @@ -478,7 +478,7 @@ export function ScaledProportionsPage() {
diff --git a/gui/src/components/onboarding/pages/mounting/AutomaticMounting.tsx b/gui/src/components/onboarding/pages/mounting/AutomaticMounting.tsx index 32e16a80e8..fddbb05227 100644 --- a/gui/src/components/onboarding/pages/mounting/AutomaticMounting.tsx +++ b/gui/src/components/onboarding/pages/mounting/AutomaticMounting.tsx @@ -23,7 +23,7 @@ export function AutomaticMountingPage() { const { l10n } = useLocalization(); const { applyProgress, state } = useOnboarding(); - applyProgress(0.7); + applyProgress(0.6); return ( <> diff --git a/gui/src/components/onboarding/pages/mounting/ManualMounting.tsx b/gui/src/components/onboarding/pages/mounting/ManualMounting.tsx index 366b8ba3e1..fc23c2a857 100644 --- a/gui/src/components/onboarding/pages/mounting/ManualMounting.tsx +++ b/gui/src/components/onboarding/pages/mounting/ManualMounting.tsx @@ -28,7 +28,7 @@ export function ManualMountingPage() { const [selectedRole, setSelectRole] = useState(BodyPart.NONE); - applyProgress(0.7); + applyProgress(0.6); const assignedTrackers = useAtomValue(assignedTrackersAtom); diff --git a/gui/src/components/onboarding/pages/mounting/MountingChoose.tsx b/gui/src/components/onboarding/pages/mounting/MountingChoose.tsx index 3c18965e8d..486770161d 100644 --- a/gui/src/components/onboarding/pages/mounting/MountingChoose.tsx +++ b/gui/src/components/onboarding/pages/mounting/MountingChoose.tsx @@ -13,7 +13,7 @@ export function MountingChoose() { const [animated, setAnimated] = useState(false); const [showWarning, setShowWarning] = useState(false); - applyProgress(0.65); + applyProgress(0.55); return ( <> diff --git a/gui/src/components/onboarding/pages/quiz/MocapPreferencesQuestions.tsx b/gui/src/components/onboarding/pages/quiz/MocapPreferencesQuestions.tsx new file mode 100644 index 0000000000..5f24842339 --- /dev/null +++ b/gui/src/components/onboarding/pages/quiz/MocapPreferencesQuestions.tsx @@ -0,0 +1,168 @@ +import { useOnboarding } from '@/hooks/onboarding'; +import { Typography } from '@/components/commons/Typography'; +import { Button } from '@/components/commons/Button'; +import { HumanIcon } from '@/components/commons/icon/HumanIcon'; +import { HeadsetIcon } from '@/components/commons/icon/HeadsetIcon'; +import { useState } from 'react'; +import { SittingIcon } from '@/components/commons/icon/SittingIcon'; +import { VMCFileUpload } from '@/components/settings/pages/VMCSettings'; +import { QuizButton } from './SlimeSetQuestion'; +import { useNavigate } from 'react-router-dom'; + +export function QuizMocapPosQuestion() { + const nav = useNavigate(); + const { + applyProgress, + setMocapPos, + mocapPos, + playspace, + setPlayspace, + onboardingEnded, + } = useOnboarding(); + + const [headTracker, setHeadTracker] = useState(true); + + applyProgress(0.9); + + const canContinue = (!headTracker || mocapPos) && playspace; + + const end = () => { + onboardingEnded(); + nav('/'); + }; + + return ( +
+
+
+ + +
+
+
+
+ +
+
+ setHeadTracker(true)} + icon={} + name="onboarding-quiz-mocap_preferences-head_tracker-yes" + /> + { + setHeadTracker(false); + setMocapPos(undefined); + }} + icon={} + name="onboarding-quiz-mocap_preferences-head_tracker-no" + /> +
+
+
+ +
+
+
+ + +
+ +
+
+ + {headTracker && ( + <> +
+
+
+ + +
+
+ setPlayspace('sitting')} + icon={} + name="onboarding-quiz-mocap_preferences-playspace-sitting" + /> + setPlayspace('standing')} + icon={} + name="onboarding-quiz-mocap_preferences-playspace-standing" + /> +
+
+
+
+
+
+ +
+
+ setMocapPos('forehead')} + icon={ + + } + name="onboarding-quiz-mocap_preferences-head_tracker_location-forehead" + /> + setMocapPos('face')} + icon={ + + } + name="onboarding-quiz-mocap_preferences-head_tracker_location-face" + /> +
+
+
+ + )} + +
+
+
+
+ ); +} diff --git a/gui/src/components/onboarding/pages/quiz/MoreSetsConfirm.tsx b/gui/src/components/onboarding/pages/quiz/MoreSetsConfirm.tsx new file mode 100644 index 0000000000..80087aecfb --- /dev/null +++ b/gui/src/components/onboarding/pages/quiz/MoreSetsConfirm.tsx @@ -0,0 +1,30 @@ +import { ButtonConfirmModal } from '@/components/commons/ButtonConfirmModal'; +import { Typography } from '@/components/commons/Typography'; +import { useNavigate } from 'react-router-dom'; + +export function MoreSetsConfirm() { + const navigate = useNavigate(); + + return ( + navigate('/onboarding/trackers-assign')} + onCancel={(reason) => { + if (reason === 'cancel') navigate('/onboarding/quiz/slime-set'); + }} + > + + + + ); +} diff --git a/gui/src/components/onboarding/pages/quiz/RuntimeQuestion.tsx b/gui/src/components/onboarding/pages/quiz/RuntimeQuestion.tsx new file mode 100644 index 0000000000..c4694e5848 --- /dev/null +++ b/gui/src/components/onboarding/pages/quiz/RuntimeQuestion.tsx @@ -0,0 +1,56 @@ +import { useOnboarding } from '@/hooks/onboarding'; +import { Typography } from '@/components/commons/Typography'; +import { QuizButton } from './SlimeSetQuestion'; +import { SteamIcon } from '@/components/commons/icon/SteamIcon'; +import { HeadsetIcon } from '@/components/commons/icon/HeadsetIcon'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '@/components/commons/Button'; + +export function QuizRuntimeQuestion() { + const { applyProgress, setVrcOSC, vrcOsc, onboardingEnded } = useOnboarding(); + const nav = useNavigate(); + + applyProgress(0.9); + + const next = (type: typeof vrcOsc) => { + setVrcOSC(type); + onboardingEnded(); + nav('/'); + }; + + return ( +
+
+
+ +
+ +
+
+
+ next(false)} + icon={} + name="onboarding-quiz-runtime-answer-steamvr" + /> + next(true)} + icon={} + name="onboarding-quiz-runtime-answer-standalone" + /> +
+
+
+
+
+
+
+ ); +} diff --git a/gui/src/components/onboarding/pages/quiz/SlimeSetQuestion.tsx b/gui/src/components/onboarding/pages/quiz/SlimeSetQuestion.tsx new file mode 100644 index 0000000000..4cd45a2f81 --- /dev/null +++ b/gui/src/components/onboarding/pages/quiz/SlimeSetQuestion.tsx @@ -0,0 +1,136 @@ +import { useOnboarding } from '@/hooks/onboarding'; +import { ReactNode } from 'react'; +import { Typography } from '@/components/commons/Typography'; +import classNames from 'classnames'; +import { USBIcon } from '@/components/commons/icon/UsbIcon'; +import { useNavigate } from 'react-router-dom'; + +export function QuizButton({ + name, + active, + icon, + onClick, +}: { + active?: boolean; + name: string; + icon: ReactNode; + onClick: () => void; +}) { + return ( +
+ {icon} + +
+ ); +} + +export function QuizSlimeSetQuestion() { + const { applyProgress, setSlimeSet, slimeSet } = useOnboarding(); + const nav = useNavigate(); + + applyProgress(0.2); + + const next = (type: typeof slimeSet) => { + setSlimeSet(type); + switch (type) { + case 'butterfly': + case 'dongle-slime': + nav('/onboarding/dongle'); + break; + case 'slime-v1': + case 'wifi-slime': + nav('/onboarding/wifi-creds'); + break; + } + }; + + return ( +
+
+
+ + +
+ +
+
+ +
+ next('slime-v1')} + icon={ + + } + name="onboarding-quiz-slimeset-answer-regular" + /> + next('butterfly')} + icon={ + + } + name="onboarding-quiz-slimeset-answer-butterfly" + /> +
+
+
+ +
+ next('wifi-slime')} + icon={ + + + + + } + name="onboarding-quiz-slimeset-answer-wifi" + /> + next('dongle-slime')} + icon={ +
+ +
+ } + name="onboarding-quiz-slimeset-answer-dongle" + /> +
+
+
+
+
+ ); +} diff --git a/gui/src/components/onboarding/pages/quiz/UsageQuestion.tsx b/gui/src/components/onboarding/pages/quiz/UsageQuestion.tsx new file mode 100644 index 0000000000..840bc17cc1 --- /dev/null +++ b/gui/src/components/onboarding/pages/quiz/UsageQuestion.tsx @@ -0,0 +1,62 @@ +import { useOnboarding } from '@/hooks/onboarding'; +import { Typography } from '@/components/commons/Typography'; +import { Button } from '@/components/commons/Button'; +import { QuizButton } from './SlimeSetQuestion'; +import { useNavigate } from 'react-router-dom'; +import { HeadsetIcon } from '@/components/commons/icon/HeadsetIcon'; +import { HumanIcon } from '@/components/commons/icon/HumanIcon'; + +export function QuizUsageQuestion() { + const { applyProgress, setUsage, usage } = useOnboarding(); + const nav = useNavigate(); + + applyProgress(0.8); + + const next = (type: typeof usage) => { + setUsage(type); + if (type === 'vr-gaming') { + if (window.__ANDROID__) { + nav('/'); + } else { + nav('/onboarding/quiz/runtime'); + } + } else nav('/onboarding/quiz/mocap-pos'); + }; + + return ( +
+
+
+ + +
+
+
+ } + name="onboarding-quiz-usage-answer-VRC" + onClick={() => next('vr-gaming')} + /> + } + name="onboarding-quiz-usage-answer-mocap_vtubing" + onClick={() => next('mocap')} + /> +
+
+
+
+
+
+ ); +} diff --git a/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx b/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx index 78a6c5abe4..f1b3286340 100644 --- a/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx +++ b/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx @@ -11,6 +11,7 @@ import { ResetType } from 'solarxr-protocol'; export function PreparationStep({ nextStep, prevStep, + isActive, }: VerticalStepComponentProps) { return (
@@ -53,7 +54,14 @@ export function PreparationStep({
); diff --git a/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/VerifyMounting.tsx b/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/VerifyMounting.tsx index 18f969e872..6c5d72862f 100644 --- a/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/VerifyMounting.tsx +++ b/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/VerifyMounting.tsx @@ -10,6 +10,7 @@ import { ManualMountingPageStayAligned } from '@/components/onboarding/pages/mou export function VerifyMountingStep({ nextStep, prevStep, + isActive, }: VerticalStepComponentProps) { const { isMobile } = useBreakpoint('mobile'); const [isOpen, setOpen] = useState(false); @@ -79,7 +80,11 @@ export function VerifyMountingStep({ onClick={() => setDisableMounting(true)} type={ResetType.Mounting} group="default" - onReseted={goNextStep} + onReseted={() => { + if (isActive) { + nextStep(); + } + }} onFailed={() => setDisableMounting(false)} /> diff --git a/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx b/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx index 2039a06fd1..5655585d37 100644 --- a/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx +++ b/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx @@ -55,7 +55,7 @@ export function TrackersAssignPage() { const { isMd } = useBreakpoint('md'); const { l10n } = useLocalization(); const { config, setConfig } = useConfig(); - const { applyProgress, state } = useOnboarding(); + const { applyProgress, state, slimeSet } = useOnboarding(); const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); const defaultValues = { mirrorView: config?.mirrorView ?? defaultConfig.mirrorView, @@ -100,7 +100,11 @@ export function TrackersAssignPage() { tapDetectionSettings.mountingResetDelay, tapDetectionSettings.mountingResetEnabled, tapDetectionSettings.mountingResetTaps, - true + true, + null, + tapDetectionSettings.yawResetTracker, + tapDetectionSettings.fullResetTracker, + tapDetectionSettings.mountingResetTracker ); sendRPCPacket( @@ -329,9 +333,24 @@ export function TrackersAssignPage() {
{!state.alonePage && ( <> - + {state.alonePage && ( + + )} + {!state.alonePage && ( + + )}
+
+ + {l10n.getString( + 'settings-general-fk_settings-velocity_settings' + )} + +
+ + {l10n.getString( + 'settings-general-fk_settings-velocity_settings-description' + )} + +
+
+
+ +
+ {config?.debug && ( <>
@@ -1144,32 +1243,31 @@ export function GeneralSettings() { />
- -
- - {l10n.getString( - 'settings-general-fk_settings-self_localization-title' - )} - - - {l10n.getString( - 'settings-general-fk_settings-self_localization-description' - )} - -
-
- -
)} +
+ + {l10n.getString( + 'settings-general-fk_settings-self_localization-title' + )} + + + {l10n.getString( + 'settings-general-fk_settings-self_localization-description' + )} + +
+
+ +
@@ -1187,34 +1285,80 @@ export function GeneralSettings() { {l10n.getString('settings-general-gesture_control-description')} -
- - - +
+
+ + + +
+
+
+ + {l10n.getString( + 'settings-general-gesture_control-yawResetTracker' + )} + + +
+
+ + {l10n.getString( + 'settings-general-gesture_control-mountingResetTracker' + )} + + +
+
+ + {l10n.getString( + 'settings-general-gesture_control-fullResetTracker' + )} + + +
+
} disabled={!step.ignorable || !step.enabled} label={l10n.getString(trackingchecklistIdtoLabel[step.id])} outlined diff --git a/gui/src/components/settings/pages/OSCRouterSettings.tsx b/gui/src/components/settings/pages/OSCRouterSettings.tsx index af2d5d7b24..0e5a68ae2c 100644 --- a/gui/src/components/settings/pages/OSCRouterSettings.tsx +++ b/gui/src/components/settings/pages/OSCRouterSettings.tsx @@ -102,9 +102,9 @@ export function OSCRouterSettings() { formData.router.oscSettings.address = settings.oscRouter.oscSettings.address.toString(); } - } - reset(formData); + reset(formData); + } }); return ( diff --git a/gui/src/components/settings/pages/VMCSettings.tsx b/gui/src/components/settings/pages/VMCSettings.tsx index 0551ef9690..f13af7de4b 100644 --- a/gui/src/components/settings/pages/VMCSettings.tsx +++ b/gui/src/components/settings/pages/VMCSettings.tsx @@ -8,6 +8,7 @@ import { SettingsResponseT, OSCSettingsT, VMCOSCSettingsT, + VRMSettingsT, } from 'solarxr-protocol'; import { useWebsocketAPI } from '@/hooks/websocket-api'; import { CheckBox } from '@/components/commons/Checkbox'; @@ -31,7 +32,6 @@ import { boolean, object } from 'yup'; interface VMCSettingsForm { vmc: { oscSettings: OSCSettings; - vrmJson?: FileList; anchorHip: boolean; mirrorTracking: boolean; }; @@ -50,10 +50,84 @@ const defaultValues = { }, }; +export function VMCFileUpload() { + const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); + const { l10n } = useLocalization(); + const [modelName, setModelName] = useState(null); + + const { control, watch } = useForm<{ + vrmJson?: FileList; + }>({ + defaultValues: { vrmJson: undefined }, + reValidateMode: 'onChange', + mode: 'onChange', + }); + + const vrmJson = watch('vrmJson'); + + const updateVRMJson = async () => { + const req = new ChangeSettingsRequestT(); + const vrm = new VRMSettingsT(); + if (vrmJson !== undefined) { + if (vrmJson.length > 0) { + const file = await parseVRMFile(vrmJson[0]); + if (file) { + vrm.vrmJson = file.json; + setModelName(file.name); + } + } else { + vrm.vrmJson = ''; + setModelName(null); + } + } + req.vrm = vrm; + sendRPCPacket(RpcMessage.ChangeSettingsRequest, req); + }; + + useEffect(() => { + updateVRMJson(); + }, [vrmJson]); + + useEffect(() => { + sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT()); + }, []); + + useRPCPacket(RpcMessage.SettingsResponse, (settings: SettingsResponseT) => { + const vrmJson = settings.vrm?.vrmJson?.toString(); + if (vrmJson) { + let data: any; + try { + data = JSON.parse(vrmJson); + setModelName(getVRMName(data) || ''); + } catch (e) { + error('Failed to fetch VRM name: ' + e); + } + } + }); + + return ( + + ); +} + export function VMCSettings() { const { l10n } = useLocalization(); const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); - const [modelName, setModelName] = useState(null); const { oscValidator } = useOscSettingsValidator(); const { reset, control, watch, handleSubmit } = useForm({ @@ -81,18 +155,6 @@ export function VMCSettings() { new OSCSettingsT(), values.vmc.oscSettings ); - if (values.vmc.vrmJson !== undefined) { - if (values.vmc.vrmJson.length > 0) { - const file = await parseVRMFile(values.vmc.vrmJson[0]); - if (file) { - vmcOsc.vrmJson = file.json; - setModelName(file.name); - } - } else { - vmcOsc.vrmJson = ''; - setModelName(null); - } - } vmcOsc.anchorHip = values.vmc.anchorHip; vmcOsc.mirrorTracking = values.vmc.mirrorTracking; @@ -124,16 +186,12 @@ export function VMCSettings() { formData.vmc.oscSettings.address = settings.vmcOsc.oscSettings.address.toString(); } - const vrmJson = settings.vmcOsc.vrmJson?.toString(); - if (vrmJson) { - setModelName(getVRMName(vrmJson) || ''); - } formData.vmc.anchorHip = settings.vmcOsc.anchorHip; formData.vmc.mirrorTracking = settings.vmcOsc.mirrorTracking; - } - reset(formData); + reset(formData); + } }); return ( @@ -238,23 +296,7 @@ export function VMCSettings() {
- - {/* For some reason, linux (GNOME) is detecting the VRM file is a VRML */} +
{l10n.getString('settings-osc-vmc-anchor_hip')} @@ -344,16 +386,34 @@ async function parseVRMFile( .slice(gltfHeaderEnd, gltfHeaderEnd + jsonLength, 'application/json') .text(); - const name = getVRMName(json); + let data: any; + try { + data = JSON.parse(json); + } catch (e) { + error('Failed to parse VRM glTF header: ' + e); + return null; + } + + const name = getVRMName(data); if (name === null) return null; - return { json, name }; + // Only keep the fields we care about + /* eslint-disable camelcase */ + const vrmJson = { + extensions: { + VRM: data.extensions.VRM, + VRMC_vrm: data.extensions.VRMC_vrm, + }, + extensionsUsed: data.extensionsUsed, + nodes: data.nodes, + }; + /* eslint-enable camelcase */ + + return { json: JSON.stringify(vrmJson), name }; } -function getVRMName(json: string): string | null { +function getVRMName(data: any): string | null { try { - const data = JSON.parse(json); - if (typeof data?.extensions?.VRMC_vrm?.specVersion === 'string') { const name = data.extensions.VRMC_vrm.meta.name; @@ -369,7 +429,7 @@ function getVRMName(json: string): string | null { return data?.extensions?.VRM?.meta?.title || ''; } } catch (e) { - error(e); + error('Failed to fetch VRM name: ' + e); return null; } } diff --git a/gui/src/components/tracker/TrackerBattery.tsx b/gui/src/components/tracker/TrackerBattery.tsx index 86fd11c7ef..184b847796 100644 --- a/gui/src/components/tracker/TrackerBattery.tsx +++ b/gui/src/components/tracker/TrackerBattery.tsx @@ -13,7 +13,9 @@ export function TrackerBattery({ textColor = 'primary', }: { /** - * a [0, 1] value range is expected + * Normally [0, 1] value range + * Values >1 indicate fully charged + * Values <0 or >2 (Byte was cast to UByte, so -1 -> 255) also indicate 0% */ value: number; voltage?: number | null; @@ -36,11 +38,13 @@ export function TrackerBattery({ const debug = config?.debug || config?.devSettings.moreInfo; const showVoltage = moreInfo && voltage && debug; + const pct = value > 2 ? 0 : Math.min(Math.max(value, 0), 1); + return ( {percentFormatter.format(value)} + content={percentFormatter.format(pct)} >
@@ -61,7 +65,7 @@ export function TrackerBattery({ )} {!charging && (!runtime || debug) && ( - {percentFormatter.format(value)} + {percentFormatter.format(pct)} )} {showVoltage && ( diff --git a/gui/src/components/tracker/TrackersTable.tsx b/gui/src/components/tracker/TrackersTable.tsx index b8ef08d0c9..f02ac96d76 100644 --- a/gui/src/components/tracker/TrackersTable.tsx +++ b/gui/src/components/tracker/TrackersTable.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import { IPv4 } from 'ip-num/IPNumber'; +import { IPv4 } from 'ip-num'; import { createContext, ReactNode, useContext, useMemo } from 'react'; import { useConfig } from '@/hooks/config'; import { useTracker } from '@/hooks/tracker'; diff --git a/gui/src/components/tracking-checklist/TrackingChecklist.tsx b/gui/src/components/tracking-checklist/TrackingChecklist.tsx index 4e2f42e305..b3c56b9f68 100644 --- a/gui/src/components/tracking-checklist/TrackingChecklist.tsx +++ b/gui/src/components/tracking-checklist/TrackingChecklist.tsx @@ -6,7 +6,12 @@ import { } from '@/hooks/tracking-checklist'; import classNames from 'classnames'; import { + ChangeSettingsRequestT, ResetType, + RpcMessage, + SettingsRequestT, + SettingsResponseT, + SteamVRTrackersSettingT, TrackingChecklistPublicNetworksT, TrackingChecklistStepId, } from 'solarxr-protocol'; @@ -29,6 +34,7 @@ import { TrackingChecklistModal } from './TrackingChecklistModal'; import { NavLink, useNavigate } from 'react-router-dom'; import { useBreakpoint } from '@/hooks/breakpoint'; import { openUrl } from '@/hooks/crossplatform'; +import { useWebsocketAPI } from '@/hooks/websocket-api'; function Step({ step: { status, id, optional, firstRequired }, @@ -102,6 +108,55 @@ function Step({ ); } +function SteamVRHandsEnabled() { + const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); + const [steamVrTrackers, setSteamVrTrackers] = useState | null>(null); + + useEffect(() => { + sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT()); + }, []); + + useRPCPacket(RpcMessage.SettingsResponse, (settings: SettingsResponseT) => { + if (settings.steamVrTrackers) { + setSteamVrTrackers(settings.steamVrTrackers); + } + }); + + const disableHandTrackers = () => { + const settings = new ChangeSettingsRequestT(); + settings.steamVrTrackers = new SteamVRTrackersSettingT( + steamVrTrackers?.waist, + steamVrTrackers?.chest, + steamVrTrackers?.automaticTrackerToggle, + steamVrTrackers?.leftFoot, + steamVrTrackers?.rightFoot, + steamVrTrackers?.leftKnee, + steamVrTrackers?.rightKnee, + steamVrTrackers?.leftElbow, + steamVrTrackers?.rightElbow, + false, + false + ); + sendRPCPacket(RpcMessage.ChangeSettingsRequest, settings); + }; + + return ( +
+ +
+
+
+ ); +} + const stepContentLookup: Record< number, ( @@ -352,6 +407,9 @@ const stepContentLookup: Record< ); }, + [TrackingChecklistStepId.STEAMVR_HANDS_ENABLED]: () => { + return ; + }, }; export function TrackingChecklistMobile() { diff --git a/gui/src/components/widgets/DeveloperModeWidget.tsx b/gui/src/components/widgets/DeveloperModeWidget.tsx index fbe77bae5a..08310abffe 100644 --- a/gui/src/components/widgets/DeveloperModeWidget.tsx +++ b/gui/src/components/widgets/DeveloperModeWidget.tsx @@ -1,7 +1,6 @@ import { useEffect } from 'react'; -import { useForm } from 'react-hook-form'; +import { FieldPath, useForm } from 'react-hook-form'; import { useConfig } from '@/hooks/config'; -import { useWebsocketAPI } from '@/hooks/websocket-api'; import { CheckBox } from '@/components/commons/Checkbox'; import { useLocalization } from '@fluent/react'; @@ -28,7 +27,10 @@ export const defaultValues: DeveloperModeWidgetForm = { export function DeveloperModeWidget() { const { l10n } = useLocalization(); const { config, setConfig } = useConfig(); - const { reconnect } = useWebsocketAPI(); + + const onSubmit = (formData: DeveloperModeWidgetForm) => { + setConfig({ devSettings: formData }); + }; const { reset, control, handleSubmit, watch } = useForm({ @@ -44,20 +46,13 @@ export function DeveloperModeWidget() { return () => subscription.unsubscribe(); }, []); - const onSubmit = async (formData: DeveloperModeWidgetForm) => { - const needReconnect = - config?.devSettings?.fastDataFeed !== formData.fastDataFeed; - await setConfig({ devSettings: formData }); - if (needReconnect) reconnect(); - }; - const makeToggle = ([name, label]: string[], index: number) => ( } label={l10n.getString(`widget-developer_mode-${label}`)} /> ); diff --git a/gui/src/hooks/app.ts b/gui/src/hooks/app.ts index 0623db19bb..a110ca551d 100644 --- a/gui/src/hooks/app.ts +++ b/gui/src/hooks/app.ts @@ -16,6 +16,8 @@ import { getSentryOrCompute, updateSentryContext } from '@/utils/sentry'; import { fetchCurrentFirmwareRelease, FirmwareRelease } from './firmware-update'; import { DEFAULT_LOCALE, LangContext } from '@/i18n/config'; +const isSteam = await window.electronAPI.isSteam(); + export interface AppContext { currentFirmwareRelease: FirmwareRelease | null; } @@ -40,7 +42,7 @@ export function useProvideAppContext(): AppContext { startDataFeed.dataFeeds = [dataFeedConfig, bonesDataFeedConfig]; sendDataFeedPacket(DataFeedMessage.StartDataFeed, startDataFeed); } - }, [isConnected]); + }, [isConnected, config?.debug, config?.devSettings?.fastDataFeed]); useDataFeedPacket(DataFeedMessage.DataFeedUpdate, (packet: DataFeedUpdateT) => { if (packet.index === 0) { @@ -79,7 +81,7 @@ export function useProvideAppContext(): AppContext { if (config.errorTracking !== undefined) { // Alows for sentry to refresh if user change the setting once the gui // is initialized - getSentryOrCompute(config.errorTracking ?? false, config.uuid); + getSentryOrCompute(config.errorTracking ?? false, config.uuid, isSteam); } }, [config]); diff --git a/gui/src/hooks/config.ts b/gui/src/hooks/config.ts index 03e0de6b7e..5ad358f14d 100644 --- a/gui/src/hooks/config.ts +++ b/gui/src/hooks/config.ts @@ -48,6 +48,7 @@ export interface Config { homeLayout: 'default' | 'table'; skeletonPreview: boolean; lastUsedProportions: 'manual' | 'autobone' | 'scaled' | null; + dontShowUdevModal: boolean; } export interface ConfigContext { @@ -79,6 +80,7 @@ export const defaultConfig: Config = { homeLayout: 'default', skeletonPreview: true, lastUsedProportions: null, + dontShowUdevModal: false, }; const localStore: CrossStorage = { @@ -103,10 +105,7 @@ function fallbackToDefaults(loadedConfig: any): Config { // allows to load everything before the first render export const loadConfig = async () => { try { - const json = await store.get('config.json'); - - if (!json) throw new Error('Config has ceased existing for some reason'); - + const json = (await store.get('config.json')) ?? '{}'; const loadedConfig = fallbackToDefaults(JSON.parse(json)); if (!loadedConfig.uuid) { diff --git a/gui/src/hooks/datafeed-config.ts b/gui/src/hooks/datafeed-config.ts index 0bf7337b90..588783d3f7 100644 --- a/gui/src/hooks/datafeed-config.ts +++ b/gui/src/hooks/datafeed-config.ts @@ -5,7 +5,7 @@ export function useDataFeedConfig() { const { config } = useConfig(); const fastDataFeed = config?.debug && config?.devSettings?.fastDataFeed; - const feedMaxTps = fastDataFeed ? 40 : 10; + const feedMaxTps = fastDataFeed ? 90 : 10; const trackerData = new TrackerDataMaskT(); trackerData.position = true; @@ -39,8 +39,13 @@ export function useDataFeedConfig() { } export function useBonesDataFeedConfig() { + const { config } = useConfig(); + + const fastDataFeed = config?.debug && config?.devSettings?.fastDataFeed; + const feedMaxTps = fastDataFeed ? 90 : 40; + const dataFeedConfig = new DataFeedConfigT(); dataFeedConfig.boneMask = true; - dataFeedConfig.minimumTimeSinceLast = 1000 / 40; + dataFeedConfig.minimumTimeSinceLast = 1000 / feedMaxTps; return dataFeedConfig; } diff --git a/gui/src/hooks/discord-presence.ts b/gui/src/hooks/discord-presence.ts index fab155665d..d008fbbb26 100644 --- a/gui/src/hooks/discord-presence.ts +++ b/gui/src/hooks/discord-presence.ts @@ -6,26 +6,26 @@ import { useAtomValue } from 'jotai'; import { useElectron } from './electron'; export function useDiscordPresence() { + const electron = useElectron(); + if (!electron.isElectron) return; + const { config } = useConfig(); const { l10n } = useLocalization(); - const electron = useElectron(); const imuTrackers = useAtomValue(connectedIMUTrackersAtom); const imuTrackersCount = useMemo(() => imuTrackers.length, [imuTrackers.length]); useEffect(() => { - if (config?.discordPresence === false) return; + if (config?.discordPresence === false) { + electron.api.setPresence({ enable: false }); + return; + } - if (!electron.isElectron) return; electron.api.setPresence({ enable: true, activity: l10n.getString('settings-general-interface-discord_presence-message', { amount: imuTrackersCount, }), + iconText: (__VERSION_TAG__ || __COMMIT_HASH__) + (__GIT_CLEAN__ ? '' : '-dirty'), }); - - return () => { - if (!electron.isElectron) return; - electron.api.setPresence({ enable: false }); - }; }, [config?.discordPresence, imuTrackersCount]); } diff --git a/gui/src/hooks/firmware-update.ts b/gui/src/hooks/firmware-update.ts index 515a6a168d..06c71afc10 100644 --- a/gui/src/hooks/firmware-update.ts +++ b/gui/src/hooks/firmware-update.ts @@ -152,7 +152,8 @@ export function checkForUpdate( if ( canUpdate && device.hardwareStatus?.batteryPctEstimate != null && - device.hardwareStatus.batteryPctEstimate < 50 + (device.hardwareStatus.batteryPctEstimate < 50 || + device.hardwareStatus.batteryPctEstimate > 200) ) { return 'low-battery'; } diff --git a/gui/src/hooks/onboarding.ts b/gui/src/hooks/onboarding.ts index 0ee82895fd..47cc1be8da 100644 --- a/gui/src/hooks/onboarding.ts +++ b/gui/src/hooks/onboarding.ts @@ -1,6 +1,28 @@ -import { createContext, Reducer, useContext, useLayoutEffect, useReducer } from 'react'; +import { + createContext, + Reducer, + useContext, + useEffect, + useLayoutEffect, + useReducer, + useState, +} from 'react'; import { useLocation } from 'react-router-dom'; import { useConfig } from './config'; +import { useWebsocketAPI } from './websocket-api'; +import { + ChangeSettingsRequestT, + ModelSettingsT, + ModelTogglesT, + OSCSettingsT, + ResetsSettingsT, + RpcMessage, + SettingsRequestT, + SettingsResponseT, + SkeletonBone, + VRCOSCSettingsT, +} from 'solarxr-protocol'; +import { useManualProportions } from './manual-proportions'; type OnboardingAction = | { type: 'progress'; value: number } @@ -13,13 +35,6 @@ interface OnboardingState { alonePage: boolean; } -export interface OnboardingContext { - state: OnboardingState; - applyProgress: (value: number) => void; - setWifiCredentials: (ssid: string, password?: string) => void; - skipSetup: () => void; -} - export function reducer(state: OnboardingState, action: OnboardingAction) { switch (action.type) { case 'wifi-creds': @@ -42,8 +57,17 @@ export function reducer(state: OnboardingState, action: OnboardingAction) { } } -export function useProvideOnboarding(): OnboardingContext { +export type OnboardingContext = ReturnType; + +export function useProvideOnboarding() { const { setConfig } = useConfig(); + const [slimeSet, setSlimeSet] = useState< + 'butterfly' | 'slime-v1' | 'dongle-slime' | 'wifi-slime' + >(); + const [usage, setUsage] = useState<'vr-gaming' | 'mocap' | 'vtubing'>(); + const [vrcOsc, setVrcOSC] = useState(); + const [mocapPos, setMocapPos] = useState<'forehead' | 'face'>(); + const [playspace, setPlayspace] = useState<'sitting' | 'standing'>(); const [state, dispatch] = useReducer>( reducer, { @@ -52,7 +76,10 @@ export function useProvideOnboarding(): OnboardingContext { } ); + const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); + const { state: locatioState } = useLocation(); + const [settings, setSettings] = useState(); useLayoutEffect(() => { const { alonePage = false }: { alonePage?: boolean } = (locatioState as any) || {}; @@ -61,8 +88,64 @@ export function useProvideOnboarding(): OnboardingContext { dispatch({ type: 'alone-page', value: alonePage }); }, [locatioState, state]); + useEffect(() => { + sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT()); + }, []); + + useRPCPacket(RpcMessage.SettingsResponse, (settings: SettingsResponseT) => { + setSettings(settings); + }); + + const { changeBoneValue } = useManualProportions({ + type: 'linear', + }); + + const onboardingEnded = () => { + setConfig({ doneOnboarding: true }); + if (!settings?.modelSettings || !settings?.vrcOsc) return; + const req = new ChangeSettingsRequestT(); + const modelSettings = new ModelSettingsT(); + const oscSettings = new VRCOSCSettingsT(); + + const mocap = usage === 'mocap' || usage === 'vtubing'; + + const toggles = Object.assign(new ModelTogglesT(), settings.modelSettings.toggles); + toggles.selfLocalization = mocap && playspace === 'standing'; + modelSettings.toggles = toggles; + req.modelSettings = modelSettings; + + const resets = Object.assign(new ResetsSettingsT(), settings.resetsSettings); + resets.resetHmdPitch = mocapPos === 'forehead'; + req.resetsSettings = resets; + + const osc = Object.assign(new OSCSettingsT(), settings.vrcOsc.oscSettings); + osc.enabled = vrcOsc ?? false; + oscSettings.oscSettings = osc; + req.vrcOsc = oscSettings; + + sendRPCPacket(RpcMessage.ChangeSettingsRequest, req); + + if (mocap) { + changeBoneValue({ bone: SkeletonBone.HAND_Z, type: 'bone', newValue: 0 }); + } + }; + + const onboardingStarted = () => { + setSlimeSet(undefined); + setUsage(undefined); + setVrcOSC(undefined); + setMocapPos(undefined); + setPlayspace(undefined); + }; + return { state, + slimeSet, + usage, + vrcOsc, + mocapPos, + playspace, + setPlayspace, applyProgress: (value: number) => { useLayoutEffect(() => { dispatch({ type: 'progress', value }); @@ -74,6 +157,12 @@ export function useProvideOnboarding(): OnboardingContext { skipSetup: () => { setConfig({ doneOnboarding: true }); }, + setSlimeSet, + setUsage, + setVrcOSC, + setMocapPos, + onboardingEnded, + onboardingStarted, }; } diff --git a/gui/src/hooks/tracking-checklist.ts b/gui/src/hooks/tracking-checklist.ts index 8bb7c433e6..239908b899 100644 --- a/gui/src/hooks/tracking-checklist.ts +++ b/gui/src/hooks/tracking-checklist.ts @@ -30,6 +30,10 @@ export const trackingchecklistIdtoLabel: Record 'tracking_checklist-FEET_MOUNTING_CALIBRATION', [TrackingChecklistStepId.STAY_ALIGNED_CONFIGURED]: 'tracking_checklist-STAY_ALIGNED_CONFIGURED', + [TrackingChecklistStepId.STEAMVR_HANDS_ENABLED]: + 'tracking_checklist-STEAMVR_HANDS_ENABLED', + [TrackingChecklistStepId.STANDABLE_INSTALLED]: + 'tracking_checklist-STANDABLE_INSTALLED', }; export type TrackingChecklistStepStatus = diff --git a/gui/src/sounds/sounds.ts b/gui/src/sounds/sounds.ts index 8d9206619c..c485ed1992 100644 --- a/gui/src/sounds/sounds.ts +++ b/gui/src/sounds/sounds.ts @@ -97,8 +97,6 @@ function createAudio(path: string): HTMLAudioElement { export function restartAndPlay(audio: HTMLAudioElement | null, volume: number) { if (!audio) return; try { - audio.load(); // LINUX: Solves wierd bug where webkit would unload sounds wierdly and make the sounds not play anymore - audio.volume = Math.min(1, Math.pow(volume, Math.E) + 0.05); audio.currentTime = 0; const playPromise = audio.play(); diff --git a/gui/src/utils/sentry.ts b/gui/src/utils/sentry.ts index 087d3f1e0f..34c287942e 100644 --- a/gui/src/utils/sentry.ts +++ b/gui/src/utils/sentry.ts @@ -9,10 +9,20 @@ import { } from 'react-router-dom'; import { DeviceDataT } from 'solarxr-protocol'; -export function getSentryOrCompute(enabled = false, uuid: string) { +export function getSentryOrCompute(enabled = false, uuid: string, isSteam: boolean) { + // to be considered prod it needs to be a proper version tag, non-dirty + const isProd = import.meta.env.PROD && __GIT_CLEAN__ && __VERSION_TAG__; + + // We disable sentry for non prod environments + if (!isProd) enabled = false; + Sentry.setUser({ id: uuid }); + // if sentry is already initialized - SKIP - if (enabled && Sentry.isInitialized()) return; + if (enabled && Sentry.isInitialized()) { + log('Sentry already enabled, skipping initialization'); + return; + } const client = Sentry.getClient(); if (client) { @@ -40,10 +50,10 @@ export function getSentryOrCompute(enabled = false, uuid: string) { }), ], beforeSend: (ev) => (newClient?.getOptions().enabled ? ev : null), - environment: import.meta.env.MODE, + environment: isSteam ? 'steam' : 'production', release: (__VERSION_TAG__ || __COMMIT_HASH__) + (__GIT_CLEAN__ ? '' : '-dirty'), // Tracing - tracesSampleRate: import.meta.env.PROD ? 0.5 : 1.0, // Capture 50% of the transactions + tracesSampleRate: 0.5, // Capture 50% of the transactions // Set profilesSampleRate to 1.0 to profile every transaction. // Since profilesSampleRate is relative to tracesSampleRate, // the final profiling rate can be computed as tracesSampleRate * profilesSampleRate @@ -51,7 +61,7 @@ export function getSentryOrCompute(enabled = false, uuid: string) { // results in 25% of transactions being profiled (0.5*0.5=0.25) profilesSampleRate: 0.2, // Session Replay - replaysSessionSampleRate: import.meta.env.PROD ? 0.1 : 1.0, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production. + replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production. replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur. normalizeDepth: 8, enabled, diff --git a/package.json b/package.json index 51a7f97260..4b500ef7c1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "slimevr", "version": "0.0.0", "private": true, - "packageManager": "pnpm@9.12.2", + "packageManager": "pnpm@10.33.0", "workspaces": [ "solarxr-protocol", "gui" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48926911a5..5f57d32e7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,10 +10,10 @@ importers: devDependencies: husky: specifier: ^9.1.6 - version: 9.1.6 + version: 9.1.7 lint-staged: specifier: ^15.2.10 - version: 15.2.10 + version: 15.5.2 gui: dependencies: @@ -22,7 +22,7 @@ importers: version: 1.0.1-rc.1 '@xhayper/discord-rpc': specifier: ^1.3.0 - version: 1.3.0 + version: 1.3.3 commander: specifier: ^14.0.3 version: 14.0.3 @@ -31,7 +31,7 @@ importers: version: 0.0.8(encoding@0.1.13) glob: specifier: ^13.0.3 - version: 13.0.3 + version: 13.0.6 open: specifier: ^11.0.0 version: 11.0.0 @@ -50,7 +50,13 @@ importers: version: 4.0.9 '@electron/asar': specifier: ^4.0.1 - version: 4.0.1 + version: 4.2.0 + '@eslint/eslintrc': + specifier: ^3.3.5 + version: 3.3.5 + '@eslint/js': + specifier: ^9.39.4 + version: 9.39.4 '@fluent/bundle': specifier: ^0.18.0 version: 0.18.0 @@ -59,40 +65,40 @@ importers: version: 0.15.2(@fluent/bundle@0.18.0)(react@18.3.1) '@fontsource/poppins': specifier: ^5.1.0 - version: 5.1.0 + version: 5.2.7 '@formatjs/intl-localematcher': specifier: ^0.2.32 version: 0.2.32 '@hookform/resolvers': specifier: ^3.6.0 - version: 3.6.0(react-hook-form@7.66.0(react@18.3.1)) + version: 3.10.0(react-hook-form@7.72.1(react@18.3.1)) '@openapi-codegen/cli': specifier: ^3.1.0 version: 3.1.0(encoding@0.1.13) '@openapi-codegen/typescript': specifier: ^8.0.2 - version: 8.0.2 + version: 8.1.1 '@react-hookz/deep-equal': specifier: ^3.0.3 - version: 3.0.3 + version: 3.0.4 '@sentry/react': specifier: 10.29.0 version: 10.29.0(react@18.3.1) '@sentry/vite-plugin': specifier: ^2.22.7 - version: 2.22.7(encoding@0.1.13) + version: 2.23.1(encoding@0.1.13) '@stylistic/eslint-plugin': specifier: ^5.5.0 - version: 5.5.0(eslint@9.39.1(jiti@2.6.1)) + version: 5.10.0(eslint@9.39.4(jiti@1.21.7)) '@tailwindcss/forms': specifier: ^0.5.9 - version: 0.5.9(tailwindcss@3.4.14(ts-node@9.1.1(typescript@5.6.3))) + version: 0.5.11(tailwindcss@3.4.19(yaml@2.8.3)) '@tailwindcss/typography': specifier: ^0.5.15 - version: 0.5.15(tailwindcss@3.4.14(ts-node@9.1.1(typescript@5.6.3))) + version: 0.5.19(tailwindcss@3.4.19(yaml@2.8.3)) '@tanstack/react-query': specifier: ^5.48.0 - version: 5.48.0(react@18.3.1) + version: 5.99.0(react@18.3.1) '@tweenjs/tween.js': specifier: ^25.0.0 version: 25.0.0 @@ -104,13 +110,13 @@ importers: version: 2.0.7 '@types/node': specifier: ^24.3.1 - version: 24.10.0 + version: 24.12.2 '@types/react': specifier: ^18.3.11 - version: 18.3.11 + version: 18.3.28 '@types/react-dom': specifier: ^18.3.0 - version: 18.3.0 + version: 18.3.7(@types/react@18.3.28) '@types/react-helmet': specifier: ^6.1.11 version: 6.1.11 @@ -119,25 +125,25 @@ importers: version: 3.16.3 '@types/semver': specifier: ^7.5.8 - version: 7.5.8 + version: 7.7.1 '@types/three': specifier: ^0.163.0 version: 0.163.0 '@typescript-eslint/eslint-plugin': specifier: ^8.48.1 - version: 8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3) + version: 8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) '@typescript-eslint/parser': specifier: ^8.48.1 - version: 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3) + version: 8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) '@vitejs/plugin-react': specifier: ^4.3.2 - version: 4.3.2(vite@5.4.9(@types/node@24.10.0)(sass@1.80.2)(terser@5.31.1)) + version: 4.7.0(vite@5.4.21(@types/node@24.12.2)(sass@1.99.0)) ajv: specifier: ^8.17.1 - version: 8.17.1 + version: 8.18.0 autoprefixer: specifier: ^10.4.20 - version: 10.4.20(postcss@8.4.38) + version: 10.5.0(postcss@8.5.10) browser-fs-access: specifier: ^0.35.0 version: 0.35.0 @@ -146,58 +152,58 @@ importers: version: 2.5.1 convert: specifier: ^5.12.0 - version: 5.13.1 + version: 5.14.1 dmg-license: specifier: ^1.0.11 version: 1.0.11 dotenv: specifier: ^16.4.5 - version: 16.4.5 + version: 16.6.1 electron: specifier: ^40.3.0 - version: 40.3.0 + version: 40.9.1 electron-builder: - specifier: ^26.7.0 - version: 26.7.0(electron-builder-squirrel-windows@26.7.0) + specifier: ^26.9.0 + version: 26.9.0(electron-builder-squirrel-windows@26.9.0) electron-vite: specifier: ^5.0.0 - version: 5.0.0(@swc/core@1.6.5)(vite@5.4.9(@types/node@24.10.0)(sass@1.80.2)(terser@5.31.1)) + version: 5.0.0(@swc/core@1.15.26)(vite@5.4.21(@types/node@24.12.2)(sass@1.99.0)) eslint: specifier: ^9.39.1 - version: 9.39.1(jiti@2.6.1) + version: 9.39.4(jiti@1.21.7) eslint-import-resolver-typescript: specifier: ^3.10.1 - version: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) + version: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + version: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-jsx-a11y: specifier: ^6.10.2 - version: 6.10.2(eslint@9.39.1(jiti@2.6.1)) + version: 6.10.2(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-react: specifier: ^7.37.5 - version: 7.37.5(eslint@9.39.1(jiti@2.6.1)) + version: 7.37.5(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-react-hooks: specifier: ^7.0.1 - version: 7.0.1(eslint@9.39.1(jiti@2.6.1)) + version: 7.0.1(eslint@9.39.4(jiti@1.21.7)) flatbuffers: specifier: 22.10.26 version: 22.10.26 globals: specifier: ^15.10.0 - version: 15.10.0 + version: 15.15.0 intl-pluralrules: specifier: ^2.0.1 version: 2.0.1 ip-num: specifier: ^1.5.1 - version: 1.5.1 + version: 1.5.2 jotai: specifier: ^2.12.2 - version: 2.12.2(@types/react@18.3.11)(react@18.3.1) + version: 2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1) prettier: specifier: ^3.3.3 - version: 3.3.3 + version: 3.8.3 prompts: specifier: ^2.4.2 version: 2.4.2 @@ -209,37 +215,37 @@ importers: version: 18.3.1(react@18.3.1) react-error-boundary: specifier: ^4.0.13 - version: 4.0.13(react@18.3.1) + version: 4.1.2(react@18.3.1) react-helmet: specifier: ^6.1.0 version: 6.1.0(react@18.3.1) react-hook-form: specifier: ^7.63.0 - version: 7.66.0(react@18.3.1) + version: 7.72.1(react@18.3.1) react-markdown: specifier: ^9.0.1 - version: 9.0.1(@types/react@18.3.11)(react@18.3.1) + version: 9.1.0(@types/react@18.3.28)(react@18.3.1) react-modal: specifier: ^3.16.1 - version: 3.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 3.16.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-responsive: specifier: ^10.0.0 - version: 10.0.0(react@18.3.1) + version: 10.0.1(react@18.3.1) react-router-dom: specifier: ^6.26.2 - version: 6.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) remark-gfm: specifier: ^4.0.0 - version: 4.0.0 + version: 4.0.1 rollup-plugin-visualizer: specifier: ^5.12.0 - version: 5.12.0(rollup@4.24.0) + version: 5.14.0(rollup@4.60.1) sass: specifier: ^1.79.4 - version: 1.80.2 + version: 1.99.0 semver: specifier: ^7.6.3 - version: 7.6.3 + version: 7.7.4 solarxr-protocol: specifier: file:../solarxr-protocol version: link:../solarxr-protocol @@ -251,31 +257,31 @@ importers: version: 1.2.0 tailwindcss: specifier: ^3.4.13 - version: 3.4.14(ts-node@9.1.1(typescript@5.6.3)) + version: 3.4.19(yaml@2.8.3) three: specifier: ^0.163.0 version: 0.163.0 ts-pattern: specifier: ^5.4.0 - version: 5.5.0 + version: 5.9.0 typescript: specifier: ^5.6.3 - version: 5.6.3 + version: 5.9.3 typescript-eslint: specifier: ^8.46.2 - version: 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3) + version: 8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) use-double-tap: specifier: ^1.3.6 - version: 1.3.6(react@18.3.1) + version: 1.3.7(react@18.3.1) uuid: specifier: ^13.0.0 version: 13.0.0 vite: specifier: ^5.4.8 - version: 5.4.9(@types/node@24.10.0)(sass@1.80.2)(terser@5.31.1) + version: 5.4.21(@types/node@24.12.2)(sass@1.99.0) yup: specifier: ^1.4.0 - version: 1.4.0 + version: 1.7.1 solarxr-protocol: dependencies: @@ -299,204 +305,62 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@ampproject/remapping@2.3.0': - resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} - engines: {node: '>=6.0.0'} - - '@babel/code-frame@7.24.7': - resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} - engines: {node: '>=6.9.0'} - - '@babel/code-frame@7.25.7': - resolution: {integrity: sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.24.7': - resolution: {integrity: sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.25.8': - resolution: {integrity: sha512-ZsysZyXY4Tlx+Q53XdnOFmqwfB9QDTHYxaZYajWRoBLuLEAwI2UIbtxOjWh/cFaa9IKUlcB+DDuoskLuKu56JA==} - engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} - '@babel/core@7.24.7': - resolution: {integrity: sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.25.8': - resolution: {integrity: sha512-Oixnb+DzmRT30qu9d3tJSQkxuygWm32DFykT4bRoORPa9hZ/L4KhVB/XiRm6KG+roIEM7DBQlmg27kw2HZkdZg==} - engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.24.7': - resolution: {integrity: sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.25.7': - resolution: {integrity: sha512-5Dqpl5fyV9pIAD62yK9P7fcA768uVPUyrQmqpqstHWgMma4feF1x/oFysBCVZLY5wJ2GkMUCdsNDnGZrPoR6rA==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.24.7': - resolution: {integrity: sha512-ctSdRHBi20qWOfy27RUb4Fhp07KSJ3sXcuSvTrXrc4aG8NSYDo1ici3Vhg9bg69y5bj0Mr1lh0aeEgTvc12rMg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.25.7': - resolution: {integrity: sha512-DniTEax0sv6isaw6qSQSfV4gVRNtw2rte8HHM45t9ZR0xILaufBRNkpMifCRiAPyvL4ACD6v0gfCwCmtOQaV4A==} - engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} - '@babel/helper-environment-visitor@7.24.7': - resolution: {integrity: sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-function-name@7.24.7': - resolution: {integrity: sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==} - engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.28.0': resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-hoist-variables@7.24.7': - resolution: {integrity: sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.24.7': - resolution: {integrity: sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.25.7': - resolution: {integrity: sha512-o0xCgpNmRohmnoWKQ0Ij8IdddjyBFE4T2kagL/x6M3+4zUgc+4qTOUBoNe4XxDskt1HPKO007ZPiMgLDq2s7Kw==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.24.7': - resolution: {integrity: sha512-1fuJEwIrp+97rM4RWdO+qrRsZlAeL1lQJoPqtCYWv0NL115XM93hIH4CSRln2w52SqvmY5hqdtauB6QFCDiZNQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-module-transforms@7.25.7': - resolution: {integrity: sha512-k/6f8dKG3yDz/qCwSM+RKovjMix563SLxQFo0UhRNo239SP6n9u5/eLtKD6EAjwta2JHJ49CsD8pms2HdNiMMQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-plugin-utils@7.24.7': - resolution: {integrity: sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg==} - engines: {node: '>=6.9.0'} - '@babel/helper-plugin-utils@7.28.6': resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} engines: {node: '>=6.9.0'} - '@babel/helper-simple-access@7.24.7': - resolution: {integrity: sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-simple-access@7.25.7': - resolution: {integrity: sha512-FPGAkJmyoChQeM+ruBGIDyrT2tKfZJO8NcxdC+CWNJi7N8/rZpSxK7yvBJ5O/nF1gfu5KzN7VKG3YVSLFfRSxQ==} - engines: {node: '>=6.9.0'} - - '@babel/helper-split-export-declaration@7.24.7': - resolution: {integrity: sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.24.7': - resolution: {integrity: sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.25.7': - resolution: {integrity: sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.24.7': - resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.25.7': - resolution: {integrity: sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.24.7': - resolution: {integrity: sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.25.7': - resolution: {integrity: sha512-ytbPLsm+GjArDYXJ8Ydr1c/KJuutjF2besPNbIZnZ6MKUxi/uTA22t2ymmA4WFjZFpjiAMO0xuuJPqK2nvDVfQ==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.24.7': - resolution: {integrity: sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.25.7': - resolution: {integrity: sha512-Sv6pASx7Esm38KQpF/U/OXLwPPrdGHNKoeblRxgZRLXnAtnkEe4ptJPDtAZM7fBLadbc1Q07kQpSiGQ0Jg6tRA==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} - engines: {node: '>=6.9.0'} - - '@babel/highlight@7.24.7': - resolution: {integrity: sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==} - engines: {node: '>=6.9.0'} - - '@babel/highlight@7.25.7': - resolution: {integrity: sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==} + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.24.7': - resolution: {integrity: sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/parser@7.25.8': - resolution: {integrity: sha512-HcttkxzdPucv3nNFmfOOMfFf64KgdJVqm1KaCm25dPGMLElo9nsLvXeJECQg8UzPuBGLyTSA0ZzqCtDSzKTEoQ==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/parser@7.29.0': - resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} engines: {node: '>=6.0.0'} hasBin: true @@ -506,54 +370,30 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-jsx-self@7.24.7': - resolution: {integrity: sha512-fOPQYbGSgH0HUp4UJO4sMBFjY6DuWq+2i8rixyUMb3CdGixs/gccURvYOAhajBdKDoGajFr3mUq5rH3phtkGzw==} + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-react-jsx-source@7.24.7': - resolution: {integrity: sha512-J2z+MWzZHVOemyLweMqngXrgGC42jQ//R0KdxqkIz/OrbVIIlhFI3WigZ5fO+nwFvBlncr4MGapd8vTyc7RPNQ==} + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/runtime@7.24.7': - resolution: {integrity: sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.24.7': - resolution: {integrity: sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.25.7': - resolution: {integrity: sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.24.7': - resolution: {integrity: sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.25.7': - resolution: {integrity: sha512-jatJPT1Zjqvh/1FyJs6qAHL+Dzb7sTb+xr7Q+gM1b+1oBsMsQQ4FkVKb6dFlJvLlVssqkRzV05Jzervt9yhnzg==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} - '@babel/types@7.24.7': - resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.25.8': - resolution: {integrity: sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -572,8 +412,8 @@ packages: resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} engines: {node: '>=18'} - '@discordjs/rest@2.6.0': - resolution: {integrity: sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==} + '@discordjs/rest@2.6.1': + resolution: {integrity: sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==} engines: {node: '>=18'} '@discordjs/util@1.2.0': @@ -583,8 +423,8 @@ packages: '@dword-design/dedent@0.7.0': resolution: {integrity: sha512-OFmAmzKiDUh9m7WRMYcoEOPI7b5tS5hdqQmtKDwF+ZssVJv8a+GHo9VOtFsmlw3h8Roh/9QzFWIsjSFZyQUMdg==} - '@dword-design/endent@1.4.1': - resolution: {integrity: sha512-e2sCTzth5kyRdM0o+yEb5wBVzUdJL8Y6HblRGRV0Bif0knf1ZjRLhUjdCrqM+Muirb68X/xJzgdRDJVmLqgXGA==} + '@dword-design/endent@1.4.7': + resolution: {integrity: sha512-yK1ivALMq9Eys6O9SgO+jBpRbQBLruFL2FvWMWPcoqiY1hPDHleY02Vir6jN6j7tDiMog3tjVtqKb4eWXdKo1w==} '@dword-design/eslint-plugin-import-alias@4.0.9': resolution: {integrity: sha512-GbDZ0HJOnLgc8X2iGM+SW/2Y2Zi8qJ96+zsJWO4nhfEmHSKPcC3MUldVFLrrHy1z5R+MEba6tp7455U2UYB0bQ==} @@ -600,8 +440,8 @@ packages: engines: {node: '>=10.12.0'} hasBin: true - '@electron/asar@4.0.1': - resolution: {integrity: sha512-F4Ykm1jiBGY1WV/o8Q8oFW8Nq0u+S2/vPujzNJtdSJ6C4LHC4CiGLn7c17s7SolZ23gcvCebMncmZtNc+MkxPQ==} + '@electron/asar@4.2.0': + resolution: {integrity: sha512-npW1NW5yy8EB9XY/vEw9sUdgmq0sJEhmSBb6bqyFOAw1CSkrhvAvO6QWlW8CdIMo8VN1lkdF345l/MeW0LrY0Q==} engines: {node: '>=22.12.0'} hasBin: true @@ -640,14 +480,14 @@ packages: engines: {node: '>=14.14'} hasBin: true - '@emnapi/core@1.7.0': - resolution: {integrity: sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw==} + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/runtime@1.7.0': - resolution: {integrity: sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} @@ -943,22 +783,18 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.0': - resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/regexpp@4.10.1': - resolution: {integrity: sha512-Zm2NGpWELsQAD1xsJzGQpYfvICSsFkEpU0jxBjfdC6uNEWXcHnfs9hScFWtXVDVl+rBQJGrl4g1vcKIejpH9dA==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint-community/regexpp@4.12.2': resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/config-helpers@0.4.2': @@ -969,12 +805,12 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.39.1': - resolution: {integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==} + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.7': @@ -1005,14 +841,14 @@ packages: peerDependencies: '@fluent/bundle': '>= 0.13.0' - '@fontsource/poppins@5.1.0': - resolution: {integrity: sha512-tpLXlnNi2fwQjiipvuj4uNFHCdoLA8izRsKdoexZuEzjx0r/g1aKLf4ta6lFgF7L+/+AFdmaXFlUwwvmDzYH+g==} + '@fontsource/poppins@5.2.7': + resolution: {integrity: sha512-6uQyPmseo4FgI97WIhA4yWRlNaoLk4vSDK/PyRwdqqZb5zAEuc+Kunt8JTMcsHYUEGYBtN15SNkMajMdqUSUmg==} '@formatjs/intl-localematcher@0.2.32': resolution: {integrity: sha512-k/MEBstff4sttohyEpXxCmC3MqbUn9VvHGlZ8fauLzkbwXmVrEeyzS+4uhrvAk9DWU9/7otYWxyDox4nT/KVLQ==} - '@hookform/resolvers@3.6.0': - resolution: {integrity: sha512-UBcpyOX3+RR+dNnqBd0lchXpoL8p4xC21XP8H6Meb8uve5Br1GCnmg0PcBoKKqPKgGu9GHQ/oygcmPrQhetwqw==} + '@hookform/resolvers@3.10.0': + resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==} peerDependencies: react-hook-form: ^7.0.0 @@ -1036,10 +872,6 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@isaacs/cliui@9.0.0': - resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} - engines: {node: '>=18'} - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -1047,10 +879,6 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - '@jridgewell/gen-mapping@0.3.5': - resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} - engines: {node: '>=6.0.0'} - '@jridgewell/remapping@2.3.5': resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} @@ -1058,22 +886,9 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - '@jridgewell/set-array@1.2.1': - resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} - engines: {node: '>=6.0.0'} - - '@jridgewell/source-map@0.3.11': - resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} - - '@jridgewell/sourcemap-codec@1.4.15': - resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} - '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - '@jridgewell/trace-mapping@0.3.25': - resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} - '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} @@ -1138,83 +953,95 @@ packages: resolution: {integrity: sha512-w9OO+3rFKWkAdKfjnjBg5pisBAKXtZAbDPNTu46Vcd7yJH1JsMH9SDoyRN2vMZZIR4ir1pLMn8Oom9kzuTAFrQ==} hasBin: true - '@openapi-codegen/typescript@8.0.2': - resolution: {integrity: sha512-7X9WR+qlIMcMxiBgheGzyQcChLSPVqNYf9SAFJdTOJQLWfy+gaXiDonUC8WC7p6Hpz7eM6OLU1i7f/h+2RlH1w==} + '@openapi-codegen/typescript@8.1.1': + resolution: {integrity: sha512-KGnFyoQc4lhtVVvX6lk2btF9GDDUbu8Jvn5C89aHuyIA4nor/Rlt7f7QHaYpecFDX8RZrPcYGrDZ8OFNUkEOlA==} - '@parcel/watcher-android-arm64@2.4.1': - resolution: {integrity: sha512-LOi/WTbbh3aTn2RYddrO8pnapixAziFl6SMxHM69r3tvdSm94JtCenaKgk1GRg5FJ5wpMCpHeW+7yqPlvZv7kg==} + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [android] - '@parcel/watcher-darwin-arm64@2.4.1': - resolution: {integrity: sha512-ln41eihm5YXIY043vBrrHfn94SIBlqOWmoROhsMVTSXGh0QahKGy77tfEywQ7v3NywyxBBkGIfrWRHm0hsKtzA==} + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [darwin] - '@parcel/watcher-darwin-x64@2.4.1': - resolution: {integrity: sha512-yrw81BRLjjtHyDu7J61oPuSoeYWR3lDElcPGJyOvIXmor6DEo7/G2u1o7I38cwlcoBHQFULqF6nesIX3tsEXMg==} + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [darwin] - '@parcel/watcher-freebsd-x64@2.4.1': - resolution: {integrity: sha512-TJa3Pex/gX3CWIx/Co8k+ykNdDCLx+TuZj3f3h7eOjgpdKM+Mnix37RYsYU4LHhiYJz3DK5nFCCra81p6g050w==} + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [freebsd] - '@parcel/watcher-linux-arm-glibc@2.4.1': - resolution: {integrity: sha512-4rVYDlsMEYfa537BRXxJ5UF4ddNwnr2/1O4MHM5PjI9cvV2qymvhwZSFgXqbS8YoTk5i/JR0L0JDs69BUn45YA==} + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + libc: [musl] - '@parcel/watcher-linux-arm64-glibc@2.4.1': - resolution: {integrity: sha512-BJ7mH985OADVLpbrzCLgrJ3TOpiZggE9FMblfO65PlOCdG++xJpKUJ0Aol74ZUIYfb8WsRlUdgrZxKkz3zXWYA==} + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] - '@parcel/watcher-linux-arm64-musl@2.4.1': - resolution: {integrity: sha512-p4Xb7JGq3MLgAfYhslU2SjoV9G0kI0Xry0kuxeG/41UfpjHGOhv7UoUDAz/jb1u2elbhazy4rRBL8PegPJFBhA==} + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + libc: [musl] - '@parcel/watcher-linux-x64-glibc@2.4.1': - resolution: {integrity: sha512-s9O3fByZ/2pyYDPoLM6zt92yu6P4E39a03zvO0qCHOTjxmt3GHRMLuRZEWhWLASTMSrrnVNWdVI/+pUElJBBBg==} + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [glibc] - '@parcel/watcher-linux-x64-musl@2.4.1': - resolution: {integrity: sha512-L2nZTYR1myLNST0O632g0Dx9LyMNHrn6TOt76sYxWLdff3cB22/GZX2UPtJnaqQPdCRoszoY5rcOj4oMTtp5fQ==} + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + libc: [musl] - '@parcel/watcher-win32-arm64@2.4.1': - resolution: {integrity: sha512-Uq2BPp5GWhrq/lcuItCHoqxjULU1QYEcyjSO5jqqOK8RNFDBQnenMMx4gAl3v8GiWa59E9+uDM7yZ6LxwUIfRg==} + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [win32] - '@parcel/watcher-win32-ia32@2.4.1': - resolution: {integrity: sha512-maNRit5QQV2kgHFSYwftmPBxiuK5u4DXjbXx7q6eKjq5dsLXZ4FJiVvlcw35QXzk0KrUecJmuVFbj4uV9oYrcw==} + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} engines: {node: '>= 10.0.0'} cpu: [ia32] os: [win32] - '@parcel/watcher-win32-x64@2.4.1': - resolution: {integrity: sha512-+DvS92F9ezicfswqrvIRM2njcYJbd5mb9CUgtrHCHmvn7pPPa+nMDRu1o1bYYz/l5IB2NVGNJWiH7h1E58IF2A==} + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [win32] - '@parcel/watcher@2.4.1': - resolution: {integrity: sha512-HNjmfLQEVRZmHRET336f20H/8kOozUGwk7yajvsonjNxbj2wBTK1WsQuHkD5yYh9RxFGL2EyDHryOihOwUoKDA==} + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} '@pinojs/redact@0.4.0': @@ -1224,92 +1051,153 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@react-hookz/deep-equal@3.0.3': - resolution: {integrity: sha512-SLy+NmiDpncqc2d9TR4Y4R7f8lUFOQK9WbnIq02A6wDxy+dTHfA2Np0dPvj0SFp6i1nqERLmEUe9MxPLuO/IqA==} + '@react-hookz/deep-equal@3.0.4': + resolution: {integrity: sha512-QpcSUTP1I0lI8/GvSEvclkoEGRWjbYxuUWaecxr8eJRceivGF18wHoJwaej2NYxMn7SqhKaZ9JYQvmdATiSG8A==} engines: {node: '>=18.0.0'} deprecated: PACKAGE IS DEPRECATED AND WILL BE DETED SOON, USE @ver0/deep-equal INSTEAD - '@remix-run/router@1.20.0': - resolution: {integrity: sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==} + '@remix-run/router@1.23.2': + resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} engines: {node: '>=14.0.0'} - '@rollup/rollup-android-arm-eabi@4.24.0': - resolution: {integrity: sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==} + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.24.0': - resolution: {integrity: sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==} + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.24.0': - resolution: {integrity: sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==} + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.24.0': - resolution: {integrity: sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==} + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} cpu: [x64] os: [darwin] - '@rollup/rollup-linux-arm-gnueabihf@4.24.0': - resolution: {integrity: sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==} + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.24.0': - resolution: {integrity: sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==} + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] + libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.24.0': - resolution: {integrity: sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==} + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.24.0': - resolution: {integrity: sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==} + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] - '@rollup/rollup-linux-powerpc64le-gnu@4.24.0': - resolution: {integrity: sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==} + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] + libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.24.0': - resolution: {integrity: sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==} + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-s390x-gnu@4.24.0': - resolution: {integrity: sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==} + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.24.0': - resolution: {integrity: sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==} + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] + libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.24.0': - resolution: {integrity: sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==} + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.24.0': - resolution: {integrity: sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==} + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.24.0': - resolution: {integrity: sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==} + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.24.0': - resolution: {integrity: sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==} + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} cpu: [x64] os: [win32] @@ -1343,16 +1231,16 @@ packages: resolution: {integrity: sha512-45NVw9PwB9TQ8z+xJ6G6Za+wmQ1RTA35heBSzR6U4bknj8LmA04k2iwnobvxCBEQXeLfcJEO1vFgagMoqMZMBw==} engines: {node: '>=18'} - '@sentry/babel-plugin-component-annotate@2.22.7': - resolution: {integrity: sha512-aa7XKgZMVl6l04NY+3X7BP7yvQ/s8scn8KzQfTLrGRarziTlMGrsCOBQtCNWXOPEbtxAIHpZ9dsrAn5EJSivOQ==} + '@sentry/babel-plugin-component-annotate@2.23.1': + resolution: {integrity: sha512-l1z8AvI6k9I+2z49OgvP3SlzB1M0Lw24KtceiJibNaSyQwxsItoT9/XftZ/8BBtkosVmNOTQhL1eUsSkuSv1LA==} engines: {node: '>= 14'} '@sentry/browser@10.29.0': resolution: {integrity: sha512-XdbyIR6F4qoR9Z1JCWTgunVcTJjS9p2Th+v4wYs4ME+ZdLC4tuKKmRgYg3YdSIWCn1CBfIgdI6wqETSf7H6Njw==} engines: {node: '>=18'} - '@sentry/bundler-plugin-core@2.22.7': - resolution: {integrity: sha512-ouQh5sqcB8vsJ8yTTe0rf+iaUkwmeUlGNFi35IkCFUQlWJ22qS6OfvNjOqFI19e6eGUXks0c/2ieFC4+9wJ+1g==} + '@sentry/bundler-plugin-core@2.23.1': + resolution: {integrity: sha512-JA6utNiwMKv6Jfj0Hmk0DI/XUizSHg7HhhkFETKhRlYEhZAdkyz1atDBg0ncKNgRBKyHeSYWcMFtUyo26VB76w==} engines: {node: '>= 14'} '@sentry/cli-darwin@2.39.1': @@ -1411,8 +1299,8 @@ packages: peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x - '@sentry/vite-plugin@2.22.7': - resolution: {integrity: sha512-sYRNiNm4toQGq2BfZSJPdw36em3eQaLu+3NTFpA7Hl4g3Sp2Rt3CYObnW5bxlFEruRhxzvdyB383N9OefVZ6KA==} + '@sentry/vite-plugin@2.23.1': + resolution: {integrity: sha512-avtjtIQ019sZW3FklpmNNsQOnYZjCHpnVxgDGElfZb+AaR4AvtHNlxXLJp+iqEfSK+Xok8MJarJqIgCaWcF40Q==} engines: {node: '>= 14'} '@sindresorhus/is@4.6.0': @@ -1423,77 +1311,95 @@ packages: resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} engines: {node: '>=14.16'} - '@stylistic/eslint-plugin@5.5.0': - resolution: {integrity: sha512-IeZF+8H0ns6prg4VrkhgL+yrvDXWDH2cKchrbh80ejG9dQgZWp10epHMbgRuQvgchLII/lfh6Xn3lu6+6L86Hw==} + '@stylistic/eslint-plugin@5.10.0': + resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: '>=9.0.0' + eslint: ^9.0.0 || ^10.0.0 - '@swc/core-darwin-arm64@1.6.5': - resolution: {integrity: sha512-RGQhMdni2v1/ANQ/2K+F+QYdzaucekYBewZcX1ogqJ8G5sbPaBdYdDN1qQ4kHLCIkPtGP6qC7c71qPEqL2RidQ==} + '@swc/core-darwin-arm64@1.15.26': + resolution: {integrity: sha512-OmcP96CFsNOwa65tamQayRcfqhNlcQ3YCWOq+0Wb+CAM4uB7kOMrXY41Gj4atthxrGhLQ9pg7Vk26iApb88idA==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.6.5': - resolution: {integrity: sha512-/pSN0/Jtcbbb9+ovS9rKxR3qertpFAM3OEJr/+Dh/8yy7jK5G5EFPIrfsw/7Q5987ERPIJIH6BspK2CBB2tgcg==} + '@swc/core-darwin-x64@1.15.26': + resolution: {integrity: sha512-liTTTpKSv89ivIxcZ+iU1cRige9Y7JkOjVnJ2Ystzl+DsWNHqt7wLTTgm/u7gEqmmAS2JKryODLQn3q1UtFNPQ==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.6.5': - resolution: {integrity: sha512-B0g/dROCE747RRegs/jPHuKJgwXLracDhnqQa80kFdgWEMjlcb7OMCgs5OX86yJGRS4qcYbiMGD0Pp7Kbqn3yw==} + '@swc/core-linux-arm-gnueabihf@1.15.26': + resolution: {integrity: sha512-Y/g+m3I8CeBof5A3kWWOS6QA2HOIUytF5EeTgfwcAK+GKT/tGe7Xqo5svBtaqflU5od2zzbMTWqkinPXgRWGgA==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.6.5': - resolution: {integrity: sha512-W8meapgXTq8AOtSvDG4yKR8ant2WWD++yOjgzAleB5VAC+oC+aa8YJROGxj8HepurU8kurqzcialwoMeq5SZZQ==} + '@swc/core-linux-arm64-gnu@1.15.26': + resolution: {integrity: sha512-19IvwyPfBN/rz9s7qXhOTQmW0922+pjpRUUvIebu+CMM75nX6YuDzHsGx8hSmn5dS89SNaMCh1lgUuXqm++6jg==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] - '@swc/core-linux-arm64-musl@1.6.5': - resolution: {integrity: sha512-jyCKqoX50Fg8rJUQqh4u5PqnE7nqYKXHjVH2WcYr114/MU21zlsI+YL6aOQU1XP8bJQ2gPQ1rnlnGJdEHiKS/w==} + '@swc/core-linux-arm64-musl@1.15.26': + resolution: {integrity: sha512-iNlbvTIo425rkKzDLLWFJGnFXr3myETUdIDHcjuiPNZE8b0ogmcAuilC4yEJX7FSHGbnlsoJcCT2xf4b3VJmmQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] + + '@swc/core-linux-ppc64-gnu@1.15.26': + resolution: {integrity: sha512-AuuEOtG+YXKIjIUup4RsxYNklx6XVB3WKWfhxG6hnfPrn7vp89RNOLbbyyprgj6Sk7k9ulwGVTJElEvmBNPSCA==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + libc: [glibc] - '@swc/core-linux-x64-gnu@1.6.5': - resolution: {integrity: sha512-G6HmUn/RRIlXC0YYFfBz2qh6OZkHS/KUPkhoG4X9ADcgWXXjOFh6JrefwsYj8VBAJEnr5iewzjNfj+nztwHaeA==} + '@swc/core-linux-s390x-gnu@1.15.26': + resolution: {integrity: sha512-JcMDWQvW1BchUyRg8E0jHiTx7CQYpUr5uDEL1dnPDECrEjBEGG2ynmJ3XX70sWXql0JagqR1t3VpANYFWdUnqA==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-gnu@1.15.26': + resolution: {integrity: sha512-FW7V7Mbpq4+PA7BiAq76LJs8MdNuUSylyuRVfQRkhIyeWadFroZ+KOPgjku8Z/fXzngxBRvsk+PGGB0t8mGcjA==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] - '@swc/core-linux-x64-musl@1.6.5': - resolution: {integrity: sha512-AQpBjBnelQDSbeTJA50AXdS6+CP66LsXIMNTwhPSgUfE7Bx1ggZV11Fsi4Q5SGcs6a8Qw1cuYKN57ZfZC5QOuA==} + '@swc/core-linux-x64-musl@1.15.26': + resolution: {integrity: sha512-w8erqMHsVcdGwUfJxF6LaiTuPoKnyLOcUbhLcxiXrlLt5MLjtlgcIeUY/NWK/oPoyqkgH+/i8pOJnMTxvl83ZQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] - '@swc/core-win32-arm64-msvc@1.6.5': - resolution: {integrity: sha512-MZTWM8kUwS30pVrtbzSGEXtek46aXNb/mT9D6rsS7NvOuv2w+qZhjR1rzf4LNbbn5f8VnR4Nac1WIOYZmfC5ng==} + '@swc/core-win32-arm64-msvc@1.15.26': + resolution: {integrity: sha512-uDCWCNpUiqkbvPmsuPUTn/P7ag9SqNXD2JT/W3dUu7yZ2krzN+nmmoQ2xRX63/J6RYiHI7aT4jo7Z++lsljlPA==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.6.5': - resolution: {integrity: sha512-WZdu4gISAr3yOm1fVwKhhk6+MrP7kVX0KMP7+ZQFTN5zXQEiDSDunEJKVgjMVj3vlR+6mnAqa/L0V9Qa8+zKlQ==} + '@swc/core-win32-ia32-msvc@1.15.26': + resolution: {integrity: sha512-2k1ax1QmmqLEnpC0uRCw7OXhBfyvdPqERBXupDasjYbChT6ZSO/uha28Bp38cw0viKIG79L27aTDkbkABsMW3w==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.6.5': - resolution: {integrity: sha512-ezXgucnMTzlFIxQZw7ls/5r2hseFaRoDL04cuXUOs97E8r+nJSmFsRQm/ygH5jBeXNo59nyZCalrjJAjwfgACA==} + '@swc/core-win32-x64-msvc@1.15.26': + resolution: {integrity: sha512-aUuYecSEGa4SUSdyCWaI/vk8jdseifYnsF1GZQx2+piL8GIuT/5QrVcFfmes4Iwy7FIVXxtzD063z/FfpZ7K7w==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.6.5': - resolution: {integrity: sha512-tyVvUK/HDOUUsK6/GmWvnqUtD9oDpPUA4f7f7JCOV8hXxtfjMtAZeBKf93yrB1XZet69TDR7EN0hFC6i4MF0Ig==} + '@swc/core@1.15.26': + resolution: {integrity: sha512-tglZGyx8N5PC+x1Nd/JrZxqpqlcZoSuG9gTDKO6AuFToFiVB3uS8HvbKFuO7g3lJzvFf9riAb94xs9HU2UhAHQ==} engines: {node: '>=10'} peerDependencies: - '@swc/helpers': '*' + '@swc/helpers': '>=0.5.17' peerDependenciesMeta: '@swc/helpers': optional: true @@ -1501,8 +1407,8 @@ packages: '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} - '@swc/types@0.1.9': - resolution: {integrity: sha512-qKnCno++jzcJ4lM4NTfYifm1EFSCeIfKiAHAfkENZAV5Kl9PjJIyd2yeeVv6c/2CckuLyv2NmRC5pv6pm2WQBg==} + '@swc/types@0.1.26': + resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==} '@szmarczak/http-timer@4.0.6': resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} @@ -1512,26 +1418,26 @@ packages: resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} - '@tailwindcss/forms@0.5.9': - resolution: {integrity: sha512-tM4XVr2+UVTxXJzey9Twx48c1gcxFStqn1pQz0tRsX8o3DvxhN5oY5pvyAbUx7VTaZxpej4Zzvc6h+1RJBzpIg==} + '@tailwindcss/forms@0.5.11': + resolution: {integrity: sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==} peerDependencies: - tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20' + tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1' - '@tailwindcss/typography@0.5.15': - resolution: {integrity: sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==} + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} peerDependencies: - tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20' + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' - '@tanstack/query-core@5.48.0': - resolution: {integrity: sha512-lZAfPPeVIqXCswE9SSbG33B6/91XOWt/Iq41bFeWb/mnHwQSIfFRbkS4bfs+WhIk9abRArF9Id2fp0Mgo+hq6Q==} + '@tanstack/query-core@5.99.0': + resolution: {integrity: sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==} - '@tanstack/react-query@5.48.0': - resolution: {integrity: sha512-GDExbjYWzvDokyRqMSWXdrPiYpp95Aig0oeMIrxTaruOJJgWiWfUP//OAaowm2RrRkGVsavSZdko/XmIrrV2Nw==} + '@tanstack/react-query@5.99.0': + resolution: {integrity: sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==} peerDependencies: - react: ^18.0.0 + react: ^18 || ^19 - '@tweenjs/tween.js@23.1.2': - resolution: {integrity: sha512-kMCNaZCJugWI86xiEHaY338CU5JpD0B97p1j1IKNn/Zto8PgACjQx0UxbHjmOcLl/dDOBnItwD07KmCs75pxtQ==} + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} '@tweenjs/tween.js@25.0.0': resolution: {integrity: sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==} @@ -1545,26 +1451,26 @@ packages: '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} - '@types/babel__generator@7.6.8': - resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} '@types/babel__template@7.4.4': resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} - '@types/babel__traverse@7.20.6': - resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} - '@types/debug@4.1.12': - resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - '@types/estree@1.0.6': - resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} '@types/file-saver@2.0.7': resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} @@ -1575,8 +1481,8 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - '@types/http-cache-semantics@4.0.4': - resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1590,20 +1496,22 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - '@types/ms@0.7.34': - resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@24.10.0': - resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==} + '@types/node@24.12.2': + resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} '@types/plist@3.0.5': resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} - '@types/prop-types@15.7.12': - resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} - '@types/react-dom@18.3.0': - resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==} + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 '@types/react-helmet@6.1.11': resolution: {integrity: sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g==} @@ -1611,17 +1519,17 @@ packages: '@types/react-modal@3.16.3': resolution: {integrity: sha512-xXuGavyEGaFQDgBv4UVm8/ZsG+qxeQ7f77yNrW3n+1J6XAstUy5rYHeIHPh1KzsGc6IkCIdu6lQ2xWzu1jBTLg==} - '@types/react@18.3.11': - resolution: {integrity: sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==} + '@types/react@18.3.28': + resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} - '@types/semver@7.5.8': - resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - '@types/stats.js@0.17.3': - resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} '@types/three@0.163.0': resolution: {integrity: sha512-uIdDhsXRpQiBUkflBS/i1l3JX14fW6Ot9csed60nfbZNXHDTRsnV2xnTVwXcgbvTiboAR4IW+t+lTL5f1rqIqA==} @@ -1635,8 +1543,8 @@ packages: '@types/verror@1.10.11': resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} - '@types/webxr@0.5.16': - resolution: {integrity: sha512-0E0Cl84FECtzrB4qG19TNTqpunw0F1YF0QZZnFMF6pDw1kNKJtrlTKlVB34stGIsHbZsYQ7H0tNjPfZftkHHoA==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -1644,126 +1552,67 @@ packages: '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@typescript-eslint/eslint-plugin@8.46.4': - resolution: {integrity: sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.46.4 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/eslint-plugin@8.48.1': - resolution: {integrity: sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==} + '@typescript-eslint/eslint-plugin@8.58.2': + resolution: {integrity: sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.48.1 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/parser@8.46.4': - resolution: {integrity: sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/parser@8.48.1': - resolution: {integrity: sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/project-service@8.46.4': - resolution: {integrity: sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/project-service@8.48.1': - resolution: {integrity: sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/scope-manager@8.46.4': - resolution: {integrity: sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/scope-manager@8.48.1': - resolution: {integrity: sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/parser': ^8.58.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.46.4': - resolution: {integrity: sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A==} + '@typescript-eslint/parser@8.58.2': + resolution: {integrity: sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.48.1': - resolution: {integrity: sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==} + '@typescript-eslint/project-service@8.58.2': + resolution: {integrity: sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.46.4': - resolution: {integrity: sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ==} + '@typescript-eslint/scope-manager@8.58.2': + resolution: {integrity: sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.48.1': - resolution: {integrity: sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==} + '@typescript-eslint/tsconfig-utils@8.58.2': + resolution: {integrity: sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/types@8.46.4': - resolution: {integrity: sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/types@8.48.1': - resolution: {integrity: sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.46.4': - resolution: {integrity: sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA==} + '@typescript-eslint/type-utils@8.58.2': + resolution: {integrity: sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/typescript-estree@8.48.1': - resolution: {integrity: sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==} + '@typescript-eslint/types@8.58.2': + resolution: {integrity: sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.46.4': - resolution: {integrity: sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg==} + '@typescript-eslint/typescript-estree@8.58.2': + resolution: {integrity: sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.48.1': - resolution: {integrity: sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==} + '@typescript-eslint/utils@8.58.2': + resolution: {integrity: sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/visitor-keys@8.46.4': - resolution: {integrity: sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.48.1': - resolution: {integrity: sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==} + '@typescript-eslint/visitor-keys@8.58.2': + resolution: {integrity: sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@ungap/structured-clone@1.2.0': - resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -1804,41 +1653,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1860,22 +1717,22 @@ packages: cpu: [x64] os: [win32] - '@vitejs/plugin-react@4.3.2': - resolution: {integrity: sha512-hieu+o05v4glEBucTcKMK3dlES0OeJlD9YVOAPraVMOInBCwzumaIFiUjr4bHK7NPgnAHgiskUoceKercrN8vg==} + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: - vite: ^4.2.0 || ^5.0.0 + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 '@vladfrangu/async_event_emitter@2.4.7': resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - '@xhayper/discord-rpc@1.3.0': - resolution: {integrity: sha512-0NmUTiODl7u3UEjmO6y0Syp3dmgVLAt2EHrH4QKTQcXRwtF8Wl7Eipdn/GSSZ8HkDwxQFvcDGJMxT9VWB0pH8g==} - engines: {node: '>=18.20.7'} + '@xhayper/discord-rpc@1.3.3': + resolution: {integrity: sha512-Ih48GHiua7TtZgKO+f0uZPhCeQqb84fY2qUys/oMh8UbUfiUkUJLVCmd/v2AK0/pV33euh0aqSXo7+9LiPSwGw==} + engines: {node: '>=20'} - '@xmldom/xmldom@0.8.11': - resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + '@xmldom/xmldom@0.8.12': + resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==} engines: {node: '>=10.0.0'} abbrev@3.0.1: @@ -1887,13 +1744,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.12.0: - resolution: {integrity: sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==} - engines: {node: '>=0.4.0'} - hasBin: true - - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} hasBin: true @@ -1910,34 +1762,30 @@ packages: peerDependencies: ajv: ^6.9.1 - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} - ansi-escapes@7.0.0: - resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - ansi-regex@6.0.1: - resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} - ansi-styles@3.2.1: - resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} - engines: {node: '>=4'} - ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} - ansi-styles@6.2.1: - resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} any-promise@1.3.0: @@ -1950,12 +1798,12 @@ packages: app-builder-bin@5.0.0-alpha.12: resolution: {integrity: sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==} - app-builder-lib@26.7.0: - resolution: {integrity: sha512-/UgCD8VrO79Wv8aBNpjMfsS1pIUfIPURoRn0Ik6tMe5avdZF+vQgl/juJgipcMmH3YS0BD573lCdCHyoi84USg==} + app-builder-lib@26.9.0: + resolution: {integrity: sha512-f/1GhVrDfBH7sSzhAwiXa1rpR/7pB7Av5woUjmt+QYE0QyNvrOAiY05rngtR/PK4/1BzS6/zVoYobIwDAsrtBA==} engines: {node: '>=14.0.0'} peerDependencies: - dmg-builder: 26.7.0 - electron-builder-squirrel-windows: 26.7.0 + dmg-builder: 26.9.0 + electron-builder-squirrel-windows: 26.9.0 arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -1970,10 +1818,6 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} - array-buffer-byte-length@1.0.1: - resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} - engines: {node: '>= 0.4'} - array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -1982,10 +1826,6 @@ packages: resolution: {integrity: sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==} engines: {node: '>=0.10.0'} - array-includes@3.1.8: - resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} - engines: {node: '>= 0.4'} - array-includes@3.1.9: resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} @@ -1998,18 +1838,10 @@ packages: resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} engines: {node: '>= 0.4'} - array.prototype.flat@1.3.2: - resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==} - engines: {node: '>= 0.4'} - array.prototype.flat@1.3.3: resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} engines: {node: '>= 0.4'} - array.prototype.flatmap@1.3.2: - resolution: {integrity: sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==} - engines: {node: '>= 0.4'} - array.prototype.flatmap@1.3.3: resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} engines: {node: '>= 0.4'} @@ -2018,10 +1850,6 @@ packages: resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} engines: {node: '>= 0.4'} - arraybuffer.prototype.slice@1.0.3: - resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} - engines: {node: '>= 0.4'} - arraybuffer.prototype.slice@1.0.4: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} @@ -2041,6 +1869,10 @@ packages: resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} engines: {node: '>=0.12.0'} + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -2055,8 +1887,8 @@ packages: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} - autoprefixer@10.4.20: - resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==} + autoprefixer@10.5.0: + resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: @@ -2066,8 +1898,8 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axe-core@4.10.1: - resolution: {integrity: sha512-qPC9o+kD8Tir0lzNGLeghbOrWMr3ZJpaRlCIb6Uobt/7N4FiEDvqUMnxzCHRHmg8vOg14kr5gVNyScRmbMaJ9g==} + axe-core@4.11.3: + resolution: {integrity: sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==} engines: {node: '>=4'} axobject-query@4.1.0: @@ -2077,8 +1909,8 @@ packages: babel-plugin-add-module-exports@1.0.4: resolution: {integrity: sha512-g+8yxHUZ60RcyaUpfNzy56OtWW+x9cyEe9j+CranqLiqbju2yf/Cy6ZtYK40EZxtrdHllzlVZgLmcOUCTlJ7Jg==} - babel-plugin-module-resolver@5.0.2: - resolution: {integrity: sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg==} + babel-plugin-module-resolver@5.0.3: + resolution: {integrity: sha512-h8h6H71ZvdLJZxZrYkaeR30BojTaV7O9GfqacY14SNj5CNB8ocL9tydNzTC0JrnNN7vY3eJhwCmkDj7tuEUaqQ==} bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -2086,13 +1918,18 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - balanced-match@4.0.2: - resolution: {integrity: sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==} - engines: {node: 20 || >=22} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.19: + resolution: {integrity: sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==} + engines: {node: '>=6.0.0'} + hasBin: true + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2107,15 +1944,15 @@ packages: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. - brace-expansion@1.1.11: - resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - brace-expansion@2.0.1: - resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} - brace-expansion@5.0.2: - resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} - engines: {node: 20 || >=22} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} @@ -2124,13 +1961,8 @@ packages: browser-fs-access@0.35.0: resolution: {integrity: sha512-sLoadumpRfsjprP8XzVjpQc0jK8yqHBx0PtUTGYj2fftT+P/t+uyDAQdMgGAPKD011in/O+YYGh7fIs0oG/viw==} - browserslist@4.23.1: - resolution: {integrity: sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - browserslist@4.24.0: - resolution: {integrity: sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -2143,12 +1975,12 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - builder-util-runtime@9.5.1: - resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} + builder-util-runtime@9.6.0: + resolution: {integrity: sha512-k9V5I18PXLepLZ8jVmPzsH+gVCVZ+9aMVLyCZ0ZLOkT2KSyiBblDCCN8WxDbjOpfLGNHZqqJPBmW0HYeqxlgkQ==} engines: {node: '>=12.0.0'} - builder-util@26.4.1: - resolution: {integrity: sha512-FlgH43XZ50w3UtS1RVGDWOz8v9qMXPC7upMtKMtBEnYdt1OVoS61NYhKm/4x+cIaWqJTXua0+VVPI+fSPGXNIw==} + builder-util@26.9.0: + resolution: {integrity: sha512-+eocmbdisnyb40B9nAp/KREfYLXvGagxV50KZv/Zh4aflsr1fdY9Qxs6QG1Jtx1vH5d5NQ3hIcemUi4RSlFK/Q==} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} @@ -2186,15 +2018,8 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.2: - resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} - - call-bind@1.0.7: - resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} - engines: {node: '>= 0.4'} - - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} engines: {node: '>= 0.4'} call-bound@1.0.4: @@ -2212,11 +2037,8 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - caniuse-lite@1.0.30001636: - resolution: {integrity: sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==} - - caniuse-lite@1.0.30001669: - resolution: {integrity: sha512-DlWzFDJqstqtIVx1zeSpIMLjunf5SmwOw0N2Ck/QSQdS8PLS4+9HrLaYei4w8BIAL7IB/UEDu889d8vhCTPA0w==} + caniuse-lite@1.0.30001788: + resolution: {integrity: sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==} case@1.6.3: resolution: {integrity: sha512-mzDSXIPaFwVDvZAHqZ9VlbyF4yyXRuX6IvB06WvPYkqJVO24kX1PPhv9bfpKNFZyxYFmmgo03HUiD8iklmJYRQ==} @@ -2225,16 +2047,12 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chalk@2.4.2: - resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} - engines: {node: '>=4'} - chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chalk@5.3.0: - resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} character-entities-html4@2.1.0: @@ -2253,8 +2071,8 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} - chokidar@4.0.1: - resolution: {integrity: sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} chownr@3.0.0: @@ -2319,16 +2137,10 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} - color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} - color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -2342,10 +2154,6 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - commander@12.1.0: - resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} - engines: {node: '>=18'} - commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} @@ -2354,9 +2162,6 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} - commander@2.20.3: - resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -2379,8 +2184,8 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - convert@5.13.1: - resolution: {integrity: sha512-LB7K75X/D7Xp9ZYhNrjcny8kr+xzlDcw/KK6lccXrHhxvr2E/LO/UtlYRZRdpAVb9xe5uEBY++uish8Rz5+9IQ==} + convert@5.14.1: + resolution: {integrity: sha512-zp1UyVnTL7g/T+sw1Bb1yCIDZIR50eKFnYJXsmRP2SXs3RfzligO3JWc2ZtqzdJX0/7xwfma/J8j69JD5MB2aA==} core-util-is@1.0.2: resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} @@ -2394,10 +2199,6 @@ packages: cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} - cross-spawn@7.0.3: - resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} - engines: {node: '>= 8'} - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2410,32 +2211,20 @@ packages: engines: {node: '>=4'} hasBin: true - csstype@3.1.3: - resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - data-view-buffer@1.0.1: - resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} - engines: {node: '>= 0.4'} - data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} - data-view-byte-length@1.0.1: - resolution: {integrity: sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==} - engines: {node: '>= 0.4'} - data-view-byte-length@1.0.2: resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} engines: {node: '>= 0.4'} - data-view-byte-offset@1.0.0: - resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} - engines: {node: '>= 0.4'} - data-view-byte-offset@1.0.1: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} @@ -2454,24 +2243,6 @@ packages: supports-color: optional: true - debug@4.3.5: - resolution: {integrity: sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - debug@4.3.7: - resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2481,8 +2252,8 @@ packages: supports-color: optional: true - decode-named-character-reference@1.0.2: - resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} @@ -2542,11 +2313,6 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} - detect-libc@1.0.3: - resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} - engines: {node: '>=0.10'} - hasBin: true - detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2560,15 +2326,15 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - diff@4.0.2: - resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + diff@4.0.4: + resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} - discord-api-types@0.38.39: - resolution: {integrity: sha512-XRdDQvZvID1XvcFftjSmd4dcmMi/RL/jSy5sduBDAvCGFcNFHThdIQXCEBDZFe52lCNEzuIL0QJoKYAmRmxLUA==} + discord-api-types@0.38.46: + resolution: {integrity: sha512-Ae7NcagMG+FPxwuQxGCPEHmLCKMm8YBMPWEuF5J3L+KWrlH4XGR3UoVo4Ne8bwhhHXbpf+DxDqOeW2jBFupXCQ==} discord-rich-presence@0.0.8: resolution: {integrity: sha512-IpVMPjv15C9UvppxvrrGdv6bzQHOW1P1vLoMH15HvdJwGJ3dBd2bnrJ63Uy36YRUfrAMxGLiwUDHncvC8AuPaQ==} @@ -2580,8 +2346,8 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - dmg-builder@26.7.0: - resolution: {integrity: sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==} + dmg-builder@26.9.0: + resolution: {integrity: sha512-5uSyg0yY+JRMMGwFjIweaukYkCGcZSZwvH5VPlBHiKkTEP1a1/uV+bs4y+VAxPMgzg67YB4CF1vD4U/2d3chfg==} dmg-license@1.0.11: resolution: {integrity: sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==} @@ -2597,8 +2363,8 @@ packages: resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} engines: {node: '>=12'} - dotenv@16.4.5: - resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} dunder-proto@1.0.1: @@ -2620,22 +2386,19 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - electron-builder-squirrel-windows@26.7.0: - resolution: {integrity: sha512-3EqkQK+q0kGshdPSKEPb2p5F75TENMKu6Fe5aTdeaPfdzFK4Yjp5L0d6S7K8iyvqIsGQ/ei4bnpyX9wt+kVCKQ==} + electron-builder-squirrel-windows@26.9.0: + resolution: {integrity: sha512-Jzsxa3Kjv94ZRZDK7pc8bzu5iglwGsDOBMWTqLpWx2sCEvb3TLW9PMWqOjpDwizqkMBp8GjP6rceLzQYLFIVow==} - electron-builder@26.7.0: - resolution: {integrity: sha512-LoXbCvSFxLesPneQ/fM7FB4OheIDA2tjqCdUkKlObV5ZKGhYgi5VHPHO/6UUOUodAlg7SrkPx7BZJPby+Vrtbg==} + electron-builder@26.9.0: + resolution: {integrity: sha512-7BEeGz8Xlk7Qvitj70nGAQaq7WQIZ2amsDvsyKSZbxgtL2pM9fm/woZrtn0hoID6Fl7wkn456dK3OmtFNzgiOA==} engines: {node: '>=14.0.0'} hasBin: true - electron-publish@26.6.0: - resolution: {integrity: sha512-LsyHMMqbvJ2vsOvuWJ19OezgF2ANdCiHpIucDHNiLhuI+/F3eW98ouzWSRmXXi82ZOPZXC07jnIravY4YYwCLQ==} + electron-publish@26.9.0: + resolution: {integrity: sha512-gsy+U7JfDuD1lPOrCXeECDQoUsWjFah3s1Fv3pqKnjdJuKYDDvGdvC74kLHVG6nl5G0uQ7YN0eftCQ4rUmhvVw==} - electron-to-chromium@1.4.803: - resolution: {integrity: sha512-61H9mLzGOCLLVsnLiRzCbc63uldP0AniRYPV3hbGVtONA1pI7qSGILdbofR7A8TMbOypDocEAjH/e+9k1QIe3g==} - - electron-to-chromium@1.5.41: - resolution: {integrity: sha512-dfdv/2xNjX0P8Vzme4cfzHqnPm5xsZXwsolTYr0eyW18IUmNyG08vL+fttvinTfhKfIKdRoqkDIC9e9iWQCNYQ==} + electron-to-chromium@1.5.339: + resolution: {integrity: sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg==} electron-vite@5.0.0: resolution: {integrity: sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==} @@ -2652,13 +2415,13 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@40.3.0: - resolution: {integrity: sha512-ZaDkTZpNHr863tyZHieoqbaiLI0e3RVCXoEC5y1Ld70/Q5H1mPV9d5TK0h1dWtaSFVOW0w8iDvtdLwAXtasXpg==} + electron@40.9.1: + resolution: {integrity: sha512-dgUqGjpTJeLZQEbvZQlnjMtPCUfOGRBNJIYjw8yC2ZN9O1QS172r09trhLke+rC8JCLBGbf2DeU63AArd0tbhQ==} engines: {node: '>= 12.20.55'} hasBin: true - emoji-regex@10.3.0: - resolution: {integrity: sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2669,8 +2432,8 @@ packages: encoding@0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} - end-of-stream@1.4.4: - resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} @@ -2683,16 +2446,8 @@ packages: err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} - es-abstract@1.23.3: - resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} - engines: {node: '>= 0.4'} - - es-abstract@1.24.0: - resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} - engines: {node: '>= 0.4'} - - es-define-property@1.0.0: - resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} es-define-property@1.0.1: @@ -2706,37 +2461,22 @@ packages: es-get-iterator@1.1.3: resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} - es-iterator-helpers@1.2.1: - resolution: {integrity: sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.0.0: - resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} + es-iterator-helpers@1.3.2: + resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} engines: {node: '>= 0.4'} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - es-set-tostringtag@2.0.3: - resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} - engines: {node: '>= 0.4'} - es-set-tostringtag@2.1.0: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-shim-unscopables@1.0.2: - resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==} - es-shim-unscopables@1.1.0: resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} engines: {node: '>= 0.4'} - es-to-primitive@1.2.1: - resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} - engines: {node: '>= 0.4'} - es-to-primitive@1.3.0: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} @@ -2757,18 +2497,10 @@ packages: engines: {node: '>=18'} hasBin: true - escalade@3.1.2: - resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} - engines: {node: '>=6'} - escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - escape-string-regexp@1.0.5: - resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} - engines: {node: '>=0.8.0'} - escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -2777,8 +2509,8 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} eslint-import-resolver-typescript@3.10.1: resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} @@ -2854,8 +2586,12 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.39.1: - resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -2868,8 +2604,8 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esquery@1.5.0: - resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} esrecurse@4.3.0: @@ -2887,8 +2623,8 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - eventemitter3@5.0.1: - resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} execa@4.1.0: resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} @@ -2919,14 +2655,14 @@ packages: fast-content-type-parse@2.0.1: resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==} - fast-copy@4.0.2: - resolution: {integrity: sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==} + fast-copy@4.0.3: + resolution: {integrity: sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==} fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-glob@3.3.2: - resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} fast-json-parse@1.0.3: @@ -2944,8 +2680,8 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fastq@1.17.1: - resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} @@ -2969,15 +2705,15 @@ packages: file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - filelist@1.0.4: - resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - find-babel-config@2.1.1: - resolution: {integrity: sha512-5Ji+EAysHGe1OipH7GN4qDjok5Z1uw5KAwDCbicU/4wyTZY7CqOCzcWbG7J5ad9mazq67k89fXlbc1MuIfl9uA==} + find-babel-config@2.1.2: + resolution: {integrity: sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg==} find-up@3.0.0: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} @@ -2997,20 +2733,13 @@ packages: flatbuffers@22.10.26: resolution: {integrity: sha512-sdO3emf/BlLfOogW6KwHuXg16APR/E86jNacDXfSInPzt8SSEzxlHcqDekfM/IJ1CGC5bvDksfNufNhS8h1FRA==} - flatted@3.3.1: - resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} - - for-each@0.3.3: - resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - foreground-child@3.2.1: - resolution: {integrity: sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==} - engines: {node: '>=14'} - foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -3023,15 +2752,15 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} - fraction.js@4.3.7: - resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} - fs-extra@11.3.3: - resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} + fs-extra@11.3.4: + resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} engines: {node: '>=14.14'} fs-extra@7.0.1: @@ -3061,10 +2790,6 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function.prototype.name@1.1.6: - resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} - engines: {node: '>= 0.4'} - function.prototype.name@1.1.8: resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} engines: {node: '>= 0.4'} @@ -3072,6 +2797,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -3080,17 +2809,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.2.0: - resolution: {integrity: sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==} + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} - get-intrinsic@1.2.1: - resolution: {integrity: sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==} - - get-intrinsic@1.2.4: - resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} - engines: {node: '>= 0.4'} - get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -3111,16 +2833,12 @@ packages: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} - get-symbol-description@1.0.2: - resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} - engines: {node: '>= 0.4'} - get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.13.0: - resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} @@ -3130,21 +2848,14 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.4.1: - resolution: {integrity: sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==} - engines: {node: '>=16 || 14 >=14.18'} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - - glob@11.1.0: - resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} - engines: {node: 20 || >=22} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - glob@13.0.3: - resolution: {integrity: sha512-/g3B0mC+4x724v1TgtBlBtt2hPi/EWptsIAmXUx9Z2rvBYleQcsrmaOzd5LyL50jf/Soi83ZDJmw2+XqvH/EeA==} - engines: {node: 20 || >=22} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} @@ -3159,25 +2870,18 @@ packages: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} engines: {node: '>=10.0'} - globals@11.12.0: - resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} - engines: {node: '>=4'} - globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} - globals@15.10.0: - resolution: {integrity: sha512-tqFIbz83w4Y5TCbtgjZjApohbuh7K9BxGYFm7ifwDR240tvdb7P9x+/9VvUKlmkPoiknoJtanI8UOrqxS3a7lQ==} + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} engines: {node: '>=18'} globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} - gopd@1.0.1: - resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3200,15 +2904,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - - has-bigints@1.0.2: - resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} - - has-flag@3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} - engines: {node: '>=4'} + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} @@ -3217,40 +2915,24 @@ packages: has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - has-proto@1.0.3: - resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} - engines: {node: '>= 0.4'} - has-proto@1.2.0: resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} engines: {node: '>= 0.4'} - has-symbols@1.0.3: - resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} - engines: {node: '>= 0.4'} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} - has-tostringtag@1.0.0: - resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} - engines: {node: '>= 0.4'} - has-tostringtag@1.0.2: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - has@1.0.3: - resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} - engines: {node: '>= 0.4.0'} - hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hast-util-to-jsx-runtime@2.3.2: - resolution: {integrity: sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg==} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -3277,8 +2959,8 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} - http-cache-semantics@4.1.1: - resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} @@ -3311,8 +2993,8 @@ packages: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} - husky@9.1.6: - resolution: {integrity: sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A==} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} hasBin: true @@ -3331,19 +3013,19 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.1: - resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} ignore@7.0.5: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - immutable@4.3.6: - resolution: {integrity: sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==} + immutable@5.1.5: + resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} - import-fresh@3.3.0: - resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} imurmurhash@0.1.4: @@ -3357,12 +3039,8 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - inline-style-parser@0.2.4: - resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} - - internal-slot@1.0.7: - resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} - engines: {node: '>= 0.4'} + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} @@ -3375,8 +3053,8 @@ packages: resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} engines: {node: '>= 12'} - ip-num@1.5.1: - resolution: {integrity: sha512-QziFxgxq3mjIf5CuwlzXFYscHxgLqdEdJKRo2UJ5GurL5zrSRMzT/O+nK0ABimoFH8MWF8YwIiwECYsHc1LpUQ==} + ip-num@1.5.2: + resolution: {integrity: sha512-CUxHEJuJk74GIQA75LD9RDkpK/3NJv8yRnseOh79AlRmFwT1HgYPKDOiVkd0QVR5MNwMj9EnOnS1yJn9CaYNZA==} is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -3384,25 +3062,18 @@ packages: is-alphanumerical@2.0.1: resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} - is-arguments@1.1.1: - resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} - engines: {node: '>= 0.4'} - - is-array-buffer@3.0.4: - resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} engines: {node: '>= 0.4'} is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} - is-async-function@2.0.0: - resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==} + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} engines: {node: '>= 0.4'} - is-bigint@1.0.4: - resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} - is-bigint@1.1.0: resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} engines: {node: '>= 0.4'} @@ -3411,10 +3082,6 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} - is-boolean-object@1.1.2: - resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} - engines: {node: '>= 0.4'} - is-boolean-object@1.2.2: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} @@ -3426,29 +3093,14 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - is-core-module@2.13.1: - resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} - - is-core-module@2.15.1: - resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} - engines: {node: '>= 0.4'} - is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} - is-data-view@1.0.1: - resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==} - engines: {node: '>= 0.4'} - is-data-view@1.0.2: resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} engines: {node: '>= 0.4'} - is-date-object@1.0.5: - resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} - engines: {node: '>= 0.4'} - is-date-object@1.1.0: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} @@ -3470,9 +3122,6 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-finalizationregistry@1.0.2: - resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} - is-finalizationregistry@1.1.1: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} @@ -3485,12 +3134,12 @@ packages: resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} engines: {node: '>=12'} - is-fullwidth-code-point@5.0.0: - resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==} + is-fullwidth-code-point@5.1.0: + resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} - is-generator-function@1.0.10: - resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} is-glob@4.0.3: @@ -3521,10 +3170,6 @@ packages: resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} engines: {node: '>= 0.4'} - is-number-object@1.0.7: - resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} - engines: {node: '>= 0.4'} - is-number-object@1.1.1: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} @@ -3537,10 +3182,6 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} - is-regex@1.1.4: - resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} - engines: {node: '>= 0.4'} - is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3549,10 +3190,6 @@ packages: resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} engines: {node: '>= 0.4'} - is-shared-array-buffer@1.0.3: - resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} - engines: {node: '>= 0.4'} - is-shared-array-buffer@1.0.4: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} @@ -3565,26 +3202,14 @@ packages: resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - is-string@1.0.7: - resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} - engines: {node: '>= 0.4'} - is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} - is-symbol@1.0.4: - resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} - engines: {node: '>= 0.4'} - is-symbol@1.1.1: resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} engines: {node: '>= 0.4'} - is-typed-array@1.1.13: - resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} - engines: {node: '>= 0.4'} - is-typed-array@1.1.15: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} @@ -3597,23 +3222,20 @@ packages: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} - is-weakref@1.0.2: - resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} - is-weakref@1.1.1: resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} engines: {node: '>= 0.4'} - is-weakset@2.0.3: - resolution: {integrity: sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==} + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} is-wsl@2.2.0: resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} engines: {node: '>=8'} - is-wsl@3.1.0: - resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} isarray@2.0.5: @@ -3638,34 +3260,35 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} - jackspeak@3.4.0: - resolution: {integrity: sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==} - engines: {node: '>=14'} - - jackspeak@4.2.3: - resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} - engines: {node: 20 || >=22} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} jake@10.9.4: resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} engines: {node: '>=10'} hasBin: true - jiti@1.21.6: - resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jotai@2.12.2: - resolution: {integrity: sha512-oN8715y7MkjXlSrpyjlR887TOuc/NLZMs9gvgtfWH/JP47ChwO0lR2ijSwBvPMYyXRAPT+liIAhuBavluKGgtA==} + jotai@2.19.1: + resolution: {integrity: sha512-sqm9lVZiqBHZH8aSRk32DSiZDHY3yUIlulXYn9GQj7/LvoUdYXSMti7ZPJGo+6zjzKFt5a25k/I6iBCi43PJcw==} engines: {node: '>=12.20.0'} peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' '@types/react': '>=17.0.0' react: '>=17.0.0' peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true '@types/react': optional: true react: @@ -3678,17 +3301,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true - jsesc@2.5.2: - resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} - engines: {node: '>=4'} - hasBin: true - - jsesc@3.0.2: - resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} hasBin: true @@ -3719,8 +3337,8 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - jsonfile@6.1.0: - resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} @@ -3747,24 +3365,20 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - lilconfig@2.1.0: - resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} - engines: {node: '>=10'} - - lilconfig@3.1.2: - resolution: {integrity: sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==} + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - lint-staged@15.2.10: - resolution: {integrity: sha512-5dY5t743e1byO19P9I4b3x8HJwalIznL5E1FWYnU6OWw33KxNBSLAc6Cy7F2PsFEO8FKnLwjwm5hx7aMF0jzZg==} + lint-staged@15.5.2: + resolution: {integrity: sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==} engines: {node: '>=18.12.0'} hasBin: true - listr2@8.2.5: - resolution: {integrity: sha512-iyAZCeyD+c1gPyE9qpFu8af0Y+MRtmKOncdGoA2S5EY8iFq99dmmvkNnHiWo+pj0s7yH7l3KPIgee77tKpXPWQ==} + listr2@8.3.3: + resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==} engines: {node: '>=18.0.0'} locate-path@3.0.0: @@ -3775,17 +3389,11 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} - lodash.castarray@4.4.0: - resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==} - - lodash.isplainobject@4.0.6: - resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} - lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} @@ -3810,12 +3418,11 @@ packages: resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - lru-cache@10.2.2: - resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==} - engines: {node: 14 || >=16.14} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.6: - resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} + lru-cache@11.3.5: + resolution: {integrity: sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -3856,17 +3463,17 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mdast-util-find-and-replace@3.0.1: - resolution: {integrity: sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==} + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} - mdast-util-from-markdown@2.0.2: - resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} mdast-util-gfm-autolink-literal@2.0.1: resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} - mdast-util-gfm-footnote@2.0.0: - resolution: {integrity: sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==} + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} mdast-util-gfm-strikethrough@2.0.0: resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} @@ -3877,14 +3484,14 @@ packages: mdast-util-gfm-task-list-item@2.0.0: resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} - mdast-util-gfm@3.0.0: - resolution: {integrity: sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==} + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} mdast-util-mdx-expression@2.0.1: resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} - mdast-util-mdx-jsx@3.1.3: - resolution: {integrity: sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ==} + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} mdast-util-mdxjs-esm@2.0.1: resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} @@ -3892,8 +3499,8 @@ packages: mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} - mdast-util-to-hast@13.2.0: - resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} @@ -3911,8 +3518,8 @@ packages: meshoptimizer@0.18.1: resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==} - micromark-core-commonmark@2.0.1: - resolution: {integrity: sha512-CUQyKr1e///ZODyD1U3xit6zXwy1a8q2a1S1HKtIlmgvurrEpaw/Y9y6KSIbF8P59cn/NjzHyO+Q2fAyYLQrAA==} + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} micromark-extension-gfm-autolink-literal@2.1.0: resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} @@ -3923,8 +3530,8 @@ packages: micromark-extension-gfm-strikethrough@2.1.0: resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} - micromark-extension-gfm-table@2.1.0: - resolution: {integrity: sha512-Ub2ncQv+fwD70/l4ou27b4YzfNaCJOvyX4HxXU15m7mpYY+rjuWzsLIPZHJL253Z643RpbcP1oeIJlQ/SKW67g==} + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} micromark-extension-gfm-tagfilter@2.0.0: resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} @@ -3935,69 +3542,65 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} - micromark-factory-destination@2.0.0: - resolution: {integrity: sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==} - - micromark-factory-label@2.0.0: - resolution: {integrity: sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==} + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} - micromark-factory-space@2.0.0: - resolution: {integrity: sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==} + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} - micromark-factory-title@2.0.0: - resolution: {integrity: sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==} + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} - micromark-factory-whitespace@2.0.0: - resolution: {integrity: sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==} + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} - micromark-util-character@2.1.0: - resolution: {integrity: sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==} + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} - micromark-util-chunked@2.0.0: - resolution: {integrity: sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==} + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} - micromark-util-classify-character@2.0.0: - resolution: {integrity: sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==} + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} - micromark-util-combine-extensions@2.0.0: - resolution: {integrity: sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==} + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} - micromark-util-decode-numeric-character-reference@2.0.1: - resolution: {integrity: sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==} + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} - micromark-util-decode-string@2.0.0: - resolution: {integrity: sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==} + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} - micromark-util-encode@2.0.0: - resolution: {integrity: sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==} + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} - micromark-util-html-tag-name@2.0.0: - resolution: {integrity: sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==} + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} - micromark-util-normalize-identifier@2.0.0: - resolution: {integrity: sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==} + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} - micromark-util-resolve-all@2.0.0: - resolution: {integrity: sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==} + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} - micromark-util-sanitize-uri@2.0.0: - resolution: {integrity: sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==} + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} - micromark-util-subtokenize@2.0.1: - resolution: {integrity: sha512-jZNtiFl/1aY73yS3UGQkutD0UbhTt68qnRpw2Pifmz5wV9h8gOVsN70v+Lq/f1rKaU/W8pxRe8y8Q9FX1AOe1Q==} + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} - micromark-util-symbol@2.0.0: - resolution: {integrity: sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==} + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} - micromark-util-types@2.0.0: - resolution: {integrity: sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==} + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} - micromark@4.0.0: - resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} - micromatch@4.0.7: - resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} - engines: {node: '>=8.6'} + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} @@ -4044,23 +3647,23 @@ packages: resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} hasBin: true - minimatch@10.2.0: - resolution: {integrity: sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==} - engines: {node: 20 || >=22} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} engines: {node: '>=10'} - minimatch@8.0.4: - resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==} + minimatch@8.0.7: + resolution: {integrity: sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==} engines: {node: '>=16 || 14 >=14.17'} - minimatch@9.0.4: - resolution: {integrity: sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} minimist@1.2.8: @@ -4074,8 +3677,8 @@ packages: resolution: {integrity: sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==} engines: {node: ^18.17.0 || >=20.5.0} - minipass-flush@1.0.5: - resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} engines: {node: '>= 8'} minipass-pipeline@1.2.4: @@ -4094,8 +3697,8 @@ packages: resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} engines: {node: '>=8'} - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} minizlib@3.1.0: @@ -4106,17 +3709,14 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true - ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} - nanoid@3.3.7: - resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -4132,8 +3732,8 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - node-abi@4.26.0: - resolution: {integrity: sha512-8QwIZqikRvDIkXS2S93LjzhsSPJuIbfaMETWH+Bx8oOT9Sa9UsUtBFQlc3gBNd1+QINjaTloitXr1W3dQLi9Iw==} + node-abi@4.28.0: + resolution: {integrity: sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g==} engines: {node: '>=22.12.0'} node-addon-api@1.7.2: @@ -4145,6 +3745,10 @@ packages: node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + node-fetch-h2@2.3.0: resolution: {integrity: sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==} engines: {node: 4.x || >=6.0.0} @@ -4166,11 +3770,8 @@ packages: node-readfiles@0.2.0: resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==} - node-releases@2.0.14: - resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} - - node-releases@2.0.18: - resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + node-releases@2.0.37: + resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} nopt@8.1.0: resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} @@ -4181,16 +3782,12 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - normalize-range@0.1.2: - resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} - engines: {node: '>=0.10.0'} - normalize-url@6.1.0: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} - normalize-url@8.0.1: - resolution: {integrity: sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==} + normalize-url@8.1.1: + resolution: {integrity: sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==} engines: {node: '>=14.16'} npm-run-path@4.0.1: @@ -4225,9 +3822,6 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} - object-inspect@1.13.1: - resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -4240,10 +3834,6 @@ packages: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} - object.assign@4.1.5: - resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==} - engines: {node: '>= 0.4'} - object.assign@4.1.7: resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} engines: {node: '>= 0.4'} @@ -4260,10 +3850,6 @@ packages: resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} engines: {node: '>= 0.4'} - object.values@1.2.0: - resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} - engines: {node: '>= 0.4'} - object.values@1.2.1: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} @@ -4352,8 +3938,8 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} - parse-entities@4.0.1: - resolution: {integrity: sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -4391,9 +3977,9 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} - path-scurry@2.0.1: - resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} - engines: {node: 20 || >=22} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} pe-library@0.4.1: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} @@ -4407,18 +3993,15 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - picocolors@1.0.1: - resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pidtree@0.6.0: @@ -4447,8 +4030,8 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true - pirates@4.0.6: - resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} pkg-up@3.1.0: @@ -4463,8 +4046,8 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} - possible-typed-array-names@1.0.0: - resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} postcss-import@15.1.0: @@ -4473,26 +4056,32 @@ packages: peerDependencies: postcss: ^8.0.0 - postcss-js@4.0.1: - resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} engines: {node: ^12 || ^14 || >= 16} peerDependencies: postcss: ^8.4.21 - postcss-load-config@4.0.2: - resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} - engines: {node: '>= 14'} + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} peerDependencies: + jiti: '>=1.21.0' postcss: '>=8.0.9' - ts-node: '>=9.0.0' + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: + jiti: + optional: true postcss: optional: true - ts-node: + tsx: + optional: true + yaml: optional: true - postcss-nested@6.0.1: - resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} engines: {node: '>=12.0'} peerDependencies: postcss: ^8.2.14 @@ -4501,19 +4090,15 @@ packages: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} - postcss-selector-parser@6.1.0: - resolution: {integrity: sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==} + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.4.38: - resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==} - engines: {node: ^10 || ^12 || >=14} - - postcss@8.4.47: - resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} + postcss@8.5.10: + resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} postject@1.0.0-alpha.6: @@ -4529,8 +4114,8 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.3.3: - resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} hasBin: true @@ -4562,14 +4147,14 @@ packages: property-expr@2.0.6: resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} - property-information@6.5.0: - resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - pump@3.0.0: - resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} @@ -4590,8 +4175,8 @@ packages: peerDependencies: react: ^18.3.1 - react-error-boundary@4.0.13: - resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==} + react-error-boundary@4.1.2: + resolution: {integrity: sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==} peerDependencies: react: '>=16.13.1' @@ -4603,8 +4188,8 @@ packages: peerDependencies: react: '>=16.3.0' - react-hook-form@7.66.0: - resolution: {integrity: sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==} + react-hook-form@7.72.1: + resolution: {integrity: sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==} engines: {node: '>=18.0.0'} peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 @@ -4615,38 +4200,37 @@ packages: react-lifecycles-compat@3.0.4: resolution: {integrity: sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==} - react-markdown@9.0.1: - resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==} + react-markdown@9.1.0: + resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} peerDependencies: '@types/react': '>=18' react: '>=18' - react-modal@3.16.1: - resolution: {integrity: sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg==} - engines: {node: '>=8'} + react-modal@3.16.3: + resolution: {integrity: sha512-yCYRJB5YkeQDQlTt17WGAgFJ7jr2QYcWa1SHqZ3PluDmnKJ/7+tVU+E6uKyZ0nODaeEj+xCpK4LcSnKXLMC0Nw==} peerDependencies: - react: ^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 - react-dom: ^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 + react: ^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19 + react-dom: ^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19 - react-refresh@0.14.2: - resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} - react-responsive@10.0.0: - resolution: {integrity: sha512-N6/UiRLGQyGUqrarhBZmrSmHi2FXSD++N5VbSKsBBvWfG0ZV7asvUBluSv5lSzdMyEVjzZ6Y8DL4OHABiztDOg==} + react-responsive@10.0.1: + resolution: {integrity: sha512-OM5/cRvbtUWEX8le8RCT8scA8y2OPtb0Q/IViEyCEM5FBN8lRrkUOZnu87I88A6njxDldvxG+rLBxWiA7/UM9g==} engines: {node: '>=14'} peerDependencies: react: '>=16.8.0' - react-router-dom@6.27.0: - resolution: {integrity: sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==} + react-router-dom@6.30.3: + resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} engines: {node: '>=14.0.0'} peerDependencies: react: '>=16.8' react-dom: '>=16.8' - react-router@6.27.0: - resolution: {integrity: sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==} + react-router@6.30.3: + resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} engines: {node: '>=14.0.0'} peerDependencies: react: '>=16.8' @@ -4675,9 +4259,9 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} - readdirp@4.0.2: - resolution: {integrity: sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==} - engines: {node: '>= 14.16.0'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} @@ -4687,20 +4271,9 @@ packages: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} - reflect.getprototypeof@1.0.6: - resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==} - engines: {node: '>= 0.4'} - reftools@1.1.9: resolution: {integrity: sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==} - regenerator-runtime@0.14.1: - resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} - - regexp.prototype.flags@1.5.2: - resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} - engines: {node: '>= 0.4'} - regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -4709,14 +4282,14 @@ packages: resolution: {tarball: https://codeload.github.com/devsnek/node-register-scheme/tar.gz/e7cc9a63a1f512565da44cb57316d9fb10750e17} version: 0.0.2 - remark-gfm@4.0.0: - resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==} + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} - remark-rehype@11.1.1: - resolution: {integrity: sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ==} + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} @@ -4746,12 +4319,14 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve@1.22.8: - resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} hasBin: true - resolve@2.0.0-next.5: - resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} hasBin: true responselike@2.0.1: @@ -4773,8 +4348,8 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} - reusify@1.0.4: - resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} rfdc@1.4.1: @@ -4789,18 +4364,21 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} - rollup-plugin-visualizer@5.12.0: - resolution: {integrity: sha512-8/NU9jXcHRs7Nnj07PF2o4gjxmm9lXIrZ8r175bT9dK8qoLlvKTwRMArRCMgpMGlq8CTLugRvEmyMeMXIU2pNQ==} - engines: {node: '>=14'} + rollup-plugin-visualizer@5.14.0: + resolution: {integrity: sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==} + engines: {node: '>=18'} hasBin: true peerDependencies: + rolldown: 1.x rollup: 2.x || 3.x || 4.x peerDependenciesMeta: + rolldown: + optional: true rollup: optional: true - rollup@4.24.0: - resolution: {integrity: sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==} + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -4811,10 +4389,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - safe-array-concat@1.1.2: - resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} - engines: {node: '>=0.4'} - safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -4826,10 +4400,6 @@ packages: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} - safe-regex-test@1.0.3: - resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} - engines: {node: '>= 0.4'} - safe-regex-test@1.1.0: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} @@ -4841,16 +4411,16 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sanitize-filename@1.6.3: - resolution: {integrity: sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==} + sanitize-filename@1.6.4: + resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} - sass@1.80.2: - resolution: {integrity: sha512-9wXY8cGBlUmoUoT+vwOZOFCiS+naiWVjqlreN9ar9PudXbGwlMTFwCR5K9kB4dFumJ6ib98wZyAObJKsWf1nAA==} + sass@1.99.0: + resolution: {integrity: sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==} engines: {node: '>=14.0.0'} hasBin: true - sax@1.4.4: - resolution: {integrity: sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==} + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} scheduler@0.23.2: @@ -4870,13 +4440,8 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.6.3: - resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} - engines: {node: '>=10'} - hasBin: true - - semver@7.7.3: - resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true @@ -4925,8 +4490,8 @@ packages: should@13.2.3: resolution: {integrity: sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -4937,10 +4502,6 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.0.6: - resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} - engines: {node: '>= 0.4'} - side-channel@1.1.0: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} @@ -4971,8 +4532,8 @@ packages: resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} engines: {node: '>=12'} - slice-ansi@7.1.0: - resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} + slice-ansi@7.1.2: + resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} smart-buffer@4.2.0: @@ -4990,10 +4551,6 @@ packages: sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} - source-map-js@1.2.0: - resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} - engines: {node: '>=0.10.0'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5005,9 +4562,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - source-map@0.7.4: - resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} - engines: {node: '>= 8'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -5021,8 +4578,8 @@ packages: spdx-expression-parse@3.0.1: resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} - spdx-license-ids@3.0.18: - resolution: {integrity: sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==} + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} spdx-ranges@2.1.1: resolution: {integrity: sha512-mcdpQFV7UDAgLpXEE/jOMqvK4LBoO0uTQg0uvXUewmEFhpiZx5yJSZITHB8w1ZahKdhfZqP5GPEOKLyEq5p8XA==} @@ -5048,10 +4605,6 @@ packages: resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} engines: {node: '>= 6'} - stop-iteration-iterator@1.0.0: - resolution: {integrity: sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==} - engines: {node: '>= 0.4'} - stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -5068,8 +4621,8 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} - string-width@7.1.0: - resolution: {integrity: sha512-SEIJCWiX7Kg4c129n48aDRwLbFb2LJmXXFrWBG4NGaRtMQ3myKPKbwrD1BKqQn74oCoNMBVrfDEr5M9YxCsrkw==} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} string.prototype.includes@2.0.1: @@ -5087,10 +4640,6 @@ packages: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} - string.prototype.trim@1.2.9: - resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} - engines: {node: '>= 0.4'} - string.prototype.trimend@1.0.9: resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} engines: {node: '>= 0.4'} @@ -5109,8 +4658,8 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.0: - resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} strip-bom@3.0.0: @@ -5133,11 +4682,14 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} - style-to-object@1.0.8: - resolution: {integrity: sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==} + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} - sucrase@3.35.0: - resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} hasBin: true @@ -5145,10 +4697,6 @@ packages: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} - supports-color@5.5.0: - resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} - engines: {node: '>=4'} - supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -5164,15 +4712,14 @@ packages: tailwind-gradient-mask-image@1.2.0: resolution: {integrity: sha512-tUJaGhvqbJFiVKJu6EU5n//KvGdVvY3L3VOFNqjztk13+ifAk00pcSNHBTgHfUiBGOEzDn0gFRbSmsftUV1lXA==} - tailwindcss@3.4.14: - resolution: {integrity: sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==} + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} engines: {node: '>=14.0.0'} hasBin: true - tar@7.5.7: - resolution: {integrity: sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==} + tar@7.5.13: + resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me temp-file@3.4.0: resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} @@ -5181,11 +4728,6 @@ packages: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} engines: {node: '>=6.0.0'} - terser@5.31.1: - resolution: {integrity: sha512-37upzU1+viGvuFtBo9NPufCb9dwM0+l9hMxYyWfBA+fbwrPqNJAhbZ6W47bBFnZHKHTUBnMvi87434qq+qnxOg==} - engines: {node: '>=10'} - hasBin: true - thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -5209,8 +4751,8 @@ packages: tinycolor2@1.6.0: resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} tmp-promise@3.0.3: @@ -5220,10 +4762,6 @@ packages: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} - to-fast-properties@2.0.0: - resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} - engines: {node: '>=4'} - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -5243,8 +4781,8 @@ packages: truncate-utf8-bytes@1.0.2: resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -5259,8 +4797,8 @@ packages: peerDependencies: typescript: '>=2.7' - ts-pattern@5.5.0: - resolution: {integrity: sha512-jqbIpTsa/KKTJYWgPNsFNbLVpwCgzXfFJ1ukNn4I8hMwyQzHMJnk/BqWzggB0xpkILuKzaO/aMYhS0SkaJyKXg==} + ts-pattern@5.9.0: + resolution: {integrity: sha512-6s5V71mX8qBUmlgbrfL33xDUwO0fq48rxAu2LBE11WBeGdpCPOsXksQbZJHvHwhrd3QjUusd3mAOM5Gg0mFBLg==} tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -5268,8 +4806,8 @@ packages: tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - tslib@2.6.3: - resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} tsutils@3.21.0: resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} @@ -5292,44 +4830,28 @@ packages: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} - typed-array-buffer@1.0.2: - resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==} - engines: {node: '>= 0.4'} - typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} - typed-array-byte-length@1.0.1: - resolution: {integrity: sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==} - engines: {node: '>= 0.4'} - typed-array-byte-length@1.0.3: resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} engines: {node: '>= 0.4'} - typed-array-byte-offset@1.0.2: - resolution: {integrity: sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==} - engines: {node: '>= 0.4'} - typed-array-byte-offset@1.0.4: resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} engines: {node: '>= 0.4'} - typed-array-length@1.0.6: - resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} - engines: {node: '>= 0.4'} - typed-array-length@1.0.7: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript-eslint@8.46.4: - resolution: {integrity: sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg==} + typescript-eslint@8.58.2: + resolution: {integrity: sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' typescript@4.8.2: resolution: {integrity: sha512-C0I1UsrrDHo2fYI5oaCGbSejwX4ch+9Y5jTQELvovfmFkK3HHSZJB8MSJcWLmCUBzQBchCrZ9rMRV6GuNrvGtw==} @@ -5341,18 +4863,15 @@ packages: engines: {node: '>=4.2.0'} hasBin: true - typescript@5.6.3: - resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} - engines: {node: '>=14.17'} - hasBin: true - typescript@5.7.3: resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} engines: {node: '>=14.17'} hasBin: true - unbox-primitive@1.0.2: - resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} @@ -5361,8 +4880,8 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@6.21.3: - resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} + undici@6.24.1: + resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} engines: {node: '>=18.17'} unified@11.0.5: @@ -5376,8 +4895,8 @@ packages: resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==} engines: {node: ^18.17.0 || >=20.5.0} - unist-util-is@6.0.0: - resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} @@ -5385,11 +4904,11 @@ packages: unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} - unist-util-visit-parents@6.0.1: - resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} - unist-util-visit@5.0.0: - resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} @@ -5408,14 +4927,8 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} - update-browserslist-db@1.0.16: - resolution: {integrity: sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - update-browserslist-db@1.1.1: - resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' @@ -5423,8 +4936,8 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - use-double-tap@1.3.6: - resolution: {integrity: sha512-zWmzlihSTuLJpT+YJqhVUySV8UNvmdmaXokBEIh+FxR4m/vaSk2cS5hlqEPDj64rmkHlL7zfRTrJPw30Jd0OZA==} + use-double-tap@1.3.7: + resolution: {integrity: sha512-OOgpAtT1U96D8jX6w/ODWhLMDpS92wVYIQyQQ0hDnOgLrPHNrkYSjTnkoPT41GaO30xF5OBJ7mAxF34sHxnLng==} peerDependencies: react: '>=16.8.0' @@ -5442,14 +4955,14 @@ packages: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} - vfile-message@4.0.2: - resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@5.4.9: - resolution: {integrity: sha512-20OVpJHh0PAM0oSOELa5GaZNWeDjcAvQjGXy2Uyr+Tp+/D2/Hdz6NLgpJLsarPTA2QJ6v8mX2P1ZfbsSKvdMkg==} + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -5488,8 +5001,8 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - webpack-sources@3.2.3: - resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} + webpack-sources@3.3.4: + resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==} engines: {node: '>=10.13.0'} webpack-virtual-modules@0.5.0: @@ -5498,17 +5011,10 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} - which-boxed-primitive@1.0.2: - resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} - which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} - which-builtin-type@1.1.3: - resolution: {integrity: sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==} - engines: {node: '>= 0.4'} - which-builtin-type@1.2.1: resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} engines: {node: '>= 0.4'} @@ -5517,12 +5023,8 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} - which-typed-array@1.1.15: - resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} - engines: {node: '>= 0.4'} - - which-typed-array@1.1.19: - resolution: {integrity: sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==} + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} which@2.0.2: @@ -5547,8 +5049,8 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} - wrap-ansi@9.0.0: - resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} wrappy@1.0.2: @@ -5566,8 +5068,8 @@ packages: utf-8-validate: optional: true - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -5600,18 +5102,13 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} - yaml@1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} - yaml@2.4.5: - resolution: {integrity: sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==} - engines: {node: '>= 14'} - hasBin: true - - yaml@2.5.1: - resolution: {integrity: sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==} - engines: {node: '>= 14'} + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} hasBin: true yargs-parser@20.2.9: @@ -5641,8 +5138,8 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yup@1.4.0: - resolution: {integrity: sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==} + yup@1.7.1: + resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} @@ -5650,8 +5147,8 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@4.1.13: - resolution: {integrity: sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -5662,81 +5159,22 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@ampproject/remapping@2.3.0': - dependencies: - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 - - '@babel/code-frame@7.24.7': - dependencies: - '@babel/highlight': 7.24.7 - picocolors: 1.0.1 - - '@babel/code-frame@7.25.7': - dependencies: - '@babel/highlight': 7.25.7 - picocolors: 1.0.1 - '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.24.7': {} - - '@babel/compat-data@7.25.8': {} - '@babel/compat-data@7.29.0': {} - '@babel/core@7.24.7': - dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.24.7 - '@babel/generator': 7.24.7 - '@babel/helper-compilation-targets': 7.24.7 - '@babel/helper-module-transforms': 7.24.7(@babel/core@7.24.7) - '@babel/helpers': 7.24.7 - '@babel/parser': 7.24.7 - '@babel/template': 7.24.7 - '@babel/traverse': 7.24.7 - '@babel/types': 7.24.7 - convert-source-map: 2.0.0 - debug: 4.3.5 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/core@7.25.8': - dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.25.7 - '@babel/generator': 7.25.7 - '@babel/helper-compilation-targets': 7.25.7 - '@babel/helper-module-transforms': 7.25.7(@babel/core@7.25.8) - '@babel/helpers': 7.25.7 - '@babel/parser': 7.25.8 - '@babel/template': 7.25.7 - '@babel/traverse': 7.25.7 - '@babel/types': 7.25.8 - convert-source-map: 2.0.0 - debug: 4.3.5 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - '@babel/core@7.29.0': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 @@ -5749,81 +5187,24 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.24.7': - dependencies: - '@babel/types': 7.24.7 - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 - jsesc: 2.5.2 - - '@babel/generator@7.25.7': - dependencies: - '@babel/types': 7.25.8 - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 - jsesc: 3.0.2 - '@babel/generator@7.29.1': dependencies: - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/types': 7.29.0 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.0.2 - - '@babel/helper-compilation-targets@7.24.7': - dependencies: - '@babel/compat-data': 7.24.7 - '@babel/helper-validator-option': 7.24.7 - browserslist: 4.23.1 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-compilation-targets@7.25.7': - dependencies: - '@babel/compat-data': 7.25.8 - '@babel/helper-validator-option': 7.25.7 - browserslist: 4.24.0 - lru-cache: 5.1.1 - semver: 6.3.1 + jsesc: 3.1.0 '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.24.0 + browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-environment-visitor@7.24.7': - dependencies: - '@babel/types': 7.25.8 - - '@babel/helper-function-name@7.24.7': - dependencies: - '@babel/template': 7.25.7 - '@babel/types': 7.25.8 - '@babel/helper-globals@7.28.0': {} - '@babel/helper-hoist-variables@7.24.7': - dependencies: - '@babel/types': 7.25.8 - - '@babel/helper-module-imports@7.24.7': - dependencies: - '@babel/traverse': 7.25.7 - '@babel/types': 7.25.8 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.25.7': - dependencies: - '@babel/traverse': 7.25.7 - '@babel/types': 7.25.8 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -5831,27 +5212,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.24.7(@babel/core@7.24.7)': - dependencies: - '@babel/core': 7.24.7 - '@babel/helper-environment-visitor': 7.24.7 - '@babel/helper-module-imports': 7.24.7 - '@babel/helper-simple-access': 7.24.7 - '@babel/helper-split-export-declaration': 7.24.7 - '@babel/helper-validator-identifier': 7.24.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.25.7(@babel/core@7.25.8)': - dependencies: - '@babel/core': 7.25.8 - '@babel/helper-module-imports': 7.25.7 - '@babel/helper-simple-access': 7.25.7 - '@babel/helper-validator-identifier': 7.25.7 - '@babel/traverse': 7.25.7 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -5861,84 +5221,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-plugin-utils@7.24.7': {} - '@babel/helper-plugin-utils@7.28.6': {} - '@babel/helper-simple-access@7.24.7': - dependencies: - '@babel/traverse': 7.25.7 - '@babel/types': 7.25.8 - transitivePeerDependencies: - - supports-color - - '@babel/helper-simple-access@7.25.7': - dependencies: - '@babel/traverse': 7.25.7 - '@babel/types': 7.25.8 - transitivePeerDependencies: - - supports-color - - '@babel/helper-split-export-declaration@7.24.7': - dependencies: - '@babel/types': 7.25.8 - - '@babel/helper-string-parser@7.24.7': {} - - '@babel/helper-string-parser@7.25.7': {} - '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-validator-identifier@7.24.7': {} - - '@babel/helper-validator-identifier@7.25.7': {} - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-option@7.24.7': {} - - '@babel/helper-validator-option@7.25.7': {} - '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.24.7': - dependencies: - '@babel/template': 7.24.7 - '@babel/types': 7.24.7 - - '@babel/helpers@7.25.7': - dependencies: - '@babel/template': 7.25.7 - '@babel/types': 7.25.8 - - '@babel/helpers@7.28.6': + '@babel/helpers@7.29.2': dependencies: '@babel/template': 7.28.6 '@babel/types': 7.29.0 - '@babel/highlight@7.24.7': - dependencies: - '@babel/helper-validator-identifier': 7.24.7 - chalk: 2.4.2 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/highlight@7.25.7': - dependencies: - '@babel/helper-validator-identifier': 7.25.7 - chalk: 2.4.2 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/parser@7.24.7': - dependencies: - '@babel/types': 7.24.7 - - '@babel/parser@7.25.8': - dependencies: - '@babel/types': 7.25.8 - - '@babel/parser@7.29.0': + '@babel/parser@7.29.2': dependencies: '@babel/types': 7.29.0 @@ -5947,89 +5243,36 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 - '@babel/plugin-transform-react-jsx-self@7.24.7(@babel/core@7.25.8)': - dependencies: - '@babel/core': 7.25.8 - '@babel/helper-plugin-utils': 7.24.7 - - '@babel/plugin-transform-react-jsx-source@7.24.7(@babel/core@7.25.8)': + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/core': 7.25.8 - '@babel/helper-plugin-utils': 7.24.7 - - '@babel/runtime@7.24.7': - dependencies: - regenerator-runtime: 0.14.1 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/template@7.24.7': + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': dependencies: - '@babel/code-frame': 7.24.7 - '@babel/parser': 7.24.7 - '@babel/types': 7.24.7 + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 - '@babel/template@7.25.7': - dependencies: - '@babel/code-frame': 7.25.7 - '@babel/parser': 7.25.8 - '@babel/types': 7.25.8 + '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/types': 7.29.0 - '@babel/traverse@7.24.7': - dependencies: - '@babel/code-frame': 7.24.7 - '@babel/generator': 7.24.7 - '@babel/helper-environment-visitor': 7.24.7 - '@babel/helper-function-name': 7.24.7 - '@babel/helper-hoist-variables': 7.24.7 - '@babel/helper-split-export-declaration': 7.24.7 - '@babel/parser': 7.24.7 - '@babel/types': 7.24.7 - debug: 4.3.5 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color - - '@babel/traverse@7.25.7': - dependencies: - '@babel/code-frame': 7.25.7 - '@babel/generator': 7.25.7 - '@babel/parser': 7.25.8 - '@babel/template': 7.25.7 - '@babel/types': 7.25.8 - debug: 4.3.5 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 '@babel/generator': 7.29.1 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/types': 7.29.0 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.24.7': - dependencies: - '@babel/helper-string-parser': 7.24.7 - '@babel/helper-validator-identifier': 7.24.7 - to-fast-properties: 2.0.0 - - '@babel/types@7.25.8': - dependencies: - '@babel/helper-string-parser': 7.25.7 - '@babel/helper-validator-identifier': 7.25.7 - to-fast-properties: 2.0.0 - '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -6048,65 +5291,65 @@ snapshots: '@develar/schema-utils@2.6.5': dependencies: - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) + ajv: 6.14.0 + ajv-keywords: 3.5.2(ajv@6.14.0) '@discordjs/collection@2.1.1': {} - '@discordjs/rest@2.6.0': + '@discordjs/rest@2.6.1': dependencies: '@discordjs/collection': 2.1.1 '@discordjs/util': 1.2.0 '@sapphire/async-queue': 1.5.5 '@sapphire/snowflake': 3.5.5 '@vladfrangu/async_event_emitter': 2.4.7 - discord-api-types: 0.38.39 + discord-api-types: 0.38.46 magic-bytes.js: 1.13.0 - tslib: 2.6.3 - undici: 6.21.3 + tslib: 2.8.1 + undici: 6.24.1 '@discordjs/util@1.2.0': dependencies: - discord-api-types: 0.38.39 + discord-api-types: 0.38.46 '@dword-design/dedent@0.7.0': dependencies: babel-plugin-add-module-exports: 1.0.4 - '@dword-design/endent@1.4.1': + '@dword-design/endent@1.4.7': dependencies: - '@dword-design/dedent': 0.7.0 + dedent: '@dword-design/dedent@0.7.0' fast-json-parse: 1.0.3 objectorarray: 1.0.5 '@dword-design/eslint-plugin-import-alias@4.0.9': dependencies: - '@babel/core': 7.24.7 + '@babel/core': 7.29.0 '@dword-design/functions': 5.0.27 - babel-plugin-module-resolver: 5.0.2 + babel-plugin-module-resolver: 5.0.3 deepmerge: 4.3.1 - jiti: 1.21.6 + jiti: 1.21.7 transitivePeerDependencies: - supports-color '@dword-design/functions@5.0.27': dependencies: - '@dword-design/endent': 1.4.1 + '@dword-design/endent': 1.4.7 delay: 5.0.0 - lodash: 4.17.21 + lodash: 4.18.1 tinycolor2: 1.6.0 '@electron/asar@3.4.1': dependencies: commander: 5.1.0 glob: 7.2.3 - minimatch: 3.1.2 + minimatch: 3.1.5 - '@electron/asar@4.0.1': + '@electron/asar@4.2.0': dependencies: commander: 13.1.0 - glob: 11.1.0 - minimatch: 10.2.0 + glob: 13.0.6 + minimatch: 10.2.5 '@electron/fuses@1.8.0': dependencies: @@ -6168,13 +5411,13 @@ snapshots: detect-libc: 2.1.2 got: 11.8.6 graceful-fs: 4.2.11 - node-abi: 4.26.0 + node-abi: 4.28.0 node-api-version: 0.2.1 node-gyp: 11.5.0 ora: 5.4.1 read-binary-file-arch: 1.0.6 - semver: 7.7.3 - tar: 7.5.7 + semver: 7.7.4 + tar: 7.5.13 yargs: 17.7.2 transitivePeerDependencies: - supports-color @@ -6185,8 +5428,8 @@ snapshots: '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3 dir-compare: 4.2.0 - fs-extra: 11.3.3 - minimatch: 9.0.4 + fs-extra: 11.3.4 + minimatch: 9.0.9 plist: 3.1.0 transitivePeerDependencies: - supports-color @@ -6195,27 +5438,27 @@ snapshots: dependencies: cross-dirname: 0.1.0 debug: 4.4.3 - fs-extra: 11.3.3 + fs-extra: 11.3.4 minimist: 1.2.8 postject: 1.0.0-alpha.6 transitivePeerDependencies: - supports-color optional: true - '@emnapi/core@1.7.0': + '@emnapi/core@1.10.0': dependencies: - '@emnapi/wasi-threads': 1.1.0 - tslib: 2.6.3 + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 optional: true - '@emnapi/runtime@1.7.0': + '@emnapi/runtime@1.10.0': dependencies: - tslib: 2.6.3 + tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.1.0': + '@emnapi/wasi-threads@1.2.1': dependencies: - tslib: 2.6.3 + tslib: 2.8.1 optional: true '@esbuild/aix-ppc64@0.21.5': @@ -6365,20 +5608,18 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@1.21.7))': dependencies: - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.4(jiti@1.21.7) eslint-visitor-keys: 3.4.3 - '@eslint-community/regexpp@4.10.1': {} - '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.1': + '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -6390,21 +5631,21 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.1': + '@eslint/eslintrc@3.3.5': dependencies: - ajv: 6.12.6 + ajv: 6.14.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 - ignore: 5.3.1 - import-fresh: 3.3.0 - js-yaml: 4.1.0 - minimatch: 3.1.2 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color - '@eslint/js@9.39.1': {} + '@eslint/js@9.39.4': {} '@eslint/object-schema@2.1.7': {} @@ -6428,15 +5669,15 @@ snapshots: dependencies: '@fluent/bundle': 0.18.0 - '@fontsource/poppins@5.1.0': {} + '@fontsource/poppins@5.2.7': {} '@formatjs/intl-localematcher@0.2.32': dependencies: - tslib: 2.6.3 + tslib: 2.8.1 - '@hookform/resolvers@3.6.0(react-hook-form@7.66.0(react@18.3.1))': + '@hookform/resolvers@3.10.0(react-hook-form@7.72.1(react@18.3.1))': dependencies: - react-hook-form: 7.66.0(react@18.3.1) + react-hook-form: 7.72.1(react@18.3.1) '@humanfs/core@0.19.1': {} @@ -6453,28 +5694,20 @@ snapshots: dependencies: string-width: 5.1.2 string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.0 + strip-ansi: 7.2.0 strip-ansi-cjs: strip-ansi@6.0.1 wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@isaacs/cliui@9.0.0': {} - '@isaacs/fs-minipass@4.0.1': dependencies: - minipass: 7.1.2 + minipass: 7.1.3 '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/gen-mapping@0.3.5': - dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.4.15 - '@jridgewell/trace-mapping': 0.3.25 - '@jridgewell/remapping@2.3.5': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -6482,23 +5715,8 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} - '@jridgewell/set-array@1.2.1': {} - - '@jridgewell/source-map@0.3.11': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - optional: true - - '@jridgewell/sourcemap-codec@1.4.15': {} - '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.25': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping@0.3.31': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -6512,7 +5730,7 @@ snapshots: dependencies: debug: 4.4.3 fs-extra: 9.1.0 - lodash: 4.17.21 + lodash: 4.18.1 tmp-promise: 3.0.3 transitivePeerDependencies: - supports-color @@ -6531,8 +5749,8 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.7.0 - '@emnapi/runtime': 1.7.0 + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.10.1 optional: true @@ -6546,7 +5764,7 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 - fastq: 1.17.1 + fastq: 1.20.1 '@nolyfill/is-core-module@1.0.39': {} @@ -6555,14 +5773,14 @@ snapshots: agent-base: 7.1.4 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - lru-cache: 10.2.2 + lru-cache: 10.4.3 socks-proxy-agent: 8.0.5 transitivePeerDependencies: - supports-color '@npmcli/fs@4.0.0': dependencies: - semver: 7.7.3 + semver: 7.7.4 '@octokit/endpoint@10.1.4': dependencies: @@ -6591,146 +5809,180 @@ snapshots: dependencies: '@clack/prompts': 0.9.1 '@octokit/request': 9.2.4 - '@swc/core': 1.6.5 + '@swc/core': 1.15.26 case: 1.6.3 cli-highlight: 2.1.11 clipanion: 4.0.0-rc.4(typanion@3.14.0) fs-extra: 10.1.0 got: 12.6.1 got-fetch: 5.1.10(got@12.6.1) - js-yaml: 4.1.0 + js-yaml: 4.1.1 openapi3-ts: 2.0.2 - prettier: 3.3.3 + prettier: 3.8.3 slash: 4.0.0 swagger2openapi: 7.0.8(encoding@0.1.13) - tslib: 2.6.3 + tslib: 2.8.1 typanion: 3.14.0 typescript: 5.7.3 transitivePeerDependencies: - '@swc/helpers' - encoding - '@openapi-codegen/typescript@8.0.2': + '@openapi-codegen/typescript@8.1.1': dependencies: case: 1.6.3 - lodash: 4.17.21 + lodash: 4.18.1 openapi3-ts: 2.0.2 pluralize: 8.0.0 - tslib: 2.6.3 + tslib: 2.8.1 tsutils: 3.21.0(typescript@4.8.2) typescript: 4.8.2 - '@parcel/watcher-android-arm64@2.4.1': + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': optional: true - '@parcel/watcher-darwin-arm64@2.4.1': + '@parcel/watcher-darwin-x64@2.5.6': optional: true - '@parcel/watcher-darwin-x64@2.4.1': + '@parcel/watcher-freebsd-x64@2.5.6': optional: true - '@parcel/watcher-freebsd-x64@2.4.1': + '@parcel/watcher-linux-arm-glibc@2.5.6': optional: true - '@parcel/watcher-linux-arm-glibc@2.4.1': + '@parcel/watcher-linux-arm-musl@2.5.6': optional: true - '@parcel/watcher-linux-arm64-glibc@2.4.1': + '@parcel/watcher-linux-arm64-glibc@2.5.6': optional: true - '@parcel/watcher-linux-arm64-musl@2.4.1': + '@parcel/watcher-linux-arm64-musl@2.5.6': optional: true - '@parcel/watcher-linux-x64-glibc@2.4.1': + '@parcel/watcher-linux-x64-glibc@2.5.6': optional: true - '@parcel/watcher-linux-x64-musl@2.4.1': + '@parcel/watcher-linux-x64-musl@2.5.6': optional: true - '@parcel/watcher-win32-arm64@2.4.1': + '@parcel/watcher-win32-arm64@2.5.6': optional: true - '@parcel/watcher-win32-ia32@2.4.1': + '@parcel/watcher-win32-ia32@2.5.6': optional: true - '@parcel/watcher-win32-x64@2.4.1': + '@parcel/watcher-win32-x64@2.5.6': optional: true - '@parcel/watcher@2.4.1': + '@parcel/watcher@2.5.6': dependencies: - detect-libc: 1.0.3 + detect-libc: 2.1.2 is-glob: 4.0.3 - micromatch: 4.0.7 node-addon-api: 7.1.1 + picomatch: 4.0.4 optionalDependencies: - '@parcel/watcher-android-arm64': 2.4.1 - '@parcel/watcher-darwin-arm64': 2.4.1 - '@parcel/watcher-darwin-x64': 2.4.1 - '@parcel/watcher-freebsd-x64': 2.4.1 - '@parcel/watcher-linux-arm-glibc': 2.4.1 - '@parcel/watcher-linux-arm64-glibc': 2.4.1 - '@parcel/watcher-linux-arm64-musl': 2.4.1 - '@parcel/watcher-linux-x64-glibc': 2.4.1 - '@parcel/watcher-linux-x64-musl': 2.4.1 - '@parcel/watcher-win32-arm64': 2.4.1 - '@parcel/watcher-win32-ia32': 2.4.1 - '@parcel/watcher-win32-x64': 2.4.1 + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + optional: true '@pinojs/redact@0.4.0': {} '@pkgjs/parseargs@0.11.0': optional: true - '@react-hookz/deep-equal@3.0.3': {} + '@react-hookz/deep-equal@3.0.4': {} + + '@remix-run/router@1.23.2': {} + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true - '@remix-run/router@1.20.0': {} + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true - '@rollup/rollup-android-arm-eabi@4.24.0': + '@rollup/rollup-linux-arm64-musl@4.60.1': optional: true - '@rollup/rollup-android-arm64@4.24.0': + '@rollup/rollup-linux-loong64-gnu@4.60.1': optional: true - '@rollup/rollup-darwin-arm64@4.24.0': + '@rollup/rollup-linux-loong64-musl@4.60.1': optional: true - '@rollup/rollup-darwin-x64@4.24.0': + '@rollup/rollup-linux-ppc64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.24.0': + '@rollup/rollup-linux-ppc64-musl@4.60.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.24.0': + '@rollup/rollup-linux-riscv64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.24.0': + '@rollup/rollup-linux-riscv64-musl@4.60.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.24.0': + '@rollup/rollup-linux-s390x-gnu@4.60.1': optional: true - '@rollup/rollup-linux-powerpc64le-gnu@4.24.0': + '@rollup/rollup-linux-x64-gnu@4.60.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.24.0': + '@rollup/rollup-linux-x64-musl@4.60.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.24.0': + '@rollup/rollup-openbsd-x64@4.60.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.24.0': + '@rollup/rollup-openharmony-arm64@4.60.1': optional: true - '@rollup/rollup-linux-x64-musl@4.24.0': + '@rollup/rollup-win32-arm64-msvc@4.60.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.24.0': + '@rollup/rollup-win32-ia32-msvc@4.60.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.24.0': + '@rollup/rollup-win32-x64-gnu@4.60.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.24.0': + '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true '@rtsao/scc@1.1.0': {} @@ -6738,7 +5990,7 @@ snapshots: '@ryuziii/discord-rpc@1.0.1-rc.1': dependencies: '@types/ws': 8.18.1 - ws: 8.19.0 + ws: 8.20.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -6765,7 +6017,7 @@ snapshots: '@sentry-internal/browser-utils': 10.29.0 '@sentry/core': 10.29.0 - '@sentry/babel-plugin-component-annotate@2.22.7': {} + '@sentry/babel-plugin-component-annotate@2.23.1': {} '@sentry/browser@10.29.0': dependencies: @@ -6775,12 +6027,12 @@ snapshots: '@sentry-internal/replay-canvas': 10.29.0 '@sentry/core': 10.29.0 - '@sentry/bundler-plugin-core@2.22.7(encoding@0.1.13)': + '@sentry/bundler-plugin-core@2.23.1(encoding@0.1.13)': dependencies: - '@babel/core': 7.25.8 - '@sentry/babel-plugin-component-annotate': 2.22.7 + '@babel/core': 7.29.0 + '@sentry/babel-plugin-component-annotate': 2.23.1 '@sentry/cli': 2.39.1(encoding@0.1.13) - dotenv: 16.4.5 + dotenv: 16.6.1 find-up: 5.0.0 glob: 9.3.5 magic-string: 0.30.8 @@ -6838,9 +6090,9 @@ snapshots: hoist-non-react-statics: 3.3.2 react: 18.3.1 - '@sentry/vite-plugin@2.22.7(encoding@0.1.13)': + '@sentry/vite-plugin@2.23.1(encoding@0.1.13)': dependencies: - '@sentry/bundler-plugin-core': 2.22.7(encoding@0.1.13) + '@sentry/bundler-plugin-core': 2.23.1(encoding@0.1.13) unplugin: 1.0.1 transitivePeerDependencies: - encoding @@ -6850,65 +6102,73 @@ snapshots: '@sindresorhus/is@5.6.0': {} - '@stylistic/eslint-plugin@5.5.0(eslint@9.39.1(jiti@2.6.1))': + '@stylistic/eslint-plugin@5.10.0(eslint@9.39.4(jiti@1.21.7))': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) - '@typescript-eslint/types': 8.46.4 - eslint: 9.39.1(jiti@2.6.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@typescript-eslint/types': 8.58.2 + eslint: 9.39.4(jiti@1.21.7) eslint-visitor-keys: 4.2.1 espree: 10.4.0 estraverse: 5.3.0 - picomatch: 4.0.3 + picomatch: 4.0.4 + + '@swc/core-darwin-arm64@1.15.26': + optional: true - '@swc/core-darwin-arm64@1.6.5': + '@swc/core-darwin-x64@1.15.26': optional: true - '@swc/core-darwin-x64@1.6.5': + '@swc/core-linux-arm-gnueabihf@1.15.26': optional: true - '@swc/core-linux-arm-gnueabihf@1.6.5': + '@swc/core-linux-arm64-gnu@1.15.26': optional: true - '@swc/core-linux-arm64-gnu@1.6.5': + '@swc/core-linux-arm64-musl@1.15.26': optional: true - '@swc/core-linux-arm64-musl@1.6.5': + '@swc/core-linux-ppc64-gnu@1.15.26': optional: true - '@swc/core-linux-x64-gnu@1.6.5': + '@swc/core-linux-s390x-gnu@1.15.26': optional: true - '@swc/core-linux-x64-musl@1.6.5': + '@swc/core-linux-x64-gnu@1.15.26': optional: true - '@swc/core-win32-arm64-msvc@1.6.5': + '@swc/core-linux-x64-musl@1.15.26': optional: true - '@swc/core-win32-ia32-msvc@1.6.5': + '@swc/core-win32-arm64-msvc@1.15.26': optional: true - '@swc/core-win32-x64-msvc@1.6.5': + '@swc/core-win32-ia32-msvc@1.15.26': optional: true - '@swc/core@1.6.5': + '@swc/core-win32-x64-msvc@1.15.26': + optional: true + + '@swc/core@1.15.26': dependencies: '@swc/counter': 0.1.3 - '@swc/types': 0.1.9 + '@swc/types': 0.1.26 optionalDependencies: - '@swc/core-darwin-arm64': 1.6.5 - '@swc/core-darwin-x64': 1.6.5 - '@swc/core-linux-arm-gnueabihf': 1.6.5 - '@swc/core-linux-arm64-gnu': 1.6.5 - '@swc/core-linux-arm64-musl': 1.6.5 - '@swc/core-linux-x64-gnu': 1.6.5 - '@swc/core-linux-x64-musl': 1.6.5 - '@swc/core-win32-arm64-msvc': 1.6.5 - '@swc/core-win32-ia32-msvc': 1.6.5 - '@swc/core-win32-x64-msvc': 1.6.5 + '@swc/core-darwin-arm64': 1.15.26 + '@swc/core-darwin-x64': 1.15.26 + '@swc/core-linux-arm-gnueabihf': 1.15.26 + '@swc/core-linux-arm64-gnu': 1.15.26 + '@swc/core-linux-arm64-musl': 1.15.26 + '@swc/core-linux-ppc64-gnu': 1.15.26 + '@swc/core-linux-s390x-gnu': 1.15.26 + '@swc/core-linux-x64-gnu': 1.15.26 + '@swc/core-linux-x64-musl': 1.15.26 + '@swc/core-win32-arm64-msvc': 1.15.26 + '@swc/core-win32-ia32-msvc': 1.15.26 + '@swc/core-win32-x64-msvc': 1.15.26 '@swc/counter@0.1.3': {} - '@swc/types@0.1.9': + '@swc/types@0.1.26': dependencies: '@swc/counter': 0.1.3 @@ -6920,27 +6180,24 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@tailwindcss/forms@0.5.9(tailwindcss@3.4.14(ts-node@9.1.1(typescript@5.6.3)))': + '@tailwindcss/forms@0.5.11(tailwindcss@3.4.19(yaml@2.8.3))': dependencies: mini-svg-data-uri: 1.4.4 - tailwindcss: 3.4.14(ts-node@9.1.1(typescript@5.6.3)) + tailwindcss: 3.4.19(yaml@2.8.3) - '@tailwindcss/typography@0.5.15(tailwindcss@3.4.14(ts-node@9.1.1(typescript@5.6.3)))': + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(yaml@2.8.3))': dependencies: - lodash.castarray: 4.4.0 - lodash.isplainobject: 4.0.6 - lodash.merge: 4.6.2 postcss-selector-parser: 6.0.10 - tailwindcss: 3.4.14(ts-node@9.1.1(typescript@5.6.3)) + tailwindcss: 3.4.19(yaml@2.8.3) - '@tanstack/query-core@5.48.0': {} + '@tanstack/query-core@5.99.0': {} - '@tanstack/react-query@5.48.0(react@18.3.1)': + '@tanstack/react-query@5.99.0(react@18.3.1)': dependencies: - '@tanstack/query-core': 5.48.0 + '@tanstack/query-core': 5.99.0 react: 18.3.1 - '@tweenjs/tween.js@23.1.2': {} + '@tweenjs/tween.js@23.1.3': {} '@tweenjs/tween.js@25.0.0': {} @@ -6948,58 +6205,58 @@ snapshots: '@tybys/wasm-util@0.10.1': dependencies: - tslib: 2.6.3 + tslib: 2.8.1 optional: true '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.24.7 - '@babel/types': 7.24.7 - '@types/babel__generator': 7.6.8 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 - '@types/babel__traverse': 7.20.6 + '@types/babel__traverse': 7.28.0 - '@types/babel__generator@7.6.8': + '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.29.0 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.24.7 - '@babel/types': 7.24.7 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 - '@types/babel__traverse@7.20.6': + '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.24.7 + '@babel/types': 7.29.0 '@types/cacheable-request@6.0.3': dependencies: - '@types/http-cache-semantics': 4.0.4 + '@types/http-cache-semantics': 4.2.0 '@types/keyv': 3.1.4 - '@types/node': 24.10.0 + '@types/node': 24.12.2 '@types/responselike': 1.0.3 - '@types/debug@4.1.12': + '@types/debug@4.1.13': dependencies: - '@types/ms': 0.7.34 + '@types/ms': 2.1.0 '@types/estree-jsx@1.0.5': dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 - '@types/estree@1.0.6': {} + '@types/estree@1.0.8': {} '@types/file-saver@2.0.7': {} '@types/fs-extra@9.0.13': dependencies: - '@types/node': 24.10.0 + '@types/node': 24.12.2 '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 - '@types/http-cache-semantics@4.0.4': {} + '@types/http-cache-semantics@4.2.0': {} '@types/json-schema@7.0.15': {} @@ -7007,55 +6264,55 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 24.10.0 + '@types/node': 24.12.2 '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 - '@types/ms@0.7.34': {} + '@types/ms@2.1.0': {} - '@types/node@24.10.0': + '@types/node@24.12.2': dependencies: undici-types: 7.16.0 '@types/plist@3.0.5': dependencies: - '@types/node': 24.10.0 + '@types/node': 24.12.2 xmlbuilder: 15.1.1 - '@types/prop-types@15.7.12': {} + '@types/prop-types@15.7.15': {} - '@types/react-dom@18.3.0': + '@types/react-dom@18.3.7(@types/react@18.3.28)': dependencies: - '@types/react': 18.3.11 + '@types/react': 18.3.28 '@types/react-helmet@6.1.11': dependencies: - '@types/react': 18.3.11 + '@types/react': 18.3.28 '@types/react-modal@3.16.3': dependencies: - '@types/react': 18.3.11 + '@types/react': 18.3.28 - '@types/react@18.3.11': + '@types/react@18.3.28': dependencies: - '@types/prop-types': 15.7.12 - csstype: 3.1.3 + '@types/prop-types': 15.7.15 + csstype: 3.2.3 '@types/responselike@1.0.3': dependencies: - '@types/node': 24.10.0 + '@types/node': 24.12.2 - '@types/semver@7.5.8': {} + '@types/semver@7.7.1': {} - '@types/stats.js@0.17.3': {} + '@types/stats.js@0.17.4': {} '@types/three@0.163.0': dependencies: - '@tweenjs/tween.js': 23.1.2 - '@types/stats.js': 0.17.3 - '@types/webxr': 0.5.16 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 fflate: 0.8.2 meshoptimizer: 0.18.1 @@ -7065,203 +6322,109 @@ snapshots: '@types/verror@1.10.11': {} - '@types/webxr@0.5.16': {} + '@types/webxr@0.5.24': {} '@types/ws@8.18.1': dependencies: - '@types/node': 24.10.0 + '@types/node': 24.12.2 '@types/yauzl@2.10.3': dependencies: - '@types/node': 24.10.0 + '@types/node': 24.12.2 optional: true - '@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3)': - dependencies: - '@eslint-community/regexpp': 4.10.1 - '@typescript-eslint/parser': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3) - '@typescript-eslint/scope-manager': 8.46.4 - '@typescript-eslint/type-utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3) - '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3) - '@typescript-eslint/visitor-keys': 8.46.4 - eslint: 9.39.1(jiti@2.6.1) - graphemer: 1.4.0 - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.6.3) - typescript: 5.6.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3)': + '@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3) - '@typescript-eslint/scope-manager': 8.48.1 - '@typescript-eslint/type-utils': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3) - '@typescript-eslint/utils': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3) - '@typescript-eslint/visitor-keys': 8.48.1 - eslint: 9.39.1(jiti@2.6.1) - graphemer: 1.4.0 + '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/type-utils': 8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.2 + eslint: 9.39.4(jiti@1.21.7) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.6.3) - typescript: 5.6.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.46.4 - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.6.3) - '@typescript-eslint/visitor-keys': 8.46.4 - debug: 4.3.7 - eslint: 9.39.1(jiti@2.6.1) - typescript: 5.6.3 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3)': + '@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.48.1 - '@typescript-eslint/types': 8.48.1 - '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.6.3) - '@typescript-eslint/visitor-keys': 8.48.1 + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.2 debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) - typescript: 5.6.3 + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.46.4(typescript@5.6.3)': + '@typescript-eslint/project-service@8.58.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.46.4(typescript@5.6.3) - '@typescript-eslint/types': 8.46.4 - debug: 4.3.7 - typescript: 5.6.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.48.1(typescript@5.6.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.48.1(typescript@5.6.3) - '@typescript-eslint/types': 8.48.1 + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) + '@typescript-eslint/types': 8.58.2 debug: 4.4.3 - typescript: 5.6.3 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.46.4': - dependencies: - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/visitor-keys': 8.46.4 - - '@typescript-eslint/scope-manager@8.48.1': - dependencies: - '@typescript-eslint/types': 8.48.1 - '@typescript-eslint/visitor-keys': 8.48.1 - - '@typescript-eslint/tsconfig-utils@8.46.4(typescript@5.6.3)': + '@typescript-eslint/scope-manager@8.58.2': dependencies: - typescript: 5.6.3 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/visitor-keys': 8.58.2 - '@typescript-eslint/tsconfig-utils@8.48.1(typescript@5.6.3)': + '@typescript-eslint/tsconfig-utils@8.58.2(typescript@5.9.3)': dependencies: - typescript: 5.6.3 + typescript: 5.9.3 - '@typescript-eslint/type-utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3)': + '@typescript-eslint/type-utils@8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.6.3) - '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3) - debug: 4.3.7 - eslint: 9.39.1(jiti@2.6.1) - ts-api-utils: 2.1.0(typescript@5.6.3) - typescript: 5.6.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/type-utils@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3)': - dependencies: - '@typescript-eslint/types': 8.48.1 - '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.6.3) - '@typescript-eslint/utils': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) - ts-api-utils: 2.1.0(typescript@5.6.3) - typescript: 5.6.3 + eslint: 9.39.4(jiti@1.21.7) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.46.4': {} - - '@typescript-eslint/types@8.48.1': {} - - '@typescript-eslint/typescript-estree@8.46.4(typescript@5.6.3)': - dependencies: - '@typescript-eslint/project-service': 8.46.4(typescript@5.6.3) - '@typescript-eslint/tsconfig-utils': 8.46.4(typescript@5.6.3) - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/visitor-keys': 8.46.4 - debug: 4.3.7 - fast-glob: 3.3.2 - is-glob: 4.0.3 - minimatch: 9.0.4 - semver: 7.6.3 - ts-api-utils: 2.1.0(typescript@5.6.3) - typescript: 5.6.3 - transitivePeerDependencies: - - supports-color + '@typescript-eslint/types@8.58.2': {} - '@typescript-eslint/typescript-estree@8.48.1(typescript@5.6.3)': + '@typescript-eslint/typescript-estree@8.58.2(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.48.1(typescript@5.6.3) - '@typescript-eslint/tsconfig-utils': 8.48.1(typescript@5.6.3) - '@typescript-eslint/types': 8.48.1 - '@typescript-eslint/visitor-keys': 8.48.1 + '@typescript-eslint/project-service': 8.58.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.2(typescript@5.9.3) + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/visitor-keys': 8.58.2 debug: 4.4.3 - minimatch: 9.0.4 - semver: 7.7.3 - tinyglobby: 0.2.15 - ts-api-utils: 2.1.0(typescript@5.6.3) - typescript: 5.6.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.46.4 - '@typescript-eslint/types': 8.46.4 - '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.6.3) - eslint: 9.39.1(jiti@2.6.1) - typescript: 5.6.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3)': + '@typescript-eslint/utils@8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.48.1 - '@typescript-eslint/types': 8.48.1 - '@typescript-eslint/typescript-estree': 8.48.1(typescript@5.6.3) - eslint: 9.39.1(jiti@2.6.1) - typescript: 5.6.3 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.58.2 + '@typescript-eslint/types': 8.58.2 + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.46.4': + '@typescript-eslint/visitor-keys@8.58.2': dependencies: - '@typescript-eslint/types': 8.46.4 - eslint-visitor-keys: 4.2.1 + '@typescript-eslint/types': 8.58.2 + eslint-visitor-keys: 5.0.1 - '@typescript-eslint/visitor-keys@8.48.1': - dependencies: - '@typescript-eslint/types': 8.48.1 - eslint-visitor-keys: 4.2.1 - - '@ungap/structured-clone@1.2.0': {} + '@ungap/structured-clone@1.3.0': {} '@unrs/resolver-binding-android-arm-eabi@1.11.1': optional: true @@ -7322,40 +6485,39 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-react@4.3.2(vite@5.4.9(@types/node@24.10.0)(sass@1.80.2)(terser@5.31.1))': + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@24.12.2)(sass@1.99.0))': dependencies: - '@babel/core': 7.25.8 - '@babel/plugin-transform-react-jsx-self': 7.24.7(@babel/core@7.25.8) - '@babel/plugin-transform-react-jsx-source': 7.24.7(@babel/core@7.25.8) + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 - react-refresh: 0.14.2 - vite: 5.4.9(@types/node@24.10.0)(sass@1.80.2)(terser@5.31.1) + react-refresh: 0.17.0 + vite: 5.4.21(@types/node@24.12.2)(sass@1.99.0) transitivePeerDependencies: - supports-color '@vladfrangu/async_event_emitter@2.4.7': {} - '@xhayper/discord-rpc@1.3.0': + '@xhayper/discord-rpc@1.3.3': dependencies: - '@discordjs/rest': 2.6.0 + '@discordjs/rest': 2.6.1 '@vladfrangu/async_event_emitter': 2.4.7 - discord-api-types: 0.38.39 - ws: 8.19.0 + discord-api-types: 0.38.46 + ws: 8.20.0 transitivePeerDependencies: - bufferutil - utf-8-validate - '@xmldom/xmldom@0.8.11': {} + '@xmldom/xmldom@0.8.12': {} abbrev@3.0.1: {} - acorn-jsx@5.3.2(acorn@8.15.0): + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: - acorn: 8.15.0 - - acorn@8.12.0: {} + acorn: 8.16.0 - acorn@8.15.0: {} + acorn@8.16.0: {} agent-base@6.0.2: dependencies: @@ -7365,52 +6527,48 @@ snapshots: agent-base@7.1.4: {} - ajv-keywords@3.5.2(ajv@6.12.6): + ajv-keywords@3.5.2(ajv@6.14.0): dependencies: - ajv: 6.12.6 + ajv: 6.14.0 - ajv@6.12.6: + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 json-schema-traverse: 0.4.1 uri-js: 4.4.1 - ajv@8.17.1: + ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 fast-uri: 3.1.0 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - ansi-escapes@7.0.0: + ansi-escapes@7.3.0: dependencies: environment: 1.1.0 ansi-regex@5.0.1: {} - ansi-regex@6.0.1: {} - - ansi-styles@3.2.1: - dependencies: - color-convert: 1.9.3 + ansi-regex@6.2.2: {} ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 - ansi-styles@6.2.1: {} + ansi-styles@6.2.3: {} any-promise@1.3.0: {} anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 + picomatch: 2.3.2 app-builder-bin@5.0.0-alpha.12: {} - app-builder-lib@26.7.0(dmg-builder@26.7.0)(electron-builder-squirrel-windows@26.7.0): + app-builder-lib@26.9.0(dmg-builder@26.9.0)(electron-builder-squirrel-windows@26.9.0): dependencies: '@develar/schema-utils': 2.6.5 '@electron/asar': 3.4.1 @@ -7423,30 +6581,30 @@ snapshots: '@malept/flatpak-bundler': 0.4.0 '@types/fs-extra': 9.0.13 async-exit-hook: 2.0.1 - builder-util: 26.4.1 - builder-util-runtime: 9.5.1 + builder-util: 26.9.0 + builder-util-runtime: 9.6.0 chromium-pickle-js: 0.2.0 ci-info: 4.3.1 debug: 4.4.3 - dmg-builder: 26.7.0(electron-builder-squirrel-windows@26.7.0) - dotenv: 16.4.5 + dmg-builder: 26.9.0(electron-builder-squirrel-windows@26.9.0) + dotenv: 16.6.1 dotenv-expand: 11.0.7 ejs: 3.1.10 - electron-builder-squirrel-windows: 26.7.0(dmg-builder@26.7.0) - electron-publish: 26.6.0 + electron-builder-squirrel-windows: 26.9.0(dmg-builder@26.9.0) + electron-publish: 26.9.0 fs-extra: 10.1.0 hosted-git-info: 4.1.0 isbinaryfile: 5.0.7 jiti: 2.6.1 - js-yaml: 4.1.0 + js-yaml: 4.1.1 json5: 2.2.3 lazy-val: 1.0.5 - minimatch: 10.2.0 + minimatch: 10.2.5 plist: 3.1.0 proper-lockfile: 4.1.2 resedit: 1.7.2 - semver: 7.7.3 - tar: 7.5.7 + semver: 7.7.4 + tar: 7.5.13 temp-file: 3.4.0 tiny-async-pool: 1.3.0 which: 5.0.0 @@ -7461,11 +6619,6 @@ snapshots: aria-query@5.3.2: {} - array-buffer-byte-length@1.0.1: - dependencies: - call-bind: 1.0.7 - is-array-buffer: 3.0.4 - array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -7473,21 +6626,12 @@ snapshots: array-find-index@1.0.2: {} - array-includes@3.1.8: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - es-object-atoms: 1.0.0 - get-intrinsic: 1.2.4 - is-string: 1.0.7 - array-includes@3.1.9: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 is-string: 1.1.1 @@ -7495,76 +6639,51 @@ snapshots: array.prototype.findlast@1.2.5: dependencies: - call-bind: 1.0.7 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.23.3 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.0.0 - es-shim-unscopables: 1.0.2 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 array.prototype.findlastindex@1.2.6: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 es-object-atoms: 1.1.1 es-shim-unscopables: 1.1.0 - array.prototype.flat@1.3.2: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - es-shim-unscopables: 1.0.2 - array.prototype.flat@1.3.3: dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-shim-unscopables: 1.0.2 - - array.prototype.flatmap@1.3.2: - dependencies: - call-bind: 1.0.7 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.23.3 - es-shim-unscopables: 1.0.2 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 array.prototype.flatmap@1.3.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 - es-shim-unscopables: 1.0.2 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 array.prototype.tosorted@1.1.4: dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - es-errors: 1.3.0 - es-shim-unscopables: 1.0.2 - - arraybuffer.prototype.slice@1.0.3: - dependencies: - array-buffer-byte-length: 1.0.1 - call-bind: 1.0.7 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.23.3 + es-abstract: 1.24.2 es-errors: 1.3.0 - get-intrinsic: 1.2.4 - is-array-buffer: 3.0.4 - is-shared-array-buffer: 1.0.3 + es-shim-unscopables: 1.1.0 arraybuffer.prototype.slice@1.0.4: dependencies: array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 @@ -7577,6 +6696,8 @@ snapshots: async-exit-hook@2.0.1: {} + async-function@1.0.0: {} + async@3.2.6: {} asynckit@0.4.0: {} @@ -7585,44 +6706,43 @@ snapshots: atomic-sleep@1.0.0: {} - autoprefixer@10.4.20(postcss@8.4.38): + autoprefixer@10.5.0(postcss@8.5.10): dependencies: - browserslist: 4.24.0 - caniuse-lite: 1.0.30001669 - fraction.js: 4.3.7 - normalize-range: 0.1.2 - picocolors: 1.0.1 - postcss: 8.4.38 + browserslist: 4.28.2 + caniuse-lite: 1.0.30001788 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.10 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: dependencies: - possible-typed-array-names: 1.0.0 + possible-typed-array-names: 1.1.0 - axe-core@4.10.1: {} + axe-core@4.11.3: {} axobject-query@4.1.0: {} babel-plugin-add-module-exports@1.0.4: {} - babel-plugin-module-resolver@5.0.2: + babel-plugin-module-resolver@5.0.3: dependencies: - find-babel-config: 2.1.1 + find-babel-config: 2.1.2 glob: 9.3.5 pkg-up: 3.1.0 reselect: 4.1.8 - resolve: 1.22.8 + resolve: 1.22.12 bail@2.0.2: {} balanced-match@1.0.2: {} - balanced-match@4.0.2: - dependencies: - jackspeak: 4.2.3 + balanced-match@4.0.4: {} base64-js@1.5.1: {} + baseline-browser-mapping@2.10.19: {} + binary-extensions@2.3.0: {} bindings@1.5.0: @@ -7639,18 +6759,18 @@ snapshots: boolean@3.2.0: optional: true - brace-expansion@1.1.11: + brace-expansion@1.1.14: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.1: + brace-expansion@2.1.0: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.2: + brace-expansion@5.0.5: dependencies: - balanced-match: 4.0.2 + balanced-match: 4.0.4 braces@3.0.3: dependencies: @@ -7658,19 +6778,13 @@ snapshots: browser-fs-access@0.35.0: {} - browserslist@4.23.1: - dependencies: - caniuse-lite: 1.0.30001636 - electron-to-chromium: 1.4.803 - node-releases: 2.0.14 - update-browserslist-db: 1.0.16(browserslist@4.23.1) - - browserslist@4.24.0: + browserslist@4.28.2: dependencies: - caniuse-lite: 1.0.30001669 - electron-to-chromium: 1.5.41 - node-releases: 2.0.18 - update-browserslist-db: 1.1.1(browserslist@4.24.0) + baseline-browser-mapping: 2.10.19 + caniuse-lite: 1.0.30001788 + electron-to-chromium: 1.5.339 + node-releases: 2.0.37 + update-browserslist-db: 1.2.3(browserslist@4.28.2) buffer-crc32@0.2.13: {} @@ -7681,27 +6795,27 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - builder-util-runtime@9.5.1: + builder-util-runtime@9.6.0: dependencies: debug: 4.4.3 - sax: 1.4.4 + sax: 1.6.0 transitivePeerDependencies: - supports-color - builder-util@26.4.1: + builder-util@26.9.0: dependencies: 7zip-bin: 5.2.0 - '@types/debug': 4.1.12 + '@types/debug': 4.1.13 app-builder-bin: 5.0.0-alpha.12 - builder-util-runtime: 9.5.1 + builder-util-runtime: 9.6.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 fs-extra: 10.1.0 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 - js-yaml: 4.1.0 - sanitize-filename: 1.6.3 + js-yaml: 4.1.1 + sanitize-filename: 1.6.4 source-map-support: 0.5.21 stat-mode: 1.0.0 temp-file: 3.4.0 @@ -7719,15 +6833,15 @@ snapshots: dependencies: '@npmcli/fs': 4.0.0 fs-minipass: 3.0.3 - glob: 10.4.1 - lru-cache: 10.2.2 - minipass: 7.1.2 + glob: 10.5.0 + lru-cache: 10.4.3 + minipass: 7.1.3 minipass-collect: 2.0.1 - minipass-flush: 1.0.5 + minipass-flush: 1.0.7 minipass-pipeline: 1.2.4 p-map: 7.0.4 ssri: 12.0.0 - tar: 7.5.7 + tar: 7.5.13 unique-filename: 4.0.0 cacheable-lookup@5.0.4: {} @@ -7736,19 +6850,19 @@ snapshots: cacheable-request@10.2.14: dependencies: - '@types/http-cache-semantics': 4.0.4 + '@types/http-cache-semantics': 4.2.0 get-stream: 6.0.1 - http-cache-semantics: 4.1.1 + http-cache-semantics: 4.2.0 keyv: 4.5.4 mimic-response: 4.0.0 - normalize-url: 8.0.1 + normalize-url: 8.1.1 responselike: 3.0.0 cacheable-request@7.0.4: dependencies: clone-response: 1.0.3 get-stream: 5.2.0 - http-cache-semantics: 4.1.1 + http-cache-semantics: 4.2.0 keyv: 4.5.4 lowercase-keys: 2.0.0 normalize-url: 6.1.0 @@ -7761,23 +6875,10 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.2: - dependencies: - function-bind: 1.1.2 - get-intrinsic: 1.2.1 - - call-bind@1.0.7: - dependencies: - es-define-property: 1.0.0 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.2.4 - set-function-length: 1.2.2 - - call-bind@1.0.8: + call-bind@1.0.9: dependencies: call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.0 + es-define-property: 1.0.1 get-intrinsic: 1.3.0 set-function-length: 1.2.2 @@ -7792,26 +6893,18 @@ snapshots: camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001636: {} - - caniuse-lite@1.0.30001669: {} + caniuse-lite@1.0.30001788: {} case@1.6.3: {} ccount@2.0.1: {} - chalk@2.4.2: - dependencies: - ansi-styles: 3.2.1 - escape-string-regexp: 1.0.5 - supports-color: 5.5.0 - chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 - chalk@5.3.0: {} + chalk@5.6.2: {} character-entities-html4@2.1.0: {} @@ -7833,9 +6926,9 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - chokidar@4.0.1: + chokidar@4.0.3: dependencies: - readdirp: 4.0.2 + readdirp: 4.1.2 chownr@3.0.0: {} @@ -7874,7 +6967,7 @@ snapshots: cli-truncate@4.0.0: dependencies: slice-ansi: 5.0.0 - string-width: 7.1.0 + string-width: 7.2.0 clipanion@4.0.0-rc.4(typanion@3.14.0): dependencies: @@ -7898,16 +6991,10 @@ snapshots: clone@1.0.4: {} - color-convert@1.9.3: - dependencies: - color-name: 1.1.3 - color-convert@2.0.1: dependencies: color-name: 1.1.4 - color-name@1.1.3: {} - color-name@1.1.4: {} colorette@2.0.20: {} @@ -7918,15 +7005,10 @@ snapshots: comma-separated-tokens@2.0.3: {} - commander@12.1.0: {} - commander@13.1.0: {} commander@14.0.3: {} - commander@2.20.3: - optional: true - commander@4.1.1: {} commander@5.1.0: {} @@ -7940,7 +7022,7 @@ snapshots: convert-source-map@2.0.0: {} - convert@5.13.1: {} + convert@5.14.1: {} core-util-is@1.0.2: {} @@ -7953,12 +7035,6 @@ snapshots: cross-dirname@0.1.0: optional: true - cross-spawn@7.0.3: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -7969,40 +7045,22 @@ snapshots: cssesc@3.0.0: {} - csstype@3.1.3: {} + csstype@3.2.3: {} damerau-levenshtein@1.0.8: {} - data-view-buffer@1.0.1: - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - is-data-view: 1.0.1 - data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 is-data-view: 1.0.2 - data-view-byte-length@1.0.1: - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - is-data-view: 1.0.1 - data-view-byte-length@1.0.2: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 is-data-view: 1.0.2 - data-view-byte-offset@1.0.0: - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - is-data-view: 1.0.1 - data-view-byte-offset@1.0.1: dependencies: call-bound: 1.0.4 @@ -8017,19 +7075,11 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.3.5: - dependencies: - ms: 2.1.2 - - debug@4.3.7: - dependencies: - ms: 2.1.3 - debug@4.4.3: dependencies: ms: 2.1.3 - decode-named-character-reference@1.0.2: + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -8039,24 +7089,24 @@ snapshots: deep-equal@2.2.3: dependencies: - array-buffer-byte-length: 1.0.1 - call-bind: 1.0.7 + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 es-get-iterator: 1.1.3 - get-intrinsic: 1.2.4 - is-arguments: 1.1.1 - is-array-buffer: 3.0.4 - is-date-object: 1.0.5 - is-regex: 1.1.4 - is-shared-array-buffer: 1.0.3 + get-intrinsic: 1.3.0 + is-arguments: 1.2.0 + is-array-buffer: 3.0.5 + is-date-object: 1.1.0 + is-regex: 1.2.1 + is-shared-array-buffer: 1.0.4 isarray: 2.0.5 object-is: 1.1.6 object-keys: 1.1.1 - object.assign: 4.1.5 - regexp.prototype.flags: 1.5.2 - side-channel: 1.0.6 - which-boxed-primitive: 1.0.2 + object.assign: 4.1.7 + regexp.prototype.flags: 1.5.4 + side-channel: 1.1.0 + which-boxed-primitive: 1.1.1 which-collection: 1.0.2 - which-typed-array: 1.1.15 + which-typed-array: 1.1.20 deep-is@0.1.4: {} @@ -8077,9 +7127,9 @@ snapshots: define-data-property@1.1.4: dependencies: - es-define-property: 1.0.0 + es-define-property: 1.0.1 es-errors: 1.3.0 - gopd: 1.0.1 + gopd: 1.2.0 define-lazy-prop@2.0.0: {} @@ -8097,8 +7147,6 @@ snapshots: dequal@2.0.3: {} - detect-libc@1.0.3: {} - detect-libc@2.1.2: {} detect-node@2.1.0: @@ -8110,14 +7158,14 @@ snapshots: didyoumean@1.2.2: {} - diff@4.0.2: {} + diff@4.0.4: {} dir-compare@4.2.0: dependencies: - minimatch: 3.1.2 + minimatch: 3.1.5 p-limit: 3.1.0 - discord-api-types@0.38.39: {} + discord-api-types@0.38.46: {} discord-rich-presence@0.0.8(encoding@0.1.13): dependencies: @@ -8140,13 +7188,13 @@ snapshots: dlv@1.1.3: {} - dmg-builder@26.7.0(electron-builder-squirrel-windows@26.7.0): + dmg-builder@26.9.0(electron-builder-squirrel-windows@26.9.0): dependencies: - app-builder-lib: 26.7.0(dmg-builder@26.7.0)(electron-builder-squirrel-windows@26.7.0) - builder-util: 26.4.1 + app-builder-lib: 26.9.0(dmg-builder@26.9.0)(electron-builder-squirrel-windows@26.9.0) + builder-util: 26.9.0 fs-extra: 10.1.0 iconv-lite: 0.6.3 - js-yaml: 4.1.0 + js-yaml: 4.1.1 optionalDependencies: dmg-license: 1.0.11 transitivePeerDependencies: @@ -8157,7 +7205,7 @@ snapshots: dependencies: '@types/plist': 3.0.5 '@types/verror': 1.10.11 - ajv: 6.12.6 + ajv: 6.14.0 crc: 3.8.0 iconv-corefoundation: 1.1.7 plist: 3.1.0 @@ -8170,9 +7218,9 @@ snapshots: dotenv-expand@11.0.7: dependencies: - dotenv: 16.4.5 + dotenv: 16.6.1 - dotenv@16.4.5: {} + dotenv@16.6.1: {} dunder-proto@1.0.1: dependencies: @@ -8190,23 +7238,23 @@ snapshots: dependencies: jake: 10.9.4 - electron-builder-squirrel-windows@26.7.0(dmg-builder@26.7.0): + electron-builder-squirrel-windows@26.9.0(dmg-builder@26.9.0): dependencies: - app-builder-lib: 26.7.0(dmg-builder@26.7.0)(electron-builder-squirrel-windows@26.7.0) - builder-util: 26.4.1 + app-builder-lib: 26.9.0(dmg-builder@26.9.0)(electron-builder-squirrel-windows@26.9.0) + builder-util: 26.9.0 electron-winstaller: 5.4.0 transitivePeerDependencies: - dmg-builder - supports-color - electron-builder@26.7.0(electron-builder-squirrel-windows@26.7.0): + electron-builder@26.9.0(electron-builder-squirrel-windows@26.9.0): dependencies: - app-builder-lib: 26.7.0(dmg-builder@26.7.0)(electron-builder-squirrel-windows@26.7.0) - builder-util: 26.4.1 - builder-util-runtime: 9.5.1 + app-builder-lib: 26.9.0(dmg-builder@26.9.0)(electron-builder-squirrel-windows@26.9.0) + builder-util: 26.9.0 + builder-util-runtime: 9.6.0 chalk: 4.1.2 ci-info: 4.4.0 - dmg-builder: 26.7.0(electron-builder-squirrel-windows@26.7.0) + dmg-builder: 26.9.0(electron-builder-squirrel-windows@26.9.0) fs-extra: 10.1.0 lazy-val: 1.0.5 simple-update-notifier: 2.0.0 @@ -8215,11 +7263,11 @@ snapshots: - electron-builder-squirrel-windows - supports-color - electron-publish@26.6.0: + electron-publish@26.9.0: dependencies: '@types/fs-extra': 9.0.13 - builder-util: 26.4.1 - builder-util-runtime: 9.5.1 + builder-util: 26.9.0 + builder-util-runtime: 9.6.0 chalk: 4.1.2 form-data: 4.0.5 fs-extra: 10.1.0 @@ -8228,11 +7276,9 @@ snapshots: transitivePeerDependencies: - supports-color - electron-to-chromium@1.4.803: {} - - electron-to-chromium@1.5.41: {} + electron-to-chromium@1.5.339: {} - electron-vite@5.0.0(@swc/core@1.6.5)(vite@5.4.9(@types/node@24.10.0)(sass@1.80.2)(terser@5.31.1)): + electron-vite@5.0.0(@swc/core@1.15.26)(vite@5.4.21(@types/node@24.12.2)(sass@1.99.0)): dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.0) @@ -8240,9 +7286,9 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 5.4.9(@types/node@24.10.0)(sass@1.80.2)(terser@5.31.1) + vite: 5.4.21(@types/node@24.12.2)(sass@1.99.0) optionalDependencies: - '@swc/core': 1.6.5 + '@swc/core': 1.15.26 transitivePeerDependencies: - supports-color @@ -8251,22 +7297,22 @@ snapshots: '@electron/asar': 3.4.1 debug: 4.4.3 fs-extra: 7.0.1 - lodash: 4.17.21 + lodash: 4.18.1 temp: 0.9.4 optionalDependencies: '@electron/windows-sign': 1.2.2 transitivePeerDependencies: - supports-color - electron@40.3.0: + electron@40.9.1: dependencies: '@electron/get': 2.0.3 - '@types/node': 24.10.0 + '@types/node': 24.12.2 extract-zip: 2.0.1 transitivePeerDependencies: - supports-color - emoji-regex@10.3.0: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -8277,7 +7323,7 @@ snapshots: iconv-lite: 0.6.3 optional: true - end-of-stream@1.4.4: + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -8287,61 +7333,12 @@ snapshots: err-code@2.0.3: {} - es-abstract@1.23.3: - dependencies: - array-buffer-byte-length: 1.0.1 - arraybuffer.prototype.slice: 1.0.3 - available-typed-arrays: 1.0.7 - call-bind: 1.0.7 - data-view-buffer: 1.0.1 - data-view-byte-length: 1.0.1 - data-view-byte-offset: 1.0.0 - es-define-property: 1.0.0 - es-errors: 1.3.0 - es-object-atoms: 1.0.0 - es-set-tostringtag: 2.0.3 - es-to-primitive: 1.2.1 - function.prototype.name: 1.1.6 - get-intrinsic: 1.2.4 - get-symbol-description: 1.0.2 - globalthis: 1.0.4 - gopd: 1.0.1 - has-property-descriptors: 1.0.2 - has-proto: 1.0.3 - has-symbols: 1.0.3 - hasown: 2.0.2 - internal-slot: 1.0.7 - is-array-buffer: 3.0.4 - is-callable: 1.2.7 - is-data-view: 1.0.1 - is-negative-zero: 2.0.3 - is-regex: 1.1.4 - is-shared-array-buffer: 1.0.3 - is-string: 1.0.7 - is-typed-array: 1.1.13 - is-weakref: 1.0.2 - object-inspect: 1.13.1 - object-keys: 1.1.1 - object.assign: 4.1.5 - regexp.prototype.flags: 1.5.2 - safe-array-concat: 1.1.2 - safe-regex-test: 1.0.3 - string.prototype.trim: 1.2.9 - string.prototype.trimend: 1.0.9 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.2 - typed-array-byte-length: 1.0.1 - typed-array-byte-offset: 1.0.2 - typed-array-length: 1.0.6 - unbox-primitive: 1.0.2 - which-typed-array: 1.1.15 - - es-abstract@1.24.0: + es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 arraybuffer.prototype.slice: 1.0.4 available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 data-view-buffer: 1.0.2 data-view-byte-length: 1.0.2 @@ -8391,11 +7388,7 @@ snapshots: typed-array-byte-offset: 1.0.4 typed-array-length: 1.0.7 unbox-primitive: 1.1.0 - which-typed-array: 1.1.19 - - es-define-property@1.0.0: - dependencies: - get-intrinsic: 1.2.4 + which-typed-array: 1.1.20 es-define-property@1.0.1: {} @@ -8403,49 +7396,39 @@ snapshots: es-get-iterator@1.1.3: dependencies: - call-bind: 1.0.7 - get-intrinsic: 1.2.4 - has-symbols: 1.0.3 - is-arguments: 1.1.1 + call-bind: 1.0.9 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + is-arguments: 1.2.0 is-map: 2.0.3 is-set: 2.0.3 - is-string: 1.0.7 + is-string: 1.1.1 isarray: 2.0.5 - stop-iteration-iterator: 1.0.0 + stop-iteration-iterator: 1.1.0 - es-iterator-helpers@1.2.1: + es-iterator-helpers@1.3.2: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-set-tostringtag: 2.0.3 + es-set-tostringtag: 2.1.0 function-bind: 1.1.2 get-intrinsic: 1.3.0 globalthis: 1.0.4 gopd: 1.2.0 has-property-descriptors: 1.0.2 has-proto: 1.2.0 - has-symbols: 1.1.0 - internal-slot: 1.1.0 - iterator.prototype: 1.1.5 - safe-array-concat: 1.1.3 - - es-object-atoms@1.0.0: - dependencies: - es-errors: 1.3.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 - es-set-tostringtag@2.0.3: - dependencies: - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - es-set-tostringtag@2.1.0: dependencies: es-errors: 1.3.0 @@ -8453,25 +7436,15 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - es-shim-unscopables@1.0.2: - dependencies: - hasown: 2.0.2 - es-shim-unscopables@1.1.0: dependencies: hasown: 2.0.2 - es-to-primitive@1.2.1: - dependencies: - is-callable: 1.2.7 - is-date-object: 1.0.5 - is-symbol: 1.0.4 - es-to-primitive@1.3.0: dependencies: is-callable: 1.2.7 - is-date-object: 1.0.5 - is-symbol: 1.0.4 + is-date-object: 1.1.0 + is-symbol: 1.1.1 es6-error@4.1.1: optional: true @@ -8533,51 +7506,47 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 - escalade@3.1.2: {} - escalade@3.2.0: {} - escape-string-regexp@1.0.5: {} - escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} - eslint-import-resolver-node@0.3.9: + eslint-import-resolver-node@0.3.10: dependencies: debug: 3.2.7 is-core-module: 2.16.1 - resolve: 1.22.8 + resolve: 2.0.0-next.6 transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 - eslint: 9.39.1(jiti@2.6.1) - get-tsconfig: 4.13.0 + eslint: 9.39.4(jiti@1.21.7) + get-tsconfig: 4.14.0 is-bun-module: 2.0.0 stable-hash: 0.0.5 - tinyglobby: 0.2.15 + tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3) - eslint: 9.39.1(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) + '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -8586,13 +7555,13 @@ snapshots: array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.39.1(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint: 9.39.4(jiti@1.21.7) + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 @@ -8600,60 +7569,60 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3) + '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@1.21.7)): dependencies: aria-query: 5.3.2 - array-includes: 3.1.8 - array.prototype.flatmap: 1.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 ast-types-flow: 0.0.8 - axe-core: 4.10.1 + axe-core: 4.11.3 axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.1(jiti@2.6.1) + eslint: 9.39.4(jiti@1.21.7) hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 - minimatch: 3.1.2 + minimatch: 3.1.5 object.fromentries: 2.0.8 - safe-regex-test: 1.0.3 + safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@7.0.1(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@1.21.7)): dependencies: - '@babel/core': 7.25.8 - '@babel/parser': 7.25.8 - eslint: 9.39.1(jiti@2.6.1) + '@babel/core': 7.29.0 + '@babel/parser': 7.29.2 + eslint: 9.39.4(jiti@1.21.7) hermes-parser: 0.25.1 - zod: 4.1.13 - zod-validation-error: 4.0.2(zod@4.1.13) + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color - eslint-plugin-react@7.37.5(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@1.21.7)): dependencies: - array-includes: 3.1.8 + array-includes: 3.1.9 array.prototype.findlast: 1.2.5 array.prototype.flatmap: 1.3.3 array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 - es-iterator-helpers: 1.2.1 - eslint: 9.39.1(jiti@2.6.1) + es-iterator-helpers: 1.3.2 + eslint: 9.39.4(jiti@1.21.7) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 + minimatch: 3.1.5 object.entries: 1.1.9 object.fromentries: 2.0.8 object.values: 1.2.1 prop-types: 15.8.1 - resolve: 2.0.0-next.5 + resolve: 2.0.0-next.6 semver: 6.3.1 string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 @@ -8667,21 +7636,23 @@ snapshots: eslint-visitor-keys@4.2.1: {} - eslint@9.39.1(jiti@2.6.1): + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@1.21.7): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 + '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.39.1 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.7 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.6 - ajv: 6.12.6 + '@types/estree': 1.0.8 + ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -8689,32 +7660,32 @@ snapshots: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - esquery: 1.5.0 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 8.0.0 find-up: 5.0.0 glob-parent: 6.0.2 - ignore: 5.3.1 + ignore: 5.3.2 imurmurhash: 0.1.4 is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 3.1.2 + minimatch: 3.1.5 natural-compare: 1.4.0 optionator: 0.9.4 optionalDependencies: - jiti: 2.6.1 + jiti: 1.21.7 transitivePeerDependencies: - supports-color espree@10.4.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 4.2.1 - esquery@1.5.0: + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -8728,11 +7699,11 @@ snapshots: esutils@2.0.3: {} - eventemitter3@5.0.1: {} + eventemitter3@5.0.4: {} execa@4.1.0: dependencies: - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 get-stream: 5.2.0 human-signals: 1.1.1 is-stream: 2.0.1 @@ -8744,7 +7715,7 @@ snapshots: execa@8.0.1: dependencies: - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 get-stream: 8.0.1 human-signals: 5.0.0 is-stream: 3.0.0 @@ -8774,17 +7745,17 @@ snapshots: fast-content-type-parse@2.0.1: {} - fast-copy@4.0.2: {} + fast-copy@4.0.3: {} fast-deep-equal@3.1.3: {} - fast-glob@3.3.2: + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 '@nodelib/fs.walk': 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 - micromatch: 4.0.7 + micromatch: 4.0.8 fast-json-parse@1.0.3: {} @@ -8796,17 +7767,17 @@ snapshots: fast-uri@3.1.0: {} - fastq@1.17.1: + fastq@1.20.1: dependencies: - reusify: 1.0.4 + reusify: 1.1.0 fd-slicer@1.1.0: dependencies: pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 fflate@0.8.2: {} @@ -8817,18 +7788,17 @@ snapshots: file-uri-to-path@1.0.0: optional: true - filelist@1.0.4: + filelist@1.0.6: dependencies: - minimatch: 5.1.6 + minimatch: 5.1.9 fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - find-babel-config@2.1.1: + find-babel-config@2.1.2: dependencies: json5: 2.2.3 - path-exists: 4.0.0 find-up@3.0.0: dependencies: @@ -8841,28 +7811,19 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.3.1 + flatted: 3.4.2 keyv: 4.5.4 flatbuffers@1.12.0: {} flatbuffers@22.10.26: {} - flatted@3.3.1: {} - - for-each@0.3.3: - dependencies: - is-callable: 1.2.7 + flatted@3.4.2: {} for-each@0.3.5: dependencies: is-callable: 1.2.7 - foreground-child@3.2.1: - dependencies: - cross-spawn: 7.0.3 - signal-exit: 4.1.0 - foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -8878,18 +7839,18 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 - fraction.js@4.3.7: {} + fraction.js@5.3.4: {} fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 - jsonfile: 6.1.0 + jsonfile: 6.2.0 universalify: 2.0.1 - fs-extra@11.3.3: + fs-extra@11.3.4: dependencies: graceful-fs: 4.2.11 - jsonfile: 6.1.0 + jsonfile: 6.2.0 universalify: 2.0.1 fs-extra@7.0.1: @@ -8908,12 +7869,12 @@ snapshots: dependencies: at-least-node: 1.0.0 graceful-fs: 4.2.11 - jsonfile: 6.1.0 + jsonfile: 6.2.0 universalify: 2.0.1 fs-minipass@3.0.3: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 fs.realpath@1.0.0: {} @@ -8922,16 +7883,9 @@ snapshots: function-bind@1.1.2: {} - function.prototype.name@1.1.6: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - functions-have-names: 1.2.3 - function.prototype.name@1.1.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 functions-have-names: 1.2.3 @@ -8940,26 +7894,13 @@ snapshots: functions-have-names@1.2.3: {} + generator-function@2.0.1: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} - get-east-asian-width@1.2.0: {} - - get-intrinsic@1.2.1: - dependencies: - function-bind: 1.1.2 - has: 1.0.3 - has-proto: 1.0.3 - has-symbols: 1.0.3 - - get-intrinsic@1.2.4: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - has-proto: 1.0.3 - has-symbols: 1.0.3 - hasown: 2.0.2 + get-east-asian-width@1.5.0: {} get-intrinsic@1.3.0: dependencies: @@ -8981,25 +7922,19 @@ snapshots: get-stream@5.2.0: dependencies: - pump: 3.0.0 + pump: 3.0.4 get-stream@6.0.1: {} get-stream@8.0.1: {} - get-symbol-description@1.0.2: - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - get-intrinsic: 1.2.4 - get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.13.0: + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -9011,42 +7946,34 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.4.1: - dependencies: - foreground-child: 3.2.1 - jackspeak: 3.4.0 - minimatch: 9.0.4 - minipass: 7.1.2 - path-scurry: 1.11.1 - - glob@11.1.0: + glob@10.5.0: dependencies: foreground-child: 3.3.1 - jackspeak: 4.2.3 - minimatch: 10.2.0 - minipass: 7.1.2 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 package-json-from-dist: 1.0.1 - path-scurry: 2.0.1 + path-scurry: 1.11.1 - glob@13.0.3: + glob@13.0.6: dependencies: - minimatch: 10.2.0 - minipass: 7.1.2 - path-scurry: 2.0.1 + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 glob@7.2.3: dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 3.1.2 + minimatch: 3.1.5 once: 1.4.0 path-is-absolute: 1.0.1 glob@9.3.5: dependencies: fs.realpath: 1.0.0 - minimatch: 8.0.4 + minimatch: 8.0.7 minipass: 4.2.8 path-scurry: 1.11.1 @@ -9056,25 +7983,19 @@ snapshots: es6-error: 4.1.1 matcher: 3.0.0 roarr: 2.15.4 - semver: 7.7.3 + semver: 7.7.4 serialize-error: 7.0.1 optional: true - globals@11.12.0: {} - globals@14.0.0: {} - globals@15.10.0: {} + globals@15.15.0: {} globalthis@1.0.4: dependencies: define-properties: 1.2.1 gopd: 1.2.0 - gopd@1.0.1: - dependencies: - get-intrinsic: 1.2.4 - gopd@1.2.0: {} got-fetch@5.1.10(got@12.6.1): @@ -9111,47 +8032,31 @@ snapshots: graceful-fs@4.2.11: {} - graphemer@1.4.0: {} - - has-bigints@1.0.2: {} - - has-flag@3.0.0: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {} has-property-descriptors@1.0.2: dependencies: - es-define-property: 1.0.0 - - has-proto@1.0.3: {} + es-define-property: 1.0.1 has-proto@1.2.0: dependencies: dunder-proto: 1.0.1 - has-symbols@1.0.3: {} - has-symbols@1.1.0: {} - has-tostringtag@1.0.0: - dependencies: - has-symbols: 1.0.3 - has-tostringtag@1.0.2: dependencies: - has-symbols: 1.0.3 - - has@1.0.3: - dependencies: - function-bind: 1.1.2 + has-symbols: 1.1.0 hasown@2.0.2: dependencies: function-bind: 1.1.2 - hast-util-to-jsx-runtime@2.3.2: + hast-util-to-jsx-runtime@2.3.6: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 '@types/hast': 3.0.4 '@types/unist': 3.0.3 comma-separated-tokens: 2.0.3 @@ -9159,13 +8064,13 @@ snapshots: estree-util-is-identifier-name: 3.0.0 hast-util-whitespace: 3.0.0 mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.1.3 + mdast-util-mdx-jsx: 3.2.0 mdast-util-mdxjs-esm: 2.0.1 - property-information: 6.5.0 + property-information: 7.1.0 space-separated-tokens: 2.0.2 - style-to-object: 1.0.8 + style-to-js: 1.1.21 unist-util-position: 5.0.0 - vfile-message: 4.0.2 + vfile-message: 4.0.3 transitivePeerDependencies: - supports-color @@ -9193,7 +8098,7 @@ snapshots: html-url-attributes@3.0.1: {} - http-cache-semantics@4.1.1: {} + http-cache-semantics@4.2.0: {} http-proxy-agent@7.0.2: dependencies: @@ -9232,7 +8137,7 @@ snapshots: human-signals@5.0.0: {} - husky@9.1.6: {} + husky@9.1.7: {} hyphenate-style-name@1.1.0: {} @@ -9247,13 +8152,13 @@ snapshots: ieee754@1.2.1: {} - ignore@5.3.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} - immutable@4.3.6: {} + immutable@5.1.5: {} - import-fresh@3.3.0: + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 resolve-from: 4.0.0 @@ -9267,13 +8172,7 @@ snapshots: inherits@2.0.4: {} - inline-style-parser@0.2.4: {} - - internal-slot@1.0.7: - dependencies: - es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.0.6 + inline-style-parser@0.2.7: {} internal-slot@1.1.0: dependencies: @@ -9285,7 +8184,7 @@ snapshots: ip-address@10.1.0: {} - ip-num@1.5.1: {} + ip-num@1.5.2: {} is-alphabetical@2.0.1: {} @@ -9294,43 +8193,33 @@ snapshots: is-alphabetical: 2.0.1 is-decimal: 2.0.1 - is-arguments@1.1.1: + is-arguments@1.2.0: dependencies: - call-bind: 1.0.7 + call-bound: 1.0.4 has-tostringtag: 1.0.2 - is-array-buffer@3.0.4: - dependencies: - call-bind: 1.0.7 - get-intrinsic: 1.2.4 - is-array-buffer@3.0.5: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 - is-async-function@2.0.0: + is-async-function@2.1.1: dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 has-tostringtag: 1.0.2 - - is-bigint@1.0.4: - dependencies: - has-bigints: 1.0.2 + safe-regex-test: 1.1.0 is-bigint@1.1.0: dependencies: - has-bigints: 1.0.2 + has-bigints: 1.1.0 is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 - is-boolean-object@1.1.2: - dependencies: - call-bind: 1.0.2 - has-tostringtag: 1.0.0 - is-boolean-object@1.2.2: dependencies: call-bound: 1.0.4 @@ -9338,36 +8227,20 @@ snapshots: is-bun-module@2.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 is-callable@1.2.7: {} - is-core-module@2.13.1: - dependencies: - hasown: 2.0.2 - - is-core-module@2.15.1: - dependencies: - hasown: 2.0.2 - is-core-module@2.16.1: dependencies: hasown: 2.0.2 - is-data-view@1.0.1: - dependencies: - is-typed-array: 1.1.13 - is-data-view@1.0.2: dependencies: call-bound: 1.0.4 get-intrinsic: 1.3.0 is-typed-array: 1.1.15 - is-date-object@1.0.5: - dependencies: - has-tostringtag: 1.0.0 - is-date-object@1.1.0: dependencies: call-bound: 1.0.4 @@ -9381,10 +8254,6 @@ snapshots: is-extglob@2.1.1: {} - is-finalizationregistry@1.0.2: - dependencies: - call-bind: 1.0.8 - is-finalizationregistry@1.1.1: dependencies: call-bound: 1.0.4 @@ -9393,13 +8262,17 @@ snapshots: is-fullwidth-code-point@4.0.0: {} - is-fullwidth-code-point@5.0.0: + is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.2.0 + get-east-asian-width: 1.5.0 - is-generator-function@1.0.10: + is-generator-function@1.1.2: dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 is-glob@4.0.3: dependencies: @@ -9419,10 +8292,6 @@ snapshots: is-negative-zero@2.0.3: {} - is-number-object@1.0.7: - dependencies: - has-tostringtag: 1.0.0 - is-number-object@1.1.1: dependencies: call-bound: 1.0.4 @@ -9432,11 +8301,6 @@ snapshots: is-plain-obj@4.1.0: {} - is-regex@1.1.4: - dependencies: - call-bind: 1.0.2 - has-tostringtag: 1.0.0 - is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -9446,10 +8310,6 @@ snapshots: is-set@2.0.3: {} - is-shared-array-buffer@1.0.3: - dependencies: - call-bind: 1.0.7 - is-shared-array-buffer@1.0.4: dependencies: call-bound: 1.0.4 @@ -9458,55 +8318,39 @@ snapshots: is-stream@3.0.0: {} - is-string@1.0.7: - dependencies: - has-tostringtag: 1.0.2 - is-string@1.1.1: dependencies: call-bound: 1.0.4 has-tostringtag: 1.0.2 - is-symbol@1.0.4: - dependencies: - has-symbols: 1.0.3 - is-symbol@1.1.1: dependencies: call-bound: 1.0.4 has-symbols: 1.1.0 safe-regex-test: 1.1.0 - is-typed-array@1.1.13: - dependencies: - which-typed-array: 1.1.15 - is-typed-array@1.1.15: dependencies: - which-typed-array: 1.1.19 + which-typed-array: 1.1.20 is-unicode-supported@0.1.0: {} is-weakmap@2.0.2: {} - is-weakref@1.0.2: - dependencies: - call-bind: 1.0.7 - is-weakref@1.1.1: dependencies: call-bound: 1.0.4 - is-weakset@2.0.3: + is-weakset@2.0.4: dependencies: - call-bind: 1.0.7 - get-intrinsic: 1.2.4 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 is-wsl@2.2.0: dependencies: is-docker: 2.2.1 - is-wsl@3.1.0: + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 @@ -9523,48 +8367,44 @@ snapshots: iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 - es-object-atoms: 1.0.0 + es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 get-proto: 1.0.1 has-symbols: 1.1.0 set-function-name: 2.0.2 - jackspeak@3.4.0: + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jackspeak@4.2.3: - dependencies: - '@isaacs/cliui': 9.0.0 - jake@10.9.4: dependencies: async: 3.2.6 - filelist: 1.0.4 + filelist: 1.0.6 picocolors: 1.1.1 - jiti@1.21.6: {} + jiti@1.21.7: {} jiti@2.6.1: {} - jotai@2.12.2(@types/react@18.3.11)(react@18.3.1): + jotai@2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1): optionalDependencies: - '@types/react': 18.3.11 + '@babel/core': 7.29.0 + '@babel/template': 7.28.6 + '@types/react': 18.3.28 react: 18.3.1 joycon@3.1.1: {} js-tokens@4.0.0: {} - js-yaml@4.1.0: + js-yaml@4.1.1: dependencies: argparse: 2.0.1 - jsesc@2.5.2: {} - - jsesc@3.0.2: {} + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -9587,7 +8427,7 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jsonfile@6.1.0: + jsonfile@6.2.0: dependencies: universalify: 2.0.1 optionalDependencies: @@ -9595,10 +8435,10 @@ snapshots: jsx-ast-utils@3.3.5: dependencies: - array-includes: 3.1.8 - array.prototype.flat: 1.3.2 - object.assign: 4.1.5 - object.values: 1.2.0 + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 keyv@4.5.4: dependencies: @@ -9619,35 +8459,33 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - lilconfig@2.1.0: {} - - lilconfig@3.1.2: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} - lint-staged@15.2.10: + lint-staged@15.5.2: dependencies: - chalk: 5.3.0 - commander: 12.1.0 - debug: 4.3.7 + chalk: 5.6.2 + commander: 13.1.0 + debug: 4.4.3 execa: 8.0.1 - lilconfig: 3.1.2 - listr2: 8.2.5 + lilconfig: 3.1.3 + listr2: 8.3.3 micromatch: 4.0.8 pidtree: 0.6.0 string-argv: 0.3.2 - yaml: 2.5.1 + yaml: 2.8.3 transitivePeerDependencies: - supports-color - listr2@8.2.5: + listr2@8.3.3: dependencies: cli-truncate: 4.0.0 colorette: 2.0.20 - eventemitter3: 5.0.1 + eventemitter3: 5.0.4 log-update: 6.1.0 rfdc: 1.4.1 - wrap-ansi: 9.0.0 + wrap-ansi: 9.0.2 locate-path@3.0.0: dependencies: @@ -9658,13 +8496,9 @@ snapshots: dependencies: p-locate: 5.0.0 - lodash.castarray@4.4.0: {} - - lodash.isplainobject@4.0.6: {} - lodash.merge@4.6.2: {} - lodash@4.17.21: {} + lodash@4.18.1: {} log-symbols@4.1.0: dependencies: @@ -9673,11 +8507,11 @@ snapshots: log-update@6.1.0: dependencies: - ansi-escapes: 7.0.0 + ansi-escapes: 7.3.0 cli-cursor: 5.0.0 - slice-ansi: 7.1.0 - strip-ansi: 7.1.0 - wrap-ansi: 9.0.0 + slice-ansi: 7.1.2 + strip-ansi: 7.2.0 + wrap-ansi: 9.0.2 longest-streak@3.1.0: {} @@ -9689,9 +8523,9 @@ snapshots: lowercase-keys@3.0.0: {} - lru-cache@10.2.2: {} + lru-cache@10.4.3: {} - lru-cache@11.2.6: {} + lru-cache@11.3.5: {} lru-cache@5.1.1: dependencies: @@ -9709,7 +8543,7 @@ snapshots: magic-string@0.30.8: dependencies: - '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/sourcemap-codec': 1.5.5 make-error@1.3.6: {} @@ -9717,10 +8551,10 @@ snapshots: dependencies: '@npmcli/agent': 3.0.0 cacache: 19.0.1 - http-cache-semantics: 4.1.1 - minipass: 7.1.2 + http-cache-semantics: 4.2.0 + minipass: 7.1.3 minipass-fetch: 4.0.1 - minipass-flush: 1.0.5 + minipass-flush: 1.0.7 minipass-pipeline: 1.2.4 negotiator: 1.0.0 proc-log: 5.0.0 @@ -9742,26 +8576,26 @@ snapshots: math-intrinsics@1.1.0: {} - mdast-util-find-and-replace@3.0.1: + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 escape-string-regexp: 5.0.0 - unist-util-is: 6.0.0 - unist-util-visit-parents: 6.0.1 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 - mdast-util-from-markdown@2.0.2: + mdast-util-from-markdown@2.0.3: dependencies: '@types/mdast': 4.0.4 '@types/unist': 3.0.3 - decode-named-character-reference: 1.0.2 + decode-named-character-reference: 1.3.0 devlop: 1.1.0 mdast-util-to-string: 4.0.0 - micromark: 4.0.0 - micromark-util-decode-numeric-character-reference: 2.0.1 - micromark-util-decode-string: 2.0.0 - micromark-util-normalize-identifier: 2.0.0 - micromark-util-symbol: 2.0.0 - micromark-util-types: 2.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 unist-util-stringify-position: 4.0.0 transitivePeerDependencies: - supports-color @@ -9771,23 +8605,23 @@ snapshots: '@types/mdast': 4.0.4 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-find-and-replace: 3.0.1 - micromark-util-character: 2.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 - mdast-util-gfm-footnote@2.0.0: + mdast-util-gfm-footnote@2.1.0: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 - micromark-util-normalize-identifier: 2.0.0 + micromark-util-normalize-identifier: 2.0.1 transitivePeerDependencies: - supports-color mdast-util-gfm-strikethrough@2.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -9797,7 +8631,7 @@ snapshots: '@types/mdast': 4.0.4 devlop: 1.1.0 markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -9806,16 +8640,16 @@ snapshots: dependencies: '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-gfm@3.0.0: + mdast-util-gfm@3.1.0: dependencies: - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.0.0 + mdast-util-gfm-footnote: 2.1.0 mdast-util-gfm-strikethrough: 2.0.0 mdast-util-gfm-table: 2.0.0 mdast-util-gfm-task-list-item: 2.0.0 @@ -9829,12 +8663,12 @@ snapshots: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color - mdast-util-mdx-jsx@3.1.3: + mdast-util-mdx-jsx@3.2.0: dependencies: '@types/estree-jsx': 1.0.5 '@types/hast': 3.0.4 @@ -9842,12 +8676,12 @@ snapshots: '@types/unist': 3.0.3 ccount: 2.0.1 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 - parse-entities: 4.0.1 + parse-entities: 4.0.2 stringify-entities: 4.0.4 unist-util-stringify-position: 4.0.0 - vfile-message: 4.0.2 + vfile-message: 4.0.3 transitivePeerDependencies: - supports-color @@ -9857,7 +8691,7 @@ snapshots: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 + mdast-util-from-markdown: 2.0.3 mdast-util-to-markdown: 2.1.2 transitivePeerDependencies: - supports-color @@ -9865,18 +8699,18 @@ snapshots: mdast-util-phrasing@4.1.0: dependencies: '@types/mdast': 4.0.4 - unist-util-is: 6.0.0 + unist-util-is: 6.0.1 - mdast-util-to-hast@13.2.0: + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.2.0 + '@ungap/structured-clone': 1.3.0 devlop: 1.1.0 - micromark-util-sanitize-uri: 2.0.0 + micromark-util-sanitize-uri: 2.0.1 trim-lines: 3.0.1 unist-util-position: 5.0.0 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 vfile: 6.0.3 mdast-util-to-markdown@2.1.2: @@ -9886,9 +8720,9 @@ snapshots: longest-streak: 3.1.0 mdast-util-phrasing: 4.1.0 mdast-util-to-string: 4.0.0 - micromark-util-classify-character: 2.0.0 - micromark-util-decode-string: 2.0.0 - unist-util-visit: 5.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 zwitch: 2.0.4 mdast-util-to-string@4.0.0: @@ -9901,206 +8735,201 @@ snapshots: meshoptimizer@0.18.1: {} - micromark-core-commonmark@2.0.1: + micromark-core-commonmark@2.0.3: dependencies: - decode-named-character-reference: 1.0.2 + decode-named-character-reference: 1.3.0 devlop: 1.1.0 - micromark-factory-destination: 2.0.0 - micromark-factory-label: 2.0.0 - micromark-factory-space: 2.0.0 - micromark-factory-title: 2.0.0 - micromark-factory-whitespace: 2.0.0 - micromark-util-character: 2.1.0 - micromark-util-chunked: 2.0.0 - micromark-util-classify-character: 2.0.0 - micromark-util-html-tag-name: 2.0.0 - micromark-util-normalize-identifier: 2.0.0 - micromark-util-resolve-all: 2.0.0 - micromark-util-subtokenize: 2.0.1 - micromark-util-symbol: 2.0.0 - micromark-util-types: 2.0.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 micromark-extension-gfm-autolink-literal@2.1.0: dependencies: - micromark-util-character: 2.1.0 - micromark-util-sanitize-uri: 2.0.0 - micromark-util-symbol: 2.0.0 - micromark-util-types: 2.0.0 + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 micromark-extension-gfm-footnote@2.1.0: dependencies: devlop: 1.1.0 - micromark-core-commonmark: 2.0.1 - micromark-factory-space: 2.0.0 - micromark-util-character: 2.1.0 - micromark-util-normalize-identifier: 2.0.0 - micromark-util-sanitize-uri: 2.0.0 - micromark-util-symbol: 2.0.0 - micromark-util-types: 2.0.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 micromark-extension-gfm-strikethrough@2.1.0: dependencies: devlop: 1.1.0 - micromark-util-chunked: 2.0.0 - micromark-util-classify-character: 2.0.0 - micromark-util-resolve-all: 2.0.0 - micromark-util-symbol: 2.0.0 - micromark-util-types: 2.0.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 - micromark-extension-gfm-table@2.1.0: + micromark-extension-gfm-table@2.1.1: dependencies: devlop: 1.1.0 - micromark-factory-space: 2.0.0 - micromark-util-character: 2.1.0 - micromark-util-symbol: 2.0.0 - micromark-util-types: 2.0.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 micromark-extension-gfm-tagfilter@2.0.0: dependencies: - micromark-util-types: 2.0.0 + micromark-util-types: 2.0.2 micromark-extension-gfm-task-list-item@2.1.0: dependencies: devlop: 1.1.0 - micromark-factory-space: 2.0.0 - micromark-util-character: 2.1.0 - micromark-util-symbol: 2.0.0 - micromark-util-types: 2.0.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 micromark-extension-gfm@3.0.0: dependencies: micromark-extension-gfm-autolink-literal: 2.1.0 micromark-extension-gfm-footnote: 2.1.0 micromark-extension-gfm-strikethrough: 2.1.0 - micromark-extension-gfm-table: 2.1.0 + micromark-extension-gfm-table: 2.1.1 micromark-extension-gfm-tagfilter: 2.0.0 micromark-extension-gfm-task-list-item: 2.1.0 - micromark-util-combine-extensions: 2.0.0 - micromark-util-types: 2.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 - micromark-factory-destination@2.0.0: + micromark-factory-destination@2.0.1: dependencies: - micromark-util-character: 2.1.0 - micromark-util-symbol: 2.0.0 - micromark-util-types: 2.0.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 - micromark-factory-label@2.0.0: + micromark-factory-label@2.0.1: dependencies: devlop: 1.1.0 - micromark-util-character: 2.1.0 - micromark-util-symbol: 2.0.0 - micromark-util-types: 2.0.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 - micromark-factory-space@2.0.0: + micromark-factory-space@2.0.1: dependencies: - micromark-util-character: 2.1.0 - micromark-util-types: 2.0.0 + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 - micromark-factory-title@2.0.0: + micromark-factory-title@2.0.1: dependencies: - micromark-factory-space: 2.0.0 - micromark-util-character: 2.1.0 - micromark-util-symbol: 2.0.0 - micromark-util-types: 2.0.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 - micromark-factory-whitespace@2.0.0: + micromark-factory-whitespace@2.0.1: dependencies: - micromark-factory-space: 2.0.0 - micromark-util-character: 2.1.0 - micromark-util-symbol: 2.0.0 - micromark-util-types: 2.0.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 - micromark-util-character@2.1.0: + micromark-util-character@2.1.1: dependencies: - micromark-util-symbol: 2.0.0 - micromark-util-types: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 - micromark-util-chunked@2.0.0: + micromark-util-chunked@2.0.1: dependencies: - micromark-util-symbol: 2.0.0 + micromark-util-symbol: 2.0.1 - micromark-util-classify-character@2.0.0: + micromark-util-classify-character@2.0.1: dependencies: - micromark-util-character: 2.1.0 - micromark-util-symbol: 2.0.0 - micromark-util-types: 2.0.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 - micromark-util-combine-extensions@2.0.0: + micromark-util-combine-extensions@2.0.1: dependencies: - micromark-util-chunked: 2.0.0 - micromark-util-types: 2.0.0 + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 - micromark-util-decode-numeric-character-reference@2.0.1: + micromark-util-decode-numeric-character-reference@2.0.2: dependencies: - micromark-util-symbol: 2.0.0 + micromark-util-symbol: 2.0.1 - micromark-util-decode-string@2.0.0: + micromark-util-decode-string@2.0.1: dependencies: - decode-named-character-reference: 1.0.2 - micromark-util-character: 2.1.0 - micromark-util-decode-numeric-character-reference: 2.0.1 - micromark-util-symbol: 2.0.0 + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 - micromark-util-encode@2.0.0: {} + micromark-util-encode@2.0.1: {} - micromark-util-html-tag-name@2.0.0: {} + micromark-util-html-tag-name@2.0.1: {} - micromark-util-normalize-identifier@2.0.0: + micromark-util-normalize-identifier@2.0.1: dependencies: - micromark-util-symbol: 2.0.0 + micromark-util-symbol: 2.0.1 - micromark-util-resolve-all@2.0.0: + micromark-util-resolve-all@2.0.1: dependencies: - micromark-util-types: 2.0.0 + micromark-util-types: 2.0.2 - micromark-util-sanitize-uri@2.0.0: + micromark-util-sanitize-uri@2.0.1: dependencies: - micromark-util-character: 2.1.0 - micromark-util-encode: 2.0.0 - micromark-util-symbol: 2.0.0 + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 - micromark-util-subtokenize@2.0.1: + micromark-util-subtokenize@2.1.0: dependencies: devlop: 1.1.0 - micromark-util-chunked: 2.0.0 - micromark-util-symbol: 2.0.0 - micromark-util-types: 2.0.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 - micromark-util-symbol@2.0.0: {} + micromark-util-symbol@2.0.1: {} - micromark-util-types@2.0.0: {} + micromark-util-types@2.0.2: {} - micromark@4.0.0: + micromark@4.0.2: dependencies: - '@types/debug': 4.1.12 + '@types/debug': 4.1.13 debug: 4.4.3 - decode-named-character-reference: 1.0.2 + decode-named-character-reference: 1.3.0 devlop: 1.1.0 - micromark-core-commonmark: 2.0.1 - micromark-factory-space: 2.0.0 - micromark-util-character: 2.1.0 - micromark-util-chunked: 2.0.0 - micromark-util-combine-extensions: 2.0.0 - micromark-util-decode-numeric-character-reference: 2.0.1 - micromark-util-encode: 2.0.0 - micromark-util-normalize-identifier: 2.0.0 - micromark-util-resolve-all: 2.0.0 - micromark-util-sanitize-uri: 2.0.0 - micromark-util-subtokenize: 2.0.1 - micromark-util-symbol: 2.0.0 - micromark-util-types: 2.0.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 transitivePeerDependencies: - supports-color - micromatch@4.0.7: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mime-db@1.52.0: {} @@ -10124,41 +8953,41 @@ snapshots: mini-svg-data-uri@1.4.4: {} - minimatch@10.2.0: + minimatch@10.2.5: dependencies: - brace-expansion: 5.0.2 + brace-expansion: 5.0.5 - minimatch@3.1.2: + minimatch@3.1.5: dependencies: - brace-expansion: 1.1.11 + brace-expansion: 1.1.14 - minimatch@5.1.6: + minimatch@5.1.9: dependencies: - brace-expansion: 2.0.1 + brace-expansion: 2.1.0 - minimatch@8.0.4: + minimatch@8.0.7: dependencies: - brace-expansion: 2.0.1 + brace-expansion: 2.1.0 - minimatch@9.0.4: + minimatch@9.0.9: dependencies: - brace-expansion: 2.0.1 + brace-expansion: 2.1.0 minimist@1.2.8: {} minipass-collect@2.0.1: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 minipass-fetch@4.0.1: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 minipass-sized: 1.0.3 minizlib: 3.1.0 optionalDependencies: encoding: 0.1.13 - minipass-flush@1.0.5: + minipass-flush@1.0.7: dependencies: minipass: 3.3.6 @@ -10176,18 +9005,16 @@ snapshots: minipass@4.2.8: {} - minipass@7.1.2: {} + minipass@7.1.3: {} minizlib@3.1.0: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 mkdirp@0.5.6: dependencies: minimist: 1.2.8 - ms@2.1.2: {} - ms@2.1.3: {} mz@2.7.0: @@ -10196,7 +9023,7 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - nanoid@3.3.7: {} + nanoid@3.3.11: {} napi-postinstall@0.3.4: {} @@ -10204,17 +9031,25 @@ snapshots: negotiator@1.0.0: {} - node-abi@4.26.0: + node-abi@4.28.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 node-addon-api@1.7.2: {} - node-addon-api@7.1.1: {} + node-addon-api@7.1.1: + optional: true node-api-version@0.2.1: dependencies: - semver: 7.7.3 + semver: 7.7.4 + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 node-fetch-h2@2.3.0: dependencies: @@ -10234,9 +9069,9 @@ snapshots: make-fetch-happen: 14.0.3 nopt: 8.1.0 proc-log: 5.0.0 - semver: 7.7.3 - tar: 7.5.7 - tinyglobby: 0.2.15 + semver: 7.7.4 + tar: 7.5.13 + tinyglobby: 0.2.16 which: 5.0.0 transitivePeerDependencies: - supports-color @@ -10245,9 +9080,7 @@ snapshots: dependencies: es6-promise: 3.3.1 - node-releases@2.0.14: {} - - node-releases@2.0.18: {} + node-releases@2.0.37: {} nopt@8.1.0: dependencies: @@ -10255,11 +9088,9 @@ snapshots: normalize-path@3.0.0: {} - normalize-range@0.1.2: {} - normalize-url@6.1.0: {} - normalize-url@8.0.1: {} + normalize-url@8.1.1: {} npm-run-path@4.0.1: dependencies: @@ -10277,14 +9108,14 @@ snapshots: dependencies: '@exodus/schemasafe': 1.3.0 should: 13.2.3 - yaml: 1.10.2 + yaml: 1.10.3 oas-resolver@2.5.6: dependencies: node-fetch-h2: 2.3.0 oas-kit-common: 1.0.8 reftools: 1.1.9 - yaml: 1.10.2 + yaml: 1.10.3 yargs: 17.7.2 oas-schema-walker@1.1.5: {} @@ -10298,33 +9129,24 @@ snapshots: oas-schema-walker: 1.1.5 reftools: 1.1.9 should: 13.2.3 - yaml: 1.10.2 + yaml: 1.10.3 object-assign@4.1.1: {} object-hash@3.0.0: {} - object-inspect@1.13.1: {} - object-inspect@1.13.4: {} object-is@1.1.6: dependencies: - call-bind: 1.0.7 + call-bind: 1.0.9 define-properties: 1.2.1 object-keys@1.1.1: {} - object.assign@4.1.5: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - has-symbols: 1.0.3 - object-keys: 1.1.1 - object.assign@4.1.7: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 es-object-atoms: 1.1.1 @@ -10333,36 +9155,30 @@ snapshots: object.entries@1.1.9: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 es-object-atoms: 1.1.1 object.fromentries@2.0.8: dependencies: - call-bind: 1.0.7 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.23.3 - es-object-atoms: 1.0.0 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 object.groupby@1.0.3: dependencies: - call-bind: 1.0.7 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.23.3 - - object.values@1.2.0: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-object-atoms: 1.0.0 + es-abstract: 1.24.2 object.values@1.2.1: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.0.0 + es-object-atoms: 1.1.1 objectorarray@1.0.5: {} @@ -10401,7 +9217,7 @@ snapshots: openapi3-ts@2.0.2: dependencies: - yaml: 1.10.2 + yaml: 1.10.3 optionator@0.9.4: dependencies: @@ -10460,13 +9276,12 @@ snapshots: dependencies: callsites: 3.1.0 - parse-entities@4.0.1: + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 - character-entities: 2.0.2 character-entities-legacy: 3.0.0 character-reference-invalid: 2.0.1 - decode-named-character-reference: 1.0.2 + decode-named-character-reference: 1.3.0 is-alphanumerical: 2.0.1 is-decimal: 2.0.1 is-hexadecimal: 2.0.1 @@ -10493,13 +9308,13 @@ snapshots: path-scurry@1.11.1: dependencies: - lru-cache: 10.2.2 - minipass: 7.1.2 + lru-cache: 10.4.3 + minipass: 7.1.3 - path-scurry@2.0.1: + path-scurry@2.0.2: dependencies: - lru-cache: 11.2.6 - minipass: 7.1.2 + lru-cache: 11.3.5 + minipass: 7.1.3 pe-library@0.4.1: {} @@ -10507,13 +9322,11 @@ snapshots: pend@1.2.0: {} - picocolors@1.0.1: {} - picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} pidtree@0.6.0: {} @@ -10527,14 +9340,14 @@ snapshots: dependencies: colorette: 2.0.20 dateformat: 4.6.3 - fast-copy: 4.0.2 + fast-copy: 4.0.3 fast-safe-stringify: 2.1.1 help-me: 5.0.0 joycon: 3.1.1 minimist: 1.2.8 on-exit-leak-free: 2.1.2 pino-abstract-transport: 3.0.0 - pump: 3.0.0 + pump: 3.0.4 secure-json-parse: 4.1.0 sonic-boom: 4.2.1 strip-json-comments: 5.0.3 @@ -10560,7 +9373,7 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.0.0 - pirates@4.0.6: {} + pirates@4.0.7: {} pkg-up@3.1.0: dependencies: @@ -10568,60 +9381,54 @@ snapshots: plist@3.1.0: dependencies: - '@xmldom/xmldom': 0.8.11 + '@xmldom/xmldom': 0.8.12 base64-js: 1.5.1 xmlbuilder: 15.1.1 pluralize@8.0.0: {} - possible-typed-array-names@1.0.0: {} + possible-typed-array-names@1.1.0: {} - postcss-import@15.1.0(postcss@8.4.38): + postcss-import@15.1.0(postcss@8.5.10): dependencies: - postcss: 8.4.38 + postcss: 8.5.10 postcss-value-parser: 4.2.0 read-cache: 1.0.0 - resolve: 1.22.8 + resolve: 1.22.12 - postcss-js@4.0.1(postcss@8.4.38): + postcss-js@4.1.0(postcss@8.5.10): dependencies: camelcase-css: 2.0.1 - postcss: 8.4.38 + postcss: 8.5.10 - postcss-load-config@4.0.2(postcss@8.4.38)(ts-node@9.1.1(typescript@5.6.3)): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.10)(yaml@2.8.3): dependencies: - lilconfig: 3.1.2 - yaml: 2.4.5 + lilconfig: 3.1.3 optionalDependencies: - postcss: 8.4.38 - ts-node: 9.1.1(typescript@5.6.3) + jiti: 1.21.7 + postcss: 8.5.10 + yaml: 2.8.3 - postcss-nested@6.0.1(postcss@8.4.38): + postcss-nested@6.2.0(postcss@8.5.10): dependencies: - postcss: 8.4.38 - postcss-selector-parser: 6.1.0 + postcss: 8.5.10 + postcss-selector-parser: 6.1.2 postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-selector-parser@6.1.0: + postcss-selector-parser@6.1.2: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 postcss-value-parser@4.2.0: {} - postcss@8.4.38: - dependencies: - nanoid: 3.3.7 - picocolors: 1.0.1 - source-map-js: 1.2.0 - - postcss@8.4.47: + postcss@8.5.10: dependencies: - nanoid: 3.3.7 + nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -10634,7 +9441,7 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.3.3: {} + prettier@3.8.3: {} proc-log@5.0.0: {} @@ -10666,13 +9473,13 @@ snapshots: property-expr@2.0.6: {} - property-information@6.5.0: {} + property-information@7.1.0: {} proxy-from-env@1.1.0: {} - pump@3.0.0: + pump@3.0.4: dependencies: - end-of-stream: 1.4.4 + end-of-stream: 1.4.5 once: 1.4.0 punycode@2.3.1: {} @@ -10689,9 +9496,9 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-error-boundary@4.0.13(react@18.3.1): + react-error-boundary@4.1.2(react@18.3.1): dependencies: - '@babel/runtime': 7.24.7 + '@babel/runtime': 7.29.2 react: 18.3.1 react-fast-compare@3.2.2: {} @@ -10704,7 +9511,7 @@ snapshots: react-fast-compare: 3.2.2 react-side-effect: 2.1.2(react@18.3.1) - react-hook-form@7.66.0(react@18.3.1): + react-hook-form@7.72.1(react@18.3.1): dependencies: react: 18.3.1 @@ -10712,24 +9519,25 @@ snapshots: react-lifecycles-compat@3.0.4: {} - react-markdown@9.0.1(@types/react@18.3.11)(react@18.3.1): + react-markdown@9.1.0(@types/react@18.3.28)(react@18.3.1): dependencies: '@types/hast': 3.0.4 - '@types/react': 18.3.11 + '@types/mdast': 4.0.4 + '@types/react': 18.3.28 devlop: 1.1.0 - hast-util-to-jsx-runtime: 2.3.2 + hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 - mdast-util-to-hast: 13.2.0 + mdast-util-to-hast: 13.2.1 react: 18.3.1 remark-parse: 11.0.0 - remark-rehype: 11.1.1 + remark-rehype: 11.1.2 unified: 11.0.5 - unist-util-visit: 5.0.0 + unist-util-visit: 5.1.0 vfile: 6.0.3 transitivePeerDependencies: - supports-color - react-modal@3.16.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-modal@3.16.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: exenv: 1.2.2 prop-types: 15.8.1 @@ -10738,9 +9546,9 @@ snapshots: react-lifecycles-compat: 3.0.4 warning: 4.0.3 - react-refresh@0.14.2: {} + react-refresh@0.17.0: {} - react-responsive@10.0.0(react@18.3.1): + react-responsive@10.0.1(react@18.3.1): dependencies: hyphenate-style-name: 1.1.0 matchmediaquery: 0.4.2 @@ -10748,16 +9556,16 @@ snapshots: react: 18.3.1 shallow-equal: 3.1.0 - react-router-dom@6.27.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@remix-run/router': 1.20.0 + '@remix-run/router': 1.23.2 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-router: 6.27.0(react@18.3.1) + react-router: 6.30.3(react@18.3.1) - react-router@6.27.0(react@18.3.1): + react-router@6.30.3(react@18.3.1): dependencies: - '@remix-run/router': 1.20.0 + '@remix-run/router': 1.23.2 react: 18.3.1 react-side-effect@2.1.2(react@18.3.1): @@ -10786,47 +9594,28 @@ snapshots: readdirp@3.6.0: dependencies: - picomatch: 2.3.1 + picomatch: 2.3.2 - readdirp@4.0.2: {} + readdirp@4.1.2: {} real-require@0.2.0: {} reflect.getprototypeof@1.0.10: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 get-proto: 1.0.1 which-builtin-type: 1.2.1 - reflect.getprototypeof@1.0.6: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.0 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - globalthis: 1.0.4 - which-builtin-type: 1.1.3 - reftools@1.1.9: {} - regenerator-runtime@0.14.1: {} - - regexp.prototype.flags@1.5.2: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-errors: 1.3.0 - set-function-name: 2.0.2 - regexp.prototype.flags@1.5.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 es-errors: 1.3.0 get-proto: 1.0.1 @@ -10839,10 +9628,10 @@ snapshots: node-addon-api: 1.7.2 optional: true - remark-gfm@4.0.0: + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 - mdast-util-gfm: 3.0.0 + mdast-util-gfm: 3.1.0 micromark-extension-gfm: 3.0.0 remark-parse: 11.0.0 remark-stringify: 11.0.0 @@ -10853,17 +9642,17 @@ snapshots: remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 - micromark-util-types: 2.0.0 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 unified: 11.0.5 transitivePeerDependencies: - supports-color - remark-rehype@11.1.1: + remark-rehype@11.1.2: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - mdast-util-to-hast: 13.2.0 + mdast-util-to-hast: 13.2.1 unified: 11.0.5 vfile: 6.0.3 @@ -10889,15 +9678,19 @@ snapshots: resolve-pkg-maps@1.0.0: {} - resolve@1.22.8: + resolve@1.22.12: dependencies: - is-core-module: 2.13.1 + es-errors: 1.3.0 + is-core-module: 2.16.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - resolve@2.0.0-next.5: + resolve@2.0.0-next.6: dependencies: - is-core-module: 2.15.1 + es-errors: 1.3.0 + is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -10921,7 +9714,7 @@ snapshots: retry@0.12.0: {} - reusify@1.0.4: {} + reusify@1.1.0: {} rfdc@1.4.1: {} @@ -10939,35 +9732,44 @@ snapshots: sprintf-js: 1.1.3 optional: true - rollup-plugin-visualizer@5.12.0(rollup@4.24.0): + rollup-plugin-visualizer@5.14.0(rollup@4.60.1): dependencies: open: 8.4.2 - picomatch: 2.3.1 - source-map: 0.7.4 + picomatch: 4.0.4 + source-map: 0.7.6 yargs: 17.7.2 optionalDependencies: - rollup: 4.24.0 + rollup: 4.60.1 - rollup@4.24.0: + rollup@4.60.1: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.24.0 - '@rollup/rollup-android-arm64': 4.24.0 - '@rollup/rollup-darwin-arm64': 4.24.0 - '@rollup/rollup-darwin-x64': 4.24.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.24.0 - '@rollup/rollup-linux-arm-musleabihf': 4.24.0 - '@rollup/rollup-linux-arm64-gnu': 4.24.0 - '@rollup/rollup-linux-arm64-musl': 4.24.0 - '@rollup/rollup-linux-powerpc64le-gnu': 4.24.0 - '@rollup/rollup-linux-riscv64-gnu': 4.24.0 - '@rollup/rollup-linux-s390x-gnu': 4.24.0 - '@rollup/rollup-linux-x64-gnu': 4.24.0 - '@rollup/rollup-linux-x64-musl': 4.24.0 - '@rollup/rollup-win32-arm64-msvc': 4.24.0 - '@rollup/rollup-win32-ia32-msvc': 4.24.0 - '@rollup/rollup-win32-x64-msvc': 4.24.0 + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 fsevents: 2.3.3 run-applescript@7.1.0: {} @@ -10976,16 +9778,9 @@ snapshots: dependencies: queue-microtask: 1.2.3 - safe-array-concat@1.1.2: - dependencies: - call-bind: 1.0.7 - get-intrinsic: 1.2.4 - has-symbols: 1.0.3 - isarray: 2.0.5 - safe-array-concat@1.1.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 has-symbols: 1.1.0 @@ -10998,12 +9793,6 @@ snapshots: es-errors: 1.3.0 isarray: 2.0.5 - safe-regex-test@1.0.3: - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - is-regex: 1.1.4 - safe-regex-test@1.1.0: dependencies: call-bound: 1.0.4 @@ -11014,18 +9803,19 @@ snapshots: safer-buffer@2.1.2: {} - sanitize-filename@1.6.3: + sanitize-filename@1.6.4: dependencies: truncate-utf8-bytes: 1.0.2 - sass@1.80.2: + sass@1.99.0: dependencies: - '@parcel/watcher': 2.4.1 - chokidar: 4.0.1 - immutable: 4.3.6 - source-map-js: 1.2.0 + chokidar: 4.0.3 + immutable: 5.1.5 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.6 - sax@1.4.4: {} + sax@1.6.0: {} scheduler@0.23.2: dependencies: @@ -11040,9 +9830,7 @@ snapshots: semver@6.3.1: {} - semver@7.6.3: {} - - semver@7.7.3: {} + semver@7.7.4: {} serialize-error@7.0.1: dependencies: @@ -11054,8 +9842,8 @@ snapshots: define-data-property: 1.1.4 es-errors: 1.3.0 function-bind: 1.1.2 - get-intrinsic: 1.2.4 - gopd: 1.0.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 has-property-descriptors: 1.0.2 set-function-name@2.0.2: @@ -11105,7 +9893,7 @@ snapshots: should-type-adaptors: 1.1.0 should-util: 1.0.1 - side-channel-list@1.0.0: + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -11125,18 +9913,11 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.0.6: - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - get-intrinsic: 1.2.4 - object-inspect: 1.13.1 - side-channel@1.1.0: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 @@ -11146,7 +9927,7 @@ snapshots: simple-update-notifier@2.0.0: dependencies: - semver: 7.7.3 + semver: 7.7.4 sisteransi@1.0.5: {} @@ -11160,13 +9941,13 @@ snapshots: slice-ansi@5.0.0: dependencies: - ansi-styles: 6.2.1 + ansi-styles: 6.2.3 is-fullwidth-code-point: 4.0.0 - slice-ansi@7.1.0: + slice-ansi@7.1.2: dependencies: - ansi-styles: 6.2.1 - is-fullwidth-code-point: 5.0.0 + ansi-styles: 6.2.3 + is-fullwidth-code-point: 5.1.0 smart-buffer@4.2.0: {} @@ -11187,8 +9968,6 @@ snapshots: dependencies: atomic-sleep: 1.0.0 - source-map-js@1.2.0: {} - source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -11198,7 +9977,7 @@ snapshots: source-map@0.6.1: {} - source-map@0.7.4: {} + source-map@0.7.6: {} space-separated-tokens@2.0.2: {} @@ -11213,9 +9992,9 @@ snapshots: spdx-expression-parse@3.0.1: dependencies: spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.18 + spdx-license-ids: 3.0.23 - spdx-license-ids@3.0.18: {} + spdx-license-ids@3.0.23: {} spdx-ranges@2.1.1: {} @@ -11232,16 +10011,12 @@ snapshots: ssri@12.0.0: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 stable-hash@0.0.5: {} stat-mode@1.0.0: {} - stop-iteration-iterator@1.0.0: - dependencies: - internal-slot: 1.0.7 - stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -11259,28 +10034,28 @@ snapshots: dependencies: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 - strip-ansi: 7.1.0 + strip-ansi: 7.2.0 - string-width@7.1.0: + string-width@7.2.0: dependencies: - emoji-regex: 10.3.0 - get-east-asian-width: 1.2.0 - strip-ansi: 7.1.0 + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 string.prototype.includes@2.0.1: dependencies: - call-bind: 1.0.7 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.23.3 + es-abstract: 1.24.2 string.prototype.matchall@4.0.12: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.0.0 + es-object-atoms: 1.1.1 get-intrinsic: 1.3.0 gopd: 1.2.0 has-symbols: 1.1.0 @@ -11292,35 +10067,28 @@ snapshots: string.prototype.repeat@1.0.0: dependencies: define-properties: 1.2.1 - es-abstract: 1.23.3 + es-abstract: 1.24.2 string.prototype.trim@1.2.10: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-data-property: 1.1.4 define-properties: 1.2.1 - es-abstract: 1.24.0 + es-abstract: 1.24.2 es-object-atoms: 1.1.1 has-property-descriptors: 1.0.2 - string.prototype.trim@1.2.9: - dependencies: - call-bind: 1.0.7 - define-properties: 1.2.1 - es-abstract: 1.23.3 - es-object-atoms: 1.0.0 - string.prototype.trimend@1.0.9: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.0.0 + es-object-atoms: 1.1.1 string.prototype.trimstart@1.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 es-object-atoms: 1.1.1 @@ -11337,9 +10105,9 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.0: + strip-ansi@7.2.0: dependencies: - ansi-regex: 6.0.1 + ansi-regex: 6.2.2 strip-bom@3.0.0: {} @@ -11351,18 +10119,22 @@ snapshots: strip-json-comments@5.0.3: {} - style-to-object@1.0.8: + style-to-js@1.1.21: dependencies: - inline-style-parser: 0.2.4 + style-to-object: 1.0.14 - sucrase@3.35.0: + style-to-object@1.0.14: dependencies: - '@jridgewell/gen-mapping': 0.3.5 + inline-style-parser: 0.2.7 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 commander: 4.1.1 - glob: 10.4.1 lines-and-columns: 1.2.4 mz: 2.7.0 - pirates: 4.0.6 + pirates: 4.0.7 + tinyglobby: 0.2.16 ts-interface-checker: 0.1.13 sumchecker@3.0.1: @@ -11371,10 +10143,6 @@ snapshots: transitivePeerDependencies: - supports-color - supports-color@5.5.0: - dependencies: - has-flag: 3.0.0 - supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -11392,45 +10160,46 @@ snapshots: oas-schema-walker: 1.1.5 oas-validator: 5.0.8 reftools: 1.1.9 - yaml: 1.10.2 + yaml: 1.10.3 yargs: 17.7.2 transitivePeerDependencies: - encoding tailwind-gradient-mask-image@1.2.0: {} - tailwindcss@3.4.14(ts-node@9.1.1(typescript@5.6.3)): + tailwindcss@3.4.19(yaml@2.8.3): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 chokidar: 3.6.0 didyoumean: 1.2.2 dlv: 1.1.3 - fast-glob: 3.3.2 + fast-glob: 3.3.3 glob-parent: 6.0.2 is-glob: 4.0.3 - jiti: 1.21.6 - lilconfig: 2.1.0 - micromatch: 4.0.7 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 normalize-path: 3.0.0 object-hash: 3.0.0 - picocolors: 1.0.1 - postcss: 8.4.38 - postcss-import: 15.1.0(postcss@8.4.38) - postcss-js: 4.0.1(postcss@8.4.38) - postcss-load-config: 4.0.2(postcss@8.4.38)(ts-node@9.1.1(typescript@5.6.3)) - postcss-nested: 6.0.1(postcss@8.4.38) - postcss-selector-parser: 6.1.0 - resolve: 1.22.8 - sucrase: 3.35.0 + picocolors: 1.1.1 + postcss: 8.5.10 + postcss-import: 15.1.0(postcss@8.5.10) + postcss-js: 4.1.0(postcss@8.5.10) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.10)(yaml@2.8.3) + postcss-nested: 6.2.0(postcss@8.5.10) + postcss-selector-parser: 6.1.2 + resolve: 1.22.12 + sucrase: 3.35.1 transitivePeerDependencies: - - ts-node + - tsx + - yaml - tar@7.5.7: + tar@7.5.13: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0 - minipass: 7.1.2 + minipass: 7.1.3 minizlib: 3.1.0 yallist: 5.0.0 @@ -11444,14 +10213,6 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.6.3 - terser@5.31.1: - dependencies: - '@jridgewell/source-map': 0.3.11 - acorn: 8.15.0 - commander: 2.20.3 - source-map-support: 0.5.21 - optional: true - thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -11474,10 +10235,10 @@ snapshots: tinycolor2@1.6.0: {} - tinyglobby@0.2.15: + tinyglobby@0.2.16: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tmp-promise@3.0.3: dependencies: @@ -11485,8 +10246,6 @@ snapshots: tmp@0.2.5: {} - to-fast-properties@2.0.0: {} - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -11503,9 +10262,9 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 - ts-api-utils@2.1.0(typescript@5.6.3): + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: - typescript: 5.6.3 + typescript: 5.9.3 ts-interface-checker@0.1.13: {} @@ -11513,24 +10272,13 @@ snapshots: dependencies: arg: 4.1.3 create-require: 1.1.1 - diff: 4.0.2 + diff: 4.0.4 make-error: 1.3.6 source-map-support: 0.5.21 typescript: 4.8.4 yn: 3.1.1 - ts-node@9.1.1(typescript@5.6.3): - dependencies: - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - source-map-support: 0.5.21 - typescript: 5.6.3 - yn: 3.1.1 - optional: true - - ts-pattern@5.5.0: {} + ts-pattern@5.9.0: {} tsconfig-paths@3.15.0: dependencies: @@ -11541,7 +10289,7 @@ snapshots: tslib@1.14.1: {} - tslib@2.6.3: {} + tslib@2.8.1: {} tsutils@3.21.0(typescript@4.8.2): dependencies: @@ -11559,79 +10307,47 @@ snapshots: type-fest@2.19.0: {} - typed-array-buffer@1.0.2: - dependencies: - call-bind: 1.0.7 - es-errors: 1.3.0 - is-typed-array: 1.1.13 - typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 es-errors: 1.3.0 is-typed-array: 1.1.15 - typed-array-byte-length@1.0.1: - dependencies: - call-bind: 1.0.7 - for-each: 0.3.3 - gopd: 1.0.1 - has-proto: 1.0.3 - is-typed-array: 1.1.13 - typed-array-byte-length@1.0.3: dependencies: - call-bind: 1.0.8 - for-each: 0.3.3 + call-bind: 1.0.9 + for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 is-typed-array: 1.1.15 - typed-array-byte-offset@1.0.2: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.7 - for-each: 0.3.3 - gopd: 1.0.1 - has-proto: 1.0.3 - is-typed-array: 1.1.13 - typed-array-byte-offset@1.0.4: dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - for-each: 0.3.3 + call-bind: 1.0.9 + for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 is-typed-array: 1.1.15 reflect.getprototypeof: 1.0.10 - typed-array-length@1.0.6: - dependencies: - call-bind: 1.0.7 - for-each: 0.3.3 - gopd: 1.0.1 - has-proto: 1.0.3 - is-typed-array: 1.1.13 - possible-typed-array-names: 1.0.0 - typed-array-length@1.0.7: dependencies: - call-bind: 1.0.8 - for-each: 0.3.3 + call-bind: 1.0.9 + for-each: 0.3.5 gopd: 1.2.0 is-typed-array: 1.1.15 - possible-typed-array-names: 1.0.0 - reflect.getprototypeof: 1.0.6 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 - typescript-eslint@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3): + typescript-eslint@8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3) - '@typescript-eslint/parser': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3) - '@typescript-eslint/typescript-estree': 8.46.4(typescript@5.6.3) - '@typescript-eslint/utils': 8.46.4(eslint@9.39.1(jiti@2.6.1))(typescript@5.6.3) - eslint: 9.39.1(jiti@2.6.1) - typescript: 5.6.3 + '@typescript-eslint/eslint-plugin': 8.58.2(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.58.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.4(jiti@1.21.7) + typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -11639,27 +10355,20 @@ snapshots: typescript@4.8.4: {} - typescript@5.6.3: {} - typescript@5.7.3: {} - unbox-primitive@1.0.2: - dependencies: - call-bind: 1.0.7 - has-bigints: 1.0.2 - has-symbols: 1.0.3 - which-boxed-primitive: 1.0.2 + typescript@5.9.3: {} unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 - has-bigints: 1.0.2 + has-bigints: 1.1.0 has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 undici-types@7.16.0: {} - undici@6.21.3: {} + undici@6.24.1: {} unified@11.0.5: dependencies: @@ -11679,7 +10388,7 @@ snapshots: dependencies: imurmurhash: 0.1.4 - unist-util-is@6.0.0: + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -11691,16 +10400,16 @@ snapshots: dependencies: '@types/unist': 3.0.3 - unist-util-visit-parents@6.0.1: + unist-util-visit-parents@6.0.2: dependencies: '@types/unist': 3.0.3 - unist-util-is: 6.0.0 + unist-util-is: 6.0.1 - unist-util-visit@5.0.0: + unist-util-visit@5.1.0: dependencies: '@types/unist': 3.0.3 - unist-util-is: 6.0.0 - unist-util-visit-parents: 6.0.1 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 universal-user-agent@7.0.3: {} @@ -11710,9 +10419,9 @@ snapshots: unplugin@1.0.1: dependencies: - acorn: 8.12.0 + acorn: 8.16.0 chokidar: 3.6.0 - webpack-sources: 3.2.3 + webpack-sources: 3.3.4 webpack-virtual-modules: 0.5.0 unrs-resolver@1.11.1: @@ -11739,15 +10448,9 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - update-browserslist-db@1.0.16(browserslist@4.23.1): - dependencies: - browserslist: 4.23.1 - escalade: 3.2.0 - picocolors: 1.1.1 - - update-browserslist-db@1.1.1(browserslist@4.24.0): + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: - browserslist: 4.24.0 + browserslist: 4.28.2 escalade: 3.2.0 picocolors: 1.1.1 @@ -11755,7 +10458,7 @@ snapshots: dependencies: punycode: 2.3.1 - use-double-tap@1.3.6(react@18.3.1): + use-double-tap@1.3.7(react@18.3.1): dependencies: react: 18.3.1 @@ -11771,7 +10474,7 @@ snapshots: core-util-is: 1.0.2 extsprintf: 1.4.1 - vfile-message@4.0.2: + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 unist-util-stringify-position: 4.0.0 @@ -11779,18 +10482,17 @@ snapshots: vfile@6.0.3: dependencies: '@types/unist': 3.0.3 - vfile-message: 4.0.2 + vfile-message: 4.0.3 - vite@5.4.9(@types/node@24.10.0)(sass@1.80.2)(terser@5.31.1): + vite@5.4.21(@types/node@24.12.2)(sass@1.99.0): dependencies: esbuild: 0.21.5 - postcss: 8.4.47 - rollup: 4.24.0 + postcss: 8.5.10 + rollup: 4.60.1 optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.12.2 fsevents: 2.3.3 - sass: 1.80.2 - terser: 5.31.1 + sass: 1.99.0 warning@4.0.3: dependencies: @@ -11802,7 +10504,7 @@ snapshots: webidl-conversions@3.0.1: {} - webpack-sources@3.2.3: {} + webpack-sources@3.3.4: {} webpack-virtual-modules@0.5.0: {} @@ -11811,14 +10513,6 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 - which-boxed-primitive@1.0.2: - dependencies: - is-bigint: 1.0.4 - is-boolean-object: 1.1.2 - is-number-object: 1.0.7 - is-string: 1.0.7 - is-symbol: 1.0.4 - which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -11827,56 +10521,33 @@ snapshots: is-string: 1.1.1 is-symbol: 1.1.1 - which-builtin-type@1.1.3: - dependencies: - function.prototype.name: 1.1.8 - has-tostringtag: 1.0.2 - is-async-function: 2.0.0 - is-date-object: 1.0.5 - is-finalizationregistry: 1.0.2 - is-generator-function: 1.0.10 - is-regex: 1.2.1 - is-weakref: 1.1.1 - isarray: 2.0.5 - which-boxed-primitive: 1.0.2 - which-collection: 1.0.2 - which-typed-array: 1.1.19 - which-builtin-type@1.2.1: dependencies: call-bound: 1.0.4 function.prototype.name: 1.1.8 has-tostringtag: 1.0.2 - is-async-function: 2.0.0 + is-async-function: 2.1.1 is-date-object: 1.1.0 is-finalizationregistry: 1.1.1 - is-generator-function: 1.0.10 + is-generator-function: 1.1.2 is-regex: 1.2.1 is-weakref: 1.1.1 isarray: 2.0.5 which-boxed-primitive: 1.1.1 which-collection: 1.0.2 - which-typed-array: 1.1.19 + which-typed-array: 1.1.20 which-collection@1.0.2: dependencies: is-map: 2.0.3 is-set: 2.0.3 is-weakmap: 2.0.2 - is-weakset: 2.0.3 - - which-typed-array@1.1.15: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.7 - for-each: 0.3.3 - gopd: 1.0.1 - has-tostringtag: 1.0.2 + is-weakset: 2.0.4 - which-typed-array@1.1.19: + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 for-each: 0.3.5 get-proto: 1.0.1 @@ -11901,25 +10572,25 @@ snapshots: wrap-ansi@8.1.0: dependencies: - ansi-styles: 6.2.1 + ansi-styles: 6.2.3 string-width: 5.1.2 - strip-ansi: 7.1.0 + strip-ansi: 7.2.0 - wrap-ansi@9.0.0: + wrap-ansi@9.0.2: dependencies: - ansi-styles: 6.2.1 - string-width: 7.1.0 - strip-ansi: 7.1.0 + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 wrappy@1.0.2: {} ws@7.5.10: {} - ws@8.19.0: {} + ws@8.20.0: {} wsl-utils@0.3.1: dependencies: - is-wsl: 3.1.0 + is-wsl: 3.1.1 powershell-utils: 0.1.0 xmlbuilder@15.1.1: {} @@ -11932,11 +10603,9 @@ snapshots: yallist@5.0.0: {} - yaml@1.10.2: {} + yaml@1.10.3: {} - yaml@2.4.5: {} - - yaml@2.5.1: {} + yaml@2.8.3: {} yargs-parser@20.2.9: {} @@ -11945,7 +10614,7 @@ snapshots: yargs@16.2.0: dependencies: cliui: 7.0.4 - escalade: 3.1.2 + escalade: 3.2.0 get-caller-file: 2.0.5 require-directory: 2.1.1 string-width: 4.2.3 @@ -11971,17 +10640,17 @@ snapshots: yocto-queue@0.1.0: {} - yup@1.4.0: + yup@1.7.1: dependencies: property-expr: 2.0.6 tiny-case: 1.0.3 toposort: 2.0.2 type-fest: 2.19.0 - zod-validation-error@4.0.2(zod@4.1.13): + zod-validation-error@4.0.2(zod@4.3.6): dependencies: - zod: 4.1.13 + zod: 4.3.6 - zod@4.1.13: {} + zod@4.3.6: {} zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index be35bcb3c9..174b2672e4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,13 @@ packages: - "solarxr-protocol" - "gui" - "!solarxr-protocol/lib/flatbuffers/**" + +onlyBuiltDependencies: + - '@parcel/watcher' + - '@sentry/cli' + - '@swc/core' + - electron + - electron-winstaller + - esbuild + - register-scheme + - unrs-resolver From d9338dc8631f353c2789149a42cf7f13c4912524 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 18 Jun 2026 01:24:57 +0200 Subject: [PATCH 164/350] Sync solarxr --- solarxr-protocol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solarxr-protocol b/solarxr-protocol index 561febbfb5..6680d55163 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 561febbfb5d18bd0998e2c8edf9514224aba23e8 +Subproject commit 6680d551631ca8c83652004fcced422fe37f8de1 From 837316ba8d0eca8932a4a947b35d623f4599f31d Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 18 Jun 2026 01:29:54 +0200 Subject: [PATCH 165/350] Sync solarxr again --- solarxr-protocol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solarxr-protocol b/solarxr-protocol index 6680d55163..473e08f137 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 6680d551631ca8c83652004fcced422fe37f8de1 +Subproject commit 473e08f137dd4f97eea7be0b267c756ec7da5cb2 From 91011120f0b1f0ea45aeb301e61c73b4bfb410e8 Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 17 Jun 2026 19:55:14 -0400 Subject: [PATCH 166/350] Refactor TapDetector and tap to assign --- .../java/dev/slimevr/tracker/behaviours.kt | 102 +++++++++++------- 1 file changed, 64 insertions(+), 38 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 523a60abf7..4bb637b60a 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -18,7 +18,11 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.DeviceId +import solarxr_protocol.datatypes.TrackerId import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.rpc.TapDetectionSetupNotification +import solarxr_protocol.rpc.UnknownDeviceHandshakeNotification import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch @@ -35,13 +39,11 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { private val accelList = ArrayDeque>() private val tapTimestamps = ArrayDeque() private var waitForLowAccel = false - - data class TapDetectionContext( - val actionToExecute: TrackerActions?, - val tapsNeeded: Int, - val actionDelay: Float, - val numberTrackersOverThreshold: Int - ) + private var actionToExecute: TrackerActions? = null + private var setupModeAssign = false + private var tapsNeeded = 0 + private var actionDelay = 0f + private var numberTrackersOverThreshold = 0 @OptIn(ExperimentalCoroutinesApi::class) override fun observe(receiver: Tracker) { @@ -62,12 +64,12 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { loadTapDetection(receiver, configState, receiver.appContext.server.context.state.value) } // Inner flow (process) is refreshed everytime this tracker's acceleration is updated - .flatMapLatest { tapDetectionContext -> + .flatMapLatest { receiver.context.state - .filter { tapDetectionContext.actionToExecute != null } + .filter { actionToExecute != null || setupModeAssign } .distinctUntilChangedBy { it.rawAcceleration } .onEach { currentTracker -> - processTapDetection(receiver, currentTracker, tapDetectionContext) + processTapDetection(receiver, currentTracker) } }.launchIn(receiver.context.scope) } @@ -77,13 +79,19 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { receiver: Tracker, configState: SettingsState, serverState: VRServerState - ): TapDetectionContext { + ) { val tapDetectionConfig = configState.data.tapDetectionConfig + numberTrackersOverThreshold = tapDetectionConfig.numberTrackersOverThreshold + resetTapDetection() // If setupMode is true, double tap to assign if (tapDetectionConfig.setupMode) { - println("SetupMode is enabled!") - return TapDetectionContext(null, 2, 0f, tapDetectionConfig.numberTrackersOverThreshold) + setupModeAssign = true + + actionToExecute = null + tapsNeeded = 2 + actionDelay = 0f + return } // Get reference rotation for reset actions @@ -106,30 +114,32 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { .firstOrNull { it in trackersBodyParts } // Switch case for each possible action - val (actionToExecute, tapsNeeded, actionDelay) = when (receiver.context.state.value.bodyPart) { - null -> Triple(null, 0, 0f) // BodyParts above could be null - yawResetBodyPart if tapDetectionConfig.yawResetEnabled -> - Triple(TrackerActions.YawReset(referenceRotation), tapDetectionConfig.yawResetTaps, tapDetectionConfig.yawResetDelay) - fullResetBodyPart if tapDetectionConfig.fullResetEnabled -> - Triple(TrackerActions.FullReset(referenceRotation), tapDetectionConfig.fullResetTaps, tapDetectionConfig.fullResetDelay) - mountingResetBodyPart if tapDetectionConfig.mountingResetEnabled -> - Triple(TrackerActions.MountingReset(referenceRotation), tapDetectionConfig.mountingResetTaps, tapDetectionConfig.mountingResetDelay) - else -> Triple(null, 0, 0f) + when (receiver.context.state.value.bodyPart) { + null -> actionToExecute = null // BodyParts above could be null + yawResetBodyPart if tapDetectionConfig.yawResetEnabled -> { + actionToExecute = TrackerActions.YawReset(referenceRotation) + tapsNeeded = tapDetectionConfig.yawResetTaps + actionDelay = tapDetectionConfig.yawResetDelay + } + fullResetBodyPart if tapDetectionConfig.fullResetEnabled -> { + actionToExecute = TrackerActions.FullReset(referenceRotation) + tapsNeeded = tapDetectionConfig.fullResetTaps + actionDelay = tapDetectionConfig.fullResetDelay + } + mountingResetBodyPart if tapDetectionConfig.mountingResetEnabled -> { + actionToExecute = TrackerActions.MountingReset(referenceRotation) + tapsNeeded = tapDetectionConfig.mountingResetTaps + actionDelay = tapDetectionConfig.mountingResetDelay + } + else -> actionToExecute = null } - - resetTapDetection() - - return TapDetectionContext(actionToExecute, tapsNeeded, actionDelay, tapDetectionConfig.numberTrackersOverThreshold) } // Logic loop for tap detection private fun processTapDetection( receiver: Tracker, currentTracker: TrackerState, - context: TapDetectionContext ) { - if (context.actionToExecute == null) return - val now = System.nanoTime() // Get the acceleration of the tracker and store it @@ -148,7 +158,7 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { if (accelDelta > NEEDED_ACCEL_DELTA && !waitForLowAccel) { val othersOverThreshold = receiver.appContext.server.context.state.value.trackers.values .count { it.context.state.value.id != currentTracker.id && it.context.state.value.rawAcceleration.lenSq() > ALLOWED_BODY_ACCEL_SQUARED } - if (othersOverThreshold < context.numberTrackersOverThreshold) { + if (othersOverThreshold < numberTrackersOverThreshold) { tapTimestamps.add(now) // After a tap, a lower acceleration is needed before another one waitForLowAccel = true @@ -165,16 +175,32 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { tapTimestamps.removeFirst() } - if (tapTimestamps.isNotEmpty() && now - tapTimestamps.last() > TAP_WINDOW_PER_TAP_NS.toLong()) { - if (tapTimestamps.size >= context.tapsNeeded) { - // Taps completed! - receiver.context.scope.safeLaunch { - AppLogger.tracker.info("TapDetection triggered ${context.actionToExecute}") - delay((context.actionDelay * 1000).toLong()) - receiver.appContext.server.context.state.value.trackers.values.forEach { it.context.dispatch(context.actionToExecute) } + if (tapTimestamps.size >= tapsNeeded) { + // Taps completed! + receiver.context.scope.safeLaunch { + if (setupModeAssign) { + receiver.appContext.server.context.state.value.solarxr.values.forEach { + it.sendRpc( + TapDetectionSetupNotification( + TrackerId(DeviceId(currentTracker.deviceId.toUByte()), currentTracker.id.toUByte()) + ) + ) + } + } + + // If it has an action to execute + actionToExecute?.let { action -> + AppLogger.tracker.info("TapDetection triggered $action") + delay((actionDelay * 1000).toLong()) + receiver.appContext.server.context.state.value.trackers.values.forEach { + it.context.dispatch( + action + ) + } } - resetTapDetection() } + + resetTapDetection() } } } From 710088e513650bfa18996b7f207d8ac922d88f36 Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 17 Jun 2026 21:48:33 -0400 Subject: [PATCH 167/350] ResetManager wip + refactoring TapReset --- .../src/main/java/dev/slimevr/app-context.kt | 4 ++ .../src/main/java/dev/slimevr/reset/module.kt | 58 +++++++++++++++++++ .../java/dev/slimevr/tracker/behaviours.kt | 14 ++--- .../main/java/dev/slimevr/tracker/module.kt | 2 +- .../main/java/dev/slimevr/udp/behaviours.kt | 6 +- .../src/main/java/dev/slimevr/vrserver.kt | 4 ++ .../src/main/java/dev/slimevr/desktop/Main.kt | 3 + 7 files changed, 79 insertions(+), 12 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/reset/module.kt diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index ab110bf747..6b54e420c5 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -6,6 +6,7 @@ import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.provisioning.ProvisioningManager +import dev.slimevr.reset.ResetManager import dev.slimevr.serial.SerialServer import dev.slimevr.skeleton.Skeleton import dev.slimevr.trackingchecklist.TrackingChecklist @@ -39,6 +40,7 @@ interface AppContextProvider : Phase1ContextProvider { val bvhManager: BVHManager val vmcManager: VMCManager val vrcOscManager: VRCOSCManager + val resetManager: ResetManager fun startObserving() suspend fun dispose() } @@ -59,6 +61,7 @@ class AppContext( override val bvhManager: BVHManager, override val vmcManager: VMCManager, override val vrcOscManager: VRCOSCManager, + override val resetManager: ResetManager, ) : AppContextProvider { override fun startObserving() { skeleton.startObserving() @@ -71,6 +74,7 @@ class AppContext( udpServer.startReceiving(this, server.context.scope) vmcManager.startObserving() vrcOscManager.startObserving(this) + resetManager.startObserving() } override suspend fun dispose() { diff --git a/server/core/src/main/java/dev/slimevr/reset/module.kt b/server/core/src/main/java/dev/slimevr/reset/module.kt new file mode 100644 index 0000000000..bbd4837ce0 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/reset/module.kt @@ -0,0 +1,58 @@ +package dev.slimevr.reset + +import dev.slimevr.Phase1ContextProvider +import dev.slimevr.VRServer +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context +import dev.slimevr.util.safeLaunch +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.ResetType +import solarxr_protocol.rpc.UnknownDeviceHandshakeNotification +import kotlin.collections.listOf + +data class ResetState( + val canDoYawReset: Boolean, + val canDoMountingReset: Boolean +) + +sealed interface ResetActions { + data class ClearResets(val resetType: List) : ResetActions // sets to false + data class EndReset(val resetType: ResetType) : ResetActions // sets to true if full reset +} + +typealias ResetContext = Context +typealias ResetBehaviour = Behaviour + +class ResetManager(val context: ResetContext, val server: VRServer) { + fun startObserving() = context.observeAll(this) + + private var resetJob: Job = Job() + + suspend fun scheduleFullReset(resetType: ResetType, status: Int, bodyParts: List? = null, progress: Int = 0, duration: Int = 0) { + resetJob.cancelAndJoin() + resetJob = context.scope.safeLaunch { + server.context.state.value.solarxr.values.forEach { bridge -> + //bridge.sendRpc((macAddress = mac)) + } + //server.context.dispatch() + } + } + + companion object { + fun create(ctx: Phase1ContextProvider, scope: CoroutineScope): ResetManager { + val context = Context.create( + initialState = ResetState( + canDoYawReset = false, + canDoMountingReset = false + ), + scope = scope, + behaviours = listOf(), + name = "ResetManager", + ) + return ResetManager(context, ctx.server) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 4bb637b60a..78046c174d 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -22,7 +22,6 @@ import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.TrackerId import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.TapDetectionSetupNotification -import solarxr_protocol.rpc.UnknownDeviceHandshakeNotification import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch @@ -95,7 +94,7 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { } // Get reference rotation for reset actions - // TODO need a helper method or something for this + // TODO move to reset module val referenceRotation = serverState.trackers.firstNotNullOfOrNull { val trackerState = it.value.context.state.value if (trackerState.status == TrackerStatus.OK && trackerState.bodyPart == BodyPart.HEAD) { @@ -179,16 +178,15 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { // Taps completed! receiver.context.scope.safeLaunch { if (setupModeAssign) { - receiver.appContext.server.context.state.value.solarxr.values.forEach { - it.sendRpc( - TapDetectionSetupNotification( - TrackerId(DeviceId(currentTracker.deviceId.toUByte()), currentTracker.id.toUByte()) - ) + receiver.appContext.server.sendSolarxrRpc( + TapDetectionSetupNotification( + TrackerId(DeviceId(currentTracker.deviceId.toUByte()), currentTracker.id.toUByte()) ) - } + ) } // If it has an action to execute + // TODO use reset module actionToExecute?.let { action -> AppLogger.tracker.info("TapDetection triggered $action") delay((actionDelay * 1000).toLong()) diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 3eb288d166..f92036efc1 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -17,7 +17,7 @@ import solarxr_protocol.datatypes.hardware_info.ImuType data class TrackerIdNum(val id: Int, val trackerNum: Int) data class TrackerState( - val id: Int, + val id: Int, // trackerNum val name: String, val hardwareId: String, val sensorType: ImuType?, diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 7dca714cc1..dfb7f2879e 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -171,9 +171,9 @@ class HandshakeBehaviour : UDPConnectionBehaviour { if (mac !in settings.allowedUdpDevices) { AppLogger.udp.info("[${state.address}] Unknown MAC $mac, notifying solarxr") receiver.appContext.server.context.scope.safeLaunch { - receiver.appContext.server.context.state.value.solarxr.values.forEach { bridge -> - bridge.sendRpc(UnknownDeviceHandshakeNotification(macAddress = mac)) - } + receiver.appContext.server.sendSolarxrRpc( + UnknownDeviceHandshakeNotification(macAddress = mac) + ) } return@onPacket } diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index 24b26857f3..03adb2b82b 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -7,6 +7,7 @@ import dev.slimevr.driver.DriverBridge import dev.slimevr.solarxr.SolarXRBridge import dev.slimevr.tracker.Tracker import kotlinx.coroutines.CoroutineScope +import solarxr_protocol.rpc.RpcMessage import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch @@ -42,6 +43,9 @@ class VRServer( fun getTracker(id: Int) = context.state.value.trackers[id] fun getDevice(id: Int) = context.state.value.devices[id] + suspend fun sendSolarxrRpc(message: RpcMessage) = + context.state.value.solarxr.values.forEach { it.sendRpc(message) } + companion object { fun create(scope: CoroutineScope): VRServer { val behaviours = listOf(BaseBehaviour()) 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 fe777d7752..85291ec12e 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -30,6 +30,7 @@ import dev.slimevr.resolveConfigDirectory import dev.slimevr.skeleton.Skeleton import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.desktop.udp.resolveDesktopUdpAddress +import dev.slimevr.reset.ResetManager import dev.slimevr.udp.UdpServer import dev.slimevr.util.safeLaunch import dev.slimevr.vmc.VMCManager @@ -86,6 +87,7 @@ fun main(args: Array) = runBlocking { scope = this, oscQueryAddress = resolveDesktopOscQueryAddress(), ) + val resetManager = ResetManager.create(ctx = phase1, scope = this) val appContext = AppContext( server = server, @@ -103,6 +105,7 @@ fun main(args: Array) = runBlocking { bvhManager = bvhManager, vmcManager = vmcManager, vrcOscManager = vrcOscManager, + resetManager = resetManager, ) try { From 2cf6c0b97c884ceca16d06179fdd51a5c27d2d9b Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 17 Jun 2026 22:28:31 -0400 Subject: [PATCH 168/350] Refactor TapDetection flow --- .../java/dev/slimevr/tracker/behaviours.kt | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 78046c174d..6de04a3500 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -47,30 +47,33 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { @OptIn(ExperimentalCoroutinesApi::class) override fun observe(receiver: Tracker) { // Outer flow (loading) is refreshed when TapDetection config or a tracker's bodyPart or status changes - combine( - receiver.appContext.config.settings.context.state - .distinctUntilChangedBy { configState -> - configState.data.tapDetectionConfig }, - receiver.appContext.server.context.state - .flatMapLatest { serverState -> - combine(serverState.trackers.values.map { tracker -> tracker.context.state }) - { trackerStates -> - trackerStates.map { it.bodyPart to it.status } - } + val tapConfigChangingFlow = receiver.appContext.config.settings.context.state + .distinctUntilChangedBy { configState -> + configState.data.tapDetectionConfig } + val trackerChangingFlow = receiver.appContext.server.context.state + .flatMapLatest { serverState -> + combine(serverState.trackers.values.map { tracker -> tracker.context.state }) + { trackerStates -> + trackerStates.map { it.bodyPart to it.status } } - .distinctUntilChanged() + } + .distinctUntilChanged() + + combine( + tapConfigChangingFlow, + trackerChangingFlow ) { configState, _ -> loadTapDetection(receiver, configState, receiver.appContext.server.context.state.value) } - // Inner flow (process) is refreshed everytime this tracker's acceleration is updated - .flatMapLatest { - receiver.context.state - .filter { actionToExecute != null || setupModeAssign } - .distinctUntilChangedBy { it.rawAcceleration } - .onEach { currentTracker -> - processTapDetection(receiver, currentTracker) - } - }.launchIn(receiver.context.scope) + // Inner flow (process) is refreshed everytime this tracker's acceleration is updated + .flatMapLatest { + receiver.context.state + .filter { actionToExecute != null || setupModeAssign } + .distinctUntilChangedBy { it.rawAcceleration } + } + .onEach { currentTracker -> + processTapDetection(receiver, currentTracker) + }.launchIn(receiver.context.scope) } // Loads TapDetection config From 8c78f1c3713835474c96e2c7564f1ed5331307fc Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 18 Jun 2026 19:45:06 -0400 Subject: [PATCH 169/350] Finish ResetsManager logic --- .../src/main/java/dev/slimevr/app-context.kt | 8 +- .../main/java/dev/slimevr/config/resets.kt | 61 ++++++++ .../main/java/dev/slimevr/config/settings.kt | 12 +- .../dev/slimevr/heightcalibration/module.kt | 11 +- .../core/src/main/java/dev/slimevr/logger.kt | 1 + .../src/main/java/dev/slimevr/reset/module.kt | 58 -------- .../java/dev/slimevr/resets/behaviours.kt | 45 ++++++ .../main/java/dev/slimevr/resets/module.kt | 140 ++++++++++++++++++ .../main/java/dev/slimevr/solarxr/datafeed.kt | 21 ++- .../main/java/dev/slimevr/solarxr/module.kt | 2 +- .../dev/slimevr/solarxr/sessioncalibration.kt | 32 +--- .../java/dev/slimevr/tracker/behaviours.kt | 44 ++---- .../main/java/dev/slimevr/tracker/module.kt | 6 +- .../src/main/java/dev/slimevr/desktop/Main.kt | 6 +- 14 files changed, 303 insertions(+), 144 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/config/resets.kt delete mode 100644 server/core/src/main/java/dev/slimevr/reset/module.kt create mode 100644 server/core/src/main/java/dev/slimevr/resets/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/resets/module.kt diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index 6b54e420c5..b930404ae7 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -6,7 +6,7 @@ import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.provisioning.ProvisioningManager -import dev.slimevr.reset.ResetManager +import dev.slimevr.resets.ResetsManager import dev.slimevr.serial.SerialServer import dev.slimevr.skeleton.Skeleton import dev.slimevr.trackingchecklist.TrackingChecklist @@ -40,7 +40,7 @@ interface AppContextProvider : Phase1ContextProvider { val bvhManager: BVHManager val vmcManager: VMCManager val vrcOscManager: VRCOSCManager - val resetManager: ResetManager + val resetsManager: ResetsManager fun startObserving() suspend fun dispose() } @@ -61,7 +61,7 @@ class AppContext( override val bvhManager: BVHManager, override val vmcManager: VMCManager, override val vrcOscManager: VRCOSCManager, - override val resetManager: ResetManager, + override val resetsManager: ResetsManager, ) : AppContextProvider { override fun startObserving() { skeleton.startObserving() @@ -74,7 +74,7 @@ class AppContext( udpServer.startReceiving(this, server.context.scope) vmcManager.startObserving() vrcOscManager.startObserving(this) - resetManager.startObserving() + resetsManager.startObserving() } override suspend fun dispose() { diff --git a/server/core/src/main/java/dev/slimevr/config/resets.kt b/server/core/src/main/java/dev/slimevr/config/resets.kt new file mode 100644 index 0000000000..43e90d175e --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/config/resets.kt @@ -0,0 +1,61 @@ +package dev.slimevr.config + +import kotlinx.serialization.Serializable + +enum class ArmsResetModes(val id: Int) { + /** Upper arm going back and forearm going forward */ + BACK(0), + /** Arms going forward */ + FORWARD(1), + /** Arms going up to the sides into a T-pose */ + T_POSE_UP(2), + /** Arms going down to the sides from a T-pose */ + T_POSE_DOWN(3), + ; + + companion object { + val values = entries.toTypedArray() + + fun fromId(id: Int): ArmsResetModes? { + for (filter in values) { + if (filter.id == id) return filter + } + return null + } + } +} + +enum class MountingMethods(val id: Int) { + MANUAL(0), + AUTOMATIC(1), + ; + + companion object { + val values = MountingMethods.entries.toTypedArray() + + fun fromId(id: Int): MountingMethods? { + for (filter in values) { + if (filter.id == id) return filter + } + return null + } + } +} + +@Serializable +data class ResetsConfig( + /** Always reset mounting for feet */ + val resetMountingFeet: Boolean = false, + /** Reset mode used for the arms */ + val mode: ArmsResetModes = ArmsResetModes.BACK, + /** Yaw reset smoothing time in seconds */ + val yawResetSmoothTime: Float = 0.0f, + /** Save automatic mounting reset calibration */ + val saveMountingReset: Boolean = false, + /** Reset the HMD's pitch upon full reset */ + val resetHmdPitch: Boolean = false, + val lastMountingMethod: MountingMethods = MountingMethods.AUTOMATIC, + val yawResetDelay: Float = 0.0f, + val fullResetDelay: Float = 3.0f, + val mountingResetDelay: Float = 3.0f, +) diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 3f2047534a..027c443320 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -53,24 +53,28 @@ data class VMCConfig( val enabled: Boolean = true, val portOut: Int = 39539, val portIn: Int = 39540, - val address: String = "172.17.0.1", + val address: String = "172.17.0.1", // TODO why not 127.0.0.1? + /** Mirror the tracking before sending it (turn left <=> turn right, left leg <=> right leg) */ val mirrorTracking: Boolean = false, + /** Anchor the tracking at the hip (sitting down)? */ val anchorAtHips: Boolean = false, + /** JSON part of the VRM to be used */ val vrmJson: String? = null, ) @Serializable data class SettingsConfigState( + val version: Int = SETTINGS_CONFIG_VERSION, val trackerPort: Int = 6969, + val globalMagEnabled: Boolean = true, val mutedVRCWarnings: List = listOf(), val mutedChecklistSteps: Set = emptySet(), - val trackers: Map = emptyMap(), - val globalMagEnabled: Boolean = true, val allowedUdpDevices: Set = emptySet(), + val trackers: Map = emptyMap(), val vrcOscConfig: VRCOSCConfig = VRCOSCConfig(), val vmcConfig: VMCConfig = VMCConfig(), - val version: Int = SETTINGS_CONFIG_VERSION, val tapDetectionConfig: TapDetectionConfig = TapDetectionConfig(), + val resetsConfig: ResetsConfig = ResetsConfig(), ) private fun migrateSettingsConfig(json: JsonObject): JsonObject { diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt index 379855c5d7..6509627412 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.UserHeightCalibrationStatus data class TrackerSnapshot(val position: Vector3, val rotation: Quaternion) @@ -52,7 +53,10 @@ class HeightCalibrationManager( val hmdUpdates: Flow = serverContext.context.state .flatMapLatest { state -> val hmd = state.trackers.values - .find { it.context.state.value.bodyPart == BodyPart.HEAD && it.context.state.value.position != null } + .find { + val state = it.context.state.value + state.bodyPart == BodyPart.HEAD && state.status == TrackerStatus.OK && state.position != null + } ?: return@flatMapLatest emptyFlow() hmd.context.state.map { s -> TrackerSnapshot( @@ -65,8 +69,9 @@ class HeightCalibrationManager( val controllerUpdates: Flow = serverContext.context.state .flatMapLatest { state -> val controllers = state.trackers.values.filter { - val bodyPart = it.context.state.value.bodyPart - (bodyPart == BodyPart.LEFT_HAND || bodyPart == BodyPart.RIGHT_HAND) && it.context.state.value.position != null + val state = it.context.state.value + val bodyPart = state.bodyPart + (bodyPart == BodyPart.LEFT_HAND || bodyPart == BodyPart.RIGHT_HAND) && state.status == TrackerStatus.OK && state.position != null } if (controllers.isEmpty()) return@flatMapLatest emptyFlow() combine( diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index f328526e87..9496692a53 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -22,6 +22,7 @@ object AppLogger { val vmc = logger("VMC") val oscQuery = logger("OSCQuery") val install = logger("Install") + val resets = logger("Resets") val coroutines = noCoLogger("Coroutines") init { diff --git a/server/core/src/main/java/dev/slimevr/reset/module.kt b/server/core/src/main/java/dev/slimevr/reset/module.kt deleted file mode 100644 index bbd4837ce0..0000000000 --- a/server/core/src/main/java/dev/slimevr/reset/module.kt +++ /dev/null @@ -1,58 +0,0 @@ -package dev.slimevr.reset - -import dev.slimevr.Phase1ContextProvider -import dev.slimevr.VRServer -import dev.slimevr.context.Behaviour -import dev.slimevr.context.Context -import dev.slimevr.util.safeLaunch -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import solarxr_protocol.datatypes.BodyPart -import solarxr_protocol.rpc.ResetType -import solarxr_protocol.rpc.UnknownDeviceHandshakeNotification -import kotlin.collections.listOf - -data class ResetState( - val canDoYawReset: Boolean, - val canDoMountingReset: Boolean -) - -sealed interface ResetActions { - data class ClearResets(val resetType: List) : ResetActions // sets to false - data class EndReset(val resetType: ResetType) : ResetActions // sets to true if full reset -} - -typealias ResetContext = Context -typealias ResetBehaviour = Behaviour - -class ResetManager(val context: ResetContext, val server: VRServer) { - fun startObserving() = context.observeAll(this) - - private var resetJob: Job = Job() - - suspend fun scheduleFullReset(resetType: ResetType, status: Int, bodyParts: List? = null, progress: Int = 0, duration: Int = 0) { - resetJob.cancelAndJoin() - resetJob = context.scope.safeLaunch { - server.context.state.value.solarxr.values.forEach { bridge -> - //bridge.sendRpc((macAddress = mac)) - } - //server.context.dispatch() - } - } - - companion object { - fun create(ctx: Phase1ContextProvider, scope: CoroutineScope): ResetManager { - val context = Context.create( - initialState = ResetState( - canDoYawReset = false, - canDoMountingReset = false - ), - scope = scope, - behaviours = listOf(), - name = "ResetManager", - ) - return ResetManager(context, ctx.server) - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt new file mode 100644 index 0000000000..f4c296cae8 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt @@ -0,0 +1,45 @@ +package dev.slimevr.resets + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapLatest +import solarxr_protocol.rpc.ResetType + +const val MOUNTING_RESET_TIMEOUT = 120 * 1000L // 120 seconds + +class ResetsBasicBehaviour : ResetsBehaviour { + override fun reduce(state: ResetsState, action: ResetsActions) = when (action) { + is ResetsActions.UpdateConfig -> state.copy(config = action.config) + + // Clear the states of the `canDoXReset`s to false + is ResetsActions.ClearResets -> { + state.copy( + canDoYawReset = if (ResetType.Yaw in action.resetType) false else state.canDoYawReset, + canDoMountingReset = if (ResetType.Mounting in action.resetType) false else state.canDoMountingReset, + ) + } + + // Whenever a reset is finished + is ResetsActions.EndReset -> { + if (action.resetType == ResetType.Full) { + state.copy(canDoYawReset = true, canDoMountingReset = true, lastFullResetTime = System.nanoTime()) + } + else state.copy() + } + } +} + +class ResetsMountingTimeoutBehaviour : ResetsBehaviour { + @OptIn(ExperimentalCoroutinesApi::class) + override fun observe(receiver: ResetsManager) { + receiver.context.state + .distinctUntilChangedBy { it.lastFullResetTime } + .mapLatest { + delay(MOUNTING_RESET_TIMEOUT) + receiver.context.dispatch(ResetsActions.ClearResets(listOf(ResetType.Mounting))) + } + .launchIn(receiver.context.scope) + } +} diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt new file mode 100644 index 0000000000..e12267b12e --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -0,0 +1,140 @@ +package dev.slimevr.resets + +import dev.slimevr.AppLogger +import dev.slimevr.Phase1ContextProvider +import dev.slimevr.VRServer +import dev.slimevr.config.ResetsConfig +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context +import dev.slimevr.tracker.TrackerActions +import dev.slimevr.util.safeLaunch +import io.github.axisangles.ktmath.Quaternion +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.rpc.ResetResponse +import solarxr_protocol.rpc.ResetStatus +import solarxr_protocol.rpc.ResetType +import kotlin.collections.listOf + +data class ResetsState( + val config: ResetsConfig = ResetsConfig(), + val canDoYawReset: Boolean, + val canDoMountingReset: Boolean, + val lastFullResetTime: Long, +) + +sealed interface ResetsActions { + data class UpdateConfig(val config: ResetsConfig) : ResetsActions + data class ClearResets(val resetType: List) : ResetsActions + data class EndReset(val resetType: ResetType) : ResetsActions +} + +typealias ResetsContext = Context +typealias ResetsBehaviour = Behaviour + +class ResetsManager(val context: ResetsContext, val server: VRServer) { + fun startObserving() = context.observeAll(this) + + private var resetJob: Job = Job() + + /** + * Schedules a reset according to the resetType. + * resetSourceName is used for logging + * If delay is null, the default delay from config will be used. + * If bodyParts is null, resets all trackers. + */ + suspend fun scheduleReset(resetSourceName: String, resetType: ResetType, delay: Float? = null, bodyParts: List? = null) { + resetJob.cancelAndJoin() + resetJob = context.scope.safeLaunch { + val config = context.state.value.config + val delayMs = if (delay != null) (delay * 1000).toInt() // Use provided delay + else when (resetType) { // Fall back to config + ResetType.Yaw -> (config.yawResetDelay * 1000).toInt() + ResetType.Full -> (config.fullResetDelay * 1000).toInt() + ResetType.Mounting -> (config.mountingResetDelay * 1000).toInt() + } + val fullSeconds = delayMs / 1000 + val remainder = delayMs % 1000 + + // Tell the GUI we started a reset + server.sendSolarxrRpc( + ResetResponse(resetType, ResetStatus.STARTED, bodyParts, 0, delayMs) + ) + + // Wait for the reset delay while updating the GUI every second + repeat(fullSeconds){ index -> + delay(1000) + // Skip final tick if at the same time as finish + if (index != fullSeconds - 1 || remainder != 0) { + server.sendSolarxrRpc( + ResetResponse(resetType, ResetStatus.STARTED, bodyParts, (index + 1) * 1000, delayMs) + ) + } + } + delay((remainder).toLong()) + + executeTrackerResets(resetType, bodyParts, config) + + context.dispatch(ResetsActions.EndReset(resetType)) + + AppLogger.resets.info("${resetType.name} Reset from $resetSourceName") + + // Tell the GUI we ended a reset + server.sendSolarxrRpc( + ResetResponse(resetType, ResetStatus.FINISHED, bodyParts, delayMs, delayMs) + ) + } + } + + private fun executeTrackerResets(resetType: ResetType, bodyParts: List? = null, config: ResetsConfig) { + val allTrackers = server.context.state.value.trackers.values + + // Filter out the trackers that we want to reset + val trackers = if (!bodyParts.isNullOrEmpty()) { + allTrackers.filter { + bodyParts.contains(it.context.state.value.bodyPart) + } + } else { + // Exclude feet trackers from mounting reset if resetMountingFeet = false + allTrackers.filter { + resetType != ResetType.Mounting || + config.resetMountingFeet || + it.context.state.value.bodyPart !in setOf(BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT) + } + } + + val referenceRotation = allTrackers + .map { it.context.state.value } + .firstOrNull { it.status == TrackerStatus.OK && it.bodyPart == BodyPart.HEAD } + ?.rotation ?: Quaternion.IDENTITY + + // Dispatch the reset action to the trackers + trackers.forEach { it.context.dispatch( + when (resetType) { + ResetType.Yaw -> TrackerActions.YawReset(referenceRotation, config.yawResetSmoothTime) + ResetType.Full -> TrackerActions.FullReset(referenceRotation, config.resetHmdPitch) + ResetType.Mounting -> TrackerActions.MountingReset(referenceRotation, config.saveMountingReset) + }) + } + } + + companion object { + fun create(server: VRServer, scope: CoroutineScope): ResetsManager { + val context = Context.create( + initialState = ResetsState( + canDoYawReset = false, + canDoMountingReset = false, + lastFullResetTime = 0, + ), + scope = scope, + behaviours = listOf(ResetsBasicBehaviour(), ResetsMountingTimeoutBehaviour()), + name = "ResetsManager", + ) + return ResetsManager(context, server) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index a1f1095da1..60d9acdd45 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -3,6 +3,7 @@ package dev.slimevr.solarxr import dev.slimevr.AppLogger import dev.slimevr.VRServer import dev.slimevr.device.DeviceState +import dev.slimevr.resets.ResetsManager import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.Skeleton import dev.slimevr.tracker.TrackerState @@ -111,16 +112,20 @@ private fun createBone(bone: BoneState): solarxr_protocol.data_feed.Bone = solar headPositionG = bone.headPosition.let { Vec3f(it.x, it.y, it.z) }, ) -// TODO: Actually figure this stuff out -private fun createServerGuards(): ServerGuards = ServerGuards( - candomounting = true, - candoyawreset = true, -) +private fun createServerGuards(resetsManager: ResetsManager): ServerGuards { + val resetsState = resetsManager.context.state.value + return ServerGuards( + candomounting = resetsState.canDoMountingReset, + candoyawreset = resetsState.canDoYawReset, + //candouserheightcalibration = true, TODO + ) +} fun createDatafeedFrame( server: VRServer, datafeedConfig: DataFeedConfig, skeleton: Skeleton, + resetsManager: ResetsManager, index: Int = 0, ): DataFeedMessageHeader { val serverState = server.context.state.value @@ -133,7 +138,7 @@ fun createDatafeedFrame( null } val serverGuards = if (datafeedConfig.serverGuardsMask == true) { - createServerGuards() + createServerGuards(resetsManager) } else { null } @@ -163,7 +168,7 @@ class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : Sola val minTime = config.minimumTimeSinceLast.toLong() while (isActive) { try { - receiver.sendDataFeed(createDatafeedFrame(server = server, skeleton = skeleton, datafeedConfig = config, index = index)) + receiver.sendDataFeed(createDatafeedFrame(server = server, datafeedConfig = config, skeleton = skeleton, resetsManager = receiver.appContext.resetsManager, index = index)) } catch (e: Exception) { AppLogger.solarxr.error(e, "Error sending data feed") } @@ -178,7 +183,7 @@ class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : Sola receiver.dataFeedDispatcher.on { event -> val config = event.config ?: return@on - receiver.sendDataFeed(createDatafeedFrame(server = server, datafeedConfig = config, skeleton = skeleton)) + receiver.sendDataFeed(createDatafeedFrame(server = server, datafeedConfig = config, skeleton = skeleton, resetsManager = receiver.appContext.resetsManager)) } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 4c7db2ee2c..5ae6925791 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -85,7 +85,7 @@ class SolarXRBridge( add(KnownTrackersBehaviour(appContext.config.settings)) add(BvhBehaviour(appContext.bvhManager)) add(InstalledInfoBehaviour()) - add(SessionCalibrationBehaviour(appContext.server)) + add(SessionCalibrationBehaviour(appContext.resetsManager)) } fun create( diff --git a/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt index bbd80e2967..23f42c8440 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt @@ -1,6 +1,7 @@ package dev.slimevr.solarxr import dev.slimevr.VRServer +import dev.slimevr.resets.ResetsManager import dev.slimevr.tracker.TrackerActions import io.github.axisangles.ktmath.Quaternion import solarxr_protocol.datatypes.BodyPart @@ -8,38 +9,11 @@ import solarxr_protocol.rpc.ResetRequest import solarxr_protocol.rpc.ResetType class SessionCalibrationBehaviour( - private val server: VRServer, + private val resetsManager: ResetsManager, ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { req -> - val bodyParts = req.bodyParts - val allTrackers = server.context.state.value.trackers - - val trackers = if (!bodyParts.isNullOrEmpty()) { - allTrackers.filterValues { - bodyParts.contains(it.context.state.value.bodyPart) - } - } else { - allTrackers - } - val reference = allTrackers.firstNotNullOfOrNull { - val trackerState = it.value.context.state.value - if (trackerState.bodyPart == BodyPart.HEAD) { - trackerState.rotation - } else { - null - } - } ?: Quaternion.IDENTITY - - val action = when (req.resetType) { - // TODO: SolarXR incorrectly treats 0 as null - ResetType.Yaw, null -> TrackerActions.YawReset(reference) - ResetType.Full -> TrackerActions.FullReset(reference) - ResetType.Mounting -> TrackerActions.MountingReset(reference) - } - for ((_, tracker) in trackers) { - tracker.context.dispatch(action) - } + resetsManager.scheduleReset("SolarXRBridge", req.resetType ?: ResetType.Yaw, req.delay, req.bodyParts) } } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 6de04a3500..1004110160 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -1,6 +1,5 @@ package dev.slimevr.tracker -import dev.slimevr.AppLogger import dev.slimevr.VRServerState import dev.slimevr.config.SettingsState import dev.slimevr.skeleton.SkeletonActions @@ -20,7 +19,7 @@ import kotlinx.coroutines.isActive import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.TrackerId -import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.rpc.ResetType import solarxr_protocol.rpc.TapDetectionSetupNotification import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi @@ -38,7 +37,7 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { private val accelList = ArrayDeque>() private val tapTimestamps = ArrayDeque() private var waitForLowAccel = false - private var actionToExecute: TrackerActions? = null + private var resetType: ResetType? = null private var setupModeAssign = false private var tapsNeeded = 0 private var actionDelay = 0f @@ -68,7 +67,7 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { // Inner flow (process) is refreshed everytime this tracker's acceleration is updated .flatMapLatest { receiver.context.state - .filter { actionToExecute != null || setupModeAssign } + .filter { resetType != null || setupModeAssign } .distinctUntilChangedBy { it.rawAcceleration } } .onEach { currentTracker -> @@ -89,22 +88,12 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { // If setupMode is true, double tap to assign if (tapDetectionConfig.setupMode) { setupModeAssign = true - - actionToExecute = null + resetType = null tapsNeeded = 2 actionDelay = 0f return } - // Get reference rotation for reset actions - // TODO move to reset module - val referenceRotation = serverState.trackers.firstNotNullOfOrNull { - val trackerState = it.value.context.state.value - if (trackerState.status == TrackerStatus.OK && trackerState.bodyPart == BodyPart.HEAD) { - trackerState.rotation - } else null - } ?: Quaternion.IDENTITY - val trackersBodyParts = serverState.trackers.values .map { it.context.state.value.bodyPart } .toSet() @@ -117,23 +106,23 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { // Switch case for each possible action when (receiver.context.state.value.bodyPart) { - null -> actionToExecute = null // BodyParts above could be null + null -> resetType = null // BodyParts above could be null yawResetBodyPart if tapDetectionConfig.yawResetEnabled -> { - actionToExecute = TrackerActions.YawReset(referenceRotation) + resetType = ResetType.Yaw tapsNeeded = tapDetectionConfig.yawResetTaps actionDelay = tapDetectionConfig.yawResetDelay } fullResetBodyPart if tapDetectionConfig.fullResetEnabled -> { - actionToExecute = TrackerActions.FullReset(referenceRotation) + resetType = ResetType.Full tapsNeeded = tapDetectionConfig.fullResetTaps actionDelay = tapDetectionConfig.fullResetDelay } mountingResetBodyPart if tapDetectionConfig.mountingResetEnabled -> { - actionToExecute = TrackerActions.MountingReset(referenceRotation) + resetType = ResetType.Mounting tapsNeeded = tapDetectionConfig.mountingResetTaps actionDelay = tapDetectionConfig.mountingResetDelay } - else -> actionToExecute = null + else -> resetType = null } } @@ -180,6 +169,7 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { if (tapTimestamps.size >= tapsNeeded) { // Taps completed! receiver.context.scope.safeLaunch { + // If it's in setup mode, tap to assign if (setupModeAssign) { receiver.appContext.server.sendSolarxrRpc( TapDetectionSetupNotification( @@ -188,16 +178,9 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { ) } - // If it has an action to execute - // TODO use reset module - actionToExecute?.let { action -> - AppLogger.tracker.info("TapDetection triggered $action") - delay((actionDelay * 1000).toLong()) - receiver.appContext.server.context.state.value.trackers.values.forEach { - it.context.dispatch( - action - ) - } + // If it has a reset to execute + resetType?.let { reset -> + receiver.appContext.resetsManager.scheduleReset("TapDetection", reset, actionDelay) } } @@ -348,7 +331,6 @@ class TrackerTPSBehaviour : TrackerBehaviour { } class TrackerToSkeletonBehaviour : TrackerBehaviour { - override fun observe(receiver: Tracker) { receiver.context.state .filter { it.bodyPart != null } diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index f92036efc1..51c3221c42 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -44,9 +44,9 @@ sealed interface TrackerActions { data class SetMagStatus(val status: MagnetometerStatus) : TrackerActions data class SetStatus(val status: TrackerStatus) : TrackerActions data class SetRotation(val rotation: Quaternion? = null, val acceleration: Vector3? = null) : TrackerActions - data class FullReset(val referenceRotation: Quaternion) : TrackerActions - data class YawReset(val referenceRotation: Quaternion) : TrackerActions - data class MountingReset(val referenceRotation: Quaternion) : TrackerActions + data class FullReset(val referenceRotation: Quaternion, val resetHmdPitch: Boolean) : TrackerActions + data class YawReset(val referenceRotation: Quaternion, val yawResetSmoothTime: Float) : TrackerActions + data class MountingReset(val referenceRotation: Quaternion, val saveMountingReset: Boolean) : TrackerActions } typealias TrackerContext = Context 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 85291ec12e..f471e3a881 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -30,7 +30,7 @@ import dev.slimevr.resolveConfigDirectory import dev.slimevr.skeleton.Skeleton import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.desktop.udp.resolveDesktopUdpAddress -import dev.slimevr.reset.ResetManager +import dev.slimevr.resets.ResetsManager import dev.slimevr.udp.UdpServer import dev.slimevr.util.safeLaunch import dev.slimevr.vmc.VMCManager @@ -87,7 +87,7 @@ fun main(args: Array) = runBlocking { scope = this, oscQueryAddress = resolveDesktopOscQueryAddress(), ) - val resetManager = ResetManager.create(ctx = phase1, scope = this) + val resetsManager = ResetsManager.create(server = server, scope = this) val appContext = AppContext( server = server, @@ -105,7 +105,7 @@ fun main(args: Array) = runBlocking { bvhManager = bvhManager, vmcManager = vmcManager, vrcOscManager = vrcOscManager, - resetManager = resetManager, + resetsManager = resetsManager, ) try { From 25a066da30c648c04a425f7f2c7ffb1cf647075a Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 18 Jun 2026 20:13:50 -0400 Subject: [PATCH 170/350] Remove unneeded data from tracker reset actions --- server/core/src/main/java/dev/slimevr/resets/module.kt | 6 +++--- server/core/src/main/java/dev/slimevr/tracker/module.kt | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index e12267b12e..901e7bed29 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -115,9 +115,9 @@ class ResetsManager(val context: ResetsContext, val server: VRServer) { // Dispatch the reset action to the trackers trackers.forEach { it.context.dispatch( when (resetType) { - ResetType.Yaw -> TrackerActions.YawReset(referenceRotation, config.yawResetSmoothTime) - ResetType.Full -> TrackerActions.FullReset(referenceRotation, config.resetHmdPitch) - ResetType.Mounting -> TrackerActions.MountingReset(referenceRotation, config.saveMountingReset) + ResetType.Yaw -> TrackerActions.YawReset(referenceRotation) + ResetType.Full -> TrackerActions.FullReset(referenceRotation) + ResetType.Mounting -> TrackerActions.MountingReset(referenceRotation) }) } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 51c3221c42..f92036efc1 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -44,9 +44,9 @@ sealed interface TrackerActions { data class SetMagStatus(val status: MagnetometerStatus) : TrackerActions data class SetStatus(val status: TrackerStatus) : TrackerActions data class SetRotation(val rotation: Quaternion? = null, val acceleration: Vector3? = null) : TrackerActions - data class FullReset(val referenceRotation: Quaternion, val resetHmdPitch: Boolean) : TrackerActions - data class YawReset(val referenceRotation: Quaternion, val yawResetSmoothTime: Float) : TrackerActions - data class MountingReset(val referenceRotation: Quaternion, val saveMountingReset: Boolean) : TrackerActions + data class FullReset(val referenceRotation: Quaternion) : TrackerActions + data class YawReset(val referenceRotation: Quaternion) : TrackerActions + data class MountingReset(val referenceRotation: Quaternion) : TrackerActions } typealias TrackerContext = Context From 150c0640cf9872ace787581352b4466925f0ca97 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Thu, 18 Jun 2026 21:13:44 -0500 Subject: [PATCH 171/350] Sync flake with main --- flake.nix | 91 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 70 insertions(+), 21 deletions(-) diff --git a/flake.nix b/flake.nix index a46b028380..1767af25b7 100644 --- a/flake.nix +++ b/flake.nix @@ -8,31 +8,80 @@ flake-parts.url = "github:hercules-ci/flake-parts"; }; - outputs = inputs@{ self, nixpkgs, nixpkgs-jdk24, flake-parts, ... }: + outputs = + inputs@{ + self, + nixpkgs, + nixpkgs-jdk24, + flake-parts, + ... + }: flake-parts.lib.mkFlake { inherit inputs; } { systems = [ "x86_64-linux" ]; - perSystem = { pkgs, system, ... }: + perSystem = + { system, pkgs, ... }: let - jdkPkgs = import nixpkgs-jdk24 { inherit system; }; - java = jdkPkgs.javaPackages.compiler.temurin-bin.jdk-24; - - runtimeLibs = pkgs: (with pkgs; [ - java - - alsa-lib at-spi2-atk at-spi2-core cairo cups dbus expat - gdk-pixbuf glib gtk3 libdrm libgbm libglvnd libnotify - libxkbcommon mesa nspr nss pango systemd vulkan-loader - wayland libx11 libxcomposite libxdamage - libxext libxfixes libxrandr libxcb - libxshmfence libusb1 udev libxcrypt-legacy - rpm fpm - - wineWow64Packages.stable - zlib squashfsTools fakeroot libarchive icu - nodejs_22 pnpm pkg-config python3 gcc gnumake binutils git - node-gyp-build - ]); + java = (import nixpkgs-jdk24 { inherit system; }).jdk24; + + runtimeLibs = + pkgs: + (with pkgs; [ + java + + alsa-lib + libpulseaudio + at-spi2-atk + at-spi2-core + cairo + cups + dbus + expat + gdk-pixbuf + glib + gtk3 + libdrm + libgbm + libglvnd + libnotify + libxkbcommon + mesa + nspr + nss + pango + systemd + vulkan-loader + wayland + libX11 + libXcomposite + libXdamage + libXext + libXfixes + libXrandr + libxcb + libxshmfence + libusb1 + udev + libxcrypt-legacy + rpm + fpm + + wineWow64Packages.stable + zlib + squashfsTools + fakeroot + libarchive + icu + nodejs_22 + pnpm + pkg-config + python3 + gcc + gnumake + binutils + git + node-gyp-build + ]); slimeShell = pkgs.buildFHSEnv { name = "slimevr-env"; From 1e92eafdadb680fb2c2d8dcd7421fd88200e9aba Mon Sep 17 00:00:00 2001 From: Sapphire Date: Wed, 17 Jun 2026 23:49:04 -0500 Subject: [PATCH 172/350] Bump electron-builder to 26.15.0, improve Nix flake --- .envrc | 8 +- flake.lock | 12 +- flake.nix | 193 ++++++++----- gui/package.json | 2 +- pnpm-lock.yaml | 725 +++++++++++++++++------------------------------ 5 files changed, 389 insertions(+), 551 deletions(-) diff --git a/.envrc b/.envrc index 9fa5014993..b45a4201ec 100644 --- a/.envrc +++ b/.envrc @@ -1,9 +1,9 @@ -if ! has nix_direnv_version || ! nix_direnv_version 2.2.1; then - source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.2.1/direnvrc" "sha256-zelF0vLbEl5uaqrfIzbgNzJWGmLzCmYAkInj/LNxvKs=" +if ! has nix_direnv_version || ! nix_direnv_version 3.1.1; then + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.1.1/direnvrc" "sha256-p+fzQdrms/hDa7g+soShAybJNo4bN4SIAeSfqNKgD5I=" fi -nix_direnv_watch_file package.json -if ! use flake . --impure +watch_file package.json +if ! use flake . then echo "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2 fi diff --git a/flake.lock b/flake.lock index ab3ad00667..b5ed91e99d 100644 --- a/flake.lock +++ b/flake.lock @@ -5,11 +5,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1777988971, - "narHash": "sha256-qIoWPDs+0/8JecyYgE3gpKQxW/4bLW/gp45vow9ioCQ=", + "lastModified": 1778716662, + "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "0678d8986be1661af6bb555f3489f2fdfc31f6ff", + "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", "type": "github" }, "original": { @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1777954456, - "narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=", + "lastModified": 1781577229, + "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1", + "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 1767af25b7..b9144f07b5 100644 --- a/flake.nix +++ b/flake.nix @@ -17,88 +17,145 @@ ... }: flake-parts.lib.mkFlake { inherit inputs; } { - systems = [ "x86_64-linux" ]; + systems = [ + "aarch64-linux" + "x86_64-linux" + ]; perSystem = { system, pkgs, ... }: let java = (import nixpkgs-jdk24 { inherit system; }).jdk24; - runtimeLibs = - pkgs: - (with pkgs; [ - java + runtimeLibs = [ + pkgs.alsa-lib + pkgs.libpulseaudio + pkgs.at-spi2-atk + pkgs.at-spi2-core + pkgs.cairo + pkgs.cups + pkgs.dbus + pkgs.expat + pkgs.gdk-pixbuf + pkgs.glib + pkgs.gtk3 + pkgs.libdrm + pkgs.libgbm + pkgs.libglvnd + pkgs.libnotify + pkgs.libxkbcommon + pkgs.mesa + pkgs.nspr + pkgs.nss + pkgs.pango + pkgs.systemd + pkgs.vulkan-loader + pkgs.wayland + pkgs.libX11 + pkgs.libXcomposite + pkgs.libXdamage + pkgs.libXext + pkgs.libXfixes + pkgs.libXrandr + pkgs.libxcb + pkgs.libxshmfence + pkgs.libusb1 + pkgs.udev + pkgs.libxcrypt-legacy + ]; - alsa-lib - libpulseaudio - at-spi2-atk - at-spi2-core - cairo - cups - dbus - expat - gdk-pixbuf - glib - gtk3 - libdrm - libgbm - libglvnd - libnotify - libxkbcommon - mesa - nspr - nss - pango - systemd - vulkan-loader - wayland - libX11 - libXcomposite - libXdamage - libXext - libXfixes - libXrandr - libxcb - libxshmfence - libusb1 - udev - libxcrypt-legacy - rpm - fpm + toolDirectoryName = + if pkgs.stdenv.hostPlatform.isDarwin then + "darwin" + else if pkgs.stdenv.hostPlatform.isLinux then + { + "armv7l-linux" = "linux-arm32"; + "aarch64-linux" = "linux-arm64"; + "i686-linux" = "linux-ia32"; + "x86_64-linux" = "linux-x64"; + } + ."${pkgs.stdenv.hostPlatform.system}" + else + throw "Unsupported platform"; - wineWow64Packages.stable - zlib - squashfsTools - fakeroot - libarchive - icu - nodejs_22 - pnpm - pkg-config - python3 - gcc - gnumake - binutils - git - node-gyp-build - ]); + # we use fuse2 + # https://github.com/electron-userland/electron-builder/blob/a6117b3011a105204af8cc2eca02a56976d1ef29/packages/app-builder-lib/src/toolsets/linux.ts#L122 + runtime = pkgs.stdenvNoCC.mkDerivation { + name = "electron-builder-appimage-runtime"; + src = pkgs.fetchurl { + url = "https://github.com/electron-userland/electron-builder-binaries/releases/download/appimage-12.0.1/appimage-12.0.1.7z"; + hash = "sha256-0S/3648dHsRlLKUjen+9yjOswMdYBFY2/spi3G7LjsQ="; + }; + # https://github.com/NixOS/nixpkgs/blob/7890ba0a99c064446fe2178ef2f8e3abdf6ec42a/pkgs/by-name/lo/losslesscut-bin/build-from-windows.nix#L20 + nativeBuildInputs = [ pkgs.p7zip ]; + unpackPhase = '' + runHook preUnpack + 7z x "$src" + runHook postUnpack + ''; + installPhase = '' + mkdir $out + cp -r -t $out/ \ + lib/ \ + runtime-* + ''; + }; - slimeShell = pkgs.buildFHSEnv { - name = "slimevr-env"; - targetPkgs = runtimeLibs; - profile = '' - export JAVA_HOME=${java} - export PATH="${java}/bin:$PATH" + appImageTools = pkgs.stdenvNoCC.mkDerivation { + name = "electron-builder-appimage-tools"; + dontUnpack = true; - # Tell electron-builder to use system tools instead of downloading them - export USE_SYSTEM_FPM=true - export USE_SYSTEM_MKSQUASHFS=true + installPhase = '' + mkdir -p "$out/${toolDirectoryName}" + ln -s -t $out/${toolDirectoryName} \ + "${pkgs.desktop-file-utils}/bin/desktop-file-validate" \ + "${pkgs.squashfsTools}/bin/mksquashfs" + ln -s ${runtime}/runtime-* $out/ + ln -s ${runtime}/lib $out/lib ''; - runScript = "bash"; }; in { - devShells.default = slimeShell.env; + devShells.default = pkgs.mkShell { + packages = [ + # for running the jar + java + + # for build + pkgs.electron + pkgs.rpm + pkgs.fpm + pkgs.p7zip + pkgs.wineWow64Packages.stable + pkgs.zlib + pkgs.squashfsTools + pkgs.desktop-file-utils + pkgs.fakeroot + pkgs.libarchive + pkgs.icu + pkgs.nodejs_22 + pkgs.pnpm + pkgs.pkg-config + pkgs.python3 + pkgs.gcc + pkgs.gnumake + pkgs.binutils + pkgs.git + pkgs.node-gyp-build + ]; + buildInputs = runtimeLibs; + + JAVA_HOME = "${java}"; + USE_SYSTEM_FPM = "true"; + ELECTRON_BUILDER_7ZIP_PATH = "${pkgs.p7zip}/bin/7za"; + APPIMAGE_TOOLS_PATH = "${appImageTools}"; + #ELECTRON_SKIP_BINARY_DOWNLOAD = true; + #ELECTRON_DIST = "${pkgs.electron.dist}"; + #ELECTRON_VERSION = "${pkgs.electron.version}"; + + # for electron-vite, so `pnpm gui` works + ELECTRON_EXEC_PATH = "${pkgs.electron}/bin/electron"; + }; }; }; } diff --git a/gui/package.json b/gui/package.json index 4a6105fbb8..7e4cff6324 100644 --- a/gui/package.json +++ b/gui/package.json @@ -68,7 +68,7 @@ "dmg-license": "^1.0.11", "dotenv": "^16.4.5", "electron": "^40.3.0", - "electron-builder": "^26.9.0", + "electron-builder": "^26.15.0", "electron-vite": "^5.0.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "^9.39.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f57d32e7e..d1eb02f7b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,8 +163,8 @@ importers: specifier: ^40.3.0 version: 40.9.1 electron-builder: - specifier: ^26.9.0 - version: 26.9.0(electron-builder-squirrel-windows@26.9.0) + specifier: ^26.15.0 + version: 26.15.3(electron-builder-squirrel-windows@26.15.3) electron-vite: specifier: ^5.0.0 version: 5.0.0(@swc/core@1.15.26)(vite@5.4.21(@types/node@24.12.2)(sass@1.99.0)) @@ -298,9 +298,6 @@ importers: packages: - 7zip-bin@5.2.0: - resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} - '@alloc/quick-lru@5.2.0': resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} @@ -404,10 +401,6 @@ packages: '@clack/prompts@0.9.1': resolution: {integrity: sha512-JIpyaboYZeWYlyP0H+OoPPxd6nqueG/CmN6ixBiNFsIDHREevjIf0n0Ohh5gr5C8pEDknzgvz+pIJ8dMhzWIeg==} - '@develar/schema-utils@2.6.5': - resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} - engines: {node: '>= 8.9.0'} - '@discordjs/collection@2.1.1': resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} engines: {node: '>=18'} @@ -466,8 +459,8 @@ packages: engines: {node: '>=12.0.0'} hasBin: true - '@electron/rebuild@4.0.3': - resolution: {integrity: sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==} + '@electron/rebuild@4.0.4': + resolution: {integrity: sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==} engines: {node: '>=22.12.0'} hasBin: true @@ -868,10 +861,6 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -907,6 +896,14 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + + '@noble/hashes@2.2.0': + resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -923,14 +920,6 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} - '@npmcli/agent@3.0.0': - resolution: {integrity: sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==} - engines: {node: ^18.17.0 || >=20.5.0} - - '@npmcli/fs@4.0.0': - resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} - engines: {node: ^18.17.0 || >=20.5.0} - '@octokit/endpoint@10.1.4': resolution: {integrity: sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==} engines: {node: '>= 18'} @@ -1044,13 +1033,23 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@peculiar/asn1-schema@2.8.0': + resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} + + '@peculiar/json-schema@1.1.12': + resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==} + engines: {node: '>=8.0.0'} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/webcrypto@1.7.1': + resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} + engines: {node: '>=14.18.0'} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - '@react-hookz/deep-equal@3.0.4': resolution: {integrity: sha512-QpcSUTP1I0lI8/GvSEvclkoEGRWjbYxuUWaecxr8eJRceivGF18wHoJwaej2NYxMn7SqhKaZ9JYQvmdATiSG8A==} engines: {node: '>=18.0.0'} @@ -1735,9 +1734,9 @@ packages: resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==} engines: {node: '>=10.0.0'} - abbrev@3.0.1: - resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} - engines: {node: ^18.17.0 || >=20.5.0} + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -1757,11 +1756,6 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ajv-keywords@3.5.2: - resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} - peerDependencies: - ajv: ^6.9.1 - ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} @@ -1795,15 +1789,12 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - app-builder-bin@5.0.0-alpha.12: - resolution: {integrity: sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==} - - app-builder-lib@26.9.0: - resolution: {integrity: sha512-f/1GhVrDfBH7sSzhAwiXa1rpR/7pB7Av5woUjmt+QYE0QyNvrOAiY05rngtR/PK4/1BzS6/zVoYobIwDAsrtBA==} + app-builder-lib@26.15.3: + resolution: {integrity: sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==} engines: {node: '>=14.0.0'} peerDependencies: - dmg-builder: 26.9.0 - electron-builder-squirrel-windows: 26.9.0 + dmg-builder: 26.15.3 + electron-builder-squirrel-windows: 26.15.3 arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -1854,6 +1845,10 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -1898,6 +1893,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + axe-core@4.11.3: resolution: {integrity: sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==} engines: {node: '>=4'} @@ -1937,8 +1935,8 @@ packages: bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} @@ -1975,25 +1973,26 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - builder-util-runtime@9.6.0: - resolution: {integrity: sha512-k9V5I18PXLepLZ8jVmPzsH+gVCVZ+9aMVLyCZ0ZLOkT2KSyiBblDCCN8WxDbjOpfLGNHZqqJPBmW0HYeqxlgkQ==} + builder-util-runtime@9.7.0: + resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} engines: {node: '>=12.0.0'} - builder-util@26.9.0: - resolution: {integrity: sha512-+eocmbdisnyb40B9nAp/KREfYLXvGagxV50KZv/Zh4aflsr1fdY9Qxs6QG1Jtx1vH5d5NQ3hIcemUi4RSlFK/Q==} + builder-util@26.15.3: + resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==} + engines: {node: '>=14.0.0'} bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} + bytestreamjs@2.0.1: + resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} + engines: {node: '>=6.0.0'} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} - cacache@19.0.1: - resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==} - engines: {node: ^18.17.0 || >=20.5.0} - cacheable-lookup@5.0.4: resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} engines: {node: '>=10.6.0'} @@ -2093,10 +2092,6 @@ packages: classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} - cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} - cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -2106,10 +2101,6 @@ packages: engines: {node: '>=8.0.0', npm: '>=5.0.0'} hasBin: true - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - cli-truncate@2.1.0: resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} engines: {node: '>=8'} @@ -2133,10 +2124,6 @@ packages: clone-response@1.0.3: resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2278,9 +2265,6 @@ packages: resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} engines: {node: '>=18'} - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - defer-to-connect@2.0.1: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} @@ -2346,8 +2330,8 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} - dmg-builder@26.9.0: - resolution: {integrity: sha512-5uSyg0yY+JRMMGwFjIweaukYkCGcZSZwvH5VPlBHiKkTEP1a1/uV+bs4y+VAxPMgzg67YB4CF1vD4U/2d3chfg==} + dmg-builder@26.15.3: + resolution: {integrity: sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==} dmg-license@1.0.11: resolution: {integrity: sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==} @@ -2371,8 +2355,8 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} ebnf-parser@0.1.10: resolution: {integrity: sha512-urvSxVQ6XJcoTpc+/x2pWhhuOX4aljCNQpwzw+ifZvV1andZkAmiJc3Rq1oGEAQmcjiLceyMXOy1l8ms8qs2fQ==} @@ -2386,16 +2370,16 @@ packages: engines: {node: '>=0.10.0'} hasBin: true - electron-builder-squirrel-windows@26.9.0: - resolution: {integrity: sha512-Jzsxa3Kjv94ZRZDK7pc8bzu5iglwGsDOBMWTqLpWx2sCEvb3TLW9PMWqOjpDwizqkMBp8GjP6rceLzQYLFIVow==} + electron-builder-squirrel-windows@26.15.3: + resolution: {integrity: sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==} - electron-builder@26.9.0: - resolution: {integrity: sha512-7BEeGz8Xlk7Qvitj70nGAQaq7WQIZ2amsDvsyKSZbxgtL2pM9fm/woZrtn0hoID6Fl7wkn456dK3OmtFNzgiOA==} + electron-builder@26.15.3: + resolution: {integrity: sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==} engines: {node: '>=14.0.0'} hasBin: true - electron-publish@26.9.0: - resolution: {integrity: sha512-gsy+U7JfDuD1lPOrCXeECDQoUsWjFah3s1Fv3pqKnjdJuKYDDvGdvC74kLHVG6nl5G0uQ7YN0eftCQ4rUmhvVw==} + electron-publish@26.15.3: + resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} electron-to-chromium@1.5.339: resolution: {integrity: sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg==} @@ -2740,10 +2724,6 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - form-data-encoder@2.1.4: resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==} engines: {node: '>= 14.17'} @@ -2775,10 +2755,6 @@ packages: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} - fs-minipass@3.0.3: - resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -2848,11 +2824,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -3049,10 +3020,6 @@ packages: intl-pluralrules@2.0.1: resolution: {integrity: sha512-astxTLzIdXPeN0K9Rumi6LfMpm3rvNO0iJE+h/k8Kr/is+wPbRe4ikyDjlLr6VTh/mEfNv8RjN+gu3KwDiuhqg==} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} - engines: {node: '>= 12'} - ip-num@1.5.2: resolution: {integrity: sha512-CUxHEJuJk74GIQA75LD9RDkpK/3NJv8yRnseOh79AlRmFwT1HgYPKDOiVkd0QVR5MNwMj9EnOnS1yJn9CaYNZA==} @@ -3158,10 +3125,6 @@ packages: engines: {node: '>=14.16'} hasBin: true - is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} - is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -3214,10 +3177,6 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} - is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -3238,6 +3197,9 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -3256,13 +3218,14 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jake@10.9.4: resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} engines: {node: '>=10'} @@ -3395,10 +3358,6 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} - log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} @@ -3445,10 +3404,6 @@ packages: make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} - make-fetch-happen@14.0.3: - resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==} - engines: {node: ^18.17.0 || >=20.5.0} - markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -3669,30 +3624,6 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - minipass-collect@2.0.1: - resolution: {integrity: sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==} - engines: {node: '>=16 || 14 >=14.17'} - - minipass-fetch@4.0.1: - resolution: {integrity: sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - minipass-flush@1.0.7: - resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} - engines: {node: '>= 8'} - - minipass-pipeline@1.2.4: - resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} - engines: {node: '>=8'} - - minipass-sized@1.0.3: - resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} - engines: {node: '>=8'} - - minipass@3.3.6: - resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} - engines: {node: '>=8'} - minipass@4.2.8: resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} engines: {node: '>=8'} @@ -3728,10 +3659,6 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - negotiator@1.0.0: - resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} - engines: {node: '>= 0.6'} - node-abi@4.28.0: resolution: {integrity: sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g==} engines: {node: '>=22.12.0'} @@ -3762,20 +3689,23 @@ packages: encoding: optional: true - node-gyp@11.5.0: - resolution: {integrity: sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==} - engines: {node: ^18.17.0 || >=20.5.0} + node-gyp@12.4.0: + resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} + engines: {node: ^20.17.0 || >=22.9.0} hasBin: true + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-readfiles@0.2.0: resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==} node-releases@2.0.37: resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} - nopt@8.1.0: - resolution: {integrity: sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==} - engines: {node: ^18.17.0 || >=20.5.0} + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} hasBin: true normalize-path@3.0.0: @@ -3891,10 +3821,6 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} - own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -3923,17 +3849,10 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} - p-map@7.0.4: - resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} - engines: {node: '>=18'} - p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -4038,6 +3957,10 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} + pkijs@3.4.0: + resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} + engines: {node: '>=16.0.0'} + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -4119,9 +4042,12 @@ packages: engines: {node: '>=14'} hasBin: true - proc-log@5.0.0: - resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} - engines: {node: ^18.17.0 || >=20.5.0} + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} @@ -4160,6 +4086,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4251,9 +4184,8 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} @@ -4336,10 +4268,6 @@ packages: resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} engines: {node: '>=14.16'} - restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} - restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -4393,8 +4321,8 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} @@ -4540,14 +4468,6 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - socks-proxy-agent@8.0.5: - resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} - engines: {node: '>= 14'} - - socks@2.8.7: - resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -4594,10 +4514,6 @@ packages: sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - ssri@12.0.0: - resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} - engines: {node: ^18.17.0 || >=20.5.0} - stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -4617,10 +4533,6 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -4648,8 +4560,8 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -4884,17 +4796,13 @@ packages: resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} engines: {node: '>=18.17'} + undici@6.27.0: + resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} + engines: {node: '>=18.17'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} - unique-filename@4.0.0: - resolution: {integrity: sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==} - engines: {node: ^18.17.0 || >=20.5.0} - - unique-slug@5.0.0: - resolution: {integrity: sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==} - engines: {node: ^18.17.0 || >=20.5.0} - unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -4927,6 +4835,9 @@ packages: unrs-resolver@1.11.1: resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + unzipper@0.12.3: + resolution: {integrity: sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -4995,8 +4906,8 @@ packages: warning@4.0.3: resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + webcrypto-core@1.9.2: + resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==} webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -5037,6 +4948,11 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} hasBin: true + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -5045,10 +4961,6 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -5155,8 +5067,6 @@ packages: snapshots: - 7zip-bin@5.2.0: {} - '@alloc/quick-lru@5.2.0': {} '@babel/code-frame@7.29.0': @@ -5289,11 +5199,6 @@ snapshots: picocolors: 1.1.1 sisteransi: 1.0.5 - '@develar/schema-utils@2.6.5': - dependencies: - ajv: 6.14.0 - ajv-keywords: 3.5.2(ajv@6.14.0) - '@discordjs/collection@2.1.1': {} '@discordjs/rest@2.6.1': @@ -5404,21 +5309,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/rebuild@4.0.3': + '@electron/rebuild@4.0.4': dependencies: '@malept/cross-spawn-promise': 2.0.0 debug: 4.4.3 - detect-libc: 2.1.2 - got: 11.8.6 - graceful-fs: 4.2.11 node-abi: 4.28.0 node-api-version: 0.2.1 - node-gyp: 11.5.0 - ora: 5.4.1 + node-gyp: 12.4.0 read-binary-file-arch: 1.0.6 - semver: 7.7.4 - tar: 7.5.13 - yargs: 17.7.2 transitivePeerDependencies: - supports-color @@ -5690,15 +5588,6 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.2.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.3 @@ -5754,6 +5643,10 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/hashes@1.4.0': {} + + '@noble/hashes@2.2.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5768,20 +5661,6 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} - '@npmcli/agent@3.0.0': - dependencies: - agent-base: 7.1.4 - http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 - lru-cache: 10.4.3 - socks-proxy-agent: 8.0.5 - transitivePeerDependencies: - - supports-color - - '@npmcli/fs@4.0.0': - dependencies: - semver: 7.7.4 - '@octokit/endpoint@10.1.4': dependencies: '@octokit/types': 14.1.0 @@ -5899,10 +5778,29 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.6 optional: true - '@pinojs/redact@0.4.0': {} + '@peculiar/asn1-schema@2.8.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 - '@pkgjs/parseargs@0.11.0': - optional: true + '@peculiar/json-schema@1.1.12': + dependencies: + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/webcrypto@1.7.1': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + tslib: 2.8.1 + webcrypto-core: 1.9.2 + + '@pinojs/redact@0.4.0': {} '@react-hookz/deep-equal@3.0.4': {} @@ -6511,7 +6409,7 @@ snapshots: '@xmldom/xmldom@0.8.12': {} - abbrev@3.0.1: {} + abbrev@4.0.0: {} acorn-jsx@5.3.2(acorn@8.16.0): dependencies: @@ -6527,10 +6425,6 @@ snapshots: agent-base@7.1.4: {} - ajv-keywords@3.5.2(ajv@6.14.0): - dependencies: - ajv: 6.14.0 - ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -6566,32 +6460,33 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 - app-builder-bin@5.0.0-alpha.12: {} - - app-builder-lib@26.9.0(dmg-builder@26.9.0)(electron-builder-squirrel-windows@26.9.0): + app-builder-lib@26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3): dependencies: - '@develar/schema-utils': 2.6.5 '@electron/asar': 3.4.1 '@electron/fuses': 1.8.0 '@electron/get': 3.1.0 '@electron/notarize': 2.5.0 '@electron/osx-sign': 1.3.3 - '@electron/rebuild': 4.0.3 + '@electron/rebuild': 4.0.4 '@electron/universal': 2.0.3 '@malept/flatpak-bundler': 0.4.0 + '@noble/hashes': 2.2.0 + '@peculiar/webcrypto': 1.7.1 '@types/fs-extra': 9.0.13 + ajv: 8.18.0 + asn1js: 3.0.10 async-exit-hook: 2.0.1 - builder-util: 26.9.0 - builder-util-runtime: 9.6.0 + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 chromium-pickle-js: 0.2.0 ci-info: 4.3.1 debug: 4.4.3 - dmg-builder: 26.9.0(electron-builder-squirrel-windows@26.9.0) + dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) dotenv: 16.6.1 dotenv-expand: 11.0.7 ejs: 3.1.10 - electron-builder-squirrel-windows: 26.9.0(dmg-builder@26.9.0) - electron-publish: 26.9.0 + electron-builder-squirrel-windows: 26.15.3(dmg-builder@26.15.3) + electron-publish: 26.15.3 fs-extra: 10.1.0 hosted-git-info: 4.1.0 isbinaryfile: 5.0.7 @@ -6600,6 +6495,7 @@ snapshots: json5: 2.2.3 lazy-val: 1.0.5 minimatch: 10.2.5 + pkijs: 3.4.0 plist: 3.1.0 proper-lockfile: 4.1.2 resedit: 1.7.2 @@ -6607,6 +6503,7 @@ snapshots: tar: 7.5.13 temp-file: 3.4.0 tiny-async-pool: 1.3.0 + unzipper: 0.12.3 which: 5.0.0 transitivePeerDependencies: - supports-color @@ -6688,6 +6585,12 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + assert-plus@1.0.0: {} ast-types-flow@0.0.8: {} @@ -6719,6 +6622,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws4@1.13.2: {} + axe-core@4.11.3: {} axobject-query@4.1.0: {} @@ -6750,11 +6655,7 @@ snapshots: file-uri-to-path: 1.0.0 optional: true - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 + bluebird@3.7.2: {} boolean@3.2.0: optional: true @@ -6795,19 +6696,17 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 - builder-util-runtime@9.6.0: + builder-util-runtime@9.7.0: dependencies: debug: 4.4.3 sax: 1.6.0 transitivePeerDependencies: - supports-color - builder-util@26.9.0: + builder-util@26.15.3: dependencies: - 7zip-bin: 5.2.0 '@types/debug': 4.1.13 - app-builder-bin: 5.0.0-alpha.12 - builder-util-runtime: 9.6.0 + builder-util-runtime: 9.7.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -6827,22 +6726,9 @@ snapshots: dependencies: run-applescript: 7.1.0 - cac@6.7.14: {} + bytestreamjs@2.0.1: {} - cacache@19.0.1: - dependencies: - '@npmcli/fs': 4.0.0 - fs-minipass: 3.0.3 - glob: 10.5.0 - lru-cache: 10.4.3 - minipass: 7.1.3 - minipass-collect: 2.0.1 - minipass-flush: 1.0.7 - minipass-pipeline: 1.2.4 - p-map: 7.0.4 - ssri: 12.0.0 - tar: 7.5.13 - unique-filename: 4.0.0 + cac@6.7.14: {} cacheable-lookup@5.0.4: {} @@ -6940,10 +6826,6 @@ snapshots: classnames@2.5.1: {} - cli-cursor@3.1.0: - dependencies: - restore-cursor: 3.1.0 - cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -6957,8 +6839,6 @@ snapshots: parse5-htmlparser2-tree-adapter: 6.0.1 yargs: 16.2.0 - cli-spinners@2.9.2: {} - cli-truncate@2.1.0: dependencies: slice-ansi: 3.0.0 @@ -6989,8 +6869,6 @@ snapshots: dependencies: mimic-response: 1.0.1 - clone@1.0.4: {} - color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -7119,10 +6997,6 @@ snapshots: bundle-name: 4.1.0 default-browser-id: 5.0.1 - defaults@1.0.4: - dependencies: - clone: 1.0.4 - defer-to-connect@2.0.1: {} define-data-property@1.1.4: @@ -7147,7 +7021,8 @@ snapshots: dequal@2.0.3: {} - detect-libc@2.1.2: {} + detect-libc@2.1.2: + optional: true detect-node@2.1.0: optional: true @@ -7188,15 +7063,12 @@ snapshots: dlv@1.1.3: {} - dmg-builder@26.9.0(electron-builder-squirrel-windows@26.9.0): + dmg-builder@26.15.3(electron-builder-squirrel-windows@26.15.3): dependencies: - app-builder-lib: 26.9.0(dmg-builder@26.9.0)(electron-builder-squirrel-windows@26.9.0) - builder-util: 26.9.0 + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 fs-extra: 10.1.0 - iconv-lite: 0.6.3 js-yaml: 4.1.1 - optionalDependencies: - dmg-license: 1.0.11 transitivePeerDependencies: - electron-builder-squirrel-windows - supports-color @@ -7228,7 +7100,9 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - eastasianwidth@0.2.0: {} + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 ebnf-parser@0.1.10: {} @@ -7238,23 +7112,23 @@ snapshots: dependencies: jake: 10.9.4 - electron-builder-squirrel-windows@26.9.0(dmg-builder@26.9.0): + electron-builder-squirrel-windows@26.15.3(dmg-builder@26.15.3): dependencies: - app-builder-lib: 26.9.0(dmg-builder@26.9.0)(electron-builder-squirrel-windows@26.9.0) - builder-util: 26.9.0 + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 electron-winstaller: 5.4.0 transitivePeerDependencies: - dmg-builder - supports-color - electron-builder@26.9.0(electron-builder-squirrel-windows@26.9.0): + electron-builder@26.15.3(electron-builder-squirrel-windows@26.15.3): dependencies: - app-builder-lib: 26.9.0(dmg-builder@26.9.0)(electron-builder-squirrel-windows@26.9.0) - builder-util: 26.9.0 - builder-util-runtime: 9.6.0 + app-builder-lib: 26.15.3(dmg-builder@26.15.3)(electron-builder-squirrel-windows@26.15.3) + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 chalk: 4.1.2 ci-info: 4.4.0 - dmg-builder: 26.9.0(electron-builder-squirrel-windows@26.9.0) + dmg-builder: 26.15.3(electron-builder-squirrel-windows@26.15.3) fs-extra: 10.1.0 lazy-val: 1.0.5 simple-update-notifier: 2.0.0 @@ -7263,11 +7137,12 @@ snapshots: - electron-builder-squirrel-windows - supports-color - electron-publish@26.9.0: + electron-publish@26.15.3: dependencies: '@types/fs-extra': 9.0.13 - builder-util: 26.9.0 - builder-util-runtime: 9.6.0 + aws4: 1.13.2 + builder-util: 26.15.3 + builder-util-runtime: 9.7.0 chalk: 4.1.2 form-data: 4.0.5 fs-extra: 10.1.0 @@ -7824,11 +7699,6 @@ snapshots: dependencies: is-callable: 1.2.7 - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - form-data-encoder@2.1.4: {} form-data@4.0.5: @@ -7872,10 +7742,6 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 - fs-minipass@3.0.3: - dependencies: - minipass: 7.1.3 - fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -7946,15 +7812,6 @@ snapshots: dependencies: is-glob: 4.0.3 - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.9 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -8149,6 +8006,7 @@ snapshots: iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 + optional: true ieee754@1.2.1: {} @@ -8182,8 +8040,6 @@ snapshots: intl-pluralrules@2.0.1: {} - ip-address@10.1.0: {} - ip-num@1.5.2: {} is-alphabetical@2.0.1: {} @@ -8286,8 +8142,6 @@ snapshots: dependencies: is-docker: 3.0.0 - is-interactive@1.0.0: {} - is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -8333,8 +8187,6 @@ snapshots: dependencies: which-typed-array: 1.1.20 - is-unicode-supported@0.1.0: {} - is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -8354,6 +8206,8 @@ snapshots: dependencies: is-inside-container: 1.0.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isbinaryfile@4.0.10: {} @@ -8364,6 +8218,8 @@ snapshots: isexe@3.1.5: {} + isexe@4.0.0: {} + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -8373,12 +8229,6 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - jake@10.9.4: dependencies: async: 3.2.6 @@ -8500,11 +8350,6 @@ snapshots: lodash@4.18.1: {} - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 - log-update@6.1.0: dependencies: ansi-escapes: 7.3.0 @@ -8547,22 +8392,6 @@ snapshots: make-error@1.3.6: {} - make-fetch-happen@14.0.3: - dependencies: - '@npmcli/agent': 3.0.0 - cacache: 19.0.1 - http-cache-semantics: 4.2.0 - minipass: 7.1.3 - minipass-fetch: 4.0.1 - minipass-flush: 1.0.7 - minipass-pipeline: 1.2.4 - negotiator: 1.0.0 - proc-log: 5.0.0 - promise-retry: 2.0.1 - ssri: 12.0.0 - transitivePeerDependencies: - - supports-color - markdown-table@3.0.4: {} matcher@3.0.0: @@ -8975,34 +8804,6 @@ snapshots: minimist@1.2.8: {} - minipass-collect@2.0.1: - dependencies: - minipass: 7.1.3 - - minipass-fetch@4.0.1: - dependencies: - minipass: 7.1.3 - minipass-sized: 1.0.3 - minizlib: 3.1.0 - optionalDependencies: - encoding: 0.1.13 - - minipass-flush@1.0.7: - dependencies: - minipass: 3.3.6 - - minipass-pipeline@1.2.4: - dependencies: - minipass: 3.3.6 - - minipass-sized@1.0.3: - dependencies: - minipass: 3.3.6 - - minipass@3.3.6: - dependencies: - yallist: 4.0.0 - minipass@4.2.8: {} minipass@7.1.3: {} @@ -9029,8 +8830,6 @@ snapshots: natural-compare@1.4.0: {} - negotiator@1.0.0: {} - node-abi@4.28.0: dependencies: semver: 7.7.4 @@ -9061,20 +8860,20 @@ snapshots: optionalDependencies: encoding: 0.1.13 - node-gyp@11.5.0: + node-gyp@12.4.0: dependencies: env-paths: 2.2.1 exponential-backoff: 3.1.3 graceful-fs: 4.2.11 - make-fetch-happen: 14.0.3 - nopt: 8.1.0 - proc-log: 5.0.0 + nopt: 9.0.0 + proc-log: 6.1.0 semver: 7.7.4 tar: 7.5.13 tinyglobby: 0.2.16 - which: 5.0.0 - transitivePeerDependencies: - - supports-color + undici: 6.27.0 + which: 6.0.1 + + node-int64@0.4.0: {} node-readfiles@0.2.0: dependencies: @@ -9082,9 +8881,9 @@ snapshots: node-releases@2.0.37: {} - nopt@8.1.0: + nopt@9.0.0: dependencies: - abbrev: 3.0.1 + abbrev: 4.0.0 normalize-path@3.0.0: {} @@ -9228,18 +9027,6 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - ora@5.4.1: - dependencies: - bl: 4.1.0 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-spinners: 2.9.2 - is-interactive: 1.0.0 - is-unicode-supported: 0.1.0 - log-symbols: 4.1.0 - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -9266,12 +9053,8 @@ snapshots: dependencies: p-limit: 3.1.0 - p-map@7.0.4: {} - p-try@2.2.0: {} - package-json-from-dist@1.0.1: {} - parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -9379,6 +9162,15 @@ snapshots: dependencies: find-up: 3.0.0 + pkijs@3.4.0: + dependencies: + '@noble/hashes': 1.4.0 + asn1js: 3.0.10 + bytestreamjs: 2.0.1 + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.12 @@ -9443,7 +9235,9 @@ snapshots: prettier@3.8.3: {} - proc-log@5.0.0: {} + proc-log@6.1.0: {} + + process-nextick-args@2.0.1: {} process-warning@5.0.0: {} @@ -9484,6 +9278,12 @@ snapshots: punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -9586,10 +9386,14 @@ snapshots: dependencies: pify: 2.3.0 - readable-stream@3.6.2: + readable-stream@2.3.8: dependencies: + core-util-is: 1.0.2 inherits: 2.0.4 - string_decoder: 1.3.0 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 util-deprecate: 1.0.2 readdirp@3.6.0: @@ -9702,11 +9506,6 @@ snapshots: dependencies: lowercase-keys: 3.0.0 - restore-cursor@3.1.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -9786,7 +9585,7 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 - safe-buffer@5.2.1: {} + safe-buffer@5.1.2: {} safe-push-apply@1.0.0: dependencies: @@ -9801,7 +9600,8 @@ snapshots: safe-stable-stringify@2.5.0: {} - safer-buffer@2.1.2: {} + safer-buffer@2.1.2: + optional: true sanitize-filename@1.6.4: dependencies: @@ -9951,19 +9751,6 @@ snapshots: smart-buffer@4.2.0: {} - socks-proxy-agent@8.0.5: - dependencies: - agent-base: 7.1.4 - debug: 4.4.3 - socks: 2.8.7 - transitivePeerDependencies: - - supports-color - - socks@2.8.7: - dependencies: - ip-address: 10.1.0 - smart-buffer: 4.2.0 - sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -10009,10 +9796,6 @@ snapshots: sprintf-js@1.1.3: optional: true - ssri@12.0.0: - dependencies: - minipass: 7.1.3 - stable-hash@0.0.5: {} stat-mode@1.0.0: {} @@ -10030,12 +9813,6 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.2.0 - string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -10092,9 +9869,9 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 - string_decoder@1.3.0: + string_decoder@1.1.1: dependencies: - safe-buffer: 5.2.1 + safe-buffer: 5.1.2 stringify-entities@4.0.4: dependencies: @@ -10370,6 +10147,8 @@ snapshots: undici@6.24.1: {} + undici@6.27.0: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -10380,14 +10159,6 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 - unique-filename@4.0.0: - dependencies: - unique-slug: 5.0.0 - - unique-slug@5.0.0: - dependencies: - imurmurhash: 0.1.4 - unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -10448,6 +10219,14 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + unzipper@0.12.3: + dependencies: + bluebird: 3.7.2 + duplexer2: 0.1.4 + fs-extra: 11.3.4 + graceful-fs: 4.2.11 + node-int64: 0.4.0 + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -10498,9 +10277,13 @@ snapshots: dependencies: loose-envify: 1.4.0 - wcwidth@1.0.1: + webcrypto-core@1.9.2: dependencies: - defaults: 1.0.4 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 webidl-conversions@3.0.1: {} @@ -10562,6 +10345,10 @@ snapshots: dependencies: isexe: 3.1.5 + which@6.0.1: + dependencies: + isexe: 4.0.0 + word-wrap@1.2.5: {} wrap-ansi@7.0.0: @@ -10570,12 +10357,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.2.0 - wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 From 29968587706cafc65ace02e7d59f82665e9c6bfa Mon Sep 17 00:00:00 2001 From: Sapphire Date: Thu, 18 Jun 2026 00:49:59 -0500 Subject: [PATCH 173/350] Fix JAVA_HOME to make IntelliJ happy --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index b9144f07b5..2806c0fa38 100644 --- a/flake.nix +++ b/flake.nix @@ -145,7 +145,7 @@ ]; buildInputs = runtimeLibs; - JAVA_HOME = "${java}"; + JAVA_HOME = "${java}/lib/openjdk"; USE_SYSTEM_FPM = "true"; ELECTRON_BUILDER_7ZIP_PATH = "${pkgs.p7zip}/bin/7za"; APPIMAGE_TOOLS_PATH = "${appImageTools}"; From 33d84f19f1b7ae869ee804953fa652d89cca9b31 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Thu, 18 Jun 2026 20:55:04 -0500 Subject: [PATCH 174/350] Fix HID in flake devshell --- flake.nix | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 2806c0fa38..086bfd68e0 100644 --- a/flake.nix +++ b/flake.nix @@ -23,7 +23,12 @@ ]; perSystem = - { system, pkgs, ... }: + { + system, + lib, + pkgs, + ... + }: let java = (import nixpkgs-jdk24 { inherit system; }).jdk24; @@ -155,6 +160,15 @@ # for electron-vite, so `pnpm gui` works ELECTRON_EXEC_PATH = "${pkgs.electron}/bin/electron"; + + shellHook = '' + export LD_LIBRARY_PATH="${ + lib.makeLibraryPath [ + pkgs.systemd + pkgs.hidapi + ] + }:$LD_LIBRARY_PATH" + ''; }; }; }; From 71391e7f5a3c7fa62406aa921c040b8754357a69 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 19 Jun 2026 02:08:45 -0500 Subject: [PATCH 175/350] Sync .github and bindings-provider with main --- .github/CODEOWNERS | 9 +- .github/workflows/build.yml | 115 +++++-- .../workflows/generate-update-manifest.yml | 4 +- bindings-provider/.clang-format | 297 ++++++++++++++++++ bindings-provider/.gitignore | 12 + bindings-provider/CMakeLists.txt | 78 +++++ bindings-provider/openvr | 1 + bindings-provider/src/logger.cpp | 21 ++ bindings-provider/src/logger.hpp | 77 +++++ bindings-provider/src/main.cpp | 288 +++++++++++++++++ bindings-provider/src/paths.cpp | 33 ++ bindings-provider/src/paths.hpp | 11 + .../src/resources/action_manifest.json | 64 ++++ bindings-provider/src/resources/generic.json | 12 + bindings-provider/src/resources/knuckles.json | 12 + .../src/resources/manifest.vrmanifest | 14 + .../src/resources/oculus_touch.json | 12 + .../src/resources/resources.hpp.in | 8 + .../src/resources/vive_controller.json | 12 + bindings-provider/src/solarxr.cpp | 163 ++++++++++ bindings-provider/src/solarxr.hpp | 31 ++ bindings-provider/src/vr_utils.cpp | 72 +++++ bindings-provider/src/vr_utils.hpp | 16 + 23 files changed, 1336 insertions(+), 26 deletions(-) create mode 100644 bindings-provider/.clang-format create mode 100644 bindings-provider/.gitignore create mode 100644 bindings-provider/CMakeLists.txt create mode 160000 bindings-provider/openvr create mode 100644 bindings-provider/src/logger.cpp create mode 100644 bindings-provider/src/logger.hpp create mode 100644 bindings-provider/src/main.cpp create mode 100644 bindings-provider/src/paths.cpp create mode 100644 bindings-provider/src/paths.hpp create mode 100644 bindings-provider/src/resources/action_manifest.json create mode 100644 bindings-provider/src/resources/generic.json create mode 100644 bindings-provider/src/resources/knuckles.json create mode 100644 bindings-provider/src/resources/manifest.vrmanifest create mode 100644 bindings-provider/src/resources/oculus_touch.json create mode 100644 bindings-provider/src/resources/resources.hpp.in create mode 100644 bindings-provider/src/resources/vive_controller.json create mode 100644 bindings-provider/src/solarxr.cpp create mode 100644 bindings-provider/src/solarxr.hpp create mode 100644 bindings-provider/src/vr_utils.cpp create mode 100644 bindings-provider/src/vr_utils.hpp diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 970b297dc9..27eb2dcbb6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,3 @@ -# Global code owner -* @Eirenliel - # Make everyone be able to approve SolarXR submodule changes /solarxr-protocol @ButterscotchV @Erimelowo @loucass003 @@ -16,11 +13,11 @@ /gui/src/components/settings/ @Erimelowo @loucass003 -# Rust part of the GUI +# Electron part of the GUI /gui/electron/ @loucass003 # Some server code~ -/server/ @ButterscotchV @Eirenliel @Erimelowo +/server/ @ButterscotchV @loucass003 @Erimelowo /server/src/main/java/dev/slimevr/autobone/ @ButterscotchV /server/src/main/java/dev/slimevr/poserecorder/ @ButterscotchV @@ -30,6 +27,8 @@ /server/src/main/java/dev/slimevr/tracking/processor/ @Erimelowo /server/src/main/java/dev/slimevr/filtering/ @Erimelowo +/bindings-provider/ @ImSapphire + # Linux files *.nix @loucass003 /flake.lock @loucass003 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d731430967..a54aca04b6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,6 +13,7 @@ jobs: runs-on: ubuntu-latest outputs: matrix: ${{ steps.set-matrix.outputs.matrix }} + matrix-cpp: ${{ steps.set-matrix.outputs.matrix-cpp }} steps: - id: set-matrix shell: bash @@ -25,7 +26,44 @@ jobs: else RESULT=$(echo "$BASE" | jq -c '.') fi + RESULT_CPP=$(echo "$RESULT" | jq -c '[ .[] | select ( (.platform != "macos") and ( (.platform != "windows") or (.os | endswith("-arm") | not) ) ) ]') + echo "matrix={\"include\":$RESULT}" >> "$GITHUB_OUTPUT" + echo "matrix-cpp={\"include\":$RESULT_CPP}" >> "$GITHUB_OUTPUT" + + build-bindings-provider: + name: Build bindings provider for ${{ matrix.os }} + needs: [setup-matrix] + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.setup-matrix.outputs.matrix-cpp) }} + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + - if: matrix.platform == 'linux' + name: Install GCC + uses: egor-tensin/setup-gcc@v2 + with: + version: 16 + - name: Get CMake + uses: lukka/get-cmake@latest + - name: Configure + run: cmake -S bindings-provider/ -B bindings-provider/build -DCMAKE_BUILD_TYPE=Release + - name: Build + run: cmake --build bindings-provider/build --config Release -j6 + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: bindings-provider-${{ matrix.platform }}-${{ matrix.os }} + path: | + bindings-provider/build/slimevr-bindings-provider + bindings-provider/build/libopenvr_api.so + bindings-provider/build/Release/SlimeVR-Bindings-Provider.exe + bindings-provider/build/openvr_api.dll + gui-checks: name: Gui Checks runs-on: ubuntu-latest @@ -34,8 +72,10 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive + - name: Get tags + run: git fetch --tags origin --recurse-submodules=no --force - name: Setup PNPM - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 - name: Setup Node uses: actions/setup-node@v6 with: @@ -58,7 +98,7 @@ jobs: java-version: '17' distribution: 'adopt' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 + uses: gradle/actions/setup-gradle@v6 - name: Server Spotless Check run: ./gradlew spotlessCheck --build-cache - name: Server Detekt Lint @@ -73,19 +113,21 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive + - name: Get tags + run: git fetch --tags origin --recurse-submodules=no --force - name: Set up JDK 17 uses: actions/setup-java@v5 with: java-version: '17' distribution: 'adopt' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 + uses: gradle/actions/setup-gradle@v6 - name: Build ShadowJar run: ./gradlew :server:desktop:shadowJar --build-cache - name: Test with Gradle run: ./gradlew :server:desktop:test - name: Upload Server Jar - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: server-jar path: | @@ -100,8 +142,10 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive + - name: Get tags + run: git fetch --tags origin --recurse-submodules=no --force - name: Setup PNPM - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 - name: Setup Node uses: actions/setup-node@v6 with: @@ -114,14 +158,14 @@ jobs: - name: Tar GUI Dist run: tar -czf slimevr-gui-dist.tar.gz -C gui/out . - name: Upload GUI Dist - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: gui-dist path: slimevr-gui-dist.tar.gz package-desktop: name: Package Desktop (${{ matrix.platform }} - ${{ matrix.os }}) - needs: [setup-matrix, build-server-jar, build-gui-frontend] + needs: [setup-matrix, build-server-jar, build-gui-frontend, build-bindings-provider] strategy: fail-fast: false matrix: ${{ fromJson(needs.setup-matrix.outputs.matrix) }} @@ -131,20 +175,28 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive + - name: Get tags + run: git fetch --tags origin --recurse-submodules=no --force - name: Setup PNPM - uses: pnpm/action-setup@v4 + uses: pnpm/action-setup@v6 - name: Setup Node uses: actions/setup-node@v6 with: node-version-file: '.node-version' cache: 'pnpm' - name: Download Server Jar - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v8 with: name: server-jar path: server + - if: contains(fromJson(needs.setup-matrix.outputs.matrix-cpp).include.*.os, matrix.os) + name: Download bindings provider artifact + uses: actions/download-artifact@v8 + with: + name: bindings-provider-${{ matrix.platform }}-${{ matrix.os }} + path: bindings-provider/build/${{ matrix.platform == 'windows' && 'win' || 'linux' }}-${{ endsWith(matrix.os, '-arm') && 'arm64' || 'x64' }} - name: Download GUI Dist - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v8 with: name: gui-dist - name: Extract GUI for Electron @@ -158,10 +210,15 @@ jobs: mkdir -p gui/dist/artifacts/linux/ \ gui/dist/artifacts/win \ gui/dist/artifacts/mac - cd gui + VERSION=$(git --no-pager tag --sort -taggerdate --points-at HEAD | head -1) + [ -z "$VERSION" ] && VERSION="0.0.0" + VERSION="${VERSION#v}" + echo "Building version: $VERSION" pnpm i + cd gui pnpm exec electron-builder --${{ matrix.platform }} \ ${{ matrix.platform == 'macos' && '--universal' || '' }} \ + --config.extraMetadata.version="$VERSION" \ --publish never - name: Collect and Rename Artifacts shell: bash @@ -189,7 +246,7 @@ jobs: echo "Collected files:" ls -lh "$DEST_DIR" - name: Upload For Release - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: release-${{ matrix.platform }}-${{ matrix.os }} path: release-out/* @@ -203,15 +260,17 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive + - name: Get tags + run: git fetch --tags origin --recurse-submodules=no --force - name: Set up JDK 17 uses: actions/setup-java@v5 with: java-version: '17' distribution: 'adopt' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 + uses: gradle/actions/setup-gradle@v6 - name: Download GUI Dist - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v8 with: name: gui-dist - name: Extract GUI for Android @@ -228,11 +287,29 @@ jobs: - name: Prepare APK run: cp server/android/build/outputs/apk/release/*.apk ./SlimeVR-android.apk - name: Upload APK - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: release-android path: SlimeVR-android.apk + - name: Build Google Play release bundle + if: startsWith(github.ref, 'refs/tags/') + run: ./gradlew :server:android:bundleRelease + env: + ANDROID_STORE_FILE: ${{ secrets.ANDROID_GPLAY_STORE_FILE }} + ANDROID_STORE_PASSWD: ${{ secrets.ANDROID_GPLAY_STORE_PASSWD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_GPLAY_KEY_ALIAS }} + ANDROID_KEY_PASSWD: ${{ secrets.ANDROID_GPLAY_KEY_PASSWD }} + + - name: Upload the Google Play artifact + uses: actions/upload-artifact@v7 + if: startsWith(github.ref, 'refs/tags/') + with: + # Artifact name + name: 'SlimeVR-Android-GPDev' # optional, default is artifact + # A file, directory or wildcard pattern that describes what to upload + path: server/android/build/outputs/bundle/release/* + create-release: name: Finalize Release Draft needs: [package-desktop, bundle-android, build-server-jar, build-gui-frontend] @@ -240,22 +317,22 @@ jobs: runs-on: ubuntu-latest steps: - name: Download All Release Artifacts - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v8 with: pattern: release-* path: release-out merge-multiple: true - name: Download Server Jar - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v8 with: name: server-jar path: server - name: Download GUI Dist - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v8 with: name: gui-dist - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: draft: true generate_release_notes: true diff --git a/.github/workflows/generate-update-manifest.yml b/.github/workflows/generate-update-manifest.yml index 5e7c332345..310f273930 100644 --- a/.github/workflows/generate-update-manifest.yml +++ b/.github/workflows/generate-update-manifest.yml @@ -20,13 +20,13 @@ jobs: run: | npx @slimevr/update-manifest-generator@latest - - uses: actions/upload-artifact@v6 + - uses: actions/upload-artifact@v7 with: name: "update-manifest.json" path: ./update-manifest.json - name: Upload update-manifest.json to release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ secrets.UPDATE_MANIFEST_RELEASE_TAG }} files: ./update-manifest.json diff --git a/bindings-provider/.clang-format b/bindings-provider/.clang-format new file mode 100644 index 0000000000..479a6b9c38 --- /dev/null +++ b/bindings-provider/.clang-format @@ -0,0 +1,297 @@ +--- +Language: Cpp +AccessModifierOffset: -4 +AlignAfterOpenBracket: Align +AlignArrayOfStructures: None +AlignConsecutiveAssignments: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + AlignFunctionDeclarations: false + AlignFunctionPointers: false + PadOperators: true +AlignConsecutiveBitFields: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + AlignFunctionDeclarations: false + AlignFunctionPointers: false + PadOperators: false +AlignConsecutiveDeclarations: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + AlignFunctionDeclarations: true + AlignFunctionPointers: false + PadOperators: false +AlignConsecutiveMacros: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + AlignFunctionDeclarations: false + AlignFunctionPointers: false + PadOperators: false +AlignConsecutiveShortCaseStatements: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCaseArrows: false + AlignCaseColons: false +AlignConsecutiveTableGenBreakingDAGArgColons: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + AlignFunctionDeclarations: false + AlignFunctionPointers: false + PadOperators: false +AlignConsecutiveTableGenCondOperatorColons: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + AlignFunctionDeclarations: false + AlignFunctionPointers: false + PadOperators: false +AlignConsecutiveTableGenDefinitionColons: + Enabled: false + AcrossEmptyLines: false + AcrossComments: false + AlignCompound: false + AlignFunctionDeclarations: false + AlignFunctionPointers: false + PadOperators: false +AlignEscapedNewlines: Right +AlignOperands: DontAlign +AlignTrailingComments: + Kind: Never + OverEmptyLines: 0 +AllowAllArgumentsOnNextLine: true +AllowAllParametersOfDeclarationOnNextLine: true +AllowBreakBeforeNoexceptSpecifier: Never +AllowShortBlocksOnASingleLine: Empty +AllowShortCaseExpressionOnASingleLine: true +AllowShortCaseLabelsOnASingleLine: false +AllowShortCompoundRequirementOnASingleLine: true +AllowShortEnumsOnASingleLine: true +AllowShortFunctionsOnASingleLine: All +AllowShortIfStatementsOnASingleLine: Never +AllowShortLambdasOnASingleLine: All +AllowShortLoopsOnASingleLine: false +AllowShortNamespacesOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakBeforeMultilineStrings: false +AttributeMacros: + - __capability +BinPackArguments: true +BinPackLongBracedList: true +BinPackParameters: BinPack +BitFieldColonSpacing: Both +BracedInitializerIndentWidth: -1 +BraceWrapping: + AfterCaseLabel: false + AfterClass: false + AfterControlStatement: Never + AfterEnum: false + AfterExternBlock: false + AfterFunction: true + AfterNamespace: false + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + BeforeCatch: false + BeforeElse: false + BeforeLambdaBody: false + BeforeWhile: false + IndentBraces: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true +BreakAdjacentStringLiterals: true +BreakAfterAttributes: Leave +BreakAfterJavaFieldAnnotations: false +BreakAfterReturnType: None +BreakArrays: true +BreakBeforeBinaryOperators: All +BreakBeforeConceptDeclarations: Always +BreakBeforeBraces: Attach +BreakBeforeInlineASMColon: OnlyMultiline +BreakBeforeTemplateCloser: false +BreakBeforeTernaryOperators: true +BreakBinaryOperations: Never +BreakConstructorInitializers: BeforeComma +BreakFunctionDefinitionParameters: false +BreakInheritanceList: BeforeColon +BreakStringLiterals: true +BreakTemplateDeclarations: MultiLine +ColumnLimit: 0 +CommentPragmas: '^ IWYU pragma:' +CompactNamespaces: false +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: false +DerivePointerAlignment: false +DisableFormat: false +EmptyLineAfterAccessModifier: Never +EmptyLineBeforeAccessModifier: LogicalBlock +EnumTrailingComma: Leave +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: false +ForEachMacros: + - foreach + - Q_FOREACH + - BOOST_FOREACH +IfMacros: + - KJ_IF_MAYBE +IncludeBlocks: Preserve +IncludeCategories: + - Regex: '^"(llvm|llvm-c|clang|clang-c)/' + Priority: 2 + SortPriority: 0 + CaseSensitive: false + - Regex: '^(<|"(gtest|gmock|isl|json)/)' + Priority: 3 + SortPriority: 0 + CaseSensitive: false + - Regex: '.*' + Priority: 1 + SortPriority: 0 + CaseSensitive: false +IncludeIsMainRegex: '(Test)?$' +IncludeIsMainSourceRegex: '' +IndentAccessModifiers: false +IndentCaseBlocks: false +IndentCaseLabels: false +IndentExportBlock: true +IndentExternBlock: AfterExternBlock +IndentGotoLabels: true +IndentPPDirectives: None +IndentRequiresClause: true +IndentWidth: 4 +IndentWrappedFunctionNames: false +InsertBraces: false +InsertNewlineAtEOF: false +InsertTrailingCommas: None +IntegerLiteralSeparator: + Binary: 0 + BinaryMinDigits: 0 + Decimal: 0 + DecimalMinDigits: 0 + Hex: 0 + HexMinDigits: 0 +JavaScriptQuotes: Leave +JavaScriptWrapImports: true +KeepEmptyLines: + AtEndOfFile: false + AtStartOfBlock: true + AtStartOfFile: true +KeepFormFeed: false +LambdaBodyIndentation: Signature +LineEnding: DeriveLF +MacroBlockBegin: '' +MacroBlockEnd: '' +MainIncludeChar: Quote +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: Inner +ObjCBinPackProtocolList: Auto +ObjCBlockIndentWidth: 4 +ObjCBreakBeforeNestedBlockParam: true +ObjCSpaceAfterProperty: true +ObjCSpaceBeforeProtocolList: true +OneLineFormatOffRegex: '' +PackConstructorInitializers: BinPack +PenaltyBreakAssignment: 2 +PenaltyBreakBeforeFirstCallParameter: 19 +PenaltyBreakBeforeMemberAccess: 150 +PenaltyBreakComment: 300 +PenaltyBreakFirstLessLess: 120 +PenaltyBreakOpenParenthesis: 0 +PenaltyBreakScopeResolution: 500 +PenaltyBreakString: 1000 +PenaltyBreakTemplateDeclaration: 10 +PenaltyExcessCharacter: 1000000 +PenaltyIndentedWhitespace: 0 +PenaltyReturnTypeOnItsOwnLine: 60 +PointerAlignment: Right +PPIndentWidth: -1 +QualifierAlignment: Leave +ReferenceAlignment: Pointer +ReflowComments: Always +RemoveBracesLLVM: false +RemoveEmptyLinesInUnwrappedLines: false +RemoveParentheses: Leave +RemoveSemicolon: false +RequiresClausePosition: OwnLine +RequiresExpressionIndentation: OuterScope +SeparateDefinitionBlocks: Leave +ShortNamespaceLines: 1 +SkipMacroDefinitionBody: false +SortIncludes: + Enabled: true + IgnoreCase: false +SortJavaStaticImport: Before +SortUsingDeclarations: LexicographicNumeric +SpaceAfterCStyleCast: false +SpaceAfterLogicalNot: false +SpaceAfterOperatorKeyword: false +SpaceAfterTemplateKeyword: true +SpaceAroundPointerQualifiers: Default +SpaceBeforeAssignmentOperators: true +SpaceBeforeCaseColon: false +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeJsonColon: false +SpaceBeforeParens: ControlStatements +SpaceBeforeParensOptions: + AfterControlStatements: true + AfterForeachMacros: true + AfterFunctionDefinitionName: false + AfterFunctionDeclarationName: false + AfterIfMacros: true + AfterNot: false + AfterOverloadedOperator: false + AfterPlacementOperator: true + AfterRequiresInClause: false + AfterRequiresInExpression: false + BeforeNonEmptyParentheses: false +SpaceBeforeRangeBasedForLoopColon: true +SpaceBeforeSquareBrackets: false +SpaceInEmptyBlock: true +SpacesBeforeTrailingComments: 1 +SpacesInAngles: Never +SpacesInContainerLiterals: true +SpacesInLineCommentPrefix: + Minimum: 1 + Maximum: -1 +SpacesInParens: Never +SpacesInParensOptions: + ExceptDoubleParentheses: false + InCStyleCasts: false + InConditionalStatements: false + InEmptyParentheses: false + Other: false +SpacesInSquareBrackets: false +Standard: Latest +StatementAttributeLikeMacros: + - Q_EMIT +StatementMacros: + - Q_UNUSED + - QT_REQUIRE_VERSION +TableGenBreakInsideDAGArg: DontBreak +TabWidth: 8 +UseTab: Never +VerilogBreakBetweenInstancePorts: true +WhitespaceSensitiveMacros: + - BOOST_PP_STRINGIZE + - CF_SWIFT_NAME + - NS_SWIFT_NAME + - PP_STRINGIZE + - STRINGIZE +WrapNamespaceBodyWithEmptyLines: Leave +... + diff --git a/bindings-provider/.gitignore b/bindings-provider/.gitignore new file mode 100644 index 0000000000..5a0d18c3ef --- /dev/null +++ b/bindings-provider/.gitignore @@ -0,0 +1,12 @@ +# Garbage left behind on Windows + +.vs/ +out/ +CMakeFiles/ +vcpkg_installed/ +src/resources/resources.hpp +CMakeCache.txt +build.ninja +*.cmake +*.dll +*.log diff --git a/bindings-provider/CMakeLists.txt b/bindings-provider/CMakeLists.txt new file mode 100644 index 0000000000..76c8818b5e --- /dev/null +++ b/bindings-provider/CMakeLists.txt @@ -0,0 +1,78 @@ +cmake_minimum_required(VERSION 3.26) + +project(SlimeVR-Bindings-Provider) + +set(SOLARXR_PROTOCOL_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../solarxr-protocol") +set(FLATBUFFERS_GENERATED_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../solarxr-protocol/protocol/cpp") + +# These are used to determine the OpenVR library path +if(CMAKE_SIZEOF_VOID_P EQUAL 8) + set(PROCESSOR_ARCH "64") +else() + set(PROCESSOR_ARCH "32") +endif() +if(WIN32) + set(OPENVR_LIBRARY_NAME "openvr_api") + set(OPENVR_LIBRARY_EXT "dll") + set(PLATFORM_NAME "win") +elseif(APPLE) + set(OPENVR_LIBRARY_NAME "libopenvr_api") + set(OPENVR_LIBRARY_EXT "dylib") + set(PLATFORM_NAME "osx") +elseif(LINUX) + set(OPENVR_LIBRARY_NAME "libopenvr_api") + set(OPENVR_LIBRARY_EXT "so") + if (CMAKE_SYSTEM_PROCESSOR STREQUAL "aarch64") + set(PLATFORM_NAME "linuxarm") + elseif(CMAKE_SYSTEM_PROCESSOR STREQUAL "x86_64") + set(PLATFORM_NAME "linux") + else() + message(FATAL_ERROR "Unknown/unsupported CPU architecture") + endif() +else() + message(FATAL_ERROR "Unknown/unsupported platform") +endif() + +set(OPENVR_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/openvr") + +message(STATUS "OpenVR library search path: ${OPENVR_ROOT}/lib/${PLATFORM_NAME}${PROCESSOR_ARCH}") +find_library(OPENVR_LIBRARY NAMES openvr_api REQUIRED PATHS "${OPENVR_ROOT}/lib/${PLATFORM_NAME}${PROCESSOR_ARCH}/" NO_DEFAULT_PATH) +message(STATUS "Found OpenVR library at: ${OPENVR_LIBRARY}") + +add_executable("${PROJECT_NAME}" + "${CMAKE_CURRENT_SOURCE_DIR}/src/paths.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/logger.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/solarxr.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/vr_utils.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp" +) +set_target_properties("${PROJECT_NAME}" PROPERTIES CXX_STANDARD 23) +target_include_directories("${PROJECT_NAME}" PRIVATE "${SOLARXR_PROTOCOL_ROOT}/lib/flatbuffers/include" "${SOLARXR_PROTOCOL_ROOT}/lib/flatbuffers/" "${FLATBUFFERS_GENERATED_ROOT}/include" "${CMAKE_CURRENT_SOURCE_DIR}/include" "${OPENVR_ROOT}/headers") +target_link_libraries("${PROJECT_NAME}" PRIVATE "${OPENVR_LIBRARY}") + +if(LINUX) + set_target_properties("${PROJECT_NAME}" PROPERTIES OUTPUT_NAME slimevr-bindings-provider) +endif() + +set(OPENVR_RUNTIME_LIBRARY "${OPENVR_ROOT}/bin/${PLATFORM_NAME}${PROCESSOR_ARCH}/${OPENVR_LIBRARY_NAME}.${OPENVR_LIBRARY_EXT}") +message(STATUS "Found OpenVR runtime library: ${OPENVR_RUNTIME_LIBRARY}") +configure_file("${OPENVR_RUNTIME_LIBRARY}" "${CMAKE_CURRENT_BINARY_DIR}/." COPYONLY) + +# Embed the resources into resources.hpp +set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS + src/resources/manifest.vrmanifest + src/resources/action_manifest.json + src/resources/generic.json + src/resources/knuckles.json + src/resources/oculus_touch.json + src/resources/vive_controller.json +) +file(STRINGS src/resources/manifest.vrmanifest SVR_VRAPP_MANIFEST NEWLINE_CONSUME) +file(STRINGS src/resources/action_manifest.json SVR_VRAPP_ACTION_MANIFEST NEWLINE_CONSUME) +file(STRINGS src/resources/generic.json SVR_VRAPP_GENERIC_BINDS NEWLINE_CONSUME) +file(STRINGS src/resources/knuckles.json SVR_VRAPP_KNUCKLES_BINDS NEWLINE_CONSUME) +file(STRINGS src/resources/oculus_touch.json SVR_VRAPP_OCULUS_BINDS NEWLINE_CONSUME) +file(STRINGS src/resources/vive_controller.json SVR_VRAPP_VIVE_BINDS NEWLINE_CONSUME) + +configure_file(src/resources/resources.hpp.in src/resources/resources.hpp @ONLY) +target_include_directories("${PROJECT_NAME}" PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/src") diff --git a/bindings-provider/openvr b/bindings-provider/openvr new file mode 160000 index 0000000000..9182530513 --- /dev/null +++ b/bindings-provider/openvr @@ -0,0 +1 @@ +Subproject commit 91825305130f446f82054c1ec3d416321ace0072 diff --git a/bindings-provider/src/logger.cpp b/bindings-provider/src/logger.cpp new file mode 100644 index 0000000000..91db4dc33b --- /dev/null +++ b/bindings-provider/src/logger.cpp @@ -0,0 +1,21 @@ +#include "logger.hpp" +#include "paths.hpp" + +#ifdef _WIN32 +#define WIN32_MEAN_AND_LEAN +#include +#endif + +Logger::Logger() + : log_stream(Paths::getLogPath() / "slimevr-bindings-provider.log", + std::ios::out | std::ios::app) +#ifdef _WIN32 + , should_log_to_std_streams(GetConsoleWindow() != nullptr) +#endif +{ +} + +Logger &Logger::get() { + static Logger logger; + return logger; +} diff --git a/bindings-provider/src/logger.hpp b/bindings-provider/src/logger.hpp new file mode 100644 index 0000000000..4d098d015b --- /dev/null +++ b/bindings-provider/src/logger.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +class Logger { +private: + std::ofstream log_stream; +#ifdef _WIN32 + bool should_log_to_std_streams; +#endif + + template + void log(std::string_view suffix, std::format_string fmt, + Args &&...args) { + auto now = std::chrono::system_clock::now(); + auto time_t = std::chrono::system_clock::to_time_t(now); + + auto s = std::format(fmt, std::forward(args)...); +#ifdef _WIN32 + if (should_log_to_std_streams) { +#endif + if constexpr (important) + std::cerr << suffix << ' ' << s << '\n'; + else + std::cout << suffix << ' ' << s << '\n'; +#ifdef _WIN32 + } +#endif + + log_stream << std::put_time(std::localtime(&time_t), "[%F %T]") // + << ' ' << suffix << ' ' << s << '\n'; + } + +public: + Logger(); + + template + void debug(std::format_string fmt, Args &&...args) { +#ifndef NDEBUG + log("[DEBUG]", fmt, std::forward(args)...); +#endif + } + + template + void info(std::format_string fmt, Args &&...args) { + log("[INFO]", fmt, std::forward(args)...); + } + + template + void warning(std::format_string fmt, Args &&...args) { + log("[WARN]", fmt, std::forward(args)...); + } + + template + void error(std::format_string fmt, Args &&...args) { + log("[ERROR]", fmt, std::forward(args)...); + } + + void flush() { +#ifdef _WIN32 + if (should_log_to_std_streams) { +#endif + std::cerr.flush(); + std::cout.flush(); +#ifdef _WIN32 + } +#endif + log_stream.flush(); + } + + static Logger &get(); +}; diff --git a/bindings-provider/src/main.cpp b/bindings-provider/src/main.cpp new file mode 100644 index 0000000000..bd3da0c67d --- /dev/null +++ b/bindings-provider/src/main.cpp @@ -0,0 +1,288 @@ +#include +#include +#include +#include +#include +#include + +// These must be included before Windows.h because of some macro collisions. +#include "flatbuffers/flatbuffers.h" +#include "solarxr_protocol/generated/all_generated.h" + +#include "logger.hpp" +#include "solarxr.hpp" +#include "vr_utils.hpp" + +#include "openvr.h" + +using namespace std::chrono_literals; +using namespace solarxr_protocol; + +static void shutdown_vr(vr::IVRSystem *_sys) { vr::VR_Shutdown(); } + +static void onYawReset(SolarXRConnection &conn) { + flatbuffers::FlatBufferBuilder fbb; + + auto resetReq = rpc::CreateResetRequest(fbb, rpc::ResetType::Yaw, 0, 0.f); + auto msgHeader = rpc::CreateRpcMessageHeader( + fbb, nullptr, rpc::RpcMessage::ResetRequest, resetReq.Union()); + + auto rpcMsgs = fbb.CreateVector({ msgHeader }); + auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs, 0); + fbb.Finish(bundle); + conn.sendMsg(fbb); +} +static void onFullReset(SolarXRConnection &conn) { + flatbuffers::FlatBufferBuilder fbb; + + auto resetReq = rpc::CreateResetRequest(fbb, rpc::ResetType::Full, 0, 0.f); + auto msgHeader = rpc::CreateRpcMessageHeader( + fbb, nullptr, rpc::RpcMessage::ResetRequest, resetReq.Union()); + + auto rpcMsgs = fbb.CreateVector({ msgHeader }); + auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs, 0); + fbb.Finish(bundle); + conn.sendMsg(fbb); +} +static void onMountingCalibration(SolarXRConnection &conn) { + flatbuffers::FlatBufferBuilder fbb; + + auto resetReq = rpc::CreateResetRequest(fbb, rpc::ResetType::Mounting, 0, 0.f); + auto msgHeader = rpc::CreateRpcMessageHeader( + fbb, nullptr, rpc::RpcMessage::ResetRequest, resetReq.Union()); + + auto rpcMsgs = fbb.CreateVector({ msgHeader }); + auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs, 0); + fbb.Finish(bundle); + conn.sendMsg(fbb); +} +static void onFeetMountingCalibration(SolarXRConnection &conn) { + flatbuffers::FlatBufferBuilder fbb; + + auto bodyParts = fbb.CreateVector( + { datatypes::BodyPart::LEFT_FOOT, datatypes::BodyPart::RIGHT_FOOT }); + auto resetReq = rpc::CreateResetRequest(fbb, rpc::ResetType::Mounting, bodyParts, 0.f); + auto msgHeader = rpc::CreateRpcMessageHeader( + fbb, nullptr, rpc::RpcMessage::ResetRequest, resetReq.Union()); + + auto rpcMsgs = fbb.CreateVector({ msgHeader }); + auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs, 0); + fbb.Finish(bundle); + conn.sendMsg(fbb); +} +static void onToggleTracking(SolarXRConnection &conn) { + static bool shouldPause = false; + flatbuffers::FlatBufferBuilder fbb; + + shouldPause = !shouldPause; + auto toggleReq = rpc::CreateSetPauseTrackingRequest(fbb, shouldPause); + auto msgHeader = rpc::CreateRpcMessageHeader( + fbb, nullptr, rpc::RpcMessage::SetPauseTrackingRequest, + toggleReq.Union()); + + auto rpcMsgs = fbb.CreateVector({ msgHeader }); + auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs, 0); + fbb.Finish(bundle); + conn.sendMsg(fbb); +} + +sig_atomic_t should_exit = 0; + +static void signal_handler(int signal) { + Logger::get().info("Received signal {}", signal); + should_exit = 1; +} + +int main() { + auto &logger = Logger::get(); + + try { + SolarXRConnection conn; + std::unique_ptr sys{ nullptr, + shutdown_vr }; + + // On vrlink, VR_Init returns + // VRInitError_Driver_WirelessHmdNotConnected while the connection + // is initialising, so keep calling VR_Init until it ends up + // succeeding + + vr::EVRInitError err{ vr::VRInitError_Driver_WirelessHmdNotConnected }; + while (err == vr::VRInitError_Driver_WirelessHmdNotConnected) { + sys.reset(vr::VR_Init(&err, vr::VRApplication_Background)); + if (err == vr::VRInitError_Driver_WirelessHmdNotConnected) + std::this_thread::sleep_for(500ms); + } + + if (sys == nullptr or err != vr::VRInitError_None) { + logger.error("Failed to init OpenVR: {} ({})", + vr::VR_GetVRInitErrorAsSymbol(err), + vr::VR_GetVRInitErrorAsEnglishDescription(err)); + return 1; + } + + logger.info("Initialised OpenVR, HMD model '{}'", + VRUtils::getStringProp(vr::k_unTrackedDeviceIndex_Hmd, + vr::Prop_ModelNumber_String) + .value_or("")); + + vr::IVRApplications *app = vr::VRApplications(); + vr::IVRInput *input = vr::VRInput(); + + auto [appManifestPath, actionManifestPath] = VRUtils::initialiseManifest(); + + if (auto err = app->AddApplicationManifest(appManifestPath.string().data(), true); + err != vr::VRApplicationError_None) { + logger.error("Failed to add application manifest: {}", + app->GetApplicationsErrorNameFromEnum(err)); + return 1; + } + + // SetActionManifestPath may return IPCError if vrserver is busy and takes + // too long to reply, so keep invoking until it succeeds + { + vr::EVRInputError err{}; + constexpr int max_tries = 5; + int tries = 0; + while ((err = input->SetActionManifestPath( + actionManifestPath.string().data())) + == vr::VRInputError_IPCError + && tries++ < max_tries) { + logger.debug("IPC error loading action manifest, retrying ({}/{})", + tries, max_tries); + std::this_thread::sleep_for(20ms); + } + + if (err != vr::VRInputError_None) { + logger.error("Failed to set action manifest path: {}", + std::to_underlying(err)); + return 1; + } + } + + vr::VRActionSetHandle_t action_set; + if (auto err = input->GetActionSetHandle("/actions/main", &action_set); + err != vr::VRInputError_None) { + logger.error("Failed to get main action set handle: {}", + std::to_underlying(err)); + return 1; + } + + std::map>> + actions{ + { "/actions/main/in/YawReset", + std::make_tuple(vr::k_ulInvalidActionHandle, onYawReset) }, + { "/actions/main/in/FullReset", + std::make_tuple(vr::k_ulInvalidActionHandle, onFullReset) }, + { "/actions/main/in/MountingCalibration", + std::make_tuple(vr::k_ulInvalidActionHandle, + onMountingCalibration) }, + { "/actions/main/in/FeetMountingCalibration", + std::make_tuple(vr::k_ulInvalidActionHandle, + onFeetMountingCalibration) }, + { "/actions/main/in/ToggleTracking", + std::make_tuple(vr::k_ulInvalidActionHandle, onToggleTracking) }, + }; + + for (auto &[name, tuple] : actions) { + auto &[handle, _] = tuple; + if (auto err = input->GetActionHandle(name.c_str(), &handle); + err != vr::VRInputError_None || handle == vr::k_ulInvalidActionHandle) { + logger.warning("Failed to get action handle for action {}: {}", name, + std::to_underlying(err)); + } + } + + constexpr auto interval = 1000ms / 30; + + signal(SIGINT, signal_handler); + signal(SIGTERM, signal_handler); + while (!should_exit) { + vr::VREvent_t event{}; + while (sys->PollNextEvent(&event, sizeof(event))) { + switch (event.eventType) { + case vr::VREvent_Quit: + logger.info("OpenVR runtime requested quit"); + should_exit = 1; + break; + case vr::VREvent_Input_BindingLoadFailed: { + auto &loadData = event.data.inputBinding; + logger.debug("Binding load failed (ulAppContainer={} " + "pathMessage={} pathUrl={} pathControllerType={})", + loadData.ulAppContainer, loadData.pathMessage, + loadData.pathUrl, loadData.pathControllerType); + break; + } + case vr::VREvent_Input_BindingLoadSuccessful: { + auto &loadData = event.data.inputBinding; + logger.debug("Binding load successful (ulAppContainer={} " + "pathMessage={} pathUrl={} pathControllerType={})", + loadData.ulAppContainer, loadData.pathMessage, + loadData.pathUrl, loadData.pathControllerType); + break; + } + case vr::VREvent_Input_ActionManifestReloaded: + logger.debug("Action manifest reloaded"); + break; + case vr::VREvent_Input_ActionManifestLoadFailed: { + auto &manifestData = event.data.actionManifest; + logger.debug( + "Action manifest load failed (pathAppKey={} pathMessage={} " + "pathMessageParam={} pathManifestPath={})", + manifestData.pathAppKey, manifestData.pathMessage, + manifestData.pathMessageParam, manifestData.pathManifestPath); + break; + } + default: + break; + } + } + + if (!conn.connected()) { + logger.warning("Connection to SlimeVR lost, exiting"); + break; + } + + vr::VRActiveActionSet_t set{ + .ulActionSet = action_set, + .ulRestrictedToDevice = vr::k_ulInvalidInputValueHandle, + .ulSecondaryActionSet = vr::k_ulInvalidActionSetHandle, + .nPriority = 0, + }; + if (auto err = input->UpdateActionState(&set, sizeof(set), 1); + err != vr::VRInputError_None) { + logger.warning("Error when updating action states: {}", + std::to_underlying(err)); + } + + for (auto &[name, tuple] : actions) { + auto &[handle, callback] = tuple; + vr::InputDigitalActionData_t action_data{}; + if (auto err = input->GetDigitalActionData( + handle, &action_data, sizeof(action_data), + vr::k_ulInvalidInputValueHandle); + err != vr::VRInputError_None) { + logger.warning("Failed to get action state for {} ({}): {}", name, + handle, std::to_underlying(err)); + continue; + } + + if (action_data.bActive && action_data.bChanged && action_data.bState) { + logger.debug("Action {} triggered", name); + callback(conn); + } + } + + logger.flush(); + std::this_thread::sleep_for(interval); + } + + logger.info("Main loop done"); + } catch (std::exception &ex) { + logger.error("Exception in main: {}", ex.what()); + logger.flush(); + return 1; + } + + logger.flush(); + return 0; +} diff --git a/bindings-provider/src/paths.cpp b/bindings-provider/src/paths.cpp new file mode 100644 index 0000000000..7f33f2974b --- /dev/null +++ b/bindings-provider/src/paths.cpp @@ -0,0 +1,33 @@ +#include "paths.hpp" + +namespace fs = std::filesystem; + +fs::path Paths::getDataPath() { + fs::path basePath{}; + +#if defined(__linux__) + if (const char *dataHomeOverride = getenv("XDG_DATA_HOME")) { + basePath = dataHomeOverride; + } else { + const char *homeDir = getenv("HOME"); + if (homeDir == nullptr) + throw std::runtime_error("HOME is unset"); + + basePath = fs::path(homeDir) / ".local" / "share"; + } +#elif defined(_WIN32) + { + const char *appData = getenv("APPDATA"); + if (appData == nullptr) + throw std::runtime_error("APPDATA is unset"); + + basePath = appData; + } +#else +#error "Unsupported platform" +#endif + + return basePath / "dev.slimevr.SlimeVR"; +} + +fs::path Paths::getLogPath() { return Paths::getDataPath() / "logs"; } diff --git a/bindings-provider/src/paths.hpp b/bindings-provider/src/paths.hpp new file mode 100644 index 0000000000..6ca2cf5ac0 --- /dev/null +++ b/bindings-provider/src/paths.hpp @@ -0,0 +1,11 @@ +#pragma once + +#include + +namespace Paths { +// Throws when path cannot be found +std::filesystem::path getDataPath() noexcept(false); + +// Throws when path cannot be found +std::filesystem::path getLogPath() noexcept(false); +} // namespace Paths diff --git a/bindings-provider/src/resources/action_manifest.json b/bindings-provider/src/resources/action_manifest.json new file mode 100644 index 0000000000..1fec4f4f4a --- /dev/null +++ b/bindings-provider/src/resources/action_manifest.json @@ -0,0 +1,64 @@ +{ + "default_bindings": [ + { + "controller_type": "generic", + "binding_url": "generic.json" + }, + { + "controller_type": "oculus_touch", + "binding_url": "oculus_touch.json" + }, + { + "controller_type": "knuckles", + "binding_url": "knuckles.json" + }, + { + "controller_type": "vive_controller", + "binding_url": "vive_controller.json" + } + ], + "actions": [ + { + "name": "/actions/main/in/YawReset", + "requirement": "optional", + "type": "boolean" + }, + { + "name": "/actions/main/in/FullReset", + "requirement": "optional", + "type": "boolean" + }, + { + "name": "/actions/main/in/MountingCalibration", + "requirement": "optional", + "type": "boolean" + }, + { + "name": "/actions/main/in/FeetMountingCalibration", + "requirement": "optional", + "type": "boolean" + }, + { + "name": "/actions/main/in/ToggleTracking", + "requirement": "optional", + "type": "boolean" + } + ], + "action_sets": [ + { + "name": "/actions/main", + "usage": "leftright" + } + ], + "localization": [ + { + "language_tag": "en_us", + "/actions/main": "Utility Bindings", + "/actions/main/in/YawReset": "Yaw Reset", + "/actions/main/in/FullReset": "Full Reset", + "/actions/main/in/MountingCalibration": "Mounting Calibration", + "/actions/main/in/FeetMountingCalibration": "Feet Mounting Calibration", + "/actions/main/in/ToggleTracking": "Toggle Tracking" + } + ] +} diff --git a/bindings-provider/src/resources/generic.json b/bindings-provider/src/resources/generic.json new file mode 100644 index 0000000000..5b50aac96f --- /dev/null +++ b/bindings-provider/src/resources/generic.json @@ -0,0 +1,12 @@ +{ + "name": "Default Bindings for Generic", + "controller_type": "generic", + "bindings": { + "/actions/main": { + "sources": [], + "skeleton": [], + "haptics": [], + "poses": [] + } + } +} diff --git a/bindings-provider/src/resources/knuckles.json b/bindings-provider/src/resources/knuckles.json new file mode 100644 index 0000000000..74e7a96a2f --- /dev/null +++ b/bindings-provider/src/resources/knuckles.json @@ -0,0 +1,12 @@ +{ + "name": "Default Bindings for Knuckles", + "controller_type": "knuckles", + "bindings": { + "/actions/main": { + "sources": [], + "skeleton": [], + "haptics": [], + "poses": [] + } + } +} \ No newline at end of file diff --git a/bindings-provider/src/resources/manifest.vrmanifest b/bindings-provider/src/resources/manifest.vrmanifest new file mode 100644 index 0000000000..e701796a15 --- /dev/null +++ b/bindings-provider/src/resources/manifest.vrmanifest @@ -0,0 +1,14 @@ +{ + "source": "builtin", + "applications": [{ + "app_key": "dev.slimevr.BindingsProvider", + "is_dashboard_overlay": false, + "action_manifest_path": "action_manifest.json", + "strings": { + "en_us": { + "name": "SlimeVR Bindings Provider", + "description": "SlimeVR Bindings Provider" + } + } + }] +} diff --git a/bindings-provider/src/resources/oculus_touch.json b/bindings-provider/src/resources/oculus_touch.json new file mode 100644 index 0000000000..ae0902e401 --- /dev/null +++ b/bindings-provider/src/resources/oculus_touch.json @@ -0,0 +1,12 @@ +{ + "name": "Default Bindings for Oculus Touch", + "controller_type": "oculus_touch", + "bindings": { + "/actions/main": { + "sources": [], + "skeleton": [], + "haptics": [], + "poses": [] + } + } +} \ No newline at end of file diff --git a/bindings-provider/src/resources/resources.hpp.in b/bindings-provider/src/resources/resources.hpp.in new file mode 100644 index 0000000000..f08f2edada --- /dev/null +++ b/bindings-provider/src/resources/resources.hpp.in @@ -0,0 +1,8 @@ +#pragma once + +#cmakedefine SVR_VRAPP_MANIFEST R"(@SVR_VRAPP_MANIFEST@)" +#cmakedefine SVR_VRAPP_ACTION_MANIFEST R"(@SVR_VRAPP_ACTION_MANIFEST@)" +#cmakedefine SVR_VRAPP_GENERIC_BINDS R"(@SVR_VRAPP_GENERIC_BINDS@)" +#cmakedefine SVR_VRAPP_KNUCKLES_BINDS R"(@SVR_VRAPP_KNUCKLES_BINDS@)" +#cmakedefine SVR_VRAPP_OCULUS_BINDS R"(@SVR_VRAPP_OCULUS_BINDS@)" +#cmakedefine SVR_VRAPP_VIVE_BINDS R"(@SVR_VRAPP_VIVE_BINDS@)" diff --git a/bindings-provider/src/resources/vive_controller.json b/bindings-provider/src/resources/vive_controller.json new file mode 100644 index 0000000000..cedc703e72 --- /dev/null +++ b/bindings-provider/src/resources/vive_controller.json @@ -0,0 +1,12 @@ +{ + "name": "Default Bindings for Vive Wands", + "controller_type": "vive_controller", + "bindings": { + "/actions/main": { + "sources": [], + "skeleton": [], + "haptics": [], + "poses": [] + } + } +} diff --git a/bindings-provider/src/solarxr.cpp b/bindings-provider/src/solarxr.cpp new file mode 100644 index 0000000000..58f6ca4fe0 --- /dev/null +++ b/bindings-provider/src/solarxr.cpp @@ -0,0 +1,163 @@ +#include "solarxr.hpp" +#include "logger.hpp" + +#include + +#if defined(__linux__) +#include +#include +#elif defined(_WIN32) +#include +#else +#error "Unsupported platform" +#endif + +namespace fs = std::filesystem; + +fs::path SolarXRConnection::getSocketPath() { + using namespace std::string_view_literals; +#if defined(__linux__) +#define MAKE_SOCKET_PATH(PATH) \ + std::make_pair(PATH##sv, static_cast(getenv(PATH))) + + constexpr std::string_view socketName = "SlimeVRRpc"; + std::array, 4> socketPaths{ + MAKE_SOCKET_PATH("SLIMEVR_SOCKET_DIR"), + MAKE_SOCKET_PATH("XDG_RUNTIME_DIR"), + MAKE_SOCKET_PATH("TMPDIR"), + { "/tmp", "/tmp" } + }; + +#undef MAKE_SOCKET_PATH + + for (auto [name, path] : socketPaths) { + if (path == nullptr) { + Logger::get().info( + "Skipping socket directory {} because it doesn't exist", name); + continue; + } + fs::path socketPath = fs::path(path) / socketName; + if (fs::exists(socketPath)) + return socketPath; + + Logger::get().info( + "Skipping socket directory {} because socket does not exist", name); + } +#elif defined(_WIN32) + throw std::logic_error( + "SolarXRConnection::getSocketPath should not be called on Windows"); +#else +#error "Unsupported platform" +#endif + + throw std::runtime_error("Failed to find socket directory"); +} + +SolarXRConnection::SolarXRConnection() { +#if defined(__linux__) + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + strcpy(addr.sun_path, getSocketPath().string().data()); + + fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd == -1) { + throw std::runtime_error( + std::format("Failed to create listen socket: {}", strerror(errno))); + } + + if (connect(fd, reinterpret_cast(&addr), sizeof(addr)) == -1) { + int e = errno; + close(fd); + throw std::runtime_error(std::format("Failed to connect to socket {}: {}", + addr.sun_path, strerror(e))); + } + +#elif defined(_WIN32) + const char *pipeName = R"(\\.\pipe\SlimeVRRpc)"; + if (!WaitNamedPipe(pipeName, 5000)) { + throw std::runtime_error("Timed out waiting for pipe"); + } + + pipe = CreateFile(pipeName, GENERIC_READ | GENERIC_WRITE, 0, nullptr, + OPEN_EXISTING, 0, nullptr); + if (pipe == INVALID_HANDLE_VALUE) { + throw std::runtime_error( + std::format("Unable to open pipe (error={})", GetLastError())); + } +#else +#error "Unsupported platform" +#endif +} + +SolarXRConnection::~SolarXRConnection() { +#if defined(__linux__) + if (fd != -1) + close(fd); +#elif defined(_WIN32) + if (pipe != INVALID_HANDLE_VALUE) + CloseHandle(pipe); +#else +#error "Unsupported platform" +#endif +} + +bool SolarXRConnection::connected() { +#if defined(__linux__) + if (read(fd, nullptr, 0) == -1) { + return !(errno == EBADF || errno == EINVAL); + } + + return true; +#elif defined(_WIN32) + if (!WriteFile(pipe, nullptr, 0, nullptr, nullptr)) { + int err = GetLastError(); + // The documentation says it should return ERROR_BROKEN_PIPE + // when the write-end has been closed, but it seems to actually + // return ERROR_NO_DATA... + return !(err == ERROR_NO_DATA || err == ERROR_BROKEN_PIPE); + } + + return true; +#else +#error "Unsupported platform" +#endif +} + +void SolarXRConnection::sendMsg(flatbuffers::FlatBufferBuilder &fbb) { + // The server expects the total size of the buffer to be written at the + // start of the packet, including the first 4 bytes for the size + int size = fbb.GetSize() + 4; + // This may be wrong on mixed-endianness, but oh well... + if constexpr (std::endian::native != std::endian::little) + size = std::byteswap(size); + +#if defined(__linux__) + if (write(fd, &size, sizeof(size)) == -1) { + Logger::get().warning("Failed to write message size to socket: {}", + strerror(errno)); + return; + } + + if (write(fd, fbb.GetBufferPointer(), fbb.GetSize()) == -1) { + Logger::get().warning("Failed to write message to socket: {}", + strerror(errno)); + return; + } +#elif defined(_WIN32) + uint8_t tempBuf[2048]; + if (fbb.GetSize() > 2048 - 4) { + Logger::get().warning("Message is too big for temporary buffer"); + return; + } + + *reinterpret_cast(&tempBuf[0]) = size; + memcpy(&tempBuf[4], fbb.GetBufferPointer(), fbb.GetSize()); + if (!WriteFile(pipe, tempBuf, fbb.GetSize() + 4, nullptr, nullptr)) { + Logger::get().warning("Failed to write message to pipe (error={})", + GetLastError()); + return; + } +#else +#error "Unsupported platform" +#endif +} diff --git a/bindings-provider/src/solarxr.hpp b/bindings-provider/src/solarxr.hpp new file mode 100644 index 0000000000..98e4783220 --- /dev/null +++ b/bindings-provider/src/solarxr.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include + +#if defined(_WIN32) +#define WIN32_MEAN_AND_LEAN +#include +#endif + +#include "flatbuffers/flatbuffer_builder.h" + +class SolarXRConnection { +private: +#if defined(__linux__) + int fd{ -1 }; +#elif defined(_WIN32) + HANDLE pipe{ INVALID_HANDLE_VALUE }; +#else +#error "Unsupported platform" +#endif + + static std::filesystem::path getSocketPath(); + +public: + SolarXRConnection() noexcept(false); + + ~SolarXRConnection(); + + bool connected(); + void sendMsg(flatbuffers::FlatBufferBuilder &fbb); +}; diff --git a/bindings-provider/src/vr_utils.cpp b/bindings-provider/src/vr_utils.cpp new file mode 100644 index 0000000000..590c67251c --- /dev/null +++ b/bindings-provider/src/vr_utils.cpp @@ -0,0 +1,72 @@ +#include "vr_utils.hpp" +#include "logger.hpp" +#include "paths.hpp" +#include "resources/resources.hpp" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +std::optional +VRUtils::getStringProp(vr::TrackedDeviceIndex_t deviceIndex, + vr::ETrackedDeviceProperty prop) { + vr::IVRSystem *sys = vr::VRSystem(); + vr::ETrackedPropertyError err{ vr::TrackedProp_Success }; + + uint32_t required_len = sys->GetStringTrackedDeviceProperty(deviceIndex, prop, nullptr, 0, &err); + if (err != vr::TrackedProp_BufferTooSmall) { + Logger::get().info( + "Failed to get size of string property {} for device {}: {}", + std::to_underlying(prop), deviceIndex, + sys->GetPropErrorNameFromEnum(err)); + return std::nullopt; + } + + std::string s(required_len - 1, '\0'); + sys->GetStringTrackedDeviceProperty(deviceIndex, prop, s.data(), required_len, + &err); + if (err != vr::TrackedProp_Success) { + Logger::get().info("Failed to get string property {} for device {}: {}", + std::to_underlying(prop), deviceIndex, + sys->GetPropErrorNameFromEnum(err)); + return std::nullopt; + } + + return s; +} + +std::tuple VRUtils::initialiseManifest() { + fs::path manifestDir = Paths::getDataPath() / "bindings-provider"; + std::error_code ec; + fs::create_directories(manifestDir, ec); + if (ec) { + throw std::runtime_error( + std::format("Failed to create manifest folder: {}", ec.message())); + } + + using namespace std::string_view_literals; + // Make sure the vrmanifest and action manifest are 1st and 2nd respectively + // in the array + std::array requiredFiles{ + std::make_pair("manifest.vrmanifest"sv, SVR_VRAPP_MANIFEST), + std::make_pair("action_manifest.json"sv, SVR_VRAPP_ACTION_MANIFEST), + std::make_pair("generic.json"sv, SVR_VRAPP_GENERIC_BINDS), + std::make_pair("knuckles.json"sv, SVR_VRAPP_KNUCKLES_BINDS), + std::make_pair("oculus_touch.json"sv, SVR_VRAPP_OCULUS_BINDS), + std::make_pair("vive_controller.json"sv, SVR_VRAPP_VIVE_BINDS), + }; + + for (auto &[name, contents] : requiredFiles) { + fs::path path = manifestDir / name; + if (!fs::exists(path)) { + std::ofstream stream(path); + stream << contents; + } + } + + return { manifestDir / requiredFiles.at(0).first, + manifestDir / requiredFiles.at(1).first }; +} diff --git a/bindings-provider/src/vr_utils.hpp b/bindings-provider/src/vr_utils.hpp new file mode 100644 index 0000000000..996b93c4c9 --- /dev/null +++ b/bindings-provider/src/vr_utils.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include +#include +#include +#include + +#include "openvr.h" + +namespace VRUtils { +std::optional getStringProp(vr::TrackedDeviceIndex_t deviceIndex, + vr::ETrackedDeviceProperty prop); + +// Returns +std::tuple initialiseManifest(); +} // namespace VRUtils From c0cf2af9be81a966de1fb8d65c08ef426bf80c04 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 19 Jun 2026 02:13:55 -0500 Subject: [PATCH 176/350] Remove feeder from Steam install code --- .../src/main/java/dev/slimevr/desktop/Main.kt | 4 ++-- .../dev/slimevr/desktop/install/install.kt | 6 ++--- .../java/dev/slimevr/desktop/install/linux.kt | 24 +------------------ .../dev/slimevr/desktop/install/windows.kt | 16 ------------- 4 files changed, 6 insertions(+), 44 deletions(-) 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 f471e3a881..6e27c6a418 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -46,14 +46,14 @@ fun main(args: Array) = runBlocking { for (arg in args) { when (arg) { "--steam", "-s" -> featureFlags.steam = true - "--install", "-i" -> { runInstaller(featureFlags); return@runBlocking } + "--install", "-i" -> { runInstaller(); return@runBlocking } "--no-udev", "-u" -> featureFlags.skipCheckUdev = true } } if (CURRENT_PLATFORM != Platform.LINUX) featureFlags.skipCheckUdev = true val isInstallDisabled = System.getenv("SLIME_SERVER_DISABLE_INSTALLER")?.toIntOrNull() - if (featureFlags.steam && isInstallDisabled != 1) runInstaller(featureFlags) + if (featureFlags.steam && isInstallDisabled != 1) runInstaller() if (!featureFlags.skipCheckUdev) { val command = if (featureFlags.steam) { diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/install/install.kt b/server/desktop/src/main/java/dev/slimevr/desktop/install/install.kt index c355718784..5521c994f6 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/install/install.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/install/install.kt @@ -8,9 +8,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.IOException -suspend fun runInstaller(featureFlags: FeatureFlags) { +suspend fun runInstaller() { when (CURRENT_PLATFORM) { - Platform.LINUX -> installLinux(featureFlags) + Platform.LINUX -> installLinux() Platform.WINDOWS -> installWindows() else -> AppLogger.install.warn("Updater doesn't support operating system '$CURRENT_PLATFORM'") } @@ -28,4 +28,4 @@ suspend fun executeShellCommand(vararg command: String): String? = try { } catch (e: IOException) { AppLogger.install.warn("Error executing shell command: ${e.message}") null -} \ No newline at end of file +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/install/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/install/linux.kt index fb94b2c45a..9f04540d7a 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/install/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/install/linux.kt @@ -4,11 +4,9 @@ import dev.slimevr.AppLogger import dev.slimevr.FeatureFlags private const val LINUX_STEAM_DRIVER_DIRECTORY = "slimevr-openvr-driver-x64-linux" -private const val LINUX_FEEDER_DIRECTORY = "SlimeVR-Feeder-App-Linux" -suspend fun installLinux(featureFlags: FeatureFlags) { +suspend fun installLinux() { installLinuxSteamVRDriver() - installLinuxFeeder(featureFlags) } private suspend fun installLinuxSteamVRDriver() { @@ -30,23 +28,3 @@ private suspend fun installLinuxSteamVRDriver() { } AppLogger.install.info("SteamVR driver successfully installed") } - -private suspend fun installLinuxFeeder(featureFlags: FeatureFlags) { - val path = System.getProperty("user.dir") - executeShellCommand("chmod", "+x", "$path/$LINUX_FEEDER_DIRECTORY/SlimeVR-Feeder-App") - val command = if (featureFlags.steam) { - arrayOf("steam-runtime-launch-client", "--alongside-steam", "--", "$path/$LINUX_FEEDER_DIRECTORY/SlimeVR-Feeder-App", "--install") - } else { - arrayOf("$path/$LINUX_FEEDER_DIRECTORY/SlimeVR-Feeder-App", "--install") - } - val output = executeShellCommand(*command) - if (output == null) { - AppLogger.install.warn("Error installing feeder") - return - } - if (output.lowercase().contains("manifest is not installed")) { - AppLogger.install.warn("Could not install feeder application") - } else { - AppLogger.install.info("Successfully installed feeder application") - } -} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/install/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/install/windows.kt index 1a34d882c6..9c688bf371 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/install/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/install/windows.kt @@ -5,11 +5,9 @@ import com.sun.jna.platform.win32.WinReg import dev.slimevr.AppLogger private const val WINDOWS_STEAMVR_DRIVER_DIRECTORY = "slimevr-openvr-driver-win64" -private const val WINDOWS_FEEDER_DIRECTORY = "SlimeVR-Feeder-App-win64" suspend fun installWindows() { installWindowsSteamVRDriver() - installWindowsFeeder() } private suspend fun getKeyByPath(hkey: WinReg.HKEY, path: String): Map { @@ -49,17 +47,3 @@ private suspend fun installWindowsSteamVRDriver() { } AppLogger.install.info("SteamVR driver successfully installed") } - -private suspend fun installWindowsFeeder() { - val path = System.getProperty("user.dir") - val output = executeShellCommand("${path}\\${WINDOWS_FEEDER_DIRECTORY}\\SlimeVR-Feeder-App.exe", "--install") - if (output == null) { - AppLogger.install.warn("Error installing feeder") - return - } - if (output.lowercase().contains("manifest is not installed")) { - AppLogger.install.warn("Could not install feeder application") - } else { - AppLogger.install.info("Successfully installed feeder application") - } -} \ No newline at end of file From a69c10d7335deda45f5b7e0d364a1253414ecc9e Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 19 Jun 2026 02:24:53 -0500 Subject: [PATCH 177/350] Fix socket getting placed in XDG_CONFIG_HOME on Steam See: https://github.com/SlimeVR/SlimeVR-Server/pull/1846 --- server/desktop/src/main/java/dev/slimevr/platform.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/desktop/src/main/java/dev/slimevr/platform.kt b/server/desktop/src/main/java/dev/slimevr/platform.kt index b43cc52411..e8527c9a25 100644 --- a/server/desktop/src/main/java/dev/slimevr/platform.kt +++ b/server/desktop/src/main/java/dev/slimevr/platform.kt @@ -44,7 +44,7 @@ fun getSocketDirectory(): String { if (CURRENT_PLATFORM == Platform.LINUX) { val isPressureVessel = System.getenv("PRESSURE_VESSEL_RUNTIME")?.isNotEmpty() == true if (isPressureVessel) { - val dir = System.getenv("XDG_CONFIG_HOME")?.let { Path(it, SLIMEVR_IDENTIFIER).toString() } + val dir = System.getenv("XDG_DATA_HOME")?.let { Path(it, SLIMEVR_IDENTIFIER).toString() } ?: System.getenv("HOME")?.let { Path(it, ".local", "share", SLIMEVR_IDENTIFIER).toString() } if (dir != null) return dir } From 28bedc12f175265e028c64529cc164fc6aea576d Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 19 Jun 2026 02:26:51 -0500 Subject: [PATCH 178/350] Sync .gitmodules with main oopsies! --- .gitmodules | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitmodules b/.gitmodules index 286659e33d..8364078ff0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "solarxr-protocol"] path = solarxr-protocol url = https://github.com/SlimeVR/SolarXR-Protocol.git +[submodule "svr-bindings-provider/openvr"] + path = bindings-provider/openvr + url = https://github.com/ValveSoftware/openvr.git From 5681c351c55acc22f302f798c9b69fa5ba52d416 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 19 Jun 2026 02:29:33 -0500 Subject: [PATCH 179/350] Switch to JDK 24 in CI --- .github/workflows/build.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a54aca04b6..e77607b6ee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -92,10 +92,10 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive - - name: Set up JDK 17 + - name: Set up JDK 24 uses: actions/setup-java@v5 with: - java-version: '17' + java-version: '24' distribution: 'adopt' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 @@ -115,10 +115,10 @@ jobs: submodules: recursive - name: Get tags run: git fetch --tags origin --recurse-submodules=no --force - - name: Set up JDK 17 + - name: Set up JDK 24 uses: actions/setup-java@v5 with: - java-version: '17' + java-version: '24' distribution: 'adopt' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 @@ -262,10 +262,10 @@ jobs: submodules: recursive - name: Get tags run: git fetch --tags origin --recurse-submodules=no --force - - name: Set up JDK 17 + - name: Set up JDK 24 uses: actions/setup-java@v5 with: - java-version: '17' + java-version: '24' distribution: 'adopt' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 From d7bdeaa48a6e15225dad8387218fd0c0fa74e141 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 19 Jun 2026 02:53:59 -0500 Subject: [PATCH 180/350] Port #1785 See: https://github.com/SlimeVR/SlimeVR-Server/pull/1785 --- .../java/dev/slimevr/desktop/linux/steam.kt | 81 +++++++++++++++++++ .../java/dev/slimevr/desktop/vrchat/linux.kt | 27 +++---- 2 files changed, 92 insertions(+), 16 deletions(-) create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/linux/steam.kt diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/linux/steam.kt b/server/desktop/src/main/java/dev/slimevr/desktop/linux/steam.kt new file mode 100644 index 0000000000..3330330eaf --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/linux/steam.kt @@ -0,0 +1,81 @@ +package dev.slimevr.desktop.linux + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.FileNotFoundException +import java.io.FileReader +import java.nio.file.Path +import kotlin.io.path.Path +import kotlin.io.path.exists + +internal val STEAM_PATH = System.getenv("HOME")?.let { home -> + listOf( + Path(home, ".steam", "root"), + Path(home, ".steam", "debian-installation"), + Path(home, ".var", "app", "com.valvesoftware.Steam", "data", "Steam"), + ).firstOrNull { it.exists() } +} +internal val VDF_KEY_VALUES_PATTERN = Regex(""""(\w+)"[ \t]*(?:"(.+)")?""") + +suspend fun findAppLibraryLocation(appId: Int): Path = withContext(Dispatchers.IO) { + if (STEAM_PATH == null) throw FileNotFoundException("Unable to find Steam") + val libraryFoldersPath = STEAM_PATH.resolve("config/libraryfolders.vdf") + if (!libraryFoldersPath.exists()) throw FileNotFoundException("Steam libraryfolders.vdf does not exist") + + BufferedReader(FileReader(STEAM_PATH.resolve("config/libraryfolders.vdf").toFile())).use { reader -> + var depth = 0 + var currentLibraryPath: Path? = null + + while (reader.ready()) { + val line = reader.readLine().trim() + if (line == "{") { + depth += 1 + continue + } + if (line == "}") { + depth -= 1 + continue + } + + VDF_KEY_VALUES_PATTERN.matchEntire(line)?.let { + val key = it.groupValues[1] + val value = it.groupValues[2] + + when (depth) { + 0 -> { + require(key == "libraryfolders") { "root key must be libraryfolders" } + } + + 1 -> { + // start of a library node + } + + 2 -> { + // in a library node + if (key == "path") { + currentLibraryPath = Path(value) + } else if (key == "apps") { + // start of apps node + require(currentLibraryPath != null) { "path must come before apps in node" } + } + } + + 3 -> { + // in apps node + val curAppId = try { + key.toInt() + } catch (_: NumberFormatException) { + null + } + if (curAppId != null && curAppId == appId) { + check(currentLibraryPath != null) { "path must come before apps in node" } + return@withContext currentLibraryPath + } + } + } + } + } + throw RuntimeException("Couldn't find library folder for appid $appId") + } +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt index 828f6333f7..3e23fb8b77 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt @@ -1,6 +1,7 @@ package dev.slimevr.desktop.vrchat import dev.slimevr.AppLogger +import dev.slimevr.desktop.linux.findAppLibraryLocation import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -13,8 +14,7 @@ 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 +import java.nio.file.Path private const val USER_REG_SUBPATH = "steamapps/compatdata/438100/pfx/user.reg" private val KEY_VALUE_PATTERN = Regex(""""(.+)"=(.+)""") @@ -22,22 +22,15 @@ private val HEX_FORMAT = HexFormat { upperCase = false bytes.byteSeparator = "," } +private const val VRCHAT_APPID = 438100 -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): Map { +internal suspend fun linuxGetVRChatKeys(regPath: Path, keyPath: String, registry: MutableMap): Map { val keysMap = mutableMapOf() registry.clear() try { withContext(Dispatchers.IO) { - BufferedReader(FileReader(linuxUserRegPath?.toFile() ?: return@withContext)).use { reader -> - val actualPath = "[${path.replace("\\", """\\""")}]" + BufferedReader(FileReader(regPath.toFile() ?: return@withContext)).use { reader -> + val actualPath = "[${keyPath.replace("\\", """\\""")}]" while (reader.ready()) { val line = reader.readLine() if (!line.startsWith(actualPath)) continue @@ -86,15 +79,17 @@ internal suspend fun linuxGetDwordValue(registry: Map, key: Stri } internal fun linuxVRCConfigFlow(): Flow = flow { - val regPath = linuxUserRegPath ?: run { - AppLogger.vrc.info("[VRChatRegEdit] Couldn't find any VRChat registry file") + val regPath = try { + findAppLibraryLocation(VRCHAT_APPID).resolve(USER_REG_SUBPATH) + } catch (e: Exception) { + AppLogger.vrc.warn(e, "[VRChatRegEdit] Couldn't find any VRChat registry file") return@flow } AppLogger.vrc.info("[VRChatRegEdit] Using VRChat registry file: $regPath") val registry = mutableMapOf() while (true) { - val keys = linuxGetVRChatKeys(VRC_REG_PATH, registry) + val keys = linuxGetVRChatKeys(regPath, VRC_REG_PATH, registry) if (keys.isEmpty()) { emit(null) } else { From dd198d220597ab0cc863f31bb15e46c692c536e2 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 19 Jun 2026 03:54:43 -0500 Subject: [PATCH 181/350] Port #1859 See: https://github.com/SlimeVR/SlimeVR-Server/pull/1859 --- .../src/main/java/dev/slimevr/desktop/Main.kt | 2 +- .../dev/slimevr/desktop/install/install.kt | 28 +++++++++--- .../java/dev/slimevr/desktop/install/linux.kt | 33 +++++++++----- .../dev/slimevr/desktop/install/windows.kt | 43 +++++++++++++------ 4 files changed, 76 insertions(+), 30 deletions(-) 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 6e27c6a418..0d8b19baf2 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -61,7 +61,7 @@ fun main(args: Array) = runBlocking { } else { arrayOf("udevadm", "cat") } - featureFlags.udevRulesInstalled = executeShellCommand(*command)?.contains("slime") + featureFlags.udevRulesInstalled = executeShellCommand(*command)?.second?.contains("slime") } val configFolder = resolveConfigDirectory() ?: error("Unable to resolve config folder") diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/install/install.kt b/server/desktop/src/main/java/dev/slimevr/desktop/install/install.kt index 5521c994f6..ce55682b33 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/install/install.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/install/install.kt @@ -16,16 +16,34 @@ suspend fun runInstaller() { } } -suspend fun executeShellCommand(vararg command: String): String? = try { - val process = withContext(Dispatchers.IO) { - ProcessBuilder(*command) +suspend fun executeShellCommand(vararg command: String): Pair? = try { + withContext(Dispatchers.IO) { + val process = ProcessBuilder(*command) .redirectErrorStream(true) .start() - } - process.inputStream.bufferedReader().readText().also { + + val output = process.inputStream.bufferedReader().readText() process.waitFor() + Pair(process.exitValue(), output) } } catch (e: IOException) { AppLogger.install.warn("Error executing shell command: ${e.message}") null } + +// vrpathreg returns 1 if the driver is not present +fun shouldInstallDriver(exitCode: Int): Boolean = exitCode == 1 + +// get a descriptive reason why we're skipping driver installation +// Exit codes from vrpathreg output: +// 0 : Success +// 1 : ( finddriver only ) Driver not present +// 2 : ( finddriver only ) Error, driver installed more than once +// -1 : Configuration or permission problem +// -2 : Argument problem +fun getDriverInstallSkipReason(exitCode: Int): String = when (exitCode) { + 0 -> "driver already installed" + 2 -> "driver installed more than once" + -1 -> "SteamVR is misconfigured" + else -> "unknown reason ($exitCode)" +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/install/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/install/linux.kt index 9f04540d7a..7c0c665023 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/install/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/install/linux.kt @@ -2,7 +2,9 @@ package dev.slimevr.desktop.install import dev.slimevr.AppLogger import dev.slimevr.FeatureFlags +import dev.slimevr.desktop.linux.findAppLibraryLocation +private const val STEAMVR_APPID = 250820 private const val LINUX_STEAM_DRIVER_DIRECTORY = "slimevr-openvr-driver-x64-linux" suspend fun installLinux() { @@ -10,21 +12,32 @@ suspend fun installLinux() { } private suspend fun installLinuxSteamVRDriver() { - val path = System.getProperty("user.dir") - val vrPathReg = "${System.getProperty("user.home")}/.steam/steam/steamapps/common/SteamVR/bin/vrpathreg.sh" - val existing = executeShellCommand(vrPathReg) - if (existing == null) { - AppLogger.install.warn("SteamVR driver installation failed") + val workingDir = System.getProperty("user.dir") + val steamVRLocation = try { + findAppLibraryLocation(STEAMVR_APPID).resolve("steamapps/common/SteamVR") + } catch (e: Exception) { + AppLogger.install.warn(e, "SteamVR driver installation failed (couldn't find SteamVR install location)") return } - if (existing.contains("slimevr")) { - AppLogger.install.info("SteamVR driver is already installed") + val pathRegPath = steamVRLocation.resolve("bin/vrpathreg.sh").toString() + + val (findExitCode, _) = executeShellCommand(pathRegPath, "finddriver", "slimevr") ?: run { + AppLogger.install.warn("SteamVR driver installation failed (failed to run vrpathreg finddriver)") + return + } + if (!shouldInstallDriver(findExitCode)) { + AppLogger.install.info("Skipping SteamVR driver installation (${getDriverInstallSkipReason(findExitCode)})") return } - executeShellCommand(vrPathReg, "adddriver", "$path/$LINUX_STEAM_DRIVER_DIRECTORY") - if (executeShellCommand(vrPathReg)?.contains("slimevr") != true) { - AppLogger.install.warn("Failed to install SteamVR driver") + + val (addExitCode, _) = executeShellCommand(pathRegPath, "adddriver", "$workingDir/$LINUX_STEAM_DRIVER_DIRECTORY") ?: run { + AppLogger.install.warn("SteamVR driver installation failed (failed to run vrpathreg adddriver)") return } + if (addExitCode != 0) { + AppLogger.install.warn("SteamVR driver installation failed (vrpathreg adddriver exited with code $addExitCode)") + return + } + AppLogger.install.info("SteamVR driver successfully installed") } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/install/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/install/windows.kt index 9c688bf371..e78e3f7c64 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/install/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/install/windows.kt @@ -3,6 +3,8 @@ package dev.slimevr.desktop.install import com.sun.jna.platform.win32.Advapi32Util import com.sun.jna.platform.win32.WinReg import dev.slimevr.AppLogger +import kotlin.io.path.Path +import kotlin.io.path.exists private const val WINDOWS_STEAMVR_DRIVER_DIRECTORY = "slimevr-openvr-driver-win64" @@ -23,27 +25,40 @@ private suspend fun getKeyByPath(hkey: WinReg.HKEY, path: String): Map Date: Fri, 19 Jun 2026 04:07:35 -0500 Subject: [PATCH 182/350] Fix up GUI after merge(s) --- gui/src/components/settings/pages/Serial.tsx | 2 ++ gui/src/hooks/onboarding.ts | 16 ++++++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/gui/src/components/settings/pages/Serial.tsx b/gui/src/components/settings/pages/Serial.tsx index 8c9c1d3853..ef8c6e705e 100644 --- a/gui/src/components/settings/pages/Serial.tsx +++ b/gui/src/components/settings/pages/Serial.tsx @@ -14,6 +14,7 @@ import { SerialUpdateResponseT, SerialTrackerGetWifiScanRequestT, SerialTrackerCustomCommandRequestT, + SerialDeviceType, } from 'solarxr-protocol'; import { useWebsocketAPI } from '@/hooks/websocket-api'; import { Button } from '@/components/commons/Button'; @@ -122,6 +123,7 @@ export function Serial() { { name: l10n.getString('settings-serial-auto_dropdown_item'), port: 'Auto', + type: SerialDeviceType.ESP_TRACKER, // dummy value }, ...(res.devices || []), ]); diff --git a/gui/src/hooks/onboarding.ts b/gui/src/hooks/onboarding.ts index 47cc1be8da..812200528a 100644 --- a/gui/src/hooks/onboarding.ts +++ b/gui/src/hooks/onboarding.ts @@ -20,6 +20,7 @@ import { SettingsRequestT, SettingsResponseT, SkeletonBone, + VRCOSCSettingsRequestT, VRCOSCSettingsT, } from 'solarxr-protocol'; import { useManualProportions } from './manual-proportions'; @@ -80,6 +81,7 @@ export function useProvideOnboarding() { const { state: locatioState } = useLocation(); const [settings, setSettings] = useState(); + const [vrcOscSettings, setVrcOscSettings] = useState(); useLayoutEffect(() => { const { alonePage = false }: { alonePage?: boolean } = (locatioState as any) || {}; @@ -90,11 +92,15 @@ export function useProvideOnboarding() { useEffect(() => { sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT()); + sendRPCPacket(RpcMessage.VRCOSCSettingsRequest, new VRCOSCSettingsRequestT()); }, []); useRPCPacket(RpcMessage.SettingsResponse, (settings: SettingsResponseT) => { setSettings(settings); }); + useRPCPacket(RpcMessage.VRCOSCSettingsResponse, (settings: VRCOSCSettingsT) => { + setVrcOscSettings(settings); + }); const { changeBoneValue } = useManualProportions({ type: 'linear', @@ -102,7 +108,7 @@ export function useProvideOnboarding() { const onboardingEnded = () => { setConfig({ doneOnboarding: true }); - if (!settings?.modelSettings || !settings?.vrcOsc) return; + if (!settings?.modelSettings || !vrcOscSettings) return; const req = new ChangeSettingsRequestT(); const modelSettings = new ModelSettingsT(); const oscSettings = new VRCOSCSettingsT(); @@ -117,13 +123,11 @@ export function useProvideOnboarding() { const resets = Object.assign(new ResetsSettingsT(), settings.resetsSettings); resets.resetHmdPitch = mocapPos === 'forehead'; req.resetsSettings = resets; + sendRPCPacket(RpcMessage.ChangeSettingsRequest, req); - const osc = Object.assign(new OSCSettingsT(), settings.vrcOsc.oscSettings); + const osc = Object.assign(new VRCOSCSettingsT(), vrcOscSettings); osc.enabled = vrcOsc ?? false; - oscSettings.oscSettings = osc; - req.vrcOsc = oscSettings; - - sendRPCPacket(RpcMessage.ChangeSettingsRequest, req); + sendRPCPacket(RpcMessage.ChangeVRCOSCSettingsRequest, osc); if (mocap) { changeBoneValue({ bone: SkeletonBone.HAND_Z, type: 'bone', newValue: 0 }); From 0757935d60806160bf87b6b64df28d4c896fd6e9 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 19 Jun 2026 04:14:02 -0500 Subject: [PATCH 183/350] Fix Android compilation --- .../src/main/java/dev/slimevr/android/ForegroundService.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt index d61cba3574..a407f56dc6 100644 --- a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt +++ b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt @@ -32,6 +32,7 @@ import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.skeleton.Skeleton import dev.slimevr.trackingchecklist.TrackingChecklist +import dev.slimevr.resets.ResetsManager import dev.slimevr.udp.UdpServer import dev.slimevr.util.safeLaunch import dev.slimevr.vmc.VMCManager @@ -150,6 +151,7 @@ class ForegroundService : Service() { scope = scope, oscQueryAddress = resolveAndroidOscQueryAddress(), ) + val resetsManager = ResetsManager.create(server = server, scope = scope) val appContext = AppContext( server = server, @@ -167,6 +169,7 @@ class ForegroundService : Service() { bvhManager = bvhManager, vmcManager = vmcManager, vrcOscManager = vrcOscManager, + resetsManager = resetsManager, ) acquireLocks() From 47638cc9d5e656b2641b4abf6eec3b19841c3f66 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 19 Jun 2026 04:37:48 -0500 Subject: [PATCH 184/350] Fix GUI on Android/non-Electron Fixes: a1e0cd52d2a8fdce7e1e952f78a0c8e5952f75c8 (cherry picked from commit 257dfffc537d624d373ce505aaa1aab824b55e43) --- gui/src/components/providers/ConfigContext.tsx | 6 ++---- gui/src/hooks/app.ts | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/gui/src/components/providers/ConfigContext.tsx b/gui/src/components/providers/ConfigContext.tsx index 0e0e808797..f42b8b61b3 100644 --- a/gui/src/components/providers/ConfigContext.tsx +++ b/gui/src/components/providers/ConfigContext.tsx @@ -2,10 +2,8 @@ import { ReactNode } from 'react'; import { ConfigContextC, loadConfig, useConfigProvider } from '@/hooks/config'; import { getSentryOrCompute } from '@/utils/sentry'; -const [config, isSteam] = await Promise.all([ - loadConfig(), - window.electronAPI.isSteam(), -]); +const config = await loadConfig(); +const isSteam = window.electronAPI ? await window.electronAPI.isSteam() : false; if (config?.errorTracking !== undefined) { // load sentry ASAP to catch early errors diff --git a/gui/src/hooks/app.ts b/gui/src/hooks/app.ts index a110ca551d..d473a4ec86 100644 --- a/gui/src/hooks/app.ts +++ b/gui/src/hooks/app.ts @@ -16,7 +16,7 @@ import { getSentryOrCompute, updateSentryContext } from '@/utils/sentry'; import { fetchCurrentFirmwareRelease, FirmwareRelease } from './firmware-update'; import { DEFAULT_LOCALE, LangContext } from '@/i18n/config'; -const isSteam = await window.electronAPI.isSteam(); +const isSteam = window.electronAPI ? await window.electronAPI.isSteam() : false; export interface AppContext { currentFirmwareRelease: FirmwareRelease | null; From dc2b47a7dcb72ad4a75e2f8f2b7bc05a885ae1ff Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 19 Jun 2026 05:00:37 -0500 Subject: [PATCH 185/350] Don't catch CancellationException in ComputedSkeletonBehaviour --- server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 03124025ce..dc8d95ed1d 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -4,6 +4,7 @@ import dev.slimevr.config.UserConfig import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 +import io.ktor.utils.io.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn @@ -126,6 +127,8 @@ class ComputedSkeletonBehaviour( .fold(targetState) { state, processor -> processor.process(state) } val rootHead = Vector3(0f, targetState.userHeight, 0f) // FIXME WRONG receiver.computed.value = buildBones(processed, rootHead = rootHead) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { dev.slimevr.AppLogger.coroutines.error(e, "Error in ComputedSkeletonBehaviour") } From aa0d2e86a57ed62a1d183b249e7d78199864dec0 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 19 Jun 2026 05:01:55 -0500 Subject: [PATCH 186/350] Get rid of some stray files left behind by merge --- gui/src/components/ErrorConsentModal.tsx | 60 ------------------- .../onboarding/OnboardingContextProvicer.tsx | 16 ----- 2 files changed, 76 deletions(-) delete mode 100644 gui/src/components/ErrorConsentModal.tsx delete mode 100644 gui/src/components/onboarding/OnboardingContextProvicer.tsx diff --git a/gui/src/components/ErrorConsentModal.tsx b/gui/src/components/ErrorConsentModal.tsx deleted file mode 100644 index 358a80e16b..0000000000 --- a/gui/src/components/ErrorConsentModal.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { Localized, useLocalization } from '@fluent/react'; -import { BaseModal } from './commons/BaseModal'; -import { Button } from './commons/Button'; -import { Typography } from './commons/Typography'; - -export function ErrorConsentModal({ - isOpen = true, - cancel, - accept, -}: { - /** - * Is the parent/sibling component opened? - */ - isOpen: boolean; - /** - * Function to trigger when you still want to close the app - */ - accept: () => void; - /** - * Function to trigger when cancelling app close - */ - cancel?: () => void; -}) { - const { l10n } = useLocalization(); - - return ( - -
- <> -
-
- - {l10n.getString('error_collection_modal-title')} - - , - h1: , - }} - > - - -
-
- - - - -
-
- ); -} diff --git a/gui/src/components/onboarding/OnboardingContextProvicer.tsx b/gui/src/components/onboarding/OnboardingContextProvicer.tsx deleted file mode 100644 index 97d049bb00..0000000000 --- a/gui/src/components/onboarding/OnboardingContextProvicer.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { ReactNode } from 'react'; -import { OnboardingContextC, useProvideOnboarding } from '@/hooks/onboarding'; - -export function OnboardingContextProvider({ - children, -}: { - children: ReactNode; -}) { - const context = useProvideOnboarding(); - - return ( - - {children} - - ); -} From f202f962465f09f828485aa1f8e10558e8da1f24 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 19 Jun 2026 05:10:00 -0500 Subject: [PATCH 187/350] Run lint on GUI --- .../settings/pages/VRCOSCSettings.tsx | 77 +++++++++---------- gui/src/hooks/onboarding.ts | 2 - 2 files changed, 38 insertions(+), 41 deletions(-) diff --git a/gui/src/components/settings/pages/VRCOSCSettings.tsx b/gui/src/components/settings/pages/VRCOSCSettings.tsx index 10e4baf23f..7b2ff6788e 100644 --- a/gui/src/components/settings/pages/VRCOSCSettings.tsx +++ b/gui/src/components/settings/pages/VRCOSCSettings.tsx @@ -127,13 +127,13 @@ function StatusBadge({ kind }: { kind: BadgeKind }) { return (
- + ); } @@ -396,38 +396,39 @@ export function VRCOSCSettings() { [l10n] ); - const { reset, control, watch, handleSubmit, setValue } = useForm({ - defaultValues: createDefaultValues(), - reValidateMode: 'onChange', - mode: 'onChange', - resolver: yupResolver( - object({ - vrchat: object({ - enabled: boolean().required(), - useManualNetwork: boolean().required(), - manualNetwork: object({ - portIn: portValidator, - portOut: portValidator, - address: string() - .required(' ') - .matches( - /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|$)){4}$/i, - { message: ' ' } - ), + const { reset, control, watch, handleSubmit, setValue } = + useForm({ + defaultValues: createDefaultValues(), + reValidateMode: 'onChange', + mode: 'onChange', + resolver: yupResolver( + object({ + vrchat: object({ + enabled: boolean().required(), + useManualNetwork: boolean().required(), + manualNetwork: object({ + portIn: portValidator, + portOut: portValidator, + address: string() + .required(' ') + .matches( + /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|$)){4}$/i, + { message: ' ' } + ), + }), + trackers: object({ + head: boolean().required(), + chest: boolean().required(), + elbows: boolean().required(), + feet: boolean().required(), + knees: boolean().required(), + hands: boolean().required(), + waist: boolean().required(), + }), }), - trackers: object({ - head: boolean().required(), - chest: boolean().required(), - elbows: boolean().required(), - feet: boolean().required(), - knees: boolean().required(), - hands: boolean().required(), - waist: boolean().required(), - }), - }), - }) - ), - }); + }) + ), + }); const enabled = watch('vrchat.enabled'); const useManualNetwork = watch('vrchat.useManualNetwork'); @@ -487,7 +488,7 @@ export function VRCOSCSettings() { } } - reset(formData); + reset(formData); } } ); @@ -553,11 +554,9 @@ export function VRCOSCSettings() { asString(target.address), { shouldDirty: true } ); - setValue( - 'vrchat.manualNetwork.portOut', - target.portOut, - { shouldDirty: true } - ); + setValue('vrchat.manualNetwork.portOut', target.portOut, { + shouldDirty: true, + }); }} /> diff --git a/gui/src/hooks/onboarding.ts b/gui/src/hooks/onboarding.ts index 812200528a..6e6f767e2d 100644 --- a/gui/src/hooks/onboarding.ts +++ b/gui/src/hooks/onboarding.ts @@ -14,7 +14,6 @@ import { ChangeSettingsRequestT, ModelSettingsT, ModelTogglesT, - OSCSettingsT, ResetsSettingsT, RpcMessage, SettingsRequestT, @@ -111,7 +110,6 @@ export function useProvideOnboarding() { if (!settings?.modelSettings || !vrcOscSettings) return; const req = new ChangeSettingsRequestT(); const modelSettings = new ModelSettingsT(); - const oscSettings = new VRCOSCSettingsT(); const mocap = usage === 'mocap' || usage === 'vtubing'; From d1b119521360645aa9c03c37f87511903b9a2ff4 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 19 Jun 2026 05:11:08 -0500 Subject: [PATCH 188/350] Run spotless --- .../dev/slimevr/android/ForegroundService.kt | 2 +- .../src/main/java/dev/slimevr/android/Main.kt | 2 +- .../dev/slimevr/android/config/storage.kt | 2 +- .../main/java/dev/slimevr/android/hid/hid.kt | 9 +++-- .../java/dev/slimevr/android/ipc/ws-server.kt | 2 +- .../java/dev/slimevr/android/serial/flash.kt | 2 +- .../java/dev/slimevr/android/serial/serial.kt | 10 +++-- .../java/dev/slimevr/android/udp/address.kt | 3 +- .../dev/slimevr/android/vrchat/oscquery.kt | 2 +- .../main/java/dev/slimevr/config/resets.kt | 3 ++ .../main/java/dev/slimevr/config/settings.kt | 7 ++-- .../main/java/dev/slimevr/context/context.kt | 2 +- .../dev/slimevr/context/debug/ContextDebug.kt | 2 +- .../main/java/dev/slimevr/feature-flags.kt | 2 +- .../java/dev/slimevr/resets/behaviours.kt | 3 +- .../main/java/dev/slimevr/resets/module.kt | 37 ++++++++++------- .../main/java/dev/slimevr/solarxr/datafeed.kt | 2 +- .../dev/slimevr/solarxr/installed-info.kt | 2 +- .../java/dev/slimevr/solarxr/tapdetection.kt | 4 +- .../java/dev/slimevr/tracker/behaviours.kt | 40 +++++++++++-------- .../main/java/dev/slimevr/udp/behaviours.kt | 2 +- .../src/main/java/dev/slimevr/udp/packets.kt | 8 ++-- .../main/java/dev/slimevr/util/SafeLaunch.kt | 3 +- .../src/main/java/dev/slimevr/vrserver.kt | 3 +- .../context/ContextCallerCaptureTest.kt | 2 +- .../dev/slimevr/firmware/DoSerialFlashTest.kt | 2 +- .../provisioning/ProvisioningManagerTest.kt | 2 +- .../slimevr/tracker/SessionCalibrationTest.kt | 2 +- .../src/main/java/dev/slimevr/desktop/Main.kt | 11 +++-- .../main/java/dev/slimevr/desktop/hid/hid.kt | 6 ++- .../java/dev/slimevr/desktop/ipc/solarxr.kt | 2 +- .../java/dev/slimevr/desktop/udp/address.kt | 3 +- 32 files changed, 107 insertions(+), 77 deletions(-) diff --git a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt index a407f56dc6..7e4bef5660 100644 --- a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt +++ b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt @@ -30,9 +30,9 @@ import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.provisioning.ProvisioningManager +import dev.slimevr.resets.ResetsManager import dev.slimevr.skeleton.Skeleton import dev.slimevr.trackingchecklist.TrackingChecklist -import dev.slimevr.resets.ResetsManager import dev.slimevr.udp.UdpServer import dev.slimevr.util.safeLaunch import dev.slimevr.vmc.VMCManager diff --git a/server/android/src/main/java/dev/slimevr/android/Main.kt b/server/android/src/main/java/dev/slimevr/android/Main.kt index 131380dd40..03adaa4b8b 100644 --- a/server/android/src/main/java/dev/slimevr/android/Main.kt +++ b/server/android/src/main/java/dev/slimevr/android/Main.kt @@ -7,4 +7,4 @@ import androidx.appcompat.app.AppCompatActivity fun startVRServer(activity: AppCompatActivity) { activity.startForegroundService(Intent(activity, ForegroundService::class.java)) -} \ No newline at end of file +} diff --git a/server/android/src/main/java/dev/slimevr/android/config/storage.kt b/server/android/src/main/java/dev/slimevr/android/config/storage.kt index c7c034f937..6df85d3675 100644 --- a/server/android/src/main/java/dev/slimevr/android/config/storage.kt +++ b/server/android/src/main/java/dev/slimevr/android/config/storage.kt @@ -83,4 +83,4 @@ private class AndroidRandomAccessTextFileHandle( file.fd.sync() file.close() } -} \ No newline at end of file +} diff --git a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt index 0bc7266504..823ebd3532 100644 --- a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt +++ b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt @@ -39,8 +39,7 @@ private val HID_PRODUCT_RULES = listOf( HidProductRule(0x4E76, 0xD200, 0xFF00), // Gestures Inc. D2XX ) -private fun isCompatibleDevice(vid: Int, pid: Int) = - HID_PRODUCT_RULES.any { rule -> vid == rule.vendorId && (pid and rule.productMask) == rule.productId } +private fun isCompatibleDevice(vid: Int, pid: Int) = HID_PRODUCT_RULES.any { rule -> vid == rule.vendorId && (pid and rule.productMask) == rule.productId } private fun findHidInputEndpoint(device: UsbDevice): Pair? { for (ifaceIdx in 0 until device.interfaceCount) { @@ -50,7 +49,9 @@ private fun findHidInputEndpoint(device: UsbDevice): Pair port.write("$text\n".toByteArray(), 0) }, close = { ioManager.stop() - try { port.close() } catch (_: Exception) {} + try { + port.close() + } catch (_: Exception) {} }, ) } @@ -194,4 +198,4 @@ fun createAndroidSerialServer(context: Context, scope: CoroutineScope): SerialSe scope.safeLaunch { runAndroidSerialPoller(context, usbManager, server, wakeSignal) } return server -} \ No newline at end of file +} diff --git a/server/android/src/main/java/dev/slimevr/android/udp/address.kt b/server/android/src/main/java/dev/slimevr/android/udp/address.kt index df78a1059e..a69eb158e5 100644 --- a/server/android/src/main/java/dev/slimevr/android/udp/address.kt +++ b/server/android/src/main/java/dev/slimevr/android/udp/address.kt @@ -3,5 +3,4 @@ package dev.slimevr.android.udp import io.ktor.network.sockets.InetSocketAddress import io.ktor.network.sockets.toJavaAddress -fun resolveAndroidUdpAddress(addr: InetSocketAddress): String = - (addr.toJavaAddress() as java.net.InetSocketAddress).hostString \ No newline at end of file +fun resolveAndroidUdpAddress(addr: InetSocketAddress): String = (addr.toJavaAddress() as java.net.InetSocketAddress).hostString diff --git a/server/android/src/main/java/dev/slimevr/android/vrchat/oscquery.kt b/server/android/src/main/java/dev/slimevr/android/vrchat/oscquery.kt index e61fc5c2ef..d0c01493d5 100644 --- a/server/android/src/main/java/dev/slimevr/android/vrchat/oscquery.kt +++ b/server/android/src/main/java/dev/slimevr/android/vrchat/oscquery.kt @@ -17,4 +17,4 @@ fun resolveAndroidOscQueryAddress(): String = try { ?: InetAddress.getLoopbackAddress().hostAddress } catch (_: Exception) { InetAddress.getLoopbackAddress().hostAddress -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/config/resets.kt b/server/core/src/main/java/dev/slimevr/config/resets.kt index 43e90d175e..6959822355 100644 --- a/server/core/src/main/java/dev/slimevr/config/resets.kt +++ b/server/core/src/main/java/dev/slimevr/config/resets.kt @@ -5,10 +5,13 @@ import kotlinx.serialization.Serializable enum class ArmsResetModes(val id: Int) { /** Upper arm going back and forearm going forward */ BACK(0), + /** Arms going forward */ FORWARD(1), + /** Arms going up to the sides into a T-pose */ T_POSE_UP(2), + /** Arms going down to the sides from a T-pose */ T_POSE_DOWN(3), ; diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 027c443320..fb30acdec5 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -27,7 +27,7 @@ data class TrackerConfig( ) @Serializable -data class TapDetectionConfig ( +data class TapDetectionConfig( var yawResetDelay: Float = 0.2f, var fullResetDelay: Float = 1.0f, var mountingResetDelay: Float = 1.0f, @@ -44,9 +44,8 @@ data class TapDetectionConfig ( var fullResetTaps: Int = 3, var mountingResetTaps: Int = 3, var numberTrackersOverThreshold: Int = 1, - var setupMode: Boolean = false -) { -} + var setupMode: Boolean = false, +) @Serializable data class VMCConfig( diff --git a/server/core/src/main/java/dev/slimevr/context/context.kt b/server/core/src/main/java/dev/slimevr/context/context.kt index f0abc14c51..98d77ddf69 100644 --- a/server/core/src/main/java/dev/slimevr/context/context.kt +++ b/server/core/src/main/java/dev/slimevr/context/context.kt @@ -1,7 +1,7 @@ package dev.slimevr.context -import dev.slimevr.context.debug.contextDebugEnabled import dev.slimevr.context.debug.DebugMiddleware +import dev.slimevr.context.debug.contextDebugEnabled import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job diff --git a/server/core/src/main/java/dev/slimevr/context/debug/ContextDebug.kt b/server/core/src/main/java/dev/slimevr/context/debug/ContextDebug.kt index c985fc90c1..04941a2c63 100644 --- a/server/core/src/main/java/dev/slimevr/context/debug/ContextDebug.kt +++ b/server/core/src/main/java/dev/slimevr/context/debug/ContextDebug.kt @@ -1,3 +1,3 @@ package dev.slimevr.context.debug -var contextDebugEnabled: Boolean = false \ No newline at end of file +var contextDebugEnabled: Boolean = false diff --git a/server/core/src/main/java/dev/slimevr/feature-flags.kt b/server/core/src/main/java/dev/slimevr/feature-flags.kt index e72363b828..08e485a25a 100644 --- a/server/core/src/main/java/dev/slimevr/feature-flags.kt +++ b/server/core/src/main/java/dev/slimevr/feature-flags.kt @@ -5,4 +5,4 @@ data class FeatureFlags( var skipCheckUdev: Boolean = false, var udevRulesInstalled: Boolean? = null, val supportsSteamVR: Boolean = true, -) \ No newline at end of file +) diff --git a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt index f4c296cae8..759d80d3db 100644 --- a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt @@ -25,8 +25,9 @@ class ResetsBasicBehaviour : ResetsBehaviour { is ResetsActions.EndReset -> { if (action.resetType == ResetType.Full) { state.copy(canDoYawReset = true, canDoMountingReset = true, lastFullResetTime = System.nanoTime()) + } else { + state.copy() } - else state.copy() } } } diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index 901e7bed29..958700ea0b 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -51,27 +51,32 @@ class ResetsManager(val context: ResetsContext, val server: VRServer) { resetJob.cancelAndJoin() resetJob = context.scope.safeLaunch { val config = context.state.value.config - val delayMs = if (delay != null) (delay * 1000).toInt() // Use provided delay - else when (resetType) { // Fall back to config - ResetType.Yaw -> (config.yawResetDelay * 1000).toInt() - ResetType.Full -> (config.fullResetDelay * 1000).toInt() - ResetType.Mounting -> (config.mountingResetDelay * 1000).toInt() + val delayMs = if (delay != null) { + (delay * 1000).toInt() // Use provided delay + } else { + when (resetType) { // Fall back to config + ResetType.Yaw -> (config.yawResetDelay * 1000).toInt() + + ResetType.Full -> (config.fullResetDelay * 1000).toInt() + + ResetType.Mounting -> (config.mountingResetDelay * 1000).toInt() + } } val fullSeconds = delayMs / 1000 val remainder = delayMs % 1000 // Tell the GUI we started a reset server.sendSolarxrRpc( - ResetResponse(resetType, ResetStatus.STARTED, bodyParts, 0, delayMs) + ResetResponse(resetType, ResetStatus.STARTED, bodyParts, 0, delayMs), ) // Wait for the reset delay while updating the GUI every second - repeat(fullSeconds){ index -> + repeat(fullSeconds) { index -> delay(1000) // Skip final tick if at the same time as finish if (index != fullSeconds - 1 || remainder != 0) { server.sendSolarxrRpc( - ResetResponse(resetType, ResetStatus.STARTED, bodyParts, (index + 1) * 1000, delayMs) + ResetResponse(resetType, ResetStatus.STARTED, bodyParts, (index + 1) * 1000, delayMs), ) } } @@ -85,7 +90,7 @@ class ResetsManager(val context: ResetsContext, val server: VRServer) { // Tell the GUI we ended a reset server.sendSolarxrRpc( - ResetResponse(resetType, ResetStatus.FINISHED, bodyParts, delayMs, delayMs) + ResetResponse(resetType, ResetStatus.FINISHED, bodyParts, delayMs, delayMs), ) } } @@ -113,12 +118,14 @@ class ResetsManager(val context: ResetsContext, val server: VRServer) { ?.rotation ?: Quaternion.IDENTITY // Dispatch the reset action to the trackers - trackers.forEach { it.context.dispatch( - when (resetType) { - ResetType.Yaw -> TrackerActions.YawReset(referenceRotation) - ResetType.Full -> TrackerActions.FullReset(referenceRotation) - ResetType.Mounting -> TrackerActions.MountingReset(referenceRotation) - }) + trackers.forEach { + it.context.dispatch( + when (resetType) { + ResetType.Yaw -> TrackerActions.YawReset(referenceRotation) + ResetType.Full -> TrackerActions.FullReset(referenceRotation) + ResetType.Mounting -> TrackerActions.MountingReset(referenceRotation) + }, + ) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 60d9acdd45..55d4546c33 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -117,7 +117,7 @@ private fun createServerGuards(resetsManager: ResetsManager): ServerGuards { return ServerGuards( candomounting = resetsState.canDoMountingReset, candoyawreset = resetsState.canDoYawReset, - //candouserheightcalibration = true, TODO + // candouserheightcalibration = true, TODO ) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt b/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt index b8ab2175c0..498b0461d2 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt @@ -10,4 +10,4 @@ class InstalledInfoBehaviour : SolarXRBridgeBehaviour { receiver.sendRpc(InstalledInfoResponse(isudevinstalled = udevRulesInstalled)) } } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt index c4391cba96..0e1b0746e3 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt @@ -8,7 +8,7 @@ import solarxr_protocol.rpc.TapDetectionSettings class TapDetectionBehaviour( private val settings: Settings, - ) : SolarXRBridgeBehaviour { +) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { req -> val tapDetectionSettings = req.tapDetectionSettings ?: return@on @@ -49,5 +49,5 @@ fun buildTapDetectionSettings(config: TapDetectionConfig): TapDetectionSettings yawResetTracker = config.yawResetBodyPart, fullResetTracker = config.fullResetBodyPart, mountingResetTracker = config.mountingResetBodyPart, - numberTrackersOverThreshold = config.numberTrackersOverThreshold.toUByte() + numberTrackersOverThreshold = config.numberTrackersOverThreshold.toUByte(), ) diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 1004110160..1d93ab3775 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -48,7 +48,8 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { // Outer flow (loading) is refreshed when TapDetection config or a tracker's bodyPart or status changes val tapConfigChangingFlow = receiver.appContext.config.settings.context.state .distinctUntilChangedBy { configState -> - configState.data.tapDetectionConfig } + configState.data.tapDetectionConfig + } val trackerChangingFlow = receiver.appContext.server.context.state .flatMapLatest { serverState -> combine(serverState.trackers.values.map { tracker -> tracker.context.state }) @@ -60,26 +61,26 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { combine( tapConfigChangingFlow, - trackerChangingFlow + trackerChangingFlow, ) { configState, _ -> loadTapDetection(receiver, configState, receiver.appContext.server.context.state.value) } - // Inner flow (process) is refreshed everytime this tracker's acceleration is updated - .flatMapLatest { - receiver.context.state - .filter { resetType != null || setupModeAssign } - .distinctUntilChangedBy { it.rawAcceleration } - } - .onEach { currentTracker -> - processTapDetection(receiver, currentTracker) - }.launchIn(receiver.context.scope) + // Inner flow (process) is refreshed everytime this tracker's acceleration is updated + .flatMapLatest { + receiver.context.state + .filter { resetType != null || setupModeAssign } + .distinctUntilChangedBy { it.rawAcceleration } + } + .onEach { currentTracker -> + processTapDetection(receiver, currentTracker) + }.launchIn(receiver.context.scope) } // Loads TapDetection config private fun loadTapDetection( receiver: Tracker, configState: SettingsState, - serverState: VRServerState + serverState: VRServerState, ) { val tapDetectionConfig = configState.data.tapDetectionConfig numberTrackersOverThreshold = tapDetectionConfig.numberTrackersOverThreshold @@ -106,22 +107,27 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { // Switch case for each possible action when (receiver.context.state.value.bodyPart) { - null -> resetType = null // BodyParts above could be null + null -> resetType = null + + // BodyParts above could be null yawResetBodyPart if tapDetectionConfig.yawResetEnabled -> { resetType = ResetType.Yaw tapsNeeded = tapDetectionConfig.yawResetTaps actionDelay = tapDetectionConfig.yawResetDelay } + fullResetBodyPart if tapDetectionConfig.fullResetEnabled -> { resetType = ResetType.Full tapsNeeded = tapDetectionConfig.fullResetTaps actionDelay = tapDetectionConfig.fullResetDelay } + mountingResetBodyPart if tapDetectionConfig.mountingResetEnabled -> { resetType = ResetType.Mounting tapsNeeded = tapDetectionConfig.mountingResetTaps actionDelay = tapDetectionConfig.mountingResetDelay } + else -> resetType = null } } @@ -173,8 +179,8 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { if (setupModeAssign) { receiver.appContext.server.sendSolarxrRpc( TapDetectionSetupNotification( - TrackerId(DeviceId(currentTracker.deviceId.toUByte()), currentTracker.id.toUByte()) - ) + TrackerId(DeviceId(currentTracker.deviceId.toUByte()), currentTracker.id.toUByte()), + ), ) } @@ -189,7 +195,7 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { } } - private fun resetTapDetection(){ + private fun resetTapDetection() { accelList.clear() tapTimestamps.clear() waitForLowAccel = false @@ -217,6 +223,7 @@ class TrackerBasicBehaviour : TrackerBehaviour { ) cal != null -> state.rotation + else -> rawRotation } @@ -231,6 +238,7 @@ class TrackerBasicBehaviour : TrackerBehaviour { ) cal != null -> state.acceleration + else -> rawAcceleration } diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index dfb7f2879e..0bbe5aeee4 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -172,7 +172,7 @@ class HandshakeBehaviour : UDPConnectionBehaviour { AppLogger.udp.info("[${state.address}] Unknown MAC $mac, notifying solarxr") receiver.appContext.server.context.scope.safeLaunch { receiver.appContext.server.sendSolarxrRpc( - UnknownDeviceHandshakeNotification(macAddress = mac) + UnknownDeviceHandshakeNotification(macAddress = mac), ) } return@onPacket diff --git a/server/core/src/main/java/dev/slimevr/udp/packets.kt b/server/core/src/main/java/dev/slimevr/udp/packets.kt index bf7b8b7650..28aca1a69d 100644 --- a/server/core/src/main/java/dev/slimevr/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/udp/packets.kt @@ -273,9 +273,11 @@ data class Serial(val serial: String = "") : UDPPacket { data class BatteryLevel(val voltage: Float?, val level: Float) : UDPPacket { companion object { - fun read(src: Source): BatteryLevel = - if (src.remaining >= 8) BatteryLevel(src.readSafeFloat(), src.readSafeFloat()) - else BatteryLevel(null, src.readSafeFloat()) + fun read(src: Source): BatteryLevel = if (src.remaining >= 8) { + BatteryLevel(src.readSafeFloat(), src.readSafeFloat()) + } else { + BatteryLevel(null, src.readSafeFloat()) + } } } diff --git a/server/core/src/main/java/dev/slimevr/util/SafeLaunch.kt b/server/core/src/main/java/dev/slimevr/util/SafeLaunch.kt index e51913285e..9510fd6ab3 100644 --- a/server/core/src/main/java/dev/slimevr/util/SafeLaunch.kt +++ b/server/core/src/main/java/dev/slimevr/util/SafeLaunch.kt @@ -30,7 +30,8 @@ fun CoroutineScope.safeLaunch( ?: CoroutineExceptionHandler { ctx, throwable -> val name = ctx[CoroutineName]?.name ?: "UnknownScope" AppLogger.coroutines.error( - throwable, "Unhandled exception in safeLaunch (scope: $name)" + throwable, + "Unhandled exception in safeLaunch (scope: $name)", ) } //noinspection RAW_LAUNCH_USAGE diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index 03adb2b82b..735912ac7b 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -43,8 +43,7 @@ class VRServer( fun getTracker(id: Int) = context.state.value.trackers[id] fun getDevice(id: Int) = context.state.value.devices[id] - suspend fun sendSolarxrRpc(message: RpcMessage) = - context.state.value.solarxr.values.forEach { it.sendRpc(message) } + suspend fun sendSolarxrRpc(message: RpcMessage) = context.state.value.solarxr.values.forEach { it.sendRpc(message) } companion object { fun create(scope: CoroutineScope): VRServer { diff --git a/server/core/src/test/java/dev/slimevr/context/ContextCallerCaptureTest.kt b/server/core/src/test/java/dev/slimevr/context/ContextCallerCaptureTest.kt index ab2668da21..5226cc2a81 100644 --- a/server/core/src/test/java/dev/slimevr/context/ContextCallerCaptureTest.kt +++ b/server/core/src/test/java/dev/slimevr/context/ContextCallerCaptureTest.kt @@ -1,7 +1,7 @@ package dev.slimevr.context -import dev.slimevr.context.debug.contextDebugEnabled import dev.slimevr.context.debug.DebugMiddleware +import dev.slimevr.context.debug.contextDebugEnabled import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt index df0ec44a36..f3e4513eaf 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -2,8 +2,8 @@ package dev.slimevr.firmware import dev.slimevr.VRServer import dev.slimevr.VRServerActions -import dev.slimevr.buildTestSettings import dev.slimevr.buildTestAppContext +import dev.slimevr.buildTestSettings import dev.slimevr.buildTestVrServerStub import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions diff --git a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt index 531ce5c3e5..3b0ca3314d 100644 --- a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt +++ b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt @@ -2,9 +2,9 @@ package dev.slimevr.provisioning import dev.slimevr.VRServer import dev.slimevr.VRServerActions +import dev.slimevr.buildTestAppContext import dev.slimevr.buildTestSerialServer import dev.slimevr.buildTestSettings -import dev.slimevr.buildTestAppContext import dev.slimevr.buildTestVrServer import dev.slimevr.context.Context import dev.slimevr.device.Device diff --git a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt index cc3469c277..4d42624258 100644 --- a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt @@ -295,7 +295,7 @@ class SessionCalibrationTest { testEstimateHeadingAlign( ref * frontRot, hA, - ref + ref, ) } } 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 0d8b19baf2..8f89f3e6b5 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -20,17 +20,17 @@ import dev.slimevr.desktop.ipc.createSolarXRWebsocketServer import dev.slimevr.desktop.networkprofile.setupDesktopNetworkProfileChecker import dev.slimevr.desktop.serial.DesktopFirmwareFlasher import dev.slimevr.desktop.serial.createDesktopSerialServer +import dev.slimevr.desktop.udp.resolveDesktopUdpAddress import dev.slimevr.desktop.vrchat.createDesktopVRCConfigManager import dev.slimevr.desktop.vrchat.resolveDesktopOscQueryAddress import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.provisioning.ProvisioningManager +import dev.slimevr.resets.ResetsManager import dev.slimevr.resolveConfigDirectory import dev.slimevr.skeleton.Skeleton import dev.slimevr.trackingchecklist.TrackingChecklist -import dev.slimevr.desktop.udp.resolveDesktopUdpAddress -import dev.slimevr.resets.ResetsManager import dev.slimevr.udp.UdpServer import dev.slimevr.util.safeLaunch import dev.slimevr.vmc.VMCManager @@ -46,7 +46,12 @@ fun main(args: Array) = runBlocking { for (arg in args) { when (arg) { "--steam", "-s" -> featureFlags.steam = true - "--install", "-i" -> { runInstaller(); return@runBlocking } + + "--install", "-i" -> { + runInstaller() + return@runBlocking + } + "--no-udev", "-u" -> featureFlags.skipCheckUdev = true } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index 1e6d5e3e4c..45e3d34bc5 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -9,8 +9,8 @@ import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext @@ -115,7 +115,9 @@ fun createDesktopHIDManager(appContext: AppContextProvider, scope: CoroutineScop } } when { - data == null -> return@safeLaunch // read error, device gone + data == null -> return@safeLaunch + + // read error, device gone data.isNotEmpty() -> parseHIDPackets(data).forEach { receiver.packetEvents.emit(it) } else -> delay(1) // no data yet, yield without busy-spinning diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt index 4b8355e26e..3814de9fd7 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt @@ -40,4 +40,4 @@ suspend fun handleSolarXRBridge( } finally { bridge.disconnect() } -} \ No newline at end of file +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/udp/address.kt b/server/desktop/src/main/java/dev/slimevr/desktop/udp/address.kt index fafb58049b..44129eba38 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/udp/address.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/udp/address.kt @@ -4,5 +4,4 @@ import io.ktor.network.sockets.InetSocketAddress import io.ktor.network.sockets.toJavaAddress // Prevent DNS lookup when getting the address -fun resolveDesktopUdpAddress(addr: InetSocketAddress): String = - (addr.toJavaAddress() as java.net.InetSocketAddress).hostString +fun resolveDesktopUdpAddress(addr: InetSocketAddress): String = (addr.toJavaAddress() as java.net.InetSocketAddress).hostString From 4686e915b7e9b3de62d72c23a555d58c2f386aa8 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 19 Jun 2026 05:18:25 -0500 Subject: [PATCH 189/350] Remove unused launch import --- server/core/src/main/java/dev/slimevr/firmware/module.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/firmware/module.kt b/server/core/src/main/java/dev/slimevr/firmware/module.kt index 0b1f5a9530..63bd389bc9 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/module.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/module.kt @@ -10,7 +10,6 @@ import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.launch import solarxr_protocol.datatypes.DeviceIdTable import solarxr_protocol.rpc.FirmwarePart import solarxr_protocol.rpc.FirmwareUpdateDeviceId From 0723625272ffc156e481da848b29bb0d15103ee4 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 19 Jun 2026 05:27:21 -0500 Subject: [PATCH 190/350] Fix test build --- .../src/test/java/dev/slimevr/TestServer.kt | 22 ++++++++++++++++++- .../java/dev/slimevr/solarxr/DataFeedTest.kt | 3 +++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index f272834e79..61e673417d 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -16,6 +16,10 @@ import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.provisioning.ProvisioningManager +import dev.slimevr.resets.ResetsBasicBehaviour +import dev.slimevr.resets.ResetsManager +import dev.slimevr.resets.ResetsMountingTimeoutBehaviour +import dev.slimevr.resets.ResetsState import dev.slimevr.serial.FlashingHandler import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialServer @@ -29,7 +33,6 @@ import dev.slimevr.vmc.VMCManager import dev.slimevr.vrchat.VRCConfigManager import dev.slimevr.vrcosc.VRCOSCManager import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create( @@ -83,6 +86,22 @@ fun buildTestSkeleton(scope: CoroutineScope): Skeleton { return skeleton } +fun buildTestResetsManager(server: VRServer, scope: CoroutineScope): ResetsManager { + val context = Context.create( + initialState = ResetsState( + canDoYawReset = true, + canDoMountingReset = true, + lastFullResetTime = 0, + ), + scope = scope, + behaviours = listOf(ResetsBasicBehaviour(), ResetsMountingTimeoutBehaviour()), + name = "TestResetsManager", + ) + val resetsManager = ResetsManager(context, server) + resetsManager.startObserving() + return resetsManager +} + fun buildTestSettings(scope: CoroutineScope): Settings { val initialState = SettingsState(data = SettingsConfigState(), name = "test") val context = Context.create( @@ -122,6 +141,7 @@ abstract class TestAppContext : AppContextProvider { override val bvhManager: BVHManager get() = error("not used in test") override val vmcManager: VMCManager get() = error("not used in test") override val vrcOscManager: VRCOSCManager get() = error("not used in test") + override val resetsManager: ResetsManager get() = error("not used in test") override fun startObserving() {} override suspend fun dispose() = Unit } diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt index 67079ad4c9..da1eeadc5b 100644 --- a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -2,6 +2,7 @@ package dev.slimevr.solarxr import dev.slimevr.EventDispatcher import dev.slimevr.TestAppContext +import dev.slimevr.buildTestResetsManager import dev.slimevr.buildTestSkeleton import dev.slimevr.buildTestVrServer import dev.slimevr.context.Context @@ -18,9 +19,11 @@ import kotlin.test.assertEquals private fun testConn(backgroundScope: kotlinx.coroutines.CoroutineScope, onSend: suspend (ByteArray) -> Unit): SolarXRBridge { val server = buildTestVrServer(backgroundScope) val skeleton = buildTestSkeleton(backgroundScope) + val resetsManager = buildTestResetsManager(server, backgroundScope) val appContext = object : TestAppContext() { override val server = server override val skeleton = skeleton + override val resetsManager = resetsManager } val context = Context.create( initialState = SolarXRBridgeState(dataFeedConfigs = listOf()), From 98ccc7c66729870f64fc0098304b8f63a4fd04e0 Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 19 Jun 2026 17:47:24 -0400 Subject: [PATCH 191/350] Use sensible min and max for smoothing --- .../core/src/main/java/dev/slimevr/skeleton/processors.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt index f14d47cb70..4997ba1634 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt @@ -9,13 +9,17 @@ interface SkeletonProcessor { fun process(state: SkeletonState): SkeletonState } +private const val SMOOTH_MIN = 0.66f +private const val SMOOTH_MAX = 0.99f class SmoothingProcessor(var smoothing: Float) : SkeletonProcessor { override var enabled: Boolean = true private var smoothedRotations: Map = emptyMap() private var smoothedLengths: Map = emptyMap() + // TODO this isn't linear. Do we want linear smoothing like in main? override fun process(state: SkeletonState): SkeletonState { - val alpha = 1f - smoothing.coerceAtMost(0.99f) + val alpha = 1 - (SMOOTH_MIN + smoothing.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN)) + smoothedRotations = state.rawBones.mapValues { (bodyPart, bone) -> (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() } From 22999975ad348a509044ba85b7db71cd41774a69 Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 19 Jun 2026 19:35:23 -0400 Subject: [PATCH 192/350] Resets settings + delay from GUI --- gui/src/hooks/reset.ts | 11 ++++++ .../main/java/dev/slimevr/config/resets.kt | 32 +--------------- .../java/dev/slimevr/resets/behaviours.kt | 3 ++ .../main/java/dev/slimevr/resets/module.kt | 18 ++------- .../java/dev/slimevr/skeleton/behaviours.kt | 2 +- .../main/java/dev/slimevr/solarxr/module.kt | 1 + .../main/java/dev/slimevr/solarxr/resets.kt | 38 +++++++++++++++++++ .../dev/slimevr/solarxr/sessioncalibration.kt | 2 +- .../main/java/dev/slimevr/solarxr/settings.kt | 1 + .../java/dev/slimevr/solarxr/tapdetection.kt | 2 +- 10 files changed, 62 insertions(+), 48 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/resets.kt diff --git a/gui/src/hooks/reset.ts b/gui/src/hooks/reset.ts index 35b665c329..ccbbf82e07 100644 --- a/gui/src/hooks/reset.ts +++ b/gui/src/hooks/reset.ts @@ -48,6 +48,17 @@ export function useReset( const req = new ResetRequestT(); req.resetType = options.type; req.bodyParts = parts; + switch (options.type) { + case ResetType.Yaw: + req.delay = 0; + break; + case ResetType.Full: + req.delay = 3; + break; + case ResetType.Mounting: + req.delay = 3; + break; + } sendRPCPacket(RpcMessage.ResetRequest, req); Sentry.metrics.count('reset_click', 1, { diff --git a/server/core/src/main/java/dev/slimevr/config/resets.kt b/server/core/src/main/java/dev/slimevr/config/resets.kt index 6959822355..0459b94d22 100644 --- a/server/core/src/main/java/dev/slimevr/config/resets.kt +++ b/server/core/src/main/java/dev/slimevr/config/resets.kt @@ -1,32 +1,7 @@ package dev.slimevr.config import kotlinx.serialization.Serializable - -enum class ArmsResetModes(val id: Int) { - /** Upper arm going back and forearm going forward */ - BACK(0), - - /** Arms going forward */ - FORWARD(1), - - /** Arms going up to the sides into a T-pose */ - T_POSE_UP(2), - - /** Arms going down to the sides from a T-pose */ - T_POSE_DOWN(3), - ; - - companion object { - val values = entries.toTypedArray() - - fun fromId(id: Int): ArmsResetModes? { - for (filter in values) { - if (filter.id == id) return filter - } - return null - } - } -} +import solarxr_protocol.rpc.ArmsMountingResetMode enum class MountingMethods(val id: Int) { MANUAL(0), @@ -50,7 +25,7 @@ data class ResetsConfig( /** Always reset mounting for feet */ val resetMountingFeet: Boolean = false, /** Reset mode used for the arms */ - val mode: ArmsResetModes = ArmsResetModes.BACK, + val armsResetMode: ArmsMountingResetMode = ArmsMountingResetMode.BACK, /** Yaw reset smoothing time in seconds */ val yawResetSmoothTime: Float = 0.0f, /** Save automatic mounting reset calibration */ @@ -58,7 +33,4 @@ data class ResetsConfig( /** Reset the HMD's pitch upon full reset */ val resetHmdPitch: Boolean = false, val lastMountingMethod: MountingMethods = MountingMethods.AUTOMATIC, - val yawResetDelay: Float = 0.0f, - val fullResetDelay: Float = 3.0f, - val mountingResetDelay: Float = 3.0f, ) diff --git a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt index 759d80d3db..515c12fe4d 100644 --- a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt @@ -1,5 +1,6 @@ package dev.slimevr.resets +import dev.slimevr.config.MountingMethods import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChangedBy @@ -25,6 +26,8 @@ class ResetsBasicBehaviour : ResetsBehaviour { is ResetsActions.EndReset -> { if (action.resetType == ResetType.Full) { state.copy(canDoYawReset = true, canDoMountingReset = true, lastFullResetTime = System.nanoTime()) + } else if (action.resetType == ResetType.Mounting) { + state.copy(config = state.config.copy(lastMountingMethod = MountingMethods.AUTOMATIC)) } else { state.copy() } diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index 958700ea0b..14f0e6fa67 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -1,7 +1,6 @@ package dev.slimevr.resets import dev.slimevr.AppLogger -import dev.slimevr.Phase1ContextProvider import dev.slimevr.VRServer import dev.slimevr.config.ResetsConfig import dev.slimevr.context.Behaviour @@ -47,21 +46,10 @@ class ResetsManager(val context: ResetsContext, val server: VRServer) { * If delay is null, the default delay from config will be used. * If bodyParts is null, resets all trackers. */ - suspend fun scheduleReset(resetSourceName: String, resetType: ResetType, delay: Float? = null, bodyParts: List? = null) { + suspend fun scheduleReset(resetSourceName: String, resetType: ResetType, delay: Float = 0f, bodyParts: List? = null) { resetJob.cancelAndJoin() resetJob = context.scope.safeLaunch { - val config = context.state.value.config - val delayMs = if (delay != null) { - (delay * 1000).toInt() // Use provided delay - } else { - when (resetType) { // Fall back to config - ResetType.Yaw -> (config.yawResetDelay * 1000).toInt() - - ResetType.Full -> (config.fullResetDelay * 1000).toInt() - - ResetType.Mounting -> (config.mountingResetDelay * 1000).toInt() - } - } + val delayMs = (delay * 1000).toInt() val fullSeconds = delayMs / 1000 val remainder = delayMs % 1000 @@ -82,7 +70,7 @@ class ResetsManager(val context: ResetsContext, val server: VRServer) { } delay((remainder).toLong()) - executeTrackerResets(resetType, bodyParts, config) + executeTrackerResets(resetType, bodyParts, context.state.value.config) context.dispatch(ResetsActions.EndReset(resetType)) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index dc8d95ed1d..5375308142 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -112,7 +112,7 @@ class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour } class ComputedSkeletonBehaviour( - val hz: Float = 100f, + val hz: Float = 100f, // TODO behaviours like smoothing will behave different based on hz val processors: List = emptyList(), ) : SkeletonBehaviour { override fun observe(receiver: Skeleton) { diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 5ae6925791..5c9832f66a 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -72,6 +72,7 @@ class SolarXRBridge( appContext.vrcConfigManager?.let { vrc -> add(VrcBehaviour(vrc, appContext.server, userHeight = { appContext.skeleton.context.state.value.userHeight })) } + add(ResetsBehaviour(appContext.config.settings)) add(TapDetectionBehaviour(appContext.config.settings)) add(VrcOscBehaviour(appContext.vrcOscManager)) add(VmcBehaviour(appContext.vmcManager)) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/resets.kt b/server/core/src/main/java/dev/slimevr/solarxr/resets.kt new file mode 100644 index 0000000000..a86fb4f34b --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/resets.kt @@ -0,0 +1,38 @@ +package dev.slimevr.solarxr + +import dev.slimevr.config.ResetsConfig +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions +import dev.slimevr.config.TapDetectionConfig +import solarxr_protocol.rpc.ChangeSettingsRequest +import solarxr_protocol.rpc.ResetsSettings +import solarxr_protocol.rpc.TapDetectionSettings + +class ResetsBehaviour( + private val settings: Settings, +) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { + receiver.rpcDispatcher.on { req -> + val resetsSettings = req.resetsSettings ?: return@on + + val oldConfig = settings.context.state.value.data.resetsConfig + val newConfig = oldConfig.copy( + resetMountingFeet = resetsSettings.resetMountingFeet == true, + armsResetMode = resetsSettings.armsMountingResetMode ?: oldConfig.armsResetMode, + yawResetSmoothTime = resetsSettings.yawResetSmoothTime ?: oldConfig.yawResetSmoothTime, + saveMountingReset = resetsSettings.saveMountingReset == true, + resetHmdPitch = resetsSettings.resetHmdPitch == true, + ) + + settings.context.dispatch(SettingsActions.Update { copy(resetsConfig = newConfig) }) + } + } +} + +fun buildResetsSettings(config: ResetsConfig): ResetsSettings = ResetsSettings( + resetMountingFeet = config.resetMountingFeet, + armsMountingResetMode = config.armsResetMode, + yawResetSmoothTime = config.yawResetSmoothTime, + saveMountingReset = config.saveMountingReset, + resetHmdPitch = config.resetHmdPitch, +) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt index 23f42c8440..52a17a64f2 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt @@ -13,7 +13,7 @@ class SessionCalibrationBehaviour( ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { req -> - resetsManager.scheduleReset("SolarXRBridge", req.resetType ?: ResetType.Yaw, req.delay, req.bodyParts) + resetsManager.scheduleReset("SolarXRBridge", req.resetType ?: ResetType.Yaw, req.delay ?: 0f, req.bodyParts) } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/settings.kt b/server/core/src/main/java/dev/slimevr/solarxr/settings.kt index 9061e64060..e6a3b4261d 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/settings.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/settings.kt @@ -18,6 +18,7 @@ class SettingsBehaviour( receiver.rpcDispatcher.on { receiver.sendRpc( SettingsResponse( + resetsSettings = buildResetsSettings(settings.context.state.value.data.resetsConfig), tapDetectionSettings = buildTapDetectionSettings(settings.context.state.value.data.tapDetectionConfig), vmcOsc = buildVmcOscSettings(vmcManager.context.state.value.config), ), diff --git a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt index 0e1b0746e3..74a77999ef 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt @@ -14,7 +14,7 @@ class TapDetectionBehaviour( val tapDetectionSettings = req.tapDetectionSettings ?: return@on val oldConfig = settings.context.state.value.data.tapDetectionConfig - val newConfig = TapDetectionConfig( + val newConfig = oldConfig.copy( yawResetDelay = tapDetectionSettings.yawResetDelay ?: oldConfig.yawResetDelay, fullResetDelay = tapDetectionSettings.fullResetDelay ?: oldConfig.fullResetDelay, mountingResetDelay = tapDetectionSettings.mountingResetDelay ?: oldConfig.mountingResetDelay, From 0530cebf92af7b497e403e2c29f5168e7124898a Mon Sep 17 00:00:00 2001 From: Sapphire Date: Sun, 21 Jun 2026 13:19:04 -0500 Subject: [PATCH 193/350] Get rid of grgit (cherry picked from commit 31af38e6633fc384d993decb7e17f429c08dc054) --- build.gradle.kts | 3 --- gradle.properties | 3 --- server/android/build.gradle.kts | 11 ++++++++--- server/desktop/build.gradle.kts | 15 ++++++++++----- settings.gradle.kts | 2 -- 5 files changed, 18 insertions(+), 16 deletions(-) delete mode 100644 build.gradle.kts diff --git a/build.gradle.kts b/build.gradle.kts deleted file mode 100644 index 79534cdfcd..0000000000 --- a/build.gradle.kts +++ /dev/null @@ -1,3 +0,0 @@ -plugins { - id("org.ajoberstar.grgit") -} diff --git a/gradle.properties b/gradle.properties index 9be6eddd32..97a27f5026 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,7 +17,4 @@ kotlinVersion=2.3.10 spotlessVersion=8.2.1 shadowJarVersion=9.3.1 buildconfigVersion=6.0.7 -# We should probably stop using grgit, see: -# https://andrewoberstar.com/posts/2024-04-02-dont-commit-to-grgit/ -grgitVersion=5.3.3 wireVersion=5.3.1 diff --git a/server/android/build.gradle.kts b/server/android/build.gradle.kts index e2f1a1e4e4..e43ec3c036 100644 --- a/server/android/build.gradle.kts +++ b/server/android/build.gradle.kts @@ -17,7 +17,6 @@ plugins { id("com.github.gmazzo.buildconfig") id("com.android.application") version "8.13.2" - id("org.ajoberstar.grgit") } kotlin { @@ -133,8 +132,14 @@ dependencies { // The android block is where you configure all your Android-specific build options. extra.apply { - set("gitVersionCode", grgit.tag.list().size) - set("gitVersionName", grgit.describe(mapOf("tags" to true, "always" to true))) + val tagCount = providers.exec { + commandLine("git", "--no-pager", "tag", "-l") + }.standardOutput.asText.get().trim().split('\n').size + val description = providers.exec { + commandLine("git", "describe", "--tags", "--always") + }.standardOutput.asText.get().trim() + set("gitVersionCode", tagCount) + set("gitVersionName", description) } android { // The app's namespace. Used primarily to access app resources. diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index dcd5134bb9..6d97b84e12 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -13,7 +13,6 @@ plugins { application id("com.gradleup.shadow") id("com.github.gmazzo.buildconfig") - id("org.ajoberstar.grgit") id("com.squareup.wire") } @@ -116,12 +115,18 @@ buildConfig { useKotlinOutput { topLevelConstants = true } packageName("dev.slimevr.desktop") + val gitCommitHash = providers.exec { + commandLine("git", "rev-parse", "--short=8", "HEAD") + }.standardOutput.asText.get().trim() val gitVersionTag = providers.exec { commandLine("git", "--no-pager", "tag", "--sort", "-taggerdate", "--points-at", "HEAD") - }.standardOutput.asText.get().split('\n').first() - buildConfigField("String", "GIT_COMMIT_HASH", "\"${grgit.head().abbreviatedId}\"") - buildConfigField("String", "GIT_VERSION_TAG", "\"${gitVersionTag.trim()}\"") - buildConfigField("boolean", "GIT_CLEAN", grgit.status().isClean.toString()) + }.standardOutput.asText.get().trim() + val gitIsClean = providers.exec { + commandLine("git", "status", "--porcelain") + }.standardOutput.asText.get().trim().isEmpty() + buildConfigField("String", "GIT_COMMIT_HASH", "\"${gitCommitHash}\"") + buildConfigField("String", "GIT_VERSION_TAG", "\"${gitVersionTag}\"") + buildConfigField("boolean", "GIT_CLEAN", gitIsClean.toString()) } tasks.run { diff --git a/settings.gradle.kts b/settings.gradle.kts index 2674078111..da98375a53 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,7 +20,6 @@ pluginManagement { val spotlessVersion: String by settings val shadowJarVersion: String by settings val buildconfigVersion: String by settings - val grgitVersion: String by settings val wireVersion: String by settings plugins { kotlin("plugin.serialization") version kotlinVersion @@ -30,7 +29,6 @@ pluginManagement { id("com.diffplug.spotless") version spotlessVersion id("com.gradleup.shadow") version shadowJarVersion id("com.github.gmazzo.buildconfig") version buildconfigVersion - id("org.ajoberstar.grgit") version grgitVersion id("com.squareup.wire") version wireVersion } } From 0bbe35b5d1c9d53038db27696983603b6ca6b511 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Sun, 21 Jun 2026 16:38:55 -0500 Subject: [PATCH 194/350] Upgrade Gradle and AGP --- gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 4 ++-- gradlew | 9 ++++----- gradlew.bat | 4 ++-- server/android/build.gradle.kts | 24 ++++++++--------------- server/core/build.gradle.kts | 4 ++-- server/desktop/build.gradle.kts | 10 +++++----- settings.gradle.kts | 10 +++++----- 8 files changed, 28 insertions(+), 37 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b9530d66f5e68d973ea569d8e19de379189..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch delta 34943 zcmXuKV_+Rz)3%+)Y~1X)v28cDZQE*`9qyPrXx!Mg8{4+s*nWFo&-eXbzt+q-bFO1% zb$T* z+;w-h{ce+s>j$K)apmK~8t5)PdZP3^U%(^I<0#3(!6T+vfBowN0RfQ&0iMAo055!% z04}dC>M#Z2#PO7#|Fj;cQ$sH}E-n7nQM_V}mtmG_)(me#+~0gf?s@gam)iLoR#sr( zrR9fU_ofhp5j-5SLDQP{O+SuE)l8x9_(9@h%eY-t47J-KX-1(`hh#A6_Xs+4(pHhy zuZ1YS9axk`aYwXuq;YN>rYv|U`&U67f=tinhAD$+=o+MWXkx_;qIat_CS1o*=cIxs zIgeoK0TiIa7t`r%%feL8VieY63-Aakfi~qlE`d;ZOn8hFZFX|i^taCw6xbNLb2sOS z?PIeS%PgD)?bPB&LaQDF{PbxHrJQME<^cU5b!Hir(x32zy{YzNzE%sx;w=!C z_(A>eZXkQ1w@ASPXc|CWMNDP1kFQuMO>|1X;SHQS8w<@D;5C@L(3r^8qbbm$nTp%P z&I3Ey+ja9;ZiMbopUNc2txS9$Jf8UGS3*}Y3??(vZYLfm($WlpUGEUgQ52v@AD<~Y z#|B=mpCPt3QR%gX*c^SX>9dEqck79JX+gVPH87~q0-T;ota!lQWdt3C-wY1Ud}!j8 z*2x5$^dsTkXj}%PNKs1YzwK$-gu*lxq<&ko(qrQ_na(82lQ$ z7^0Pgg@Shn!UKTD4R}yGxefP2{8sZ~QZY)cj*SF6AlvE;^5oK=S}FEK(9qHuq|Cm! zx6ILQBsRu(=t1NRTecirX3Iv$-BkLxn^Zk|sV3^MJ1YKJxm>A+nk*r5h=>wW*J|pB zgDS%&VgnF~(sw)beMXXQ8{ncKX;A;_VLcq}Bw1EJj~-AdA=1IGrNHEh+BtIcoV+Te z_sCtBdKv(0wjY{3#hg9nf!*dpV5s7ZvNYEciEp2Rd5P#UudfqXysHiXo`pt27R?Rk zOAWL-dsa+raNw9^2NLZ#Wc^xI=E5Gwz~_<&*jqz0-AVd;EAvnm^&4Ca9bGzM_%(n{>je5hGNjCpZJ%5#Z3&4}f3I1P!6?)d65 z-~d}g{g!&`LkFK9$)f9KB?`oO{a0VXFm1`W{w5bAIC5CsyOV=q-Q7Z8YSmyo;$T?K za96q@djtok=r#TdUkd#%`|QlBywo>ifG69&;k%Ahfic6drRP;K{V8ea_t2qbY48uYWlB3Hf6hnqsCO?kYFhV+{i> zo&AE+)$%ag^)ijm!~gU78tD%tB63b_tbv9gfWzS&$r@i4q|PM+!hS+o+DpKfnnSe{ zewFbI3Jc0?=Vz}3>KmVj$qTWkoUS8@k63XRP2m^e50x-5PU<4X!I#q(zj@EyT9K_E z9P%@Sy6Mq`xD<-E!-<3@MLp2Dq8`x}F?@}V6E#A9v6xm%@x1U3>OoFY{fX5qpxngY z+=2HbnEErBv~!yl%f`Eq2%&K%JTwgN1y@FZ#=ai+TFMFlG?UV{M1#%uCi#Knkb_h| z&ivG$>~NQ4Ou2-gy=8JdRe8`nJDsqYYs?)(LJkJ}NHOj|3gZxVQJWWp>+`H?8$$J5 z*_)+tlyII%x#dId3w(oXo`YEm^-|tFNNj-0rbEuUc2-=pZDk7fxWUlw;|@M9s1 zmK9*C)1Q?F5@NPUJOYOAe`GHnYB%G37_sg3dxAttqLs6Bro)4z ziy8j%C7KKDNL8r#Oj6!IHx|N(?%Zvo31y4;*L1%_KJh$v$6XhFkw*E|fEu9`or?JD_ z13X4g92;TZm0jA0!2R5qPD$W^U z`5XK|Y^27y_Q%D>wWGtF=K00-N0;=svka>o`(;~dOS(eT0gwsP{=Rq+-e2Ajq?D<)zww5V36u6^Ta8YT4cDaw} zfuGnhr_5?)D*1+*q<3tVhg(AsKhR1Di=nsJzt_si+)uac_7zx_pl#t(dh816IM zvToHR%D)$!Zj4Q^$s8A%HLRYa>q9dpbh=*kcF7nkM0RhMIOGq^7Tgn|Fvs)A% zznI7nlbWoA2=rHHbUZ4PJMXf{T$@>W1Tt4lb|Or4L;O!oFj8Op8KEE`^x^*VSJ`9~ z;Pe~{V3x*-2c|jBrvSV8s+*Y3VqFKa@Napr#JAd}4l7;sgn|Q#M!(<|IX1<)z!AC3 zv<5YpN58Fs4NYi|ndYcb=jVO6Ztpwd={@3Yp6orUYe6EG#s{qhX+L^7zMK+@cX1hh?gbp56>jX*_Z|2u9 zb*glt!xK>j!LyLnFtxs&1SLkyiL%xbMqgxywI-U*XV%%qwa5oiufFerY!wn*GgMq` zZ6mFf8MukDPHVaCQk#oyg^dhl*9p@Jc+4Q9+0iv?{}=}+&=>n+q{o z#rEZ<&Ku65y+1eRHwcl3G7bR`e{&~^fGg|0))$uW?B@;_sWSls!ctnjH6ykmM8WJx};hvdXZ>YKLS($5`yBK38HULv}&PKRo9k zdFzj>`CDIUbq8GxeIJ?8=61G-XO?7dYZ;xqtlG?qr`wzbh7YyaD=>eup7bVH`q*N5 z)0&n)!*wW$G<3A&l$vJ^Z-%1^NF$n3iPgqr6Yn_SsAsFQw?9fj z&AvH|_-6zethC3^$mLF7mF$mTKT<_$kbV6jMK0f0UonRN_cY?yM6v&IosO?RN=h z{IqdUJvZd#@5qsr_1xVnaRr`ba-7MyU4<_XjIbr$PmPBYO6rLrxC`|5MN zD8ae4rTxau=7125zw|TQsJpqm`~hLs@w_iUd%eMY6IR9{(?;$f^?`&l?U%JfX%JyV z$IdA`V)5CkvPA0yljj4!Ja&Hjx`zIkg_ceQ;4)vhoyBeW$3D<_LDR~M-DPzQQ?&!L*PUNb^moIz|QXB=S z9^9NnEpF+>_Oh6+Xr55ZLJ7`V=H}@D<70NiNGH{~^QE-U)*Sg@O}M|%{Rcpn z{0nD@D%@8!dE*mndd2g!-q9;)jb=IUED<(Pxh`9B>V3z#f>82~&CVZASC?|;C-VKy zJU35T|3jd(p8F|#n@T~Wh2l1yURI=LC>Uj_!8i7-DE_IaSKIMAx`WMEq8kN%8sAx% zOQs~R1v12(=_ghVxzylsYZum-%8QmjM3-s2V!jY|w#ccP)}OSW?MWhNu@o-t0eTg{ zyy`}x+}GObZC(k>-upb2C6#S*NOfWbKEyReP%gay8MT!pJpsx4jwCu%>7%sY}1L6Vybj_P+;yP`YS92 z^o_G!Gr_NP!ixe7d&82H&achfi83L;le3Fs?u%E*xbeOKkJr7mp=)RXjZF;h*hR<= zP_cs1hjc}0JlHal=enmG&G8wsn%Sm$5Wcgs=Zc}}A%3i6_<4k_`-$k2E5f6QV{a$V zg3VZO36o^w5q`q2ASwJw#?n7pBJyGt3R<`Sd8d|52=h&`|CPq&1Cz&42rRCHNjDZL z$}Y*L+#N;!K2Ov){~fmQM8hVYzj3H@{yS>?q3QhhDHWfNAJ#q@qko|rhlaGG4Qrvh zmHpmg&7YvgRuI|i78-{)|wFx(R^_ z{ag(}Kbbbx=UW42sAu}kg3yB#96dJlOB{+or<(51ylVwpXII7Hrlztq!pefQ?6pQhqSb76y=sQx zOC-swAJaqnL_ok{74u_IHojFk;RSSFfjdLrfqq{syUxA$Ld6D2#TMX(Phf~dvSuuX zmN2xzjwZxWHmbvK2M#OhE#{`urOzs=>%ku}nxymK-dB~smas?Z(YM^>x#K)M@?<&L zeagMnj!XK4=Mid$NvJ+JfSjvc`4rX9mTo^+iFs0q7ntZ{gfU3oSAbK_yzW3WA^`6x zWgPSLXlEVvh!G^fOzZ-O{C_v;V6=;DE+ZqRT4mbCq}xeQ0o z98Cho%25r#!cT_ozTd~FK^@AB3OnrAAEDI4==}#I_v}iw0nhA{y99mFRG*1kxFkZP z+are- z8D|3WoYE>s0<=h)^)0>^up+nPeu}Sv-A($6t3AUedFczOLn;NW5_xM0tMvvrOSZ}) zA2YG1m4GxLAHZ5k>%}pHYtf-caXMGcYmH8ZPLX9VCew0;@Pi-8zkH^#}Cu$%FmKJb=!)Twj!PgBmY0+>VUsyyT}Jy>vMt zo<^5lmPo5Jt-=)z2-F{2{jB{CpW2JDj%~JnP*rq^=(okNQpH=}#{kqMUw{&=e-5;G z!FwJVQTDS7YGL&|=vJ+xhg{dMika2m2A#l@$PazLQ<6$GLC+>4B37`4aW3&MgENJ% z#*tOQsg{>zmcuSgU?peLA}!Rlu&K3LTc@drSBaI?91dK75;_`(V`NHjkMj``jwjJx zcm_!liUxn=^!~0|#{g2#AuX9%;GTBq&k+Jz!~Cc+r?S}y=Q1okG0PRIi3C3wgP8F| zO2jcmnVbGXp*Mu&e#a9Q5a}w7$sITx@)8b}sh(v9#V(H$3GLHF@k!Wh+)kNueq;+r zFtj+^b1TQe?R#Y8{m!7~e6%83hbPKoizd2LIg3yS5=X2HE^l4_|(2q#LB zeNv&njrS$?=zzG?0Min#kY+3A)H1uMfogMYSm|vT%3i<_d9X&~N*ZCL4iB@YaJuo; zq}-;EGx~T43kq-UHmTn!@sc z3bwcs$rp?~73h*uZl_ysD*WK3_PS1G3N^t3U=KoRm_Gz@C?M>+x9HRMk(cA4m&L`! z=Lb~4*9zt*SHJgsAMAcTy*!1W^B>4T_doWvNw7UwmyA=Wq&kE{*GVHp9Yk5goUO;k zVb_3ARrFPG;&>Jv@P&`z%}t!*M|2127pm{S)gs~f_ID^lOH@nIW9DgU$=FjqNW0pv z&GYdoxe@)RAWWx^j|$N}sj*p)_bFpk`Y=NilvsI(>!Z&KBo&I+wb*kM5Vvkkr#;q< z3CobbF+GJ#MxL?rMldP0@XiC~yQCR57=wW_<$j!SY*$5J+^v{Pn!1{&@R-lHCiK8@ z&O=XQ=V?hjM;h&qCitHmHKJ_$=`v%;jixnQrve^x9{ykWs(;!Q9mlr#{VYVE93oaW z&z+vBD}!tBghkriZy7gX7xJp8c}ajR4;JDu^0#RdQo2itM^~uc==~eBgwx5-m7vLj zP)vE#k%~*N$bT#^>(C1sohq+DwAC{U*z(D)qjgghKKSy#$dPih`R09rfbfI-FLE!` zn!tg71Wr(D7ZV*4R@GqG&7)2K*Zc6_CMJoGu#Yc>9D#{eyZ>u-mrWG@4Hk(je3lnH zu9qvXdq+!`5R1mlzWjV^jvaHl>-^Z+g^s5dy49yem$0$>341=EGuOY=W5PCFBTbNN^19iIQ57C3KcV}z~z#Rvngs#j;g2gswC(TLWlViYW}tB5T#g4 z%vDUYTo1@+&zE&`P%fXc^@prE5z;E@;; zKtpEFYftJq-c0sD6lKYoEQ;O1X4uFZZ;3gdgfAKqIc=Dj6>unXAdM}DD*@a5LHk~o zyJjW@aK;XG%qr<)7Rqh7NdUpnTR6jc;6{FKcK_v_#h{IO{mez>^^70DAWB5whqq!J zevvLUotE;I?IWWf!ieJ-Hx`TqY5)ND>K0NCb7IW40Jk*J* z^#m%kIA~Go2=R|y5zM|*ehJxyuX;lOQZkArKVbQV(XmidUH|8U^q`wP(7%F}=uG}U z2~&~CLebE`c%SCdeU(l&hryL~+Y)6I^d@|||6F15IAGo`G+CdVf zc+!EycZnQH)OBE zyTd8k{(_v9d2}osA$*>Q>Q&OB(7ShxA$}p8ChVnYlXl5My$HlVx@ATprrj0}6)ycK zcQy#bwOms1CnS+xd26}k?J;WI{HR_U+1T^I!$B^S=pJkT705QaMF88VJp!s%`?y9z8f$&Xw(A}3u_(n5G{!)yH&zN)S?c1$SZlo>XieJ zyEFa>_p9B*cY){ct8=dq>uQTf# zd4vB4)(ebwQHlSAu}(6GCe28H32pz^}l%Zqs;Yl|B=l2d9HrCcUf%wxLYs4CBqJ#{gz*u6V$>?9IT@uSf~2Rgk6CNw;C21ZbNkm>ZTc@2zeOSXVE^>i5!2>t%!1cI z{FZA`*o4=dTDG3&{v$3xVr%g;3d(!SFJU}w6x_Re(ohlni)I54Wg{t zWLK{A(}qEIH@pamgtr3serA{THlp_IR(gt0CFguk={|Ochh10)7UV4DcnO7fvL<=x z^WCMg_TI?U8(loaUnAe+Nc9I1JIO#_C`=kJG(&wy%Cr9vRFcY9^8{A3A>GuSW~Zk( zMA#t~0Dw?;3^Ue|lhSp4p%YvYmw-&3ey3}+{6Uhz?l1D|6nYNok6?4N_C!OSR=QtS z2X&QtWlkZshPo#-dXBOlSqh3D;#*_`hyohR>vl$W+QC>HPOs0zwHKN`?zIKqCTw&w&NUGNS|abulHe{D+{q z`WvLw?C4K97cd}6V6f2NtfIAO;=c>qi^+y4#oMjK?5Hy9$Tg1#S~Cxoo-Zdpnt2kG^n}`9)Df-Spvx&Oi+6xXT=N*0l|d`p!ZU ziQo9$y}PYIF~Zqh^?6QZ8YS*JtD^gynifSLMlVYRhBi*f-mJFS<>l%5sp5$V$p*X9?V-0r4bKYvo3n@XkCm4vO-_v? zOsLkR?)>ogb>Ys*m^2>*6%Db0!J?Qvpyd+ODlbslPci9r#W>d~%vcU7J_V;#Um1+` zG0>Q$TrOLUF0%a3g=PaCdQVoUUWXgk>($39-P;tusnMlJ=Dz}#S|E== zl6b3bbYaYguw3Bpv|O(YR2aBk?(jo+QqN*^6f0x+to-@2uj!nu6X{qLK>*PxM!i0C zZwrQ}prOw6Ghz?ApvM`!L3Dzc@6mp<2hO0y{_`lqtt!FcUmBG+PBwl?>0Mwu)Ey{L zU;A{ywkT}jCZpPKH4`_o0$#4*^L7=29%)~!L4*czG!bAva#7ZCDR|6@lBE&cyy5eE zlKHwzv7R9gKZTF<8}3*8uVtI)!HE%AZRD-iW!AJI7oY43@9Z$0^MO@Egj1c?o(BwF ziz1|k#WOgAG?^r1 z>+p=DK?cA-RLIvcdmwq$q?R;ina0SPj@;Mus}W_V2xHnYhOq~=sxzA`yTUOsJ`8`VOSTE=IZ!x`cZYqHbgPijF>J>N7( zqbNsHK50vkB1NI52gyb^PflpU0DRw{&v7Y}Hy2>pV@W2f1EOd2j;H?|WiV%2?Dk7u zS(NrEUDl81<}yY9J#OCwM)N?x&PB-%1{oD*`_ZLiBJ=16uR{n+Lk~!t(&9U#>ZfVd8Iqn&idGd>uo?L@sjm>c|Lk z12d3Y>N9U`342@xaHl&Q@oE5V-f$s`04q983f0#m_WF=X_A89W8C#{uCdTNUZ+))$ zakPyNU)?MDayCKxWh0(-v~1rd8FxocW=Dc6B1%N4^SgQj$?ZMoAMQ-35)IMgf&)M?c@}4QG7=DTq{nHc7yp=CZ z1dh~VkK%OTr23U1mJ*a-DxX0Psvh_13t^YcPl9t?_^$pPEhhwGp}s~f=GFR;4@;@f z@B;R1U6Df?yl#Y=BgYTlP&<|8K27||rx_?{s|L);GM3^{Nn8HZp zFqxiG6s3Nb;PW3O=u;(-o(*q!^2i)jHY%N@;O5Hder~_@$zh4xG#-7?#S^-&M~yc} zh5Y=ltLBnTzt;Y%YNqi2d1M1LOz?MJbZ|Nc6>x19&l_S*2Rgk$DhaP7Y-C)4_uPzf zQm)OY)$AFfE1(0SxkbbN4}CHnlU`RqYFGIE7S9ipx_Q0vkE5JRq4Uc%zV7$?y(x$y zV^)5zwjH~+4?xN z9s@x~w`C_cS}khfI14K4Xgn^iuBxkd^u}3cY=VZI@-8iWHolPtt?JD5lZ1V=@g6yR zj0>bd7Z(dw+@)v#r!xpZaAxgT?4Ton(h`0}fkfF!ZDSu{f*r#{ZRp^oOrO3iB|Fa- z;|+PpW5JKZxJ-kjHf`-7ohmnO=a)Xl9lhI8&$)g6R#6PBIN$QSC8kT=4zj?w&=`!qjkCvvz;ypOfR7P)w^ z-7LFhXd6GLrFa_vGLwR5MRvcV*(r!NhQ@}T-ikBGy!fHaiePD$iA{|Q1$kct2`qHz z6nAyERuqvM6i2^?g@w7W2LLr~3s?pBDk6ce8@CxV;b%4%-rXK-GOk+($sSNK;_FBku zm89B}tpzL-x{dPS-IAjwyL*t7N%7~2E)9OsWJJWHc|}BNa5Xwdx(j7i7AmZhs?#zi z5{y$uQdx?O8x3>+5MR05HwUa-YZa*|UVLOb`T)KHk|~Gmwx8MfBUtM|afuM$0wb7m zR+_lU9=W~Y$uNlxt&(@&1;6t!r69A|W%;k3-%SzLlBzc0 z`b?Jmo`8{LI=d|I3JDAa|iK*D6=I_3q?%xFSLg1 zI^!pA=K}l1joBBj8aa8XHp^;Lf`9xNa&Cv+twW&$_HAwZfHrVcNUrRccn_ z1+L!z$k@LK28nc1VB|Fbwm$wO;B~yEdww1EUn|s&{-Tu;@$d94BLL(OQYx|aCa|&2WPT{qJzbNU!ep>j){o5=6le6 z>~Amqs+mCuOR2)aB!#sK5fuui7LsO!Qzl)lz?Lm!QoQFWbNIkfdkrn|)YbSu8WwxZ zO{}a~wE2Cu)`a3X+KI#LHm(Mi+}bOB6@N~H2}Y)e*}w8_z^Sx`c?CWvu*2{K#yqGo zx!Cu*+8&tdw!eiKqZIQlJg5Cb^hZ^Zh~Mb0l(4m4hc1mP&>oTdt7eS-bEz8mU~oObme{^%56|ou~EPOSFBa7VpUZC z0gVc<@IUeo~q)&?o zU@=bz-qfWm)&0Qn@W_fc9{wx={&-#8>0xHJ-+Ijl#P&1qB-%*KUU*DCPkKCLzF*#t z0U_vrk1(&Vwy6Vm8@#Th3J5J%5ZWd)G0mifB3onY8dA&%g6Hir5gqMH|hnEBL0VVvl~aJjdljF$-X@a zMg=J-bI?2LGw-8mHVF7Jbsk1K4LgWi7U>~QovGT2*t^U&XF#iDs_E$~G+t;U;tZn_@73Y6x>vU%x` z6?l`$@U4JYYe#|GcI^f+rsy|MdB|`PQunKSKkja4IGtj9G6buN&ZSnYi|ieaf{k5q z@ABM@!S(A6Y}Sv~YJcB;9JeqsM|-fPIZZfOgc*FSzIpEdT=YYT(R(z{(~X&x%6ZM1 zY0(|PepBl4dK*@9n6@`rUMd)K^^0!^?U-1rrB*b?LEZe<5taFp!NoC^lc>}YUy?5FjT9tFmC+%%DYNa+L zWr)zMB%y_6L{S%;dk6bJPO!wmT=wPPK1b$%+ffWcO8;2T+7C28T?{!96{%d`0G~j3 z)6g<%$dC{vAKJ22nY)fnxlD>P_Xb&@>wrG+ZpfQ%RX=R2kd@bH3N*M8=BO zi|Z$Z5e`0NcU5&aN_DST8O@4v3vroq3t<_5hBX;d)*AJgWPb~p=qx4}^Ms6pgyY`) zu z^|u7XSP^~b1)*61r(}zd!JOny@$KviSp>L|jSR!u*1IgKwId5jmAi2`qe%u+XCTwU z;a62_a~Z}TqDJ?6lje5hblv1f1(6U@kWpc)z|&nRBV*UIieQR{Rru*|$L2SzxtL&| z7abeg@xniYhexYoN6zxY{nI^*xKW0Gz8D~}tE>O4iCkpWn8wt4?S`(Ftv?<8vIvbw z(FFd5`p4~#m<(3uv2+pv7uVC$R(iZuhnxFEY{o}BxPg2nYK zzOjuMR`}t3{8z#zfLXy||4JCt|1nv5VFjS#|JEhRLI>(-;Rh~J7gK{as*K1{IJ%7F zoZnXx&Y54ABfp9q!HDWAJlvFFdSC9}J*llUYXFDN8meEa<0}s z8M~X?%iKLB$*-a}G_$rTh;U{M0vc<}N#PVAE1vQdL#9a-`uH3*cbJZ~u9ag-fny$i z8aCs;3E85mgVK&vWM6}FH9o^WI#G!=%YOB#gT`1^VttnSVf4$YKja@-;zARB-`7v< z*imICw^KX73Gq-go6e?w^os0U0HSxH>60JLWhFbDeGT&Z$d3;9NWy;WvICuoZaKMi z=UvTpLDrtssbhiK&A3EuWf6!)>$sUlRcn5?Pk^OCtvApB=6suN42uKN-Xs7u7EjXh zG|>-1Rp>w1KB%sI*b5dGwFbuHNN=|})sR(dekHBL=>I~l@Nao%H=w0q==`3$zP>!I zmgoBoi7ylm<9Fw6s3&T%wJ%>VQmx(H)!iq?ABhdSzitwHlFNGcBW4sc&9DmTThb^qz`diS`xzQT# zhZff!yj2#rS>yfS5?}{inV5BfcZw zF5uh!Z8b#76;GcBDp7^zWtzQ%J;D}es(iWWWQNA{SvyhO`X8oyNL?j8Afn=x(zHct z7)3c%RKTPAyKS0gwVpGLqR2_%EowBpk>rW}MFfsR9>#2aOL!HKZtg$bAOe+#;;w?3*If zQk=HPWSlX7cF?h1PVE1D>LL{K&Ze4d!#Y2qN+^N-`~RG(O^Gjg~EsZbW^ipD9*+uf$K4Cq=H zxnYj(#+^eUa_1nRDkJJH|9$VB>+n4c)jji1MPz$dV4Ojf;)iYjgw#m+4puPdwgLSj zubNnwfz=z1DqFmy@X!!7D}kTo6yBjVFYT`CisjAgjS^cO%|(B2vzWb5PcrnxTK4xu zm?ZZkCy>+)-K8*)fo5JCWa@}^R!iI}a6OA*S&ibX6V zKk0=}K_M7m$#QEMW=_j=4tDXgH{_l5u?oFF?CXKmk73#~&>ha8CH{7jDKT2WoJ&sW zD1wk_C4Q6m{-YEWeAg*gP5`2Yl>4S@DAbob$M?&Gk2@2%+H*H2wu_)XL3fn{D8ljl zh41$!&_(kR($}4zJj3?zH-A0f2$4;9tH|N9XT48P;?coFH~9`z4S_35{xiUZC4&-3 zo3Yt|ee&RI&qBF zW$mPrwbqtHO$6De21%1=8zUX5=uMV*>#k-H>d5vP zz8OPyI|HLGKn`U2i>k8-dUX}5DJ(|Oy>)cK%QOwU>>~+Wn?bp?yFpx?yE;9q{;DTa$CFGK2S&xDNk$24GuzOgK{np ztsuRfjYmLjvhn$}jK3F_+!AtM`LVw=u&FUIGIU6>0@nqZq~REsb}_1w!VB5-wbS#J zYPBNKKJcnu^LTORcjX|sa8KU?rH5RRhfJ&l7@AtLVi|n8R7-?$+OVx!2BrQCD8{a)Kc#rtcWIC2(YYu=0edjgP9sFpp0=(eKUE2*>jc+n@q? zKTY!?h-S?Ms1kNuRAjowlnTQZF=#1S3XPx<()Wc1>r=QN?#W;6OL z2|Y0fxO0y=?Qi#F4?$+-Qpt&J>-JT?;d6ITN&7R`s4l(v17J7rOD3#Mu@anT`A z88>nZmkgV5o2{_IQ^TOFu9g}ImZrc~3yltx&sdaLvM=bAFpUK=XGx*;5U2#%A{^-G zEpT(GF(}NVJNzn$I*!S`&mA<1j#FEw4`lJ|^Ii?VA+!l%tC)`Q6kS&`LD*!rp)SSZ z!fOJa=BWFG0rWJE<~c2SnT{ykD23&sE?h7iTM20!s3!XMY*WJK_oA3FzU zScKW==wTvjelr=iu2>(0OLprW-Pv$m4wZ7v>;gB4M5m0(gOK>_@aIy}t&Y`H8crZ% zbo1L-*2^hdvzq`~_{<=PT=3jZ#UgMI*bQbOCzf~T53X2F9_QJ+KHwwQCpU%g4AGP z7i4m>KYOFyVXw`L5P#h};Q56X@OHZ-P-1qabm)G~GS>9sP0ToSI#43Q5iDCjG6r<1 zyJZa^U&>SXTW+bvJNB5oHW0xNpCGimZgaFJSb^??Uz1|jbXP-h<65N`CgZYX8jM3^ zSJ2tNSxr8>9)`mMi8nHw1aDz_?+ZRuMO@tou|Q9z11zdD#ka!jZfeXi(bGK&_vVQ^ z?b#6fYLRy70Mb9>3LcE``^rMcoxj~!hvBT%&cQK#L#nhF)C)iw(B$hY1fwak15v#J z-<0Kg=Zh1uk_^yGnO~&Hl|4?14*DFz9!$a(EAbT!5(<}0xUlYlC%`_JfofaWqfWNEfhlbLb2Ds@#m_oKXUJ0 zdSUbdO-BOnM!b2U2o3t3AQ&HGTzjL}LBTpwM2|gf3<(USB~4unKD6^_G>?@N%R2V zE+a}P6(vB@x|W>|ol!d5vws)e>m=0+2Y~#n1%kb=NXlT+^$#v9N z0Lt8wQ#?o)_j$PRavtm~z!aRPQ85^H^}u0bjlfDm(!3xG(oMQY?(DW6m1QdXq-PG; z7jW?rNj(vW&SZZ>B^q=2mU!8NLql4|nTI;pSkw9gbip(A^U<9DVj%Sjd-T0)ldwku z!O)$tFvVGRJnSI!t*v+U;QlSXfMu%J>v5B@Rq<`V$DQ>YTCkc=so?hUx&dda4;A1r z>~5vZ0E0M|B&lv|71*mTuRX`GB3G>9RzF7}+2HIgGrV-?p|bN%&4si|xxb+z1S}F2 zOBQ37uO?>1n_T3UF8nYp?uWnU&+53X|N94hR8WunjZ{}VH({S=x7sRbdLq7vyftJ? z2@;dF{)x|0nI%sYQ|%pe)%r zxP>}6S+ylPH{St~1KGov%?}z^A&&&(B(s+ngv{wKZ_L(*D^+nzoie`$NZ_*#zQ@&T zeLY@LZ5;akVZ}L=Qc=fIphsO^5%YJ0FQWW3*3|ahxk16yr=ZgTqunNMFFko^CZVSh zlk<_(ZLf{~ks&04%zz`tNla=O_`5r6W>d-%mdkEryHLIgIZyrq88$=4=Im4xR_}|) zZ!?V3+6QZ7$+wYJ=>nqKQ2L_gKw%=9`ds2Mdo6`avM-uO$tdP}7Jandkx0}XQhkn# zzq9uFBxvJ^#%sW$s)6J+j5 zXmAN{4mTo60nJnc2C6XtOBsVbJYc5&a0nZ|e?0yj+kThaCezk^Cm!F<|A=cu`uO@u zMai;5H6<@WD$n?-1{?Pzr2mF?F||EI+58#(N9dB2U*+$o$gl7(T>0jTu!?94mCA7^eb%}7cOyZN?nfVx+L$x~x>^tyJj$vmKZOXBKkU?mdopygE`0+rPi zx3F#q)PBC|6M{n@2|m%_24@G{?ql$@S=PPaEh1sG9v zxo35;K!!nAr&^P|c$6z+&vUa@eX|Uw&nednN1SCQSFNx={#kvzFb``4ixf3m zIY=2lKDmS2WGQx#gfP0BOAD4i?UoNdWtRz&Q=#>Y75@;X*z^@rxbLVa`YnIz{oaTE zNGmThd0`N_?*0!a>=f<^TOdF{&|-km!E9iB4IUs0KsvY|y6}%EN>L%XAjjOs+WGAJ z=wAmEmK)JGoI&Uq$`1%&(sh$n^lmT{o9pDd>t(CQ;o9Sr;gFtdZ>-qZg7jbc*P~uh_&U$wOO;{P3h!F3|a}dH-WoGGsXGBvB2c7p<>_CnJAYP}_#gD0t)$ z$Is_In%83bCJkJDij^-Lbnh)JKexs8f3E|dDy=BUEES;}7{*+oxV&iNODhNv#y<$} z=-mY})V@*#j#N6^A*B940E$3$zfmk;3ReX3DO;=d*_(!|f4FL$#0mL1ToWidl)O|S z_mi9mELAQ#S-D7+a2+=an87R;9t|U~1&sgF{`AZ#ZsOL+=sb67R?kPP;SQrDJP#F^ zsr<9}0#5FYl#3;3$mekh_XV=g`LVN$408Oz1ZU^F@kv7gMcyAWTE+yQfcY<&di4?0 z09J)>xHkZoQg!{E*RBSy?JCKOX7n%2$6 z-dzz8T10-8&ZG00yi<2%x`4@L8oj$ZXP|WgZ7E%-(h>@kqIJqt!{ou4J@Anf#HcEw zPSv)TmeUHAmeK2Am3|mkp+~W?)6eVg;c7e2H48x zBw;iPnvFX(a}Y+nn8^W#;6K4qA&N3hg$HYE=n|Dy)1^$6Gxud`0!yZ0d*p;(03ud^ zy^hvb&{_%?^-|c8>2fAn_!5YCX`?Ov6`*x_BAqZdP7`m!E4|c0ttvHBo2}NJT1HQs ze_rYk1e$5HO|)A}>0a7uufbmK{SDV?ndJ&?hXXVWWefy|nb5Neb%C#pK9tl%P-U{v z%DOV=mf@tF5qHo|q4_JBR-PLXOPn6TUrQ#9e83Sw*iIv zU^kn1C|EKWK_mS%Ah;Pks|+@@OxM8{T4o@Zf(mvI z55b=nM5d)6kW5m_Lx%`#@%0J~At8s1=`iJf)}P0CE6_pa-@`H5WIHbP7t4>QJLNX9vAkd8^)UWbAP6$@LZXWxAVbOYkgCYh!Pi4lzTy1%B>Pf9ZYnAH}3- z*{;*nGg_ZWZvV-oB*dF(WQ0^x71UW+hk8Cp_g2sc=tD&+CHpenk8FnaqFX;|TH%e* z9ifj@(1+=xs1s>xxwM`XyvIu)rw0VwCz$GAQ(yL@$J9)4{viA{r49G#c+Z$S3LaiI z8H1fq(Zeb|M4x7oLLr4te=>z$^SG9N2w2ERGL4D=I9HuNqS6>W3ax}f`>ts|P^Zvm z@RHI@6xXbm9v9ry(J7RMY_2a`aPR71XW4B1S$a}He-4?~NS8>v_Z&;WYl>KnqBJ7-hpw*<(4p-DB;Erm4B)LPDS{#kCnL(dCt zzl#E4aVwa$czprcYdPwIDCcme_C!|1U))PSuuI$zk*W(Ap#uWp$Ho58;-{sE*^$YJ zfcvRRKNF?1B4(sbe>9@m?fS5nel8lSJLrFy&YLbuYc7$Di~9RZ6dwe@uT*+bv?gxR zf2UDHLuJLEg$yM9E&WcA_+R7?)37(a^as(%yhwk9vCtzREf&@5r9ab0gl1l{v<@{6 zC3O?M!(VOl{tcWYFh zcWyW`&qG3pOe@HR0(&Pf@bG-DEH=)i05VspTrF}nH!FPJEICoc3S)q%V+;_aFop)l zP;Po#SxD2ff0q4{T+T}wqs1MJ(W0uHR%OPB;l?2?$s`KN)CwvpIWi|N=M^e1V@wxw zhcbE=o-@%8PA~qV;Cea8wH_!IqWp_Sb&NfdNz}9rhH)r2Br^t) zMeQA%TY4kA4{q7j(jMtJ*xS>w>)_TMT^(L-L2JjGxOJj&ZV-)ggVi{5yFFtT>@y74 zJf{=@f2D8cEh09yg6#A&72XCLgRGuD?B$3Jh}mU9;ruBh4ewxD7AzgZW*I&BN(>mh ziz!$}F_R7^NNhzIC6VZOw|xa*NB`8Izi`@_wbT62%UAIpm3#SWG=pW%ix>j~;()!P z=|~#* zs~lrgJ~te{KY{96l8>ex)n>uuGMb%`c#snwpktC*Tn4EfgILng;xZ@8J7YPjGNU7z ziy8fhkvX(Gk4lucz zopwj%<+s`80do~2D`Ae3vs%C2n@KP&f1Tw*W`gvc{0^aDj8k(=qot>B`xmPR?nWM%F_Tp@8f$^zMC-x zxq5eR4y{vI3_c*+I&2E>TUd_fzE&@Pkna^rKrwaahT_Qipb*^GDr(jJ{9!?Jf23IL z(A^If6~w*; z?}1Z(f$4(T18(_hnK5l-&KgXmo>nd-3e?K(mCc5>6~3tQ)BGjdE37LV)Q^&pwQ#S) z&+u1NlKHDJYC|%1Na3%+nyEu^jPYK6&d&RoKPnRF@-yfpj11b3Z`tb@e>%>eq_``W zHjyW%v=QIIjMQf2l5wjwh-GwmTwut$YYW7S)B^oRCLq)v5C#Y+jB#TgxNhmo8p)ig z+m?O7x>V%vtNgs^JCwARHbhpo8tiRe{t^FJ)aIYKNc@@Cy2(NO%_oXe2h_a_mDEVt zmb7j{8H0tCIim0{RsMyjf5xg%)u5J6>nIZ!1*crg#_ZLsWwQbZRQGHCjX?b^(~`4- z%8a=}HZ#K!NGa0IY^23L=>CEKsPgamPfQ#BAATw`rjrHMokCmE$m&;$>$>FdWOl&m z)`l3}takOU{5O^V!Y`N18@mT#Hk8i4BUNORx;`YLf13b*mCvaBe-8<>i!%lf^-2;U z9Xu^Lie6DxK3T%#A{V~ncqJJ#j^vgU*fE*tQzR9Izl^818it9apbd#{E7lZ_VRf}E zc~xnS$S$5Fa)vkpeqLJ|acM0jlw*p5vTxcoxin9j54VyQ6lcuBR|hLNBB)YOqvR9U z!GXe8h=^BOD85uIf0M*0GA*2n7=9$tiDqrej<}AS5rg&?cv&o6pi1XUOT5%!|GH4f zvaj?*$t>7b&`TGoQk8_MWDe?v2r}Dt(=V&+RUEinS|JRG@uWH{KKj7Hj+!Oxo*$h3 zJSiyE3UmxBOJT8wLQ9;~a_QJ0+H$+Y7xq%5dSM}87BbO_f7fWu3%N;ZkQ#*^Fy;8l z+=R>08U>@C^*y3XHwO(!x~UB1eKROeJu9R4i#yRqn*t8KOlnf8LRwpLV^InvOY4y& z6Y0aoAta#nWk$@|ua--OGHHW!xhjPv3`wq-h()h-g$Rf$X%kb&Wa>o&%jl;Juf;h@YL`0DJV={S3<~|Q zxVKlNt>PnLnaimuw=2>%bOF+Krp5q#4}8Z1N3?_qAS?S%)arm{Ww3y0Sj8X=>X^3N zqTq|)7_lk>iEJQee_T8ouuaPZ z`ZGo<5HsR>A7m?9YOlD%ISXt11#1V2EoPx>=owC%+R@3XD;+F;=(T8c8;0RJ zTsm&wf4E6n@v_B&nSvZcHW#06QG>Wc4M@NZjXq_R6tyGE%uPgmQ2BjdC;x_^K7e<&Sro+Qon7}Z6ij>=e%vr_NLQ=+o& zBpJok>#>>@t9yzoIjkHJE78hf09L;KB)w^jj*Zi;(XexzZjXje(A)F$&QZE+l#Y+n z`=Vi2$nPAb_di1SF@@cJ_apQ%rsI6t?-IX1$@BzBhvht-IL`O`<;uJelNOBA7;pvZ zfB49mXR!WQo}M^PexS)v&gcE|!8|>kr>}-xBWE7K{@1Mi2C+ZCIZxkg5`fhJ{k9ES z?Q&jg{rY^Kz9*250O|V{Qa~U%CqezPdlGEt!}O!OX%T>bVgb8HsA8Oc79FMkJ{1BQ zAj1lz_A7b%#c`?Pf$=T5(=0B&}8~QNxNwRw*HCGxKs7 zAbuqb0wZTm!A@E!voDKNVzcs90B98$d1mpu$?pVH>>OjYdz|h7=c8OvnalIse-rG> z^TJ7MQ)h{-eY_~oi=$1-J+wg3^YM~AU$kfB%yWKA6u<1KR)jRN^V))`t?f_yozaju za%E*q=!xg(Q{=;$gM(CgBtI%caf_(Rsq{@aD+#S}=pC z86ka~*GGN4VU#aFW&hkLem=}?e|vn~F~*%Z>oir1(1J)V;P~B;pF%#~KE~a%?9Q`R zT%aOCGZYoCbw1uX$~|Kog$!cB?q~!dDf0Qo*L&^G+IB- z%c7$kALW4)e5h-jQveUupWrMkF~&y@j`9uT{Dx>3B5#~;1W8xjD8D&0f6BK2KH7bP zZxi%s6BzdKTl4((Xp?-8aO}B$ceSl^VLKn+QQT7@lRQFm{BB3JY*{801(`8^XP)m0 zD?Wbj7{5On_W1Gh19`qL&mS4*kHL?eO-i0WS*?JlPt9MR=TBSiCFAu3oJ*WezdvZZ zSy&eKQ%>+G2tl=09#H+Rf3Rl+Zi1CZ#ESIpy09nYSNtA9DI^G;;Ll9Z5|JT@L8pS6 z=LDaMhSef9kKYv$QmRE_E9?E9x+#R7EG1O<>7Jl@f=`e0)6s|@lKP$XQ0bTR{H&FQ zqg^6St}cX+CEqrS#MdXVu^sKs^EdCN)gfU|nuEu;t&|cN=jWpWf4BaikH05EkAG0a z`{60><}kwSr&av3l#hRYOk3;XuMV}FV=&DU*-9CmLvT+ z+WizQMWlnqEBL#Bo<24v@d&Bg{c`sRFGPy!hJDXGw0(p%#G{63F=LblwcdY3eAs2Vm zpQhd8QdM++1Q6AEX;GK+F4-R9ZGBt;ETo9?DCrv0D+1IDFD2JwEAD ztgpk0jFnYAjJJ(@@>0vEgx;*>?T$KtwXGVHwg{EYV4k~Ae-(8Mq(-WYZ0p$a#PooH1&29;1t$_t9$S2(58GNS8RjOP4xdqRX7GP!mS( zwXWr~Th0}t^{$I4?CPWqt{rr_D@Dz&!?e*gOjo$xOPgE|Qj5EaTHR}@&3zZOyYHqB z_w%$_-a=dCx6@YnYt$*fK-=U$L01^rp)ZLX{|8V@2MEVi07E4e007D}b)$q0%WLwQzAecs$;-Nd zASxmv2qLK4kS~#nq5^hlp^Wh%1BQZAKtXf}4pBfw6cmwp&P}qWT{hR>FFo(vkMniU z{hxF9eEi_U02Ygt0^2UTZ1s{$s=JNge?~JFs`gh0d#dZJgLbsfiWrV%$9z#cWYT!t zjF?8kq{&_*;S2Vf!HtPzG*RvEF(L`GzPc~$iyD1Ci)C~-H!lhd7@Lg7h!G1np548{3_1!t0yE`k(y=0q zK|2;q#^YwpX>6fwMt8(ipwh-oMr2;Z4jPg3t-iFjiEVP5Wj8W^l0Y%930Vneg%uYl z%W`q6JIRq+8;=~^6f>R1wX0ice^UuBBdtAFI2o4_6~UJ^kg?F#!|# zYr2j}n9N@@1>7~fuMD#_D5w%BpwLtNrqnEG8-Ir6ou2E2f_VZH!ltvzf8c{mpVs8; z#;m70j=`}S=A%Yn>Zr&LhjZ?R7!(;@XXOpGy-LRkte_4{1m@;F!7*B7==^LD=cSdP zjHE!>@hvj2=j%8b%Xsz_e=^rfuoNB3(?h2TOd@BOcPH#f(lJ*VPOpv?Y41)Ks62d1 zDEI_jNFx|D6O@q)DJR1``t~a28pcUU-Hb zr2w4G3E7TSV_>3VOTsau3RY9(%sAca@`GltA}bxT)ik1H!5XYBe?kY&r90kZSdnDh zJd5IBgehf8^CirA2(Y&E2`TajRIr|su8#*Igb3yNQi%@vQ|Qug0WPFt3=sf32k5POw*CcHVT&e?km<5rfT#*GFEMn@M&;M?CEXnO;5$&MkH%LTOA|6AF?7MP{_m z+0sTkD8^Y27Oe4f``K{+ti76n(*d037~VYDfUe=5dU+nO0CJFdc)it$BU zO%5G8uizR=3aYQ|=4MC7SFo%Y*Wx+?$Cw=WD(3RQ4HU_UDH>}?$Qz?#n3%XpD7%RuqWbW)B70MGJctpNfASD{o7H++vZu$4o1xXFA?ww{ zbWYj1)>vOM11H((N3yjpV{pzA1&`%9C|O8;qTz8oAyBw>%}U=A6;BG(jxNlRaoAGy zw1!8qhjHlOwzNr^`JZaog`d$CAt|9Y>il#($06H=pOe~P#7@x2FSr@lgz zs*2f8e^n2IOcmXU-YNne%Gnnv>GNc2HZc_ZisGIydd#(P!m?R4 zivLigs3CR?D@I^FJ=eFEUL)RNUX(Or!8C~c7a#Nf0~EDxE0#HPRnWs=+UPC{6t^VV zf1XabIi-5(-Jyy?!mSgUnpB~XV_Ytcm>sjoUU_Xrk!*W}#(=%bsJCjxKxz05sY_ z@G}Yk3Dc=EH=Dtv!#Ajku0+&I@M|%_fIyc`EM&DL*fHD9e%b4a#j?E+)M{6be`;Ty zj5$`+JbiP}?32xoXwpP8m%f=<^e{tJxy7oghoq4Pa<`(&N{~HO^qjLoRa7tJT!Sk7 zSsgN9G|@;e$Q&I@$3Q{O#Il^uu=VVmiBk!-Mt8Jk<70+$)=(E;&_XY3YUUYE+mq35 zGroo+M7UH)O&>)Tg_BG8Jq8ffe>0TcVv^EJOj3He0dUd!GEAWt_X^@_X}^c)tlGf( z_1=OVsHoe4Y4tl$>Dz%B-ohQ2HH10$f&WTSjk)Q4h1*FdNq1jYJA(Ovw%S2VOJTtX z>H@W0L#UVR!W51#ZKi)IoH&G~gQ!g5)U9Z$OQB^e8fZ@i{VD?~tQIWX*I2w);@?C{sP+OFC4_IfZtP}LT~3FqJG8Qta_S@ zd{Vkvu5N`^@ADRYnG%9GerFINTpiWH}CfKwRa=su8@xYMtWNUdJgtNAiV;Y+Vvf0(n9&Vd3lf?a|2 zyyMZp2p%U3hp@Z!sUbWwglALO>sM2F-mChR0km_#io86qt3HtRNa-qlkvtm4D=F+N z{ry3=vh!+J>Fd(tHxEt;zf#bwmKV7$3^W(rBK+m*wvRirDL}s&QrJB?i6Atd4)_cB zfJ^^8jKAEEf28nXf9Xdl4z_0iFG!aQePzN$eu?%GQ4sL##QTAOx3DYVE)$-Pf-<3Y z6gGQOqPX1C)iER{rbH=aO-fALiUh}@oulAayfieU^rNVS(J z)mTl^2~@tAe^!b)l2(foB|TZJmNY8*#H->Iagn%6(yPU_l3p*iOM0^ymh>U9SJJ)W zd9fc5FN&8WzhAt?)OC&PM)w4HMnSamqf#jJo|Dn53@=S?$ zm$)mKmy~z{%+m=xH=vS$SKv$n;7+))4h8h&FQj*-2UijZ-vAYN5vYCyO)N(-fvhgV zm>{B<=vszJt~HqKx&S4vAWB_fl({a&6!&VByDvb6JBX?7UQBaugx76LJ#Go~?*9Q$ zO9u!}1dt)a<&)icU4Pq312GVW|5&xPuGV_G@op77bzQ0`Ma3II6cj;0@G{*_x6$l@ zWLq!9K8SDOg$Q2w06vsBTNM!*$jtot=1)l8KVIJeY+_#EvERRF+`CN~+)~_fcio`v z*4!Y8Ql(|4lGuxq7O`$fleEN}9cjIwL&2@>M%LYJOKqvn8>I&WVJ`e@>#4mHnuhzUW>Zd%6?zt$4SI~lcxhl zC4TO|$3j~w-G4Q7M%K!ZiRsf{m&+`_EmNcWDpuKnz~ahZga7dAl|W%-^~!;R$uf$l zI4EIk3?ryIC}TXYW(0;0`IS)TrpP}tglbN4Rm~aBg2TZCuXEfjpuhoC)~>H#Ftz@S z>Dn`9pMU{c7+4fO0Z>Z^2t=Mc0&4*P0OtV!08mQ<1d~V*7L&|-M}HA1L$(|qvP}`9 z6jDcE$(EPEf?NsMWp)>mXxB>G$Z3wYX%eT2l*V%1)^uAZjamt$qeSWzyLHo~Y15=< z+Qx3$rdOKYhok&&0FWRF%4wrdA7*Ff&CHwk{`bE(eC0czzD`8jMNZJgbLWP4J>EL1 zrBCT*rZv%;&bG!{(|=Ze!pLc^VVUu~mC-S7>p5L>bWDzGPCPxXr%ySBywjS7eiGK;*?i?^3SIg!6H8!T(g4QQ%tWV0x-GTxc>x`MRw2YvQwFLXi(-2*! zpH1fqj&WM*)ss%^jQh*xx>$V^%w2Z&j!JV31wR!8-t%AmCUa;)Y-AU<8!|LS2%021Y5tmW3yZsi6 zH<#N!hAI1YOn3Won&Sv+4!2kBB?os0>2|tcxyat=z9bOEGV>NELSSm<+>3@EO`so2dTfRpG`DsAVrtljgQiju@ zLi;Ew$mLtxrwweRuSZebVg~sWWptaT7 z4VV)J7hC9B-cNaEhxy8v@MbAw(nN(FFn>3184{8gUtj=V_*gGP(WQby4xL6c6(%y8 z3!VL#8W`a1&e9}n@)*R^Im^+5^aGq99C`xc8L2Ne1WWY>>Fx9mmi@ts)>Sv|Ef~2B zXN7kvbe@6II43cH)FLy+yI?xkdQd-GTC)hTvjO{VdXGXsOz-7Xj=I4e57Lj&0e_C+ zAH@(u#l-zKg!>k+E-Qjf-cLWyx_m%Td}$9YvGPN_@+qVd*Q)5cI$TrLpP-Mh>_<6k zysd!BC`cEXVf*Q0Y(UgdE^PYo5;;FDXeF@IGwN8mf~#|e4$?Ec!zTJEQCEM2VQr*k z8Kzplz+)oH5+-jyAK;GP8!A zSKV>V#gDFTsa`xXt|1Uc3i&PSgl%D=JEwjW^F5vD0l6G!z|~>y03#T)?a;@!*(vAwmBFr?|-8vt&)jK z!?QG5DNz%WTH4H>vbUDpIEl_O19mVOmP_8bVz-kCsYEtX_1Ovb zj+KS444hDHKJfNHwq&hQ29#QGU>;3P1P+D_kVfmXiA~y=y{YGCGep{s6iwTA*ge*SZSH9K;{Gc1^NWT z@{>XOdHMwf#oVVr5e4%x1I%+r&CEE*Qu8V$tmu5mm?%|OR}{L++~wCzm$RIp(7a-4 zuUW|Jw)8G^n5G$)e{tS^RU&@6hKR!RWWQzWdvkgoyCMKT%caX_=zlus#?;Tc<%xwM zJewbXg?^RAe+_wMk=A>m=A@r~0~#Z6hmh`q^b!Z`=jde+%aR2&hxQ>`<7bXmDk+!% ze+$*7qh)2_^In4P`ktr>O8z!|UZGd$clcz~c=h>Hr~z=--z_oAmq3RVC-fGwS&sJu z1-B|M{Jx;us@*hy_J0o)`U?9cH0RlBfikrIP@yl=AE9!T32=5+P-i$<+jN!7%+FG| z&!5nrvTOegUa57UpZ*+hJA>p2ga0MxsK21E^Uo8!3b{#gdjViLw zDj?{%qL2b=fc}>G8S&udSPszN3la#if5csvd~EsYTU;zzV}C*VHpkOH)4w1W41*h( zbOQ8mmEBsPEo@ObLg z93$OR0O5mpOQ~kA@~zx=sm%~6;&yQdTLO>ECg3w&$V;K3Rxm$Mx#E3$#)AP`Y5ET>GF+K7Ons=3AJy$clM99)e@XPVK;DaXeI#{!nwqZB>eS#gwM4Gc z+UQjZ#jeu&%Mv~fw1GC37KsP2q#o_EXrxGY9xc+Ai=@m@d~k~Hixz2HYVc*MpSt<2 z$TixLN>0<8uJ7@5d0V_2pQVkF7Vq{{!dIm33#3Ft_}G2)yjM)!d^I{4d6C{M=mM$U zf6tOXHRy?rH1$Si=)u8jv@ewuk!jjLMIV6_5a7L3EjF@9Y$D=$k&f1(*4c#dO{r8e z(v+H}hoI~Q3P)vOmA?n#aMPBi8^%0|sj#w@`5rIzh zQ!tSbr|=trz3XA)gH(s7qlZqzSnr3Gf1k$a6s-R${PJy>^CsjPC{3BNQR^|!p8G=V zW%6Eb%Fa-3=o*=+gf}`(Z);pdp9v&gz7C z*}oPKd5d(eNI!)2=dpg8p7eD2T72>A&r(Oc#kZr8Zl0T=_oWh8{A0N9vXFPxf7T*> z@F=#&(1(wn_rW1wit#=dQbR@h$qP^^nkv#IIQ!Y8pN*0_p744iBi`tUFE&yiA8GoT zkhf%^=TflG&)tw(+<*mIXdUgu%{CxCbK8#JowN2@0SO=M^#R!H6?`{v`CUe5FJ?Sw zyCTwGaWuckZrbd*cS97n*}$HSe?&KIhht~x@pz>vsk20GwyCM?#|=m*99Q+xzrHv4AaMp^qVvE1qqxlUZ9nHsoy&~b@Pi; zbSxIXMqg&hucX*B)AZGlZ<_wNNMB2M8@&ts^)Xsm@z<+UH@_KAm7Vk&fBsM1e8*q} zC%twfR;0hW%s)2}p$g))S6XPbY}b-1+g56mZJ4@bdpGTo?Oxg^+aw*3?Jyme?QuE* z>k?^{mF+lLvMtd2WXr!S_d)uoY)gJo;16IEvvuH(Z&YlEF~4MtgVERw{mtdnP$YGQ zLX5QNiKcH()87Fhz);gaf8Zxp{{AQY07^yr*Rp8*MAN@Z(f^s9xq-6?{;3ChGh2NJ z5h72l13;O%#FbbiB|~{IS`?nriNJPIz>*(s7WJjAq^m9+Eguv+(JTTuX-2FlipGi# z>xbCfU@qZdcZ!5pBz#h2ErNo*n((t*0g$h4ur7sb6@-iGc#L$?z0#Uu)Xh){P%^cBVZ7wOS8%9=n+@X6!d z0j(RK8a`Hw2l5S1eVl@8los!kPhF(7@ijcCcL%PBB!<=~MKK)m$2=`T0Eu_#R=NXI zH=h{{`4iqLa>{Mue;U1>Y8Hp4#o-&#kU!*$UlB)|#anUx3hcmxfhe0Q0&^ZadKv7! zbC8#@-C);d@h~h3LJ*D3;sie9@`|I)B2%(-WLk{fsNVS{3NYNyg}nR)ue=tyK_MEW zlVVgDvV8=;&C^-g=a&0t>2a|ceQr0P|8{y#_POQ$^YjVXUgwtkpQOvO&n@>kdb!Un z_g|vV%RaZ<|2lm`_POQ$>nH%Z&n^1GBO19cTkgk1x9oGv{j_*W>RF15CZPW_^!Tj4^T{T!k9N#2;RO7iBy{i;&QUo$Tz+ znfE#GOwP=ozrTJ1Sc55We021t`blp}YoGj;%5y1uf!uNG{2U zc(N@c!)lX%wI3y3q;Kp>H=-52V;i3A7>>%(TwkwPYfo4kR?qm|#C16kwWU$vA^EoB z6NQd%bM%nHh`l&oU46V-HClA2e;$PpNH>BcwCIK7lE8cr+NK@KmP_V`PLn)Sf8 zDbz3|Fu5lWrRhrFHeWUO$ci zK|;QNMYU4B-{xxq=2gh0MJ_>CzIO%I2C`dQ0}U%zLwzhCD9eXj_~Pck%ya+e`Xnf; z1j}62O+JMJ**YJ(mx~=JE+{p9z;saHl6M^@O>uaJ(zL_pbbfg95AEkMI{P zQrP_-wu~WeK)#DjC~RTz1jWl>>J%&u_A8uVH0UJwtHj+O|MgSsVS$&sSO#aG3~yMr6^X${<>0 zQle|Lj@}|34Nrzqkl>m>`@k4<9*UKfc&#)tI4W!!rdA{x!$&L15^Z=Vs_fD^%wvtV z4GjkS3$YfV7A6gE;|0p94J`((b7fR@!QilW^Ak`-SZ_W1@A@+aUavpvf)AYzv|)!q z4VaP^lJwjZ|A#8&wqkPDwLy5?V^3lqxn2iXkLKsKp3v z)lw?h02Q#9dcl*)Nir~*8P80hEVZkB@JF-{`qDZ}%ic=6I zm%FuV~79YG9K?LnO!Z^jy-SC}sEQ=yjZJve> zhLEVZ{w5(ZoQbyviJ%i_b(}#LLsvu9$Wy~P3VYSGP5*j5?A-{?qgO|N4=ynDG-o(t zyH$VDmx5O`yrrVG6j*nCTSp%*G6XD#7Z}brjGFxGwwDl7VfqSEf=l#B~g+q=IW=b5Z!M<&ucX9YRuprWo1}sWhaiRi-Z__Z`V_?vU@yo}2(i zFdD}DxXjRbRIlL*gGOwBofG%{2tGu67-Ps#wKfT;#rvpD6d}xUOenjnl!5P12Z*7q zw!2cYy^fD{X!wL7>>Y4wID{LA*tcu0;U>}9^SSiBWz#PcPvS>06_ak^GaXZyW_ZJ^ z=DocXy5lp)=I}XgE9)%v+M=maz{HH12<9-a6nE%cQa3OVKU(g8u^m{zqPmtPawHNk zWR7wCpHO$PtcdUx!|AF`o4_oZJa38m07T<0{69Jm_wcovhi@1zG{6_Cwr^I%)O|y^ zYO*wZw@?12&fKV)RzYoo?-}~1q;zC-qb%&GVmhg#?!i<=i!>0|LdgHijnpTlpo4>E zJ*c*hO|z2vk8U1+%7RKMp{yWG^+$Y3922QYvQ(DNhU(N_cuU6$Dzv>0=5xNOeup?c zNo$t6oTaTgSFPlQTvG0VOE^gcRX<`ALi8~FK&RITk_PxKQN!sc(4M3F**1D|x$G9+ z+(ut+b|{%kY$001J2kwwjltaQEs*i>3w*#Zn|y(f7#?GPoIb8Gtu3 z6l++mVQpv&_A5%Vi@5j`T=XJZe@D@ehm?9h2I}XB_@(}4kR&~YHrm3(cAUT?`X&;S z^aR@e0Z>Z|2MApz`fv6F008!r5R-0yTcB1zlqZ!0#k7KfkdSS=y&hcen!76`8u=i8 z2484mW8w=xfFH^@+q=`!9=6HN?9Tr;yF0V{>-UeJ0FZ%A0-r7~^SKXVk(SPwS{9eZ zQbn8-OIociE7X)VHCfZj4Ci&GFlsOiR;iIJRaxoGXw(dGxk43#&53m>S)=uTq|9>^ zv)ObhvxHhb=kS$=qTqy4rO7l7nJURDW4f$LID5`?1J}a&-2B3PE?H*h;zu740{(*5 z&`a#OtS|ymO_x%VPRj~QUFfu4XL{-O9v0OB=uyFEst^tz2VT!z4g<2#lRmMJ`j5ZM7xZ*AM>%2rvSpe(=Ig+{%mm`qu9D$$nuwfAVtg)wU1D1@Oa-0qBDX0)tL}srdd3AKVr| zu!4652w2`d0fsD36d(v8?%fw448z=eKw!vV=GK+cg<@B0$2aAJ0j^IF7?!T;tpbe1 z;%>zpHr&Lcv2JbrpgXly(as#!?0ARvZ(9Tyw9dPLBI6nnUO(iIoc8&R_JI|#ma!w& zAcT?E9qq-QVS__Pcf=Ea+u?_rKX*`?w+8~YR^5P4}7sOkF z9^v<)Wd+*~+BRU@A=_f}TNYc7Hi#bHH2iMhXaTblw9&-j;qmcz7z^KOLL_{r36tEL z;@)&98f?OhrwP%oz<(i#LEKIdh93L_^e1MUFzdwUAZf=#X!!zWeTi=n`C^CXA?1cg z9Q>gxKI!0TcYM;pGp_iegD<(`iw>T3#itznkvl%+;5k=(+QA>Y9v3?#|5p?&G^NcjljeZ~g^f18y^%J9)Cd^>|=NijQzL5oim< zlYvkmuB9`wBAK$LhSPsqg44Xt6)qW^7KbGx93STK5hI&60&Pi2F?cADNrlr=CM*jZ zLoF@q;~O@SuHKr*C$ow|6UMLxJIZx~e9?Ss^Ty`ZaDtBpPPoAs zJW(yH$N4T<;S2#yPeoF?lu&qNOqVhlu1EGea_2aYXH89ap^|@L(Gh7>iYStriu4X0 z;c?T2YBH74HPSR?ZZItAvUReitVH^z=C?2`C}=rO7dV=-77=68sE%uDQcf{6cFi77 zhpm&o07Yne+0~cxtd5_*)sP&)@HC}ize=e%9 z#0xj(imzo}crbrYe63*c7RTYjDhiU1%Z6##t_Qui5BGbp8h+wH(WFEnJTC%R=pic) zGR)Vxl-NNqUE8ZG40R2ST?P81rl{~1FV5^e_8Pg(x$FW_6(mpMLKFJ(*W5>({#DW*Q zoCKbj>CJyx?{us_MShE|Mu(*hn_8mTv>ROv%chy0TJ@sGvER$E`JN~loQ0D;f|Gu7 zWz6bozzKCPos?s8CQ8kPJJs7yy@Vnhlrv7zVopqhG;I`3KjYvJ7U3Q84o~47P9z6E zG=+Dj6AqqAR72W5+#J*NkpVf)wXA6$(M~T?7#4pzGDBrUrkr3p#=R| z)ud>4j>mb%X;#lOggUgWlJKjV=@*U0pX+Y^LM!$sbuI0$Ut`oayK%Cl!#hQF;YI3S zNlkxGOJ@1oTeu+m*V=%8d-n8%+f;C_H)8o;-_FbP`qm5+m$!#sUS3~az?6UCnEncp zrIoW1GYikZ3^9(J+*73a_E2=I+@yTZzO&nHEt<<$te&=8HKwBfgjml-JG}$lI=92@ z4z$bd>F@tEaq6laA2^*uV=f+<_SYxIZ2lu1)15Avq4jrv%t_4M85a1jrdBbg?&OBO z?w|X;yr%s=o>F|n{!ss|&@a-Ga?>Xp`Tt1WnzOgFxn}QvF`pdqH+A0O6M<{R?*8aI zm|Fe9w=3;hq}hV*9V%VFm_Nouyj`+eMRi@5yyP88PxBQT&vbZ!!)Ky@-W>G*(aL2R zRrh*#Vd#O=-{*82{_t)2Q0>X_c9z?Dty^;DE4*(gK1oaCZ038&qGr3{1N+o{&GW)S zR_RrFeoeXT93w9WTJ=k2WmwRsyZJjz~raN31L?*7OZAKosxIC_$obw$Vto-F(G};KG84}n`sf{TwU%2wY3la+hh1Mo zOk8XAThu>BWiTy&7qj>ZQ^xVsJ)L}CZf)Xc&#mN8-WF1DX4>(>Q`45ejQ0=-ZM4zk z5L6XanSS@s%!u+}4U5KdXED2N1@ELz7MFYE%Vl0?GTZp&z)8j5fxVV0(M{Jk-YLI# zD7^e3@2_*4y-s~w)iFmb?A6PWbS|JU~kQ>A{z z<#_KpR{ZVn&J%Zz?8+_T3iQ3CX&uXK`8Ms6*u@`B+O_xJ&pYz;K_cUp%GV7lwA_XQ7h?=EiYO%jA1g4LkyE%H;C7 zPBKh~SnewUyI}=DY{&pStppCf@lAGIC^PvppTgt~O9f-}d3G+pn zHcEm8XU#X20bkb$bjx(06{tEH6~T)57MRE&F1=%5uthQcpfXUA=H!#g@?du$?pR}B zus~7Bs}5H9dx4fr4CvY|pq0)*@1y!kP7|oePX>Iq6EG0Z0Tmgcm@-Wp?51-IwPcVl z;ju?iv_==K$b6Bx4B|cu^pKur092#|ys(EK0ARQEYY^^{l%|QCuAjeEkp14?q>9h4@!6nkbbJ&fg5yu+?X8=+3#!VJj5-STn zB^PM!VxULuP~>AB87AvHdVm8Jad0aGgFcF?DbAA>SBOrobXEl`gda@_j7wDOI$XgD zA?Lm7ffXYk=VyXqs+K2Iu@*=nEBNf4$p*_rnW}xj5^+A_U=u*+w%i1|eiP93x+o@C zhJh7Ihbe;@`y&KjUXYgX_u)8xbzqD+z9U^n!xP?doXqyT+|nlWGZ zf)zbpp(6wDM6oe2=%E;$(+^UFIrO3?4Q`17gDC*02i4ujCr@1I$qFe_?ym&yj++j) RhRK)Bhkwq`;Yh)md4RrtR%sNbw?F7+wVN@9oT5^KvyxHCChVwDz29-_(~6`YI}kOI zb^sOR2x~T#ZdIJ>Rf@`fWMMck8Z~Fk7!ymA-q=^Hp5eZ$X)}%69EWv#a)HMQBo+#f z36F86&q=PH!h1hfL>Ol{cXt`zy7GFq%Eq79O{IA-u!cH*(wj1wN}D2M4WT6o(qxrW zEB}r}@-+r4&wIr;xO0(AI@=cYWb?m21~K;0A^-T{gEQnxfCN&@N(#Zq#RXZY87O0m z;t0Wp7M~;I&<5qU1T+?pjfUye_TixR_f>$?rT1}+*6u;9Gn0cXM{`4grB6(W zyBDpHwv$&%UIzt(jZMh^e3jZ{I@kE301olpI{yj0+;ZWogmFjno1+v zMW;sMFf7sR(_fhVjl~QhEC!kN?S1GnQ8&fuPw9z{5eDbyAAsT&CyjpUf=RK)X*YhW zwf>HLeXJxlm0mFjo>lB@ni;CUkg)*JRligsG*5>@wN*UJvbS&X^}x zn@^UJmJ90QY)d4OLkji-vg;l*>VWz+eRS?0G0Bg!HhZc?2Wz}S3kMg^_@+65nA?uo zkBwh=aDQVGH8XVK>zh0u{gJbev&iTnS1h3p(pF$?`aC^rhJj2lK`5&HHV#_?kJb zGMSi_SJ(*5xg|k>>Dvgt0#5hN#b8)>x5&pj4Wy_c7=p-XQ=>p*vRykohWoq+vj1uk znu?X~2=n2?uaB_*+Lr;+&434q#3lhbD9@_k1Te#nwy}MM^TTHt=B7p23Hvw*C##@< z$6AnfJ+Ri~X^`J(;3$v;d?J5C5U~zQwBA9#k|t1Y#>7ZrY#I@2J`|kfQ=Sxhc*rH| z{varkusu6HJ$Ca6x^v$ZA6sX;#AVi73(ebp61*3)LCF6yToc0LMMm{D%k+S_eJ<3CTZgjVEpgE=i5mX z0o|kFlPT7$0gM?NfN_Wk=T=zCXFhtz_fJrXuKFQ#uaUzUCWj%}$pz$g05t#ar{-1o z#ZYh6o&A&s>>NA5>#m&gf?X>M)bj>Q7YY}AR8nPC<0CJ`QolY!M*@PhNF4%4$5nFf z4{VxA-;8{~$A&>%Yo@~y4|O}IqYemSgP7Sy?d}}+e`ng%{?_hDUhCm`I`hP=rda|n zVWx~(i&}Q|fj^k+l$Y30zv6ME&AX7HTjy~frLaX)QgCMmQq3_qKEcRyY7nk_fa}Z$ ztrwMjNeJ|A@3=y7o^6LMBj@LkTyHm7pK(Vxq%M=uXr;M7{wWsrG~I1ki5OQ6#92Ih%Quj|8Z|qUzyy6 zUf%s*-I*73e%AX}cTI5r+ZsgVR1jr6I*hnu%*rSWqzs(T0KD7A4U}76 z)lH{eBF=pRy0q*o<*iM4@ojv65`y{#TKm=!5+7PwC>z)to^he4BI9`z60IYcFC8XC zZ<65C;OV<=0*{u4*i@nn?J4m6_p_jauY-;RSof^%yxer|uPQvyzOCP1x_-}6H;)~6 zkQH$^6A(lu&B^q)5vwSypjGu5P`Y#UdzM%Uhuh>vlisoS7c?a}|1hah-vo_i`e5;! z93hb``au;ow+t;(wB3-=ww(pgb`ZrEODvFvfEiQvXaSX6+A0ooWdEx3u-oBf9V((3iwRO z7r|AqsNjl$(oTUVvOf^E%G%WX=xJnm>@^c!%RBGy7j<>%w26$G5`?s89=$6leu-z; zm&YocPl2@2EDw6AVuSU&r>cR{&34@7`cLYzqnX)TU_5wibwZ+NC5dMyxz3f!>0(Y zJDdZUg*VS5udu>$bd~P>Zq^r)bO{ndzlaMiO5{7vEWb3Jf#FOpb7ZDmmnP?5x?`TX z@_zlHn)+{T;BtNeJ1Kdp2+u!?dDx4`{9omcB_-%HYs2n5W-t74WV76()dbBN+P)HN zEpCJy82#5rQM+vTjIbX*7<~F)AB_%L*_LL*fW-7b@ATWT1AoUpajnr9aJ19 zmY}jSdf+bZ;V~9%$rJ-wJ3!DTQ3``rU@M~E-kH$kdWfBiS8QL&(56OM&g*O73qNi( zRjq8{%`~n?-iv!fKL>JDO7S4!aujA}t+u6;A0sxCv_hy~Y2Pbe53I*A1qHMYgSCj0z6O zJ!z}o>nI#-@4ZvRP|M!GqkTNYb7Y)$DPWBF3NCjNU-395FoDOuM6T+OSEwNQn3C`D z-I}Tw$^1)2!XX+o@sZp^B4*!UJ=|lZi63u~M4Q%rQE`2}*SW$b)?||O1ay`#&Xjc! z0RB3AaS%X&szV$SLIsGT@24^$5Z8p%ECKsnE92`h{xp^i(i3o%;W{mjAQmWf(6O8A zf7uXY$J^4o{w}0hV)1am8s1awoz0g%hOx4-7 zx8o@8k%dNJ(lA#*fC+}@0ENA#RLfdZB|fY9dXBb;(hk%{m~8J)QQ7CO5zQ4|)Jo4g z67cMld~VvYe6F!2OjfYz?+gy}S~<7gU@;?FfiET@6~z&q*ec+5vd;KI!tU4``&reW zL3}KkDT;2%n{ph5*uxMj0bNmy2YRohzP+3!P=Z6JA*Crjvb+#p4RTQ=sJAbk@>dP^ zV+h!#Ct4IB`es)P;U!P5lzZCHBH#Q(kD*pgWrlx&qj1p`4KY(+c*Kf7$j5nW^lOB#@PafVap`&1;j9^+4;EDO%G9G4gK zBzrL7D#M1;*$YefD2I-+LH{qgzvY8#|K=-X`LN578mTYqDhU}$>9W&VOs z*wW$@o?Vfqr4R0v4Yo_zlb?HKOFS zU@WY7^A8Y{P)qU9gAz52zB8JHL`Ef!)aK7P)8dct2GxC*y2eQV4gSRoLzW*ovb>hR zb0w+7w?v6Q5x1@S@t%$TP0Wiu2czDS*s8^HFl3HOkm{zwCL7#4wWP6AyUGp_WB8t8 zon>`pPm(j}2I7<SUzI=fltEbSR`iSoE1*F3pH4`ax^yEo<-pi;Os;iXcNrWfCGP^Jmp935cN;!T8bve@Qljm z>3ySDAULgN1!F~X7`sAjokd_;kBL99gBC2yjO+ zEqO##8mjsq`|9xpkae&q&F=J#A}#1%b%i3jK-lptc_O$uVki1KJ?Y=ulf*D$sa)HC z=vNki?1aP~%#31<#s+6US0>wX5}nI zhec(KhqxFhhq%8hS?5p|OZ02EJsNPTf!r5KKQB>C#3||j4cr3JZ%iiKUXDCHr!!{g z=xPxc@U28V8&DpX-UCYz*k~2e)q?lRg<{o%1r;+U)q^{v&abJ9&nc6a32ft(Yk}`j ztiQP@yEKf@Nu3F;yo9O})Roh9P08j7@%ftn7U1y;`mard4+5 zB62wpg$Py_YvQ!PE2HpuC}3el-F3g{*&a z3q{eLy6Xz|F+aMrn8R8IW2NZu{tgsyc(>*TdV79@?V$jG(O+Iz2rnDBc|1cK8gR$Y zthvVTI;(eYhOdjapHe=9KI`|2i;{VIfvnR6`qof=4a=(BTZkev78+6GJW**Z!|yvS zes)T%U573C~Hm`&XJzE=2t7tFIZM`!^r^&z;W?dOj-N+a10^>wV(l~2naa?s; zTxU{z;Go|Ve!vUjUrZ$B#mWH)NSdxi;dWa-@w)-$wBOpo`DEG<;C#W||W}&@z>C`*j9V|`ai)z*2PG`TZt6T{a zj!#m3`Vz5R9wJkNMsJ1`fSCS2mHnizWDT!G0Ukp$%*_^X1=k=%mmO$^_0_d|kc8ek4_DZwomL(>GGtfEB)Wy&cfZ@9-T|hAq&fx;XR$$_yl6iogcR{u zm9g)axS6=_IL4=wQXf|EkzO68$Ms4*JXAt8gFxLCibt^C#C|I|v|U{%A;+NaBX-Yn z`HAmP*x5Ux@@Wkpxest$F~K8v0wlb9$3gHoPU(RMt+!BfjH?`8>KMK|!{28+fAk%6 zWdfyaD;Dr~`aJHn0}HIf^Y9*keGvm6!t?o%;je)wm`Dm$fN?YtdPI7S=Y23+15L{J zr;n3MYg`<50nW^`BM$&M(+PQ7@p7Lvn(kE`cmoNS7UkQmfvXQBs_unhdfM){k`Ho! zHL0#a6}Uzs=(bu;jnBAu>}%LzU3+{sDa6~)q_|pW1~*Is5J(~!lWvX(NpK_$=3Rbn zej|)%uR0imC;D5qF7p}kdg(-e{8#o!D_}?Fa<&{!5#8^b(dQl40ES%O_S(k8Z$?Hs z;~ee=^2*5S#A*gzEJgBkXyn*|;BBH97OOmvaZ>&U&RfU0P(?jgLPyFzybR2)7wG`d zkkwi) zJ^sn7D-;I;%VS+>JLjS6a2bmmL^z^IZTokqBEWpG=9{ zZ@<^lIYqt3hPZgAFLVv6uGt}XhW&^JN!ZUQ|IO5fq;G|b|H@nr{(q!`hDI8ss7%C$ zL2}q02v(8fb2+LAD>BvnEL8L(UXN0um^QCuG@s}4!hCn@Pqn>MNXS;$oza~}dDz>J zx3WkVLJ22a;m4TGOz)iZO;Era%n#Tl)2s7~3%B<{6mR!X`g^oa>z#8i)szD%MBe?uxDud2It3SKV>?7XSimsnk#5p|TaeZ7of*wH>E{djABdP7#qXq- z7iLK+F>>2{EYrg>)K^JAP;>L@gIShuGpaElqp)%cGY2UGfX1E;7jaP6|2dI@cYG%4 zr`K1dRDGg3CuY~h+s&b2*C>xNR_n>ftWSwQDO(V&fXn=Iz`58^tosmz)h73w%~rVOFitWa9sSsrnbp|iY8z20EdnnHIxEX6||k-KWaxqmyo?2Yd?Cu$q4)Qn8~hf0=Lw#TAuOs(*CwL085Qn9qZxg=)ntN*hVHrYCF3cuI2CJk7zS2a%yTNifAL{2M>vhQxo?2 zfu8%hd1$q{Sf0+SPq8pOTIzC&9%Ju9Rc1U9&yjGazlHEDaxY|nnS7rATYCW_NA&U? zN!7-zF#DXu0}k4pjN05yu#>x8o#Jx7|Fk=%OR((ti%UVKWQNH>+JhH#ziW1hD=rk* zD#1j?WuGxd-8VqG@n_Lqj^i=VBOg@GLePo0oHX9P*e7qBzIs1lzyp;}L3tP1 zl5;OiHG&-flQ;rYznH%~hz>fuJ!n*H#O)3NM3`3Z9H|VFfS-_xHRCuLjoIS9wT!F0 zJ-kV3w>7EguDzoBPxW>Rra0#+Y?;Woi7qJ1kpxTad?O?^=1cG@GeNtRZRi8_l-1CS z`(#oF<;VYR(l(gHIYH$y2=rj5m3QL{HQgbW9O!TU*jGj!bFazIL?MYnJEvELf}=I5 zTA6EhkHVTa0U#laMQ6!wT;4Tm4_gN$lp?l~w37UJeMInp}P>2%3b^Pv_E1wcwh zI$`G-I~h!*k^k!)POFjjRQMq+MiE@Woq$h3Dt8A%*8xj1q#x?x%D+o3`s*)JOj2oD7-R4Z*QKknE3S9x z8yA8NsVl&>T`a;qPP9b7l{gF&2x9t5iVUdV-yOC12zJnqe5#5wx0so2I)@8xb$uPG zNmv=X)TjpHG(H!$6Xp>)*S}r538R99Y{Pofv}pAFlUK;xi{E43^->z1srWR=J$8N! z4jRu;EAiLG9R$5#{gR){5?o^W^!t140^f=vCVSs@vK7#`-fv`P*WV|>nX610pK08< z>r#{r)fR?2pNG}8o)?uvX#UJI)YM5CG@0E8s1lEV`rom|kBmf={%h!o|26a=lNJbX z6gkBS7e{-p$-Vubn$(l_IbwS02j;+6h2Q5F7P?Du2N!r;Ql$M>S7Frf*r3M`!bvWU zbTgl2p}E<*fv?`N8=B71Dk03J=K@EEQ^|GY*NoHaB~(}_ zx`Su{onY@5(Owc#f`!=H`+_#I<0#PTT9kxp4Ig;Y4*Zi>!ehJ3AiGpwSGd<{Q7Ddh z8jZ(NQ*Nsz5Mu_F_~rtIK$YnxRsOcP-XzNZ)r|)zZYfkLFE8jK)LV-oH{?#)EM%gW zV^O7T z0Kmc1`!7m_~ zJl!{Cb80G#fuJa1K3>!bT@5&ww_VSVYIh_R#~;If$43z`T4-@R=a1Px7r@*tdBOTw zj-VzI{klG5NP!tNEo#~KLk(n`6CMgiinc1-i79z$SlM+eaorY!WDll+m6%i+5_6Mc zf#5j#MYBbY)Z#rd21gtgo3y@c(zQVYaIYKI%y2oVzbPWm;IE#Cw$8O$fV}v}S%QDA zkwxW{fa#Goh1O|+=CF3h3DWNw+L^ly?BNQ7DY~Eca}5nt^>p#3cc9s3iDub0nh`Wy z?oH|dW8-HG@d5E@U>NWPjnhTjr7C${Iwj#;F2G@++N=Y2tjV;z57RNgE|kXQC)1h- zx8ODU>kk};J8KiSUx5jSsA_XPou1OH8=R~q9{`r>VnHkU6A=!zNOH8IGJoO!+bQys zDS2-H(7+Jfe+&zf#;OSV=83I|^M;0`Kv*#4%%O7x>@BgGMU*@ajUvY>cYw^`*jm@+ z{LZ2lr{OTMoQXn2XUsK-l72oysi9vgV4Sux^1GsW6zTV;?p#J06EvSVyUq5$f4kq< z{Chq5Z?I%ZW}6&uL+f&0uCW#^LyL!Ac2*QRII5TDGfZ43YpXyS^9%6HBqqog$Sal3 zJjI$J+@}ja9Xp)Bnbk+pi=*ZAHN}8q@g$$g<6_4?ej&Rw)I%w(%jgGlS5dTHN`9(^<}Hg zD$PbZX+X>;$v4NjGJxMDvVBiIam$cP-;h0YqQ{YgxYn-g&!}lHgaG3^B=>Z!D*7tp zu19e;r`u*+@4h41Da&NZv$qy-i6#DdI)EVvmKO*PvIKz-9E5R*k#|`$zJza8QJ)Q{ zf~Vl+I=8oaq)K!lL7Et5ycH;m&LKIvC|z4FH5bo|>#Kg5z+Jy*8Ifai}5A#%@)TgPRaC4f>Qk&} z4WciN&V(T~u^xBgH=iP(#nd;_@L&`7FUF>Qm-;hOljv(!74f&if;fz2Mg=b%^8$^C zna!2I&iCz&9I5ckX-5mVoAwz~)_&b#&k$e+pp=U2q-OjkS@yZ8ly1$2Vh?}yF0={P zPd3O@g{0L=eT-Dm9?imeUP(!As&DJ_D=5lwQ=3)XWXg)12CoB=-g-HX9RSXgL;yo0 z?$7z8Sy9w?DvA^u`Fnl7r_J&_jJ7claq*2l9E~#iJIWAPXuAHfmF3-4YjFYhOXkNJ zVz8BS_4KCUe68n{cPOTTuD<#H&?*|ayPR2-eJ2U0j$#P!>fhd(LXM>b_0^Gm27$;s ze#JTrkdpb*ws{iJ1jprw#ta&Lz6OjSJhJgmwIaVo!K}znCdX>y!=@@V_=VLZlF&@t z!{_emFt$Xar#gSZi_S5Sn#7tBp`eSwPf73&Dsh52J3bXLqWA`QLoVjU35Q3S4%|Zl zR2x4wGu^K--%q2y=+yDfT*Ktnh#24Sm86n`1p@vJRT|!$B3zs6OWxGN9<}T-XX>1; zxAt4#T(-D3XwskNhJZ6Gvd?3raBu$`W+c(+$2E{_E_;yghgs~U1&XO6$%47BLJF4O zXKZLVTr6kc$Ee0WUBU0cw+uAe!djN=dvD*scic%t)0Jp*1& zhjKqEK+U~w93c<~m_Oh;HX{|zgz=>@(45=Ynh{k#3xlfg!k z>hsq90wPe(!NljYbnuL6s`Z!wQSL8|(A*@M8K>`nPJ<9Hb^ zB6o?#^9zP>3hp0>JAite*3N?Rm>nJ1Lpq4)eqSe8KM_f(0DB?k8DNN6(3 zU#>-{0}3~vYJ7iIwC?Zbh@aJ8kfIvY%RveZltThMN73#Ew}jOwVw+|vU5u-wMoo9C zO(tv#&5`DOhlzunPV?M~qlM|K74x4cBC_AC?2GNw_-Uv&QtPOj(7L4NtVh$`J%xci zioGVvj5s|GY886)(}g`4WS3_%%PrF(O|s-n&-SdfbssL`!Gi7Hrz_r$IO@*$1fYbQ zgdp6?(IUaNPaH7}0%U|9X8HFonsJRrVwfmf*o1;k0+PwV^i%f7U{LAayu`!x*FmhN za(#a^@Idw9)jN)K!=sFC(G)ZNaYY169*IJ_ouY9>W8tC>S&MEp$+7 zy)NFumpuE>=7T@`j}8pa)MGpJaZoG(Ex3AzzH>gUU^eyWp*N2Fx+9*4k~BU;lQ1PG zj4)_JlelzJ==t*7=n2(}B4^^bqqcKFcJ7yVzbH_CWK?{eXdpKm);4|o{aM=M&`E$=_~PVi2>>L zKTN_x&qA)@ak=v=0Hl5H6~?LOfO@1+fu5(sB|VWID)w?%{m+n#7bLaszEJ#;$HMdt z9qP0gk)hIYvE1!jseA^FGTyK=i4eTPjTL$R;6FywMBZBPlh2ar9!8wlj1sinLF-1g zR5}hLq>pb1|AC-WcF!38e*kFv|9n<$etuB=xE%B=PUs}iVFl>m;BiWUqRIxYh7}L&2w@{SS-t(zUp`wLWAyO=PEE=Ekvn@YS*K@($=i zBkTMaH<&cAk${idNy0KZ8xh}u;eAl*tstdM8DYnM5N;bDa`AB+(8>DqX+mj17R2xBp45UES|H*#GHb_%Nc{xWs7l{0pqmiBIPe@r=X%Y-h<-Ceo;4I>isrw1Hd zZd*VjT`H9gxbf{b3krEKNAaV$k>SzK(gzv}>;byq##WEhzTN^@B4+VJvW>y|U}}AQ z4^Bdz9%QKBWCy+h$I?L@ffl{fLLL41Tx|M+NjjRf(`KjHG4^y=x3l z!!-{*v7_^6MiJOC@C$WV=hz9J^Y^lK9#tzs6}-

Gn4F+B~IivciU9^t0j-Mgao3 zSDF_?f~c=V=QJRSDTG0SibzjML$_?2eqZ;J*7Sv$*0SQ|ck$fX&LMyXFj}UH(!X;; zB_rKmM-taavzEk&gLSiCiBQajx$z%gBZY2MWvC{Hu6xguR`}SPCYt=dRq%rvBj{Fm zC((mn$ribN^qcyB1%X3(k|%E_DUER~AaFfd`ka)HnDr+6$D@YQOxx6KM*(1%3K(cN)g#u>Nj zSe+9sTUSkMGjfMgDtJR@vD1d)`pbSW-0<1e-=u}RsMD+k{l0hwcY_*KZ6iTiEY zvhB)Rb+_>O`_G{!9hoB`cHmH^`y16;w=svR7eT_-3lxcF;^GA1TX?&*pZ^>PO=rAR zf>Bg{MSwttyH_=OVpF`QmjK>AoqcfNU(>W7vLGI)=JN~Wip|HV<;xk6!nw-e%NfZ| zzTG*4uw&~&^A}>E>0cIw_Jv-|Eb%GzDo(dt3%-#DqGwPwTVxB|6EnQ;jGl@ua``AFlDZP;dPLtPI}=%iz-tv8 z0Wsw+|0e=GQ7YrS|6^cT|7SaRiKzV3V^_ao_ zLY3Jnp<0O6yE&KIx6-5V@Xf^n02@G2n5}2Z;SiD4L{RAFnq$Q#yt1)MDoHmEC6mX1 zS^rhw8mZJk9tiETa5*ryrCn&Ev?`7mQWz*vQE!SAF{D@b7IGpKrj^_PC2Cpj!8E{W zvFzy&O4Z-Exr$Z*YH4e|imE`&n<$L-_Bju=Axiik+hBtA4XNDik(G_;6^mQ3bT)Y% z6x=a+LKFZbjyb;`MRk~Dbxyc&L; z8*}!9&j0wewMM#O`c#7HJ|+Gh5%3~W10b6sdmCg3G_v+@H>n*c5H`f+7%{TeSrzt89GYJqm>j-!*dReeu&KHubhzjSy_c~BJcbaFtZWAB}~KP3%*u{zHi zVSUi2H8EsuSb3l7_T1hP!$xTtb{3|ZZNAJ{&Ko;#>^^43b7`eE;`87q81Jp;dZfC< z$BD`h-*j=%uTpG8Me6dF zrH%)Bw-a0}S41ILo*k2zn6P@?USXtC>pX*tzce7A^JD7^^p7K5kh-HO&2haDTL%2^ zSWQb2B6}e*;x?eKq?CdG7F=wHVY)Lb(kQu1R#1Fx|3?>_%cjNM-xJlAg9kr`!>&;E zTYmHhqHh&qbfO`~w3V;BM(q(_Q-5^!esaBI&QbZ^%N-ZDYft#FTS;%{ zKzlSwZIS%zDi#%DMK>`_vmE^krJL5@PmpT2m26Q`O)VRAL>){MN45|7GTk=q^zLpF zjS(Os=`#On$XI#$A5ewac9Ma}mDxSu^5{#jHC+24a2GbfBJ&Zn8W= zm=l7VE0g^z$3ikyU#ysh8b-PH(&-yZL$JV-of-ZM@~N^#DbQ3Ltlq*5@>WzSNxrRK zYl2VS8r;TT`wLfD_O0dhX9vR#S8rMOuUCRkWZE#OjRi$l*#C7}mgGzZBD%Z=p3z|CaVM$$pyW5-pJJDCToY zO3R5)P(Gnd>6wh9Z$Sr@cMXmClU(h-@5kmiBTNTU-|5vq&Fs!ah|o47kW?SO8uWv> zW$=Ud@@|*9p@Rb=!wl;%>k)kH7fPtcD=gd}^IxN^=Cg>zq^jij!f=1PlT|9jh3K9g zF~Z)B;kb^a0hLmJvON8Ho)foq-oC)&E)b|a^|b}6n!8&AIaousO^VnYzYfuijuEo5 z7IcUMbYD=vec4eZX7;p31NB+T9BOMJp9ZI9$dH1kJsJpEtf@}tL4)_*PxgdOge9_EaR!?wWtBx%*f$IGoR>f3Qf2aT0%+fq=1xVEqRl;UaA2Ncs4B1M1#foI2bj4 znX}t7;-FCLK&;>ZGP}{GxK67$Kz&pO%%J>DBMP_zZsLOmdpDUDp&f8=L>(Kcj+S^jA5dco4-7XN z)h;m#54CEy9)Ch-E7gHP@a@TXl=_%&|iUlIrQzn=LqONBu9FCn`3f8aqvRu=RrJ_RH1^Uf=t z%Ir*({+wEeC??C+u!hCi<5m`RsRO6ti7YaEtY0|U)-QfNsdN{=83K_}m$0Z=ElWyt znvo5=%f<;|hNnL-r#v5ab&S2*yK>~a7m(My$cfd*tff?=?7-j3^|&9H7G*W`)m8M7 zzd0+b)c@`bQN1-^dC$_04tK0{mU5tx_zo;&TWou8F(H_J?O+Y)VLXzmU^> zvL!5+1H?opj`?lAktaOu%N#k4;X;UX5LuO`4UCVO$t+kZBYu`1&6IV@J>0}x1ecuH zlD9U=_lk1TIRMm6DeY2;BJJEE%b0z;UdvH_a3%o)Z^wM&<$zhQpv90@0c+t?W`9kolKUklpX5M&Qw06u=>GPCr5Imvh*% zfI`tI-eneDRQo?m*zD1i;!B>*z4Xioa_-S=cbv-k_#Wg=)b$0@{SK>Mr!_T?H`S-?j;3$4)ITn$`g;J$^TppD)^pRz#^l?XgZ2CW z3g5G^iF*GZYQ}{B|H-fqh=_>)E~=3y3Zg=i75G5E)*a>R9bn~cNW{h5&P(vQ6!WHv zw1-89smtY~JnCQS(=9zM)6>UAi%G-r^LA9_HF0Vp3%JF2P%+E&^afy61yxnAyU;Z{ z$~H5X6?sMoUuOT_tU7i5i%5HI{^@#Hx@zhtP55>r_<3LwusK*SC#%i+gn&iRg z_8UN=rLVp*gT(K~{0X0f_=?~bBbfB`=XrTFn3U!)9n*@Uj$-mr^9PNi<22UJKAK&D z|1@Ck3(Ub;>68;)gIn_Zu{uoVRMhAkIqgBS(v2b2{gf?0xd(1sJfY`56mVy>~^w!wmX_kjW8#?_Nk{}zB9ULo>4fO(vnWfC+pG4>%*KZ?JuCdXu%aZ}q7pC%E50@U9+KQZL5 z!*I`SOtNf$Y$CsRsNaf~yyw^>#X_mCiF&*gr=cBb zoPu7PwX(+Wvl~i(XH|)jj@Cu+rzpJMn4kVvCJ~ReCf08viF$q9;CYnv-96k{G?pf_ zQglN`JiS#vok)~^Z2>41#7LPFgd_xrqNO%DQI|!Qs|nWt`co#BwY$&Wm^6#~)`_1k zpwiR~&z#mtSDuYm(=NoLv$%Y}bTjog$RJ8$j1(s})=}su0b?o8i28-|xu58ipFBml z2`4qZ$BbY5>(i2%wmh!+C}$97?X3LgTQ_{(SaFZvq9YCn@BNz z&h#;4h?5#`&_0()uJ;_rR(Q^eY*=&vu)#EeMeaN1puPv5+iQFg1EC(`_99_5v<1r4D ztc(+-eVWf_np;q$M*H49#{R)eIWCI%R&6F34;h9eNG(XNO5ao2MI8;j}y% zZeA>zX{#$;muhtY{_|;bkk~!U~Ih z2QUO}hk~o?sn;#|Mt$0}4=+BRa703n6>fBm(cesk8Cmugg_wi|BWj}V-VuU9jNH+o zgNYGSKPm>qR&nI(2Gu*})AOBfXf0J~CC50C!3KXu6-qZAG!VMZbmnqL6HWG>o$^sjoSLbQxra@WyKV$+_Qe}t7d)c`bpJG++ zw|9D3>XUH^Wplo~MN%WK18n3HeXoe*jKwVRK!=RMtIr1v z;Py~7;eZl&=^UyumN&CecrGBEat}4?mtZ>@`wPjVK@Z)FZ;05^9kztq;qmbxQIJ4kXTk)) zaVfD^K2x7SB6E!Zz@0p|Fkge*0(0?ogmTX8d=?n{2x)}K2$`bjDmcLg3#wU)i)by? zW^G8rRQKBwjke5zHScinRlE|wo0XyhBc9R52IsKWf4-@=l!yO&+l=K`-7Ib9U~hPy z!cH>H)e6$;m&w^0d`axGqDwBgu`B+L4a`xr#5g%b=0?c41`|lx0O9fiIVaFAsO$Ol zayhm4C9X%hzUf&ctylV$%ntuA$(yo*X`gaVX0$|x{#!YK^cvLmNWPZaTd3&xP7ny% zkn}2AdJkpAgmsh}Q$tY3(2RtO;%R*~8r#ZbSbMR4LaL9Sb6O&Ce(GlO${jtl&`n|D z9;zUQPXCHqTm&t^lk9RlZiiquSY_og^?kgVruz%myd95Fr!V z-$OIXSt?(pxN-M{NjA)j1KKIp(&c2RVjd_}7+CbQfw zTRjg}A0~}Ht_?-@wD0bI-;LQwT?mKywmDZ7*j4>4pR6@UVU3mb?-cbQt~aIG&RBjl zs-4UNtOH3+dAF%U=={qB@qijh4J6K?Et zPLlfPlv<+i>ty5rh;Q>iGFoaq4LyBIZl3L{KGUmqPL~ZCosOl;7w2SxcE}pvK;5|6 zly3JjUsvk|d7L3bFs&;q@_|p?vdU_UzhrS$Fw-_NoEdoIT#-0hKC37!>-i6FaO(es zY97)m4YO<|eqGMrYejC&-IFmc{=P7>qFWX;)}q!&e9-F59o>V+`X>J}%Te0$|A>0W z;7*>m4>udzwr$(C?TzhZqi<~6wv&x*+qP}v?C<}aI_Jeq*K|$4>AGurZe5=U>-0IX z>&2?v81(_Tn1tITYDSF@^Enhl9>e1$iAnX!+&YJVi>1uYEWsZ?o*Vyg+K~%XCxQP(WrdtEpc3sgbpTM_ zI7i6|pDr z{=xGh4O=PrB}pkX@o@A(%GfdU!c<$p#T*mLo^*7@bd4rIJ5eS&&A9VB$EhabJ1^TG z+dke8lOG5I(xMYZ`Xw8+olY0y6M)M0rcr%9tZHa=G0zICN@DQ>0rVASCK4=3OeMSv zD!v+POT0`UZEnP~1ro1?HPLqJ)xx0#Pg^yBJz@S6gmFN~cGvl(#fz4oTs7_Pi^+i_ zZP7<#ukx>i%V;uJJ~WwUW7pgq=>yuT+A5w(J5$1no67e(;mIO5>@`(U0{}+kg)B_8 zs=bfBbmZ{U`xjMpkAcEcEeF7^#ka}2zDU-sBt6yQqw&2p<+6Hb(Hi56S!+bU9AJJv*{ep2vD zG;PVwX@NC)+=6@I6J=nW6_99&4R00FKpUPepXoBVN*|V*C{e7X+Q({6O_^@SlI(9Y z8kRO3WDG5u=vmTjZ4DW89H&vNa;i%H@`{%(|J%tVs;1gDadzF0Jy%}C68|k?Zr!B9 z*lBN4{#6p#SQS-q#Ck&x#xhAOu4mK=Jxf+5E$h8l3-F4mQY^qaS5;Z* z-ddglOueLtXJhJ!%yJGk^-iZ_+qLJ zpTZn+6kq81D@^m(v$VFFI1Q!dtczYBt1xSn9~Q=@h%tsf*hCm%fwfx2u(u=-4|qf=I8WR*%`lsQ ziP!-b?(d_`TdA=^<$@(2c77&FowB0vhswM)fS>lYvjK7B_$<0SiQNzL6T?D721Y*( z9nG=@aWvmJMd%j$Jxp3-L4x99-X-9aGkW}yiPAo*9{^6b1>tDg4zIPFiTqVK$xq1rv1*kaE|~T5-jH#8{g31#^7M_uSsmQvNjyk; zbo|yP0w|uD1)wGrSavi=<;=H>IejRQlac$HMkU2rbq1{8UntI;oJ}*o(bXy{JC*l&^W{Y^}<%Nj1Tk z$(9f2a`BoyZZqxWF=hhmc3ldg+8&Ep%fVCSjopduonggw7@?XulP^JPo+_le`o@z)ofi9U%I z=~YZ3?Jok#3NeQ)U&qUqvoyuEMA?b&Ki=s%;_MTDX+8^>z@TOxb3qw~biG4!)XuQp z=>cVLGcp<{Piu-TqWLFz^P0>R1go1M41xFSn~y%8LZ{~t{iz!z$|ne5qkw!VwuI<6 z*6Bsnap!L>JA;B$u$J09!L&_iGdX<&v1jeDcEWM4&2q97^g9gK1%+zl7nY)PUU9<~ z!B??-0oFH5TEpfNW#V1m;(6-=mlUxm699O$g=ZrFZpn(6h%3n#!U7eFnC1BJzLFB) z-)SER^cpQ~AF(`0^?pNYWsz6(suJg4)Ke+|iTo4!8P8ND$ML1a%4|QMYe@SDDH#d& z)P6SOk~%xdQ?i^t{N0)(baSgQ(Fp*daGXR>=Vt-*#@)>A1Sfz0!iqKtjlY4}1i0v0 zyz)Z|vB+_QIX99Q+NFppI1+3`=qUen8NVELr!SOS8Vq1;{<}WKOhe7HMurM4mg~j5 z%|wM0)r4^=uC{9_OTf*An{G}>6hw}C=H|&8MY~l@u zmW-R8h;dJxjKNqEdGf85(5BrR>lY2A= z-_%9;IglQfHBuO%U)bt|g%1h-OMbL9H{TdFgM^rdBTt~gJ%{*c<;b$D13(ac>}*nJ zo@&y3%13-hUh^Oa$9U1ImdNfGO4bPX$I!c!6e;sRC>z{knTf~G5{#4J7y(vbrq-qWk%J5#0Iv((P!QKa6f#3?;#q$+(teR!nw%kOp&_W`3L^Xw}Dw&e2#l zc{fk56;UyHDpT@XdB?u!*)EdIMT8X1&e>VO;M_QH&MXI5|3xTbET#NTfyi14#+0+t zDS(NC?jbc{yIDjm-=9g^4*f1c;0!ytb~iQ;DSTKoa4ow@d-x3HI`EYcAe(li zjajb0cM*@u*kiU{)jd9yTNeRZLL+Y1&q`L>gx^Jj_B%sh2+%Z1d6xNVmTw5Fw!kd@ z+uT`4r(0=PXUZCNn9$VPo=aj+p${a|eqjB{Mf+k&$GEGV(lWHl#1xy1%5E)1KD$bK z0Z1Tsk4LpTn+b-iy}25uN>wvTfN+B~4r!aC19d7}&hDFchbqZ0;e7I0BK}RNujj9n zY8As>D%ez?Fkng~c1L3e^}<%h%!NhB5ZFmv4qmi`am*+A28lE6Pu4ekBJ8DW?YR4c zPeG`sZYLihHq~K3`oYvnQL$26Ojwnj1AOypgX_ca^06&6f`T8bedVhWj1y>F>d-sg zr9@SeL^T`CHIwyKW*F#~AZd==$aA_zOLRP>>S_&HK0s{HcEDpNQm9u|IZ{W%#*w4} zmN;)dX5OA?I{M$KLje0TCiQd&|g9E!YKD5 z)_8>@<$&L)EoO;WhhvUYgEDDJ8PPVpR_u`RN${}`PnjHc-4^~CwIh;mLF+#KK>Wc> zE|Wkj(OZ@zIa8-8rUq=a=x-F%J+$ozWaVUV@yS!{UWJ)}=^jM1_f&XffEjCb6H?Es zrqQ!sdrLtEHq=DIu@B|%&N$@{wC|>I`>>2EXn@+22x7PaM4p3V5XhXp8gSH8{)yq+VsXB@4DmPLA`4Qc`r2Z>3E&lVsUbpRejKO8Xc|ayAI6YT)d!q zrfQj!sa@T&5KPMxDUd4bZwub#5<;yenI>0~Zx=@R*M{S6d|Z3TAEsEW-w#undSQP7 z0ryg{By3CNOC^`$t=P&xCf<~vRz1}|>Oh+v>rBMi?&+;xKSGs;7Ie~^T>J4C9Ke&G zL&{aTYZk-|Pa*unK});DaF?Y=y73~NA0(lMPUz1G>G;8n^cmm2S>twrpU6ynN~J1! zHD!AXWk^D?nq)%#A^&d%DwIkh3Ku$<4{$Bnqe{R^e!E zD6qaK4g^V5kCJH~Ot$Im{2T}8sS28Gk(>QFg9I7A-=nDns|{X8NjAD%l(zhXxPR+i zsaKZiVQjKRN#@N{`Cm?#slb!NghtaUv~`T@mvslIbq5TcS-15muB2Hb$Zs``b(Pmm z>-keg*068f|SD zm-1~aS@!4?{PuWQ(%MlB?$oG~Y0UBQX_Nz{MC3%JvnoK+x5+GR`cIfTOE7r3_Xi|f z(1x{Bqg$A^m57WLbkEAc&hWkBABmV|cqNS(`o`}NaSI8Lm6{l$b%3paaK-^r1yrc* zQM|lY+je@P=AS7fX6VXPV>UYV77X|5G z5Zow(9=j+q0*H%#H}fpu-HF%`(GEbvHmWK({pqfv^b!p^KiWxjYXL)gZO^yLvY!1#{eH$?|l`7XcETF-V>)m#$Y-KUauf z^b+<*r?&Mks6o?n2JrEvgk?j+9|~S~2U~dq^}6M%or)_T?%jaFi!#+q3>YaIG?m3X z;{>&cQSHf29MCWgsDR$xyTZCe^~uYQ{iM+(@1tKCpyDxFoeVGQeW)9uT349)IDK!3 zsmbQfykCr7P5@r7$@N8b6KjN-vAfM%rz7|bveQ2v`Y|)B{2rfRwNw!r&1%%b*lWIy z+l$A~f%;yYgfY6h_(-1nXB!C4(VAsEqS^YKh9a{{_uW8t$M^?gPsm-J}^#E z_uO7hC+?sb1Iw^TeS$QC`8qwrX85eSYLIFX93I>dS^)6QIMdwX$;6F>2_T&M6o;jL zp&W3|Bd8rLlV}iSVY9G7Lo?V2_E`JVM(`rw^}DX9)wk0Q5GJ%esB@}u@C>dZ-byh| zBFz*MoXGGiF}DG?h!UZ#FN`;~1bd*pAWflMa5AtD-+Ut8Ymf#=b`potx5YLf&A%ZwGv$|Si7 z(0)Re$(F;{=Dhtq1%wCl0ijfk+T4jd3}^2Z$Q?L=1_lkM&nIax-Yo%VqZk6#Et%n& z0S9_V?yja0r@wi$m!-JJM2G=aQ@nYectR_Ln*dN6gmAR8L^dIf-bxR>0A)c$?#Ug@ zVlrY8#6Wp4wiP3OZ1@T=EBaaz(jrxuLG%?*J+=c#K7CorpL5*eKWVYiw<>#a7zv(N zO^RpkPM=xn!2?&s^7NCTu~a+aiGwc^_4Rnyqj!-l3-f+;6mkOx5@ynO(YF&u{yH5a z0{{W^{1E}V-LFeZcLzkH=SpZ_y1l&>1S=X`+@!Ai#KmNT?5ox%_;tp9`=F^;&%fxn zpX4I|M!d6`y%-8hequbo4%INVKruc+o|NwhsZB0<&TBCe}v2@CyI^$jlCsTrwmBFnzIMofx8PeKa1Av-Nj zlLtw2SI?rq_1(xc%<3sF%)ZrYIf>Xe7@jPt9BWoU%bg~g+6=1f;eW00nOrbo#*(mjYHCr_?8!#my~|i(0+2j{Uo+J%%rvg+%X5* z4!HCVyg~`t!LBG+X&89L&@QkGXe};GQ^moDsqI%U>#?IVQc53nUukdN%ij?m+%#Fv z*$`n_GFdWHC(!1z-ZhRjEV&n1wt#7VUXkgkW9Q5V;)k`XOO{*>9)xi@4}6zxlm4Ck zPC4Eq^0qB+yLg@{^VCgieuns3B!x#NzSr6q_VlhP>I4gzH4BI}DTx^r5(>Dyhc;-w znWU^i-9$N49%O1eIWyBV{K>wROpYjgCc5b?os*f=l~V;o)CB3G-E7LA7Rg3;!)~m@8(whM7Es zwF%4mEd^gMI<<|N60&DB)!+6-+8@EFbvGs4UP0$q5NEO<7?$NeaVcvz#eXkrXV;$H zPjNrI8gWTpphtwY&md>1N7T|$T^i@CM$EWZ;`6{q__Yr(^B!<>OPXT5%ICC%;4jl=T77^3T z0A$3`@j>`8*wH>vT`en;tj&YA60zbZw2F#^jE;rfTJ}-rcajHddN|Q>g}o$TX~osy`RPP=q0j_f1g@QgXPlY@q1Jh?-r4bB@~25Cj@AmJph{QR^Ya<4r(z*{F~ z=-nsVQY2K`sKEl*CR=AMEDIZD88T(wtjZ_((xf$>SIA*D#|jjfGw84wta;Nk03w~g zI(#i!OQDMse#AO065D@_gm?pQx@{rBjMat|bA$6MfVPq;S5zT5IKK&|LFZXuA zqj(kJK8jP}^ZYm?74hlPtf)m?w!rUP42d;f3Xx1K3raV-*P;*>hmzjAkyfcbEfZVM zJuLMoUQ0*&6p_BS@>f9!k`6HtNO_~}(0Jkg|_f8#- z!m%Jn^dX^G#qp$LnY0H)6WbFMeDL2eCjALoKs@6Ai81!~l3d5bNgZQ?f zTgufN#)|A&im|)K13cIGc?~(RCQ+E^pAR%xa6I`LxD$=mcOf z@v4=zb!i^TVJ(CsX?zlhk2fs((qe>+8Y#o60peO430M?7HT|g( zcVfD7@Ob>SyV%mu6}7g*=p&J}hJTo9hFn2o9Jy}QCXfAbC}WgpkeMXs7QNle)Z`PI zaU4~Uz`idIpQPmpq$?{N(5Wj_y%UX!5{=9|{BFV$P&Z}ciIVj<`zLyWb*T2wf|8o* zOk|-Qs_aJayia$?0k_jr6b#)1ONJ!Z;{~4NDyZJ6id*&SjT|kFCPH^!Q8MlaAE-*_ zNR!vqG}YZ6i}M3h>ENPmCHxC(#1( z7}2c0*RmVw1@+)M+n8t~gQT#+Yg3>|OA<9`Ynl5)ftY4g0EGA!t?E*;j*jRcB>mr~ z4f=etCrR1X;V_euWY<6p_AK%IoHB+bS8vl&LZ-5Q*QvzmfHq zZ>>MgWVvSa-wRV7cJ8O%vi&R+@2I&X=r`1P1;x8lhOpY4Z58^@Wm+--yBQ{&>GOL- zIJm(euOw?WYjBR|f~ue4(%k0i{lp`gI1~mF;g{;-0_gdf@ z*Q?M9wQ1ZdZwvrK|IY39={n^R^(zI|p=Px@ff|e_NEBug4N0vK!L9-J_DIiI7e5Pr z^Sce&Prjs*$mOY7Rf3V+?poBWP^ki{PIa+)OK%4)E`rV zxx7V^Qy14sZ;Dc2jD|ccyt5(5Zp~;Rg7N_IwB&EZ1jv&GoxT!1H7k>pY>Aa{$&oHg z`ykhr&GpvCL?|Xb;O}(ErzQAl=DZgICR);;Y=xkO<~chKzvaND<3}Wy~d>W0L>Q| z2-}wM73&w!hC@XZojB#$EnGzb4HAp3FWovUq|4f%x4KLKUg6YfVpokO|+JO^JSzIZEji>8`uBI~^1wYq9L`S;8*pu)y zTN!cO5)p_vO7vsEgglr#ee5WTiRh}7f0zLYNA)eB;_ z63%8_pGF-Dnkx@eu`dPn7Z1~vMk@*nIMW6HtpQX86HiyI1H>8W+4Y50C=@;!{F)Za-A9+#^G9aiAu<-#DuLR>+Vm6|21n$W?isfhl9KnurA)AcxJ* zIl$Iy_sl)Ewu1nV)Wiqc6M8RZ-OvG~x&%#S9h{L)QE&q|7$gk|*5h2|^bAvwHm@~P zRY4`*Kw4vB$#(Yqt2+Rd{vNGl*GA$FksiM6%fjfp!BEgA!3EEIq!j+(-cS%{(44@I z+KuDSMAy-fyJ3j}-3vV|_^?zVAkrrzw!3@QF<9e~z*m55Kjm<#D3z(4wCoyq=E3Z+5+o%*c82=9Dn;-mR<5ukCVG}$pfS0a zGXdRdAa-u4>?Cv7*|^+XrkWQGzzvT;h$l5u$vMI>9ouxPD^S{5-qvWAprQ>*&?#SpxdJ-SE&Kk2hn zy8lWI>IKrj;hSj%<-bXl8V%B!q_?jcj{k-hy&J%P3vb%^Qfyv08YOw$Qv~F2IOcFi z%I^ScI`VdU!El-&Werf%8X2asF7Tsk7{xt!qlOL$mCejuXC38O9pJ8y|M>$P50HUy zhcG}uKWP7NB@OTY;fq3kG@GPwLy>1x#YEu`vmQ=(0K)g*ckkeaAkM(C2nZ)rJS}8_IMTxIBXH|>190=4 zD%!`?a-E!T;jSVXMP%ETk{4ij&~`Q)&DZieRx)rLfXGfwvm9#PvZgMyX7+TpsoXa= z4Qq583C|0#1W{@tX6kUwtN40v^oyycsiqPP<(V!5f5bA~B0ZGZ{CU#4q>RznC|I_) z7I8BytRK$$wnfi79s*Phn%|0s_u9`zwWi2#=GE5F_sk({H`bq&(QCDy^X97O7~dVV zjm7hN0FhFY>Zr6d?l;%A(Z~&Ew$4)I4_&92>1%LB&Iz>(85AY z;VB`o-(qZZj2^wUL9TY=pDZ9{|L{Rg0eiHZxKR(>6I;B}xV?kpOG_~18o5kM9>bF; zvl22sk@FP)d1Mu!iPBd8n%hqPUH?B{lf+vBfKDaUjH};FB`hI|=TD}i4-Df(W|+FB zCt09JV@dNOy}=s3AS(U4&Ca^LI#IkDbY6-0Iby5ba=y`Wp2hYzhwTE5+|7W}HwTbp z9OzNwQYpe;mIt%rDX*W89h~mxYK3jmf-7Q*)B9kUP?Evo3sn(X81NyML>*eVx+RUlBPA+sDViBwk z7*Dl;#i5JP1+7=3^WriySJy*Ub#&|n!0jaOtW}%-grYW2t+eT{wz)iu1P?+?*78D4 z?m5`fN!6Uv7J4JU)^8tW`D-N9QO%RdtYTA8+bXhEgPf34?k{g{4Tq?|%C$Kz+U{9j z8RcUt*R}dKX*G74+BGaNebZUV{DCm;@U(5XnJYWyX(1gNvxR#br(Qa6)^hmsfX#aR zk+}yFE?Rp5@=+8!0rVoYMrk4eHt6+-pV!|CZFOXL81z;&nOQ!ct!B%hYyCe z$8CC^HadwLAC?`$JgYtvu%$b7`9Y=%pqA!R6Z96z- zLhL(4qE89OG&)oMjo05P>;5?Mp60` zPWdJ5-2@SE9T{-ytDRE{6sX)|Y1X;+C@K>yY^}14Y!088xh~SPfbJG?M1tBi?E>u?zdU>G{5+S>|$%tGJB zQ*X_vOy)g;@fbPm0a(Zh7zTzw2Ct$FB6Gz7!tmK*tZ2h588F#jY1p`jSJMli*7u-; z3tSU(fscAw1h}5i`&i`+?4UAF;AeV|b}3)i5zA^E*L0X|u;#%xYNx~?#g6jEh~;8t zQ8$5Sx)(-Y-j-9ugVW%b2(t*(k6(`>S>s9^t-podjkrgd0G}k7#${=(J0T7``%9)` zbz@# z89pMA4}>(ymEcPbh@I>#D9Az~sbv{(OXEh+fnx{b z6H8ULM@UCCdJbtvxLPl+w?prh49<(wWQ*(&g-1S%fFdrWy;&bp2wdG!zXt0n@O|(h^&64U7Am>%tK&1tn{(CN?9?pRJVbV0abQse6W* zjaunJ1r9_dkDSXE8y~{blX@E9+XdZr?+Cj9fSv4Dr%sM0X8+%}yVNrc%}Pks zfLfd-a~NL@9Ae&`->H9ihbrSTQK7`l0(9ei<9)-C-ZjdIKdOKOVrZbL^1x5+({hmz z^ka^IzOo7Z5kDX{UB^aJa=ZJ664{}im=U8r5}V}6e33gr#%&kPksN&;R!|y`-hx0+!ub!fTfgoWJ@3*jQ48CTp{?Y z$+bKR>!aBjD7x?Y0>>e`M#1*rfv0;edmByS@dJq0U>!j z12B#0J8%)E#AT3Tv<7hwsa2De$TgZ!6ya*gBbt8{dMpCoYg`{48qN!f$4KFI>9kSj zXqP7qQXV6DfRu{Jr(Mj>;=zUW>U{0sd8$z^(2$UE1b=z(K3T=YUsL(r3UwB%vS_@i zUw15;g`ql@wnozVkC>v|rqdrPO1t2>x^$SM@_>ucDEgntIq=60A2|p%szF-JmH5_! z>2S4sVX}c!H;5b!MnOy^fZYTP60VDhA{ikCTh{$>P4GK|N)1u_VGJ22k_IyXwj7Sj zcn5~M5{rQqE`|I<$3Bj`K#{b$K^z(UVwE$D46wB&kBgN&?rjSskPyQ3X&G^Acx^iv zW6lXF-}{o%ux^olbi{%ZmZM_C=6u(%CKQ={xs{jYqD zM26k$`Qj{UlW5Jt`l&1QP|d=7B{Dx;qd$8JdU$AE5&l(!MUkXC0mFRCM3JnDw?zVe z7`mm7)u~!VZs$|ahb9Y>#(9sjOV zcH~0w!lwVVM3oxLQd(|~MDZCpxbXh7qmbj2l;)N4J+?HVc6Jx7LG<@F&tGUvek#38UUOBInuVP22k}b4Ep?bEu^--cB#Ag|hqHNP79!T*v5&|g?2bQG86x5lB{ff(Rjr7|;rT&I0Ef(#dGARy zq-)N|z^0X-fAevH$bL+ip~x^dH#=T?vKN@HF~)7*3?~kd(`GwzGp*%S?H7db>`8F> zgx!tP`bl5-7lQ@AQ4i^?mNUb^ki+(Qvxg{R!^Ut%ya1_K$Ci-wGtO^W+(5We9^Z|i*}v@%bg{vBl7i??boO`xvQUh$k~C|d$i?y7U=W| z!<=;Y;tf9FpB=nOaU(_U#7Npj4id5?8H4? zsL^r@1_p9?VMR4cVe#mEOOH=f?>dB_m{#vzpM&E&KVbxd<&r?NMbz+F*duzV(?Y8LUgUpO4?&3)QPk z5&HoWONJr}EUHfHzJW4vCdqg&<>PN7f)paE#1!i^P<-8JfbLD7%T`A%By{h7P)CAW zJ1E&XBE96%#4a;dwNYQjcdiR0Nxh?uH~|2q&7C9LQ+QSv8X^PP0>Usz*HSS9C0>to ze1pO&s7BCS{x!VW_Pg@E-%TErJGYbnQ2hXL%RBzBNmFecgMmO#_uULhV~c2I)KHP{ zv{Eui!aMjaX?Mf>WoHp0KtGR^e4E^69*4@*{%8^>HwxUFNcSt7W0h7X$VzQ5JTGQg zLpd?yN%(bgiP_o-cst z@QA_VD0&n&*dj?j63J-vndy~X;lwmo=Q_8PV#w^VZOiYw;}mS|B;|u)e#GS8JRqxP zoWEuBMb#F=PknRG3P* z4GJA~MMpEbM%i4(YahXGEOSo2nB;oM z*5&1O`U}@hdRDps0PqD~2c@$6cz7sxmZ+b)O!Nllqto*I#I^<9nQ}0`3gtZjgFSc` zr<;IuXQCn=vP25FV3h8Z+}TdG6Sel7VCP+9#!U`9SHR~u*QtV&Ir;S6Z^sSGm|s;y z-f{CTn7y-&!B@eo#~6{h(77Nh6dHLyQG)b$p_3Gj)aRs!q6N>lUC*~^HSvWstrW}u z*CU=O3^xF*0&%aIQS)f~p!Vfgr70q9_)Pqs1=T}zL2n7bM8o8g#*F|Q%n>{#zGI3aoM5ptgqb|5#Q0-fuPveFm}*t#6J>nQI?04W zddadPl-27!^`1tRpwAVEqlr1diwI*)RCifevrPbt5Gp@fxs&zT5 zsb*ne&_BG~c(7H^P%7ADWn2!iMjp*h2XH3HT6VU72#$t`4=n-ZMCj(Lx2fTA@Q*v3DH1nr6oj-PQmZ9zCOcnn|~y1H8R1_aO#cRLv8n zA^SQ>qnD0V>X0{ZGw#)({*;uB(U$-bb3>y#gPQ0j{V0TAh2!q01pnET-gA>Z&%Zu& z{QmIumszVzi2m>gDlumvArvK|eWjErehNwr_*YQB+{U0n2iH{TJ z;qL1>Q|tNR;tK>w-Y~Xr!pxa~?@n`+EF(yvE$iV|s+c}C9kp5-ApELWNNyD z|D+=Q7PY%KH^%y&U#ewXB(vfZd=y2g6mLmY^!M=zO*K@jEGVFm+gRBYv6`7`j!j#_ z9w|2DzzCJJ^>~J#5j;E8*py74CK@&dIy0mkEqwTPE}}scXFHs_!v+39v(Q!~u%}FWO}FpFHX>#>99{bVQXu z&Mv05icalrL5O4IcpQ-%8V0q0)*4^oV6E1=wCFNkQG8D|Vcl#K3ekLmEmuno2}tcn+QcBWaoDND z?$>_WkP~3jJBVSpFIV5PxKA;nAt-PpDTxDvS|U0B~sCx$DrPuUWy1s-9;QX4FU@5U37&vhcuXyFpWC$dZ2bo2M?j zANK_Zrju>J;S;e;$Q-lXs>AJ;X+V(MnIVQV<}7RvF2tip0dAnk>SJRl?)-~WoU!77 zQ=Tzv)wwG*H6)RHIJxxBSAnc$34YukwX=MWwb+&MO&{6*3?R8{8xnSKM?Fx^SIqyB zbIrq9*-wfEPB-!(hD)U;417Yhr*_v$3yfCOLjgK9ct=m3wC4po@*K`;f?423NQ%Ha z=HQfTdxjl&#yC@aA?gUOwDc`m_JtKN%GtmX{+jhTzM{j)Zz!HLVWS zT3ud61ZuseM>#VB zB1v^H3>~f3ZuQ1y1W{>t-Z=ZAh`cL8Ph>}_y|h?Wg&}{_PP-`L`oK-Ig}U9hdlkA` zD(w7nYK?aP_vu?cAgjvw$DWY~|Nr`6dn+Ike-c>$`F=-2aTLj*LyZCcadEaCUHG~; z86DPAtoK5nu-&tR!-E*UKmtjQ&F-bed^U;yv{`=a-Q3MyR&EFcei`C7LwUEikDKv_ z{n2hUv{KSVf+2Ghr?p6~s8Uo}UNjM-Va{4f?=S0P)GQHiP&5mMDO6_~Oh#6NWhYTD zHVIY-Br?zR-A}*_d1E(u4)4jZiSX;qv}@p<)$5PHa8uof$- zN#h;PX!Sh`GyKY@#3`XavDTF!tlLp7pOnP|n7ydSTSeRN`9lT0{FsiXdyibTb1c%L zVA^GmC!c-pE7zzK?fNiiRLgGuZTzKsr@X+hJ&sngBnxa3+bfw(?G&G3Q%W|MUt{C{~s zF!W;nx?2MjfY!+%*n5u;$!Pee07wYZ@g^V02=j281Q-OI#l0q(9<@WCr<;o4(a|TM zH_t`S9?g&v-JRw*Z;u>5#?|UTBD=ggqWPrGOk$%Eut6-?OV>%E(R=5l*y|X#64&>rZ z#W3LPCfr7TgzQ0(qgidWUQd+uWMCx7o zEB>|%Jj&TVz$-D|qVAVU4!CF!@J}!yxFe4cX8SF|Y-XBWZzD>se-R!+{t?Wh6=}E7 zVI*Eoa1su_6K2`e8XfsS4OJM|U+&-7VS zIRJ0}JFs%}kcBm|$KkOHXW8Yj-C+KS#mq``V56%9am)P^?MzJPWU+*SyoQeWkRCz< zQ&Lq-Q>VTUJh=@7B#nHSC6HUHAey1!j}y>tP-yPh!o;992`-QHd7AI5t9 zPzm;}i0kMO6~Kl4TT`Y-BTU9Ku;r}*Q1TDl8m%S{+PFzk4&HGip;0#LkTx>X5q%>5 zvea2A%tl(PyC6CoWZ>)xHQQMu6n`UxQHJwS^%+zbld7C*CafaNLfh=(7&7eb)>jvC znLDJo2#ICn^BvWW7|$|a>!k)dOwPL;_Ao<@lzuJMoVs>;vkRhel4yyS2) zNMgz=@z?&pdF|R2kYSCb~_c?Vn#f0va))?V7TyrsA4t^o14=CVLW+YJt zornR!@R}SEh5X@8Mecwsv4(I7&TsC{FBAkUqM~hI4`ElK`EdgmwXTtz>9XPZVjTba zBi?BtsK{w&VnIK?b}XqbS5ujgFthngi(n$Qf0!GV*Ck3#A5=c-XwE4I2shGOBSw|T zij+DsI~26%8A9#jM#!kkG4k(|p=DlNOtp$^w;d!`3Z6v)Np-zYDWC&3J{ zwaUiwtA2L~pTeKQ%+q-puz^>p5WizwIVWT}a7;I6vmOl}V!9x!Q0+N)w0dK<>Zy?Q zIMqMK-zUY;#%$)=v;*}7l%0g)L@qrQ%(KKJ+7(26naCnPXDl!4!)l8vCvdPEi@Jw* z|6Y0vPmvHvkk-$$00p5yRzY+{Zx>_nKI_Xh)l_9kFz3dgjETw(U=}g;=}5EaiyMu4 z_K5!H6(p54QnUJxGgc8!K#+;aOOofhNq5c;z10R2IrtP1H4@T9A)rjBp`BPHrYhlL z+@cieQ3~0svr%Pi6*}fPW-L9x=CjjPl73d0y^9szowR56%tm}k>B)RtEMvOL*=5n6 z-O4NJdBneKC@(Ak6105naj(;SX_5pO7!J@7^!qDe`+jzeJ|J9eMX~dq_a4ty_&9?( zEDkVKBj$N0>Ka>58Y|PQq{Q2j-1e%45yo0bM~*k}vj%t;)h4!(={qG%V1_LSFm}aK zY-tE~MG&?}B;H1))pTEj@~LYqj3<1_=`$4^b24-b8Y}Do-qUr>x|NiG?ruc-9+TCz z;?EP^qy0SZdX`9sh!jt2^KgHyRrl?I`X8rO z8NK~qffuwrcv^i<^-sN;(~rF>En&Wk(?xUpXJ1i$BT!_#xy7-)Kt@ezB>Cmr;5qh^mji@urT}VzT*Om+_r%F`x$OqeakZ|EVfr%`L5IZXlLN1Lx$X$ z+~*?=bbBH!DkWE20Z&N_tCU_B5$>9N<-1b_)B4t9h0o5Fdg(TV#T=ZS;k;e9y5Pt( zcf%BKR`r}pq4b=}Y5!VT0!2?uu5S_u400^GsdDb9m9+E0!adTPK5T5=_*&)oy9xJV zF2%9jIC6B{IhfKk_L`{##PdAGvbj`=i^IWZR_QpWl7Pcg=0JJdXRWYv_wxuM9&rzRW2JGR-w|x_nY#<=SNhGv@xPUGak-)N>My zOneaxybJRv4`{BQkx7I>1a{^b!-nmXAIx>-%-v{b>i|3i&3>}pJSUmS2~`n_z^+yS z5F0W84=jO$-F%Y+=gUmi<5!s6KVLxR@N}V>dBECiGq5qIhN93#0IX18zN$3hPIm?d zV-!XFlLO}a%OLKmW?-;Ek-sboG(;JA1H1~@Hsm`!ZBY~!NrDxAkW>XLMBK-SZsJh| zutEn#h>3_B?HCwPO>9vHDV(GNHjo8$f7;~2gO;L~=q~SL-0fWZ~#j)X&6Bqf(AYY$jk0PJ03wGnXMds4rYbk)o%O?X5s6!3k zfXNPvon#Tm&!fx7m@-U0Xlej*iY)lxbYN7j0b(5#t3F$TR4GoDU7{+BI87QonpRme zOct=Q1)0SHI@Eabh9zRm!uB9RsmW9A4Z;2eABzjLU@_3Yb|{tzO}1YeB?~&EwGSvS z2b9-Gk@s+Bn7q;166{pOsgw*1jwq^ZTtTWtCL1hsmqk9p&jdx)T@RQl&dDjBieNJl zr|tj``9o2y>jP8GF7ag{X4W>)a%KhoKvyva1`M9A)97C%`B`O-U1bAu471WI(n_BRXdc33Qc~vQcM(m z%*7)yFC}Mk;$lTsaNBmW!75Q^;mHs)A-y`Vxw6QmkOqpmsncMpwYY?M85qRpg322J DDw4oP diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index fefb12bccd..ed0b3c1ea4 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip -distributionSha256Sum=f1771298a70f6db5a29daf62378c4e18a17fc33c9ba6b14362e0cdf40610380d +distributionSha256Sum=bbaeb2fef8710818cf0e261201dab964c572f92b942812df0c3620d62a529a01 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index f5feea6d6b..23d15a9367 100755 --- a/gradlew +++ b/gradlew @@ -86,8 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s -' "$PWD" ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -115,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -206,7 +205,7 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. @@ -214,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 9d21a21834..db3a6ac207 100755 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,11 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/server/android/build.gradle.kts b/server/android/build.gradle.kts index e43ec3c036..c809d7d508 100644 --- a/server/android/build.gradle.kts +++ b/server/android/build.gradle.kts @@ -12,11 +12,10 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.util.Base64 plugins { - kotlin("android") kotlin("plugin.serialization") id("com.github.gmazzo.buildconfig") - id("com.android.application") version "8.13.2" + id("com.android.application") version "9.2.1" } kotlin { @@ -131,16 +130,6 @@ dependencies { } // The android block is where you configure all your Android-specific build options. -extra.apply { - val tagCount = providers.exec { - commandLine("git", "--no-pager", "tag", "-l") - }.standardOutput.asText.get().trim().split('\n').size - val description = providers.exec { - commandLine("git", "describe", "--tags", "--always") - }.standardOutput.asText.get().trim() - set("gitVersionCode", tagCount) - set("gitVersionName", description) -} android { // The app's namespace. Used primarily to access app resources. @@ -175,11 +164,14 @@ android { // adds an offset of the version code as we might do apk releases in the middle of actual // releases if we failed on bundling or stuff val versionCodeOffset = 4 - // Defines the version number of your app. - versionCode = (extra["gitVersionCode"] as? Int)?.plus(versionCodeOffset) ?: 0 - // Defines a user-friendly version name for your app. - versionName = extra["gitVersionName"] as? String ?: "v0.0.0" + // Defines the version number of your app. + versionCode = providers.exec { + commandLine("git", "--no-pager", "tag", "-l") + }.standardOutput.asText.get().trim().split('\n').size + versionCodeOffset + versionName = providers.exec { + commandLine("git", "describe", "--tags", "--always") + }.standardOutput.asText.get().trim() logger.lifecycle("i: Configured for SlimeVR Android version \"$versionName\" ($versionCode).") diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index ee789abb32..4a789c0854 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -29,7 +29,7 @@ kotlin { jvm() sourceSets { - val commonMain by getting { + val commonMain = getByName("commonMain") { kotlin.srcDir("src/main/java") dependencies { api(project(":solarxr-protocol:generated")) @@ -54,7 +54,7 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") } } - val commonTest by getting { + val commonTest = getByName("commonTest") { kotlin.srcDir("src/test/java") dependencies { implementation(kotlin("test")) diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index 6d97b84e12..fe72ae6f4a 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -54,7 +54,7 @@ allprojects { } } -val downloadDriverProto by tasks.registering { +val downloadDriverProto = tasks.register("downloadDriverProto") { val protoFile = layout.buildDirectory.file("proto/ProtobufMessages.proto") outputs.file(protoFile) doLast { @@ -86,10 +86,10 @@ dependencies { implementation("org.hid4java:hid4java:0.8.0") implementation("io.klogging:klogging:0.11.7") - val ktor_version = "3.4.1" - implementation("io.ktor:ktor-server-core-jvm:$ktor_version") - implementation("io.ktor:ktor-server-netty-jvm:$ktor_version") - implementation("io.ktor:ktor-server-websockets-jvm:$ktor_version") + val ktorVersion = "3.4.1" + implementation("io.ktor:ktor-server-core-jvm:$ktorVersion") + implementation("io.ktor:ktor-server-netty-jvm:$ktorVersion") + implementation("io.ktor:ktor-server-websockets-jvm:$ktorVersion") testImplementation(kotlin("test")) } diff --git a/settings.gradle.kts b/settings.gradle.kts index da98375a53..2663949be1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,11 +16,11 @@ pluginManagement { mavenCentral() } - val kotlinVersion: String by settings - val spotlessVersion: String by settings - val shadowJarVersion: String by settings - val buildconfigVersion: String by settings - val wireVersion: String by settings + val kotlinVersion = providers.gradleProperty("kotlinVersion").get() + val spotlessVersion = providers.gradleProperty("spotlessVersion").get() + val shadowJarVersion = providers.gradleProperty("shadowJarVersion").get() + val buildconfigVersion = providers.gradleProperty("buildconfigVersion").get() + val wireVersion = providers.gradleProperty("wireVersion").get() plugins { kotlin("plugin.serialization") version kotlinVersion kotlin("jvm") version kotlinVersion From 11669d40b891e814cc82fd2834547af24883bfdb Mon Sep 17 00:00:00 2001 From: Sapphire Date: Sun, 21 Jun 2026 18:10:11 -0500 Subject: [PATCH 195/350] Explicitly set abbrev for git describe (cherry picked from commit c9d00e2a304ff8bcde2c55bd5040186320c0e880) --- server/android/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/android/build.gradle.kts b/server/android/build.gradle.kts index c809d7d508..da7a76f967 100644 --- a/server/android/build.gradle.kts +++ b/server/android/build.gradle.kts @@ -170,7 +170,7 @@ android { commandLine("git", "--no-pager", "tag", "-l") }.standardOutput.asText.get().trim().split('\n').size + versionCodeOffset versionName = providers.exec { - commandLine("git", "describe", "--tags", "--always") + commandLine("git", "describe", "--tags", "--abbrev=8", "--always") }.standardOutput.asText.get().trim() logger.lifecycle("i: Configured for SlimeVR Android version \"$versionName\" ($versionCode).") From 71895dec636920affafd2abecee957e323d74dab Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 22 Jun 2026 06:34:01 -0500 Subject: [PATCH 196/350] same thing but for vite lol (cherry picked from commit 6d0c35020896a05c79cd8923ff4867ed6f9e11fe) --- gui/vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/vite.config.ts b/gui/vite.config.ts index a09fed7b64..b2f86c21ea 100644 --- a/gui/vite.config.ts +++ b/gui/vite.config.ts @@ -6,7 +6,7 @@ import path from 'path'; import { visualizer } from 'rollup-plugin-visualizer'; import jotaiReactRefresh from 'jotai/babel/plugin-react-refresh'; -const commitHash = execSync('git rev-parse --verify --short HEAD').toString().trim(); +const commitHash = execSync('git rev-parse --verify --short=8 HEAD').toString().trim(); const versionTag = execSync('git --no-pager tag --sort -taggerdate --points-at HEAD') .toString() .split('\n')[0] From 9edc7473446fbf04ff734031f198d8b1590fad69 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 22 Jun 2026 15:50:25 -0500 Subject: [PATCH 197/350] Whitelist HID devices for serial access, send serial device type to GUI --- .../main/java/dev/slimevr/android/hid/hid.kt | 13 ++---------- .../java/dev/slimevr/android/serial/serial.kt | 17 ++------------- .../main/java/dev/slimevr/hid/whitelist.kt | 21 +++++++++++++++++++ .../java/dev/slimevr/provisioning/phases.kt | 12 +++++++++-- .../main/java/dev/slimevr/serial/module.kt | 8 +++++++ .../main/java/dev/slimevr/serial/whitelist.kt | 17 +++++++++++++++ .../main/java/dev/slimevr/solarxr/serial.kt | 3 ++- .../main/java/dev/slimevr/desktop/hid/hid.kt | 15 ++----------- .../java/dev/slimevr/desktop/serial/serial.kt | 17 ++------------- 9 files changed, 66 insertions(+), 57 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/hid/whitelist.kt create mode 100644 server/core/src/main/java/dev/slimevr/serial/whitelist.kt diff --git a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt index 823ebd3532..e4934fa1b3 100644 --- a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt +++ b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt @@ -16,6 +16,7 @@ import dev.slimevr.AppLogger import dev.slimevr.device.DeviceActions import dev.slimevr.hid.HIDReceiver import dev.slimevr.hid.parseHIDPackets +import dev.slimevr.hid.isCompatibleHidDevice import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -31,16 +32,6 @@ import solarxr_protocol.datatypes.TrackerStatus private const val ACTION_USB_HID_PERMISSION = "dev.slimevr.android.USB_HID_PERMISSION" private const val HID_POLL_INTERVAL_MS = 3000L -private data class HidProductRule(val vendorId: Int, val productId: Int, val productMask: Int = 0xFFFF) - -private val HID_PRODUCT_RULES = listOf( - HidProductRule(0x1209, 0x7690), // SlimeVR receiver - HidProductRule(0x1209, 0x7692), // SlimeVR tracker direct - HidProductRule(0x4E76, 0xD200, 0xFF00), // Gestures Inc. D2XX -) - -private fun isCompatibleDevice(vid: Int, pid: Int) = HID_PRODUCT_RULES.any { rule -> vid == rule.vendorId && (pid and rule.productMask) == rule.productId } - private fun findHidInputEndpoint(device: UsbDevice): Pair? { for (ifaceIdx in 0 until device.interfaceCount) { val iface = device.getInterface(ifaceIdx) @@ -89,7 +80,7 @@ fun createAndroidHIDManager(context: Context, appContext: AppContextProvider, sc val found = withContext(Dispatchers.IO) { try { usbManager.deviceList.values - .filter { device -> isCompatibleDevice(device.vendorId, device.productId) } + .filter { device -> isCompatibleHidDevice(device.vendorId, device.productId) } .associate { device -> device.deviceName to device } } catch (e: Exception) { AppLogger.hid.error(e, "HID enumeration failed") diff --git a/server/android/src/main/java/dev/slimevr/android/serial/serial.kt b/server/android/src/main/java/dev/slimevr/android/serial/serial.kt index ab992f40e3..a1dd4032a3 100644 --- a/server/android/src/main/java/dev/slimevr/android/serial/serial.kt +++ b/server/android/src/main/java/dev/slimevr/android/serial/serial.kt @@ -15,6 +15,7 @@ import dev.slimevr.AppLogger import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialPortInfo import dev.slimevr.serial.SerialServer +import dev.slimevr.serial.isKnownSerialBoard import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -27,20 +28,6 @@ private const val TAG = "SerialServer" private const val ACTION_USB_SERIAL_PERMISSION = "dev.slimevr.android.USB_SERIAL_PERMISSION" private const val POLL_INTERVAL_MS = 3000L -private val SUPPORTED_BOARDS: Set> = setOf( - Pair(0x1A86, 0x7522), // CH340 - Pair(0x1A86, 0x7523), // CH340 - Pair(0x1A86, 0x5523), // CH341 - Pair(0x1A86, 0x55D3), // CH343 - Pair(0x1A86, 0x55D4), // CH9102x - Pair(0x10C4, 0xEA60), // CP210x - Pair(0x303A, 0x1001), // ESP32-S3 - Pair(0x303A, 0x0002), // ESP32 - Pair(0x0403, 0x6001), // FTDI FT232 -) - -private fun isKnownBoard(vid: Int, pid: Int) = SUPPORTED_BOARDS.contains(vid to pid) - private fun openAndroidPort( portLocation: String, usbManager: UsbManager, @@ -128,7 +115,7 @@ private suspend fun runAndroidSerialPoller( val current = withContext(Dispatchers.IO) { UsbSerialProber.getDefaultProber() .findAllDrivers(usbManager) - .filter { driver -> isKnownBoard(driver.device.vendorId, driver.device.productId) } + .filter { driver -> isKnownSerialBoard(driver.device.vendorId, driver.device.productId) } .associateBy { driver -> driver.device.deviceName } } diff --git a/server/core/src/main/java/dev/slimevr/hid/whitelist.kt b/server/core/src/main/java/dev/slimevr/hid/whitelist.kt new file mode 100644 index 0000000000..7c0d276928 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/hid/whitelist.kt @@ -0,0 +1,21 @@ +package dev.slimevr.hid + +data class HidProductRule(val vendorId: Int, val productId: Int, val productMask: Int = 0xFFFF) + +private val RECEIVER_PRODUCT_RULES = listOf( + HidProductRule(0x1209, 0x7690), // SlimeVR receiver + HidProductRule(0x4E76, 0xD200, 0xFF00), // Gestures Inc. D2XX +) +private val TRACKER_PRODUCT_RULES = listOf( + HidProductRule(0x1209, 0x7692) // SlimeVR tracker direct +) + +private fun matchesRule(rule: HidProductRule, vid: Int, pid: Int) = vid == rule.vendorId && (pid and rule.productMask) == rule.productId +fun isCompatibleHidReceiver(vid: Int, pid: Int) = RECEIVER_PRODUCT_RULES.any { rule -> + matchesRule(rule, vid, pid) +} +fun isCompatibleHidTracker(vid: Int, pid: Int) = TRACKER_PRODUCT_RULES.any { rule -> + matchesRule(rule, vid, pid) +} + +fun isCompatibleHidDevice(vid: Int, pid: Int) = isCompatibleHidReceiver(vid, pid) || isCompatibleHidTracker(vid, pid) diff --git a/server/core/src/main/java/dev/slimevr/provisioning/phases.kt b/server/core/src/main/java/dev/slimevr/provisioning/phases.kt index d1734c13b7..c625f6d313 100644 --- a/server/core/src/main/java/dev/slimevr/provisioning/phases.kt +++ b/server/core/src/main/java/dev/slimevr/provisioning/phases.kt @@ -4,6 +4,7 @@ import dev.slimevr.VRServer import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions import dev.slimevr.firmware.waitForConnected +import dev.slimevr.hid.isCompatibleHidDevice import dev.slimevr.serial.MAC_REGEX import dev.slimevr.serial.SerialConnection import dev.slimevr.serial.SerialConnectionActions @@ -28,7 +29,14 @@ internal suspend fun selectAndOpenPort( ): Boolean { val portEntry = withTimeoutOrNull(15_000) { serialServer.context.state - .mapNotNull { state -> state.availablePorts.entries.firstOrNull() } + .mapNotNull { state -> + state.availablePorts.entries.firstOrNull { (_, info) -> + isCompatibleHidDevice( + info.vendorId, + info.productId + ) + } + } .first() } @@ -81,7 +89,7 @@ internal suspend fun obtainMacAddress( // Show the error and block until logs appear, this is not a retry. if (serialConn.context.state.value.logLines.isEmpty()) { context.dispatch(ProvisioningActions.StatusChanged(WifiProvisioningStatus.NO_SERIAL_LOGS_ERROR)) - serialConn.context.state.filter { it.logLines.isNotEmpty() }.first() + serialConn.context.state.first { it.logLines.isNotEmpty() } // The GET INFO response may have arrived while we were in the error state. val existingMac = serialConn.context.state.value.logLines diff --git a/server/core/src/main/java/dev/slimevr/serial/module.kt b/server/core/src/main/java/dev/slimevr/serial/module.kt index ffdb73d5e0..ec38523c35 100644 --- a/server/core/src/main/java/dev/slimevr/serial/module.kt +++ b/server/core/src/main/java/dev/slimevr/serial/module.kt @@ -2,9 +2,12 @@ package dev.slimevr.serial import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.hid.isCompatibleHidReceiver +import dev.slimevr.hid.isCompatibleHidTracker import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import solarxr_protocol.rpc.SerialDevice +import solarxr_protocol.rpc.SerialDeviceType interface FlashingHandler { fun openSerial(port: Any) @@ -28,6 +31,11 @@ data class SerialPortInfo( fun toSerialDevice() = SerialDevice( port = portLocation, name = descriptivePortName, + type = when { + isCompatibleHidReceiver(vendorId, productId) -> SerialDeviceType.HID_RECEIVER + isCompatibleHidTracker(vendorId, productId) -> SerialDeviceType.HID_TRACKER + else -> SerialDeviceType.ESP_TRACKER + } ) } diff --git a/server/core/src/main/java/dev/slimevr/serial/whitelist.kt b/server/core/src/main/java/dev/slimevr/serial/whitelist.kt new file mode 100644 index 0000000000..9af9c957f3 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/serial/whitelist.kt @@ -0,0 +1,17 @@ +package dev.slimevr.serial + +import dev.slimevr.hid.isCompatibleHidDevice + +private val SUPPORTED_BOARDS: Set> = setOf( + Pair(0x1A86, 0x7522), // CH340 + Pair(0x1A86, 0x7523), // CH340 + Pair(0x1A86, 0x5523), // CH341 + Pair(0x1A86, 0x55D3), // CH343 + Pair(0x1A86, 0x55D4), // CH9102x + Pair(0x10C4, 0xEA60), // CP210x + Pair(0x303A, 0x1001), // ESP32-S3 + Pair(0x303A, 0x0002), // ESP32 + Pair(0x0403, 0x6001), // FTDI FT232 +) + +fun isKnownSerialBoard(vid: Int, pid: Int) = SUPPORTED_BOARDS.contains(vid to pid) || isCompatibleHidDevice(vid, pid) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt index 10000764d2..dc3ae975bb 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt @@ -79,8 +79,9 @@ class SerialBehaviour(private val serialServer: SerialServer) : SolarXRBridgeBeh connection.context.state.collect { connState -> if (disconnected) return@collect + val device = serialServer.context.state.value.availablePorts[portLocation]?.toSerialDevice() connState.logLines.drop(lastSentCount).forEach { line -> - receiver.sendRpc(SerialUpdateResponse(log = line + "\n")) + receiver.sendRpc(SerialUpdateResponse(log = line + "\n", device = device)) } lastSentCount = connState.logLines.size diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index 45e3d34bc5..0ec3d316fa 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -4,6 +4,7 @@ import dev.slimevr.AppContextProvider import dev.slimevr.AppLogger import dev.slimevr.device.DeviceActions import dev.slimevr.hid.HIDReceiver +import dev.slimevr.hid.isCompatibleHidDevice import dev.slimevr.hid.parseHIDPackets import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope @@ -23,18 +24,6 @@ import solarxr_protocol.datatypes.TrackerStatus private const val HID_POLL_INTERVAL_MS = 3000L -private data class HidProductRule(val vendorId: Int, val productId: Int, val productMask: Int = 0xFFFF) - -private val HID_PRODUCT_RULES = listOf( - HidProductRule(0x1209, 0x7690), // SlimeVR receiver - HidProductRule(0x1209, 0x7692), // SlimeVR tracker direct - HidProductRule(0x4E76, 0xD200, 0xFF00), // Gestures Inc. D2XX -) - -private fun isCompatibleDevice(vid: Int, pid: Int) = HID_PRODUCT_RULES.any { rule -> - vid == rule.vendorId && (pid and rule.productMask) == rule.productId -} - private val hidSpec = HidServicesSpecification().apply { isAutoStart = false } // Initialize the native HID library. Must be called before enumerateDevices. @@ -46,7 +35,7 @@ private fun enumerateCompatibleDevices(): Map { val result = mutableMapOf() var info: HidDeviceInfoStructure? = root while (info != null) { - if (isCompatibleDevice(info.vendor_id.toInt(), info.product_id.toInt())) { + if (isCompatibleHidDevice(info.vendor_id.toInt(), info.product_id.toInt())) { val device = HidDevice(info, null, hidSpec) // Use path as key, unique per physical device, available without opening result[info.path] = device diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt b/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt index 81ae952510..d74cb191e5 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt @@ -6,6 +6,7 @@ import dev.slimevr.AppLogger import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialPortInfo import dev.slimevr.serial.SerialServer +import dev.slimevr.serial.isKnownSerialBoard import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -14,20 +15,6 @@ import kotlinx.coroutines.withContext import java.io.OutputStreamWriter import com.fazecast.jSerialComm.SerialPort as JSerialPort -private val SUPPORTED_BOARDS: Set> = setOf( - Pair(0x1A86, 0x7522), // CH340 - Pair(0x1A86, 0x7523), // CH340 - Pair(0x1A86, 0x5523), // CH341 - Pair(0x1A86, 0x55D3), // CH343 - Pair(0x1A86, 0x55D4), // CH9102x - Pair(0x10C4, 0xEA60), // CP210x - Pair(0x303A, 0x1001), // ESP32-S3 - Pair(0x303A, 0x0002), // ESP32 - Pair(0x0403, 0x6001), // FTDI FT232 -) - -private fun isKnownBoard(vid: Int, pid: Int) = SUPPORTED_BOARDS.contains(vid to pid) - private fun openPort( portLocation: String, scope: CoroutineScope, @@ -80,7 +67,7 @@ private suspend fun runSerialPoller(server: SerialServer) { try { val current = withContext(Dispatchers.IO) { JSerialPort.getCommPorts() - .filter { isKnownBoard(it.vendorID, it.productID) } + .filter { isKnownSerialBoard(it.vendorID, it.productID) } .associate { port -> port.portLocation to SerialPortInfo( portLocation = port.portLocation, From cdc0e21913ed18995b79251897e6f3e2e519a4eb Mon Sep 17 00:00:00 2001 From: Erimel Date: Mon, 22 Jun 2026 18:01:12 -0400 Subject: [PATCH 198/350] Update for SolarXR rewrite Big commit but I figured a single commit would be easier for conflicts --- gui/public/i18n/en/translation.ftl | 89 +------- gui/src/App.tsx | 2 - .../commons/icon/HorizontalAlignIcon.tsx | 13 ++ .../commons/icon/MartialArtsIcon.tsx | 13 ++ .../components/commons/icon/OutputIcon.tsx | 13 ++ .../commons/icon/TouchDoubleIcon.tsx | 13 ++ .../icon/{WrenchIcons.tsx => WrenchIcon.tsx} | 0 .../pages/mounting/ManualMounting.tsx | 2 - .../trackers-assign/TrackerAssignment.tsx | 23 +- .../components/settings/SettingsSidebar.tsx | 78 +++---- .../settings/pages/GeneralSettings.tsx | 201 ++++++----------- .../settings/pages/OSCRouterSettings.tsx | 210 ------------------ .../settings/pages/VRCOSCSettings.tsx | 93 +------- .../pages/components/StayAlignedSettings.tsx | 4 +- .../components/tracker/TrackerSettings.tsx | 3 - .../tracking-checklist/TrackingChecklist.tsx | 27 +-- gui/src/hooks/osc-setting-validator.ts | 2 +- .../main/java/dev/slimevr/solarxr/settings.kt | 1 + .../src/main/java/dev/slimevr/solarxr/vmc.kt | 8 +- .../main/java/dev/slimevr/solarxr/vrcosc.kt | 20 -- solarxr-protocol | 2 +- 21 files changed, 194 insertions(+), 623 deletions(-) create mode 100644 gui/src/components/commons/icon/HorizontalAlignIcon.tsx create mode 100644 gui/src/components/commons/icon/MartialArtsIcon.tsx create mode 100644 gui/src/components/commons/icon/OutputIcon.tsx create mode 100644 gui/src/components/commons/icon/TouchDoubleIcon.tsx rename gui/src/components/commons/icon/{WrenchIcons.tsx => WrenchIcon.tsx} (100%) delete mode 100644 gui/src/components/settings/pages/OSCRouterSettings.tsx diff --git a/gui/public/i18n/en/translation.ftl b/gui/public/i18n/en/translation.ftl index a6fe6d272b..e879ad765c 100644 --- a/gui/public/i18n/en/translation.ftl +++ b/gui/public/i18n/en/translation.ftl @@ -292,9 +292,6 @@ widget-overlay = Overlay widget-overlay-is_visible_label = Show Overlay in SteamVR widget-overlay-is_mirrored_label = Display Overlay as Mirror -## Widget: Drift compensation -widget-drift_compensation-clear = Clear drift compensation - ## Widget: Clear Mounting calibration widget-clear_mounting = Clear mounting calibration @@ -389,9 +386,6 @@ tracker-settings-assignment_section-edit = Edit assignment tracker-settings-mounting_section = Mounting orientation tracker-settings-mounting_section-description = Where is the tracker mounted? tracker-settings-mounting_section-edit = Edit mounting -tracker-settings-drift_compensation_section = Allow drift compensation -tracker-settings-drift_compensation_section-description = Should this tracker compensate for its drift when drift compensation is enabled? -tracker-settings-drift_compensation_section-edit = Allow drift compensation tracker-settings-use_mag = Allow magnetometer on this tracker # Multiline! tracker-settings-use_mag-description = @@ -481,13 +475,12 @@ mounting_selection_menu-close = Close ## Sidebar settings settings-sidebar-title = Settings settings-sidebar-general = General -settings-sidebar-steamvr = SteamVR +settings-sidebar-output = Output settings-sidebar-tracker_mechanics = Tracker mechanics settings-sidebar-stay_aligned = Stay Aligned settings-sidebar-fk_settings = Tracking settings settings-sidebar-gesture_control = Gesture control settings-sidebar-interface = Interface -settings-sidebar-osc_router = OSC router settings-sidebar-osc_trackers = VRChat OSC Trackers settings-sidebar-osc_vmc = VMC settings-sidebar-utils = Utilities @@ -501,15 +494,16 @@ settings-sidebar-firmware-tool = DIY Firmware Tool settings-sidebar-vrc_warnings = VRChat Config Warnings settings-sidebar-advanced = Advanced -## SteamVR settings -settings-general-steamvr = SteamVR -settings-general-steamvr-subtitle = SteamVR trackers +## Output settings +# Some keys have steamvr in them as we switched to output but want to keep existing translations +settings-general-output = Output +settings-general-output-subtitle = Output trackers # Not all translation keys support multiline, only the ones that specify it will actually # split it in lines (that also means you can split in lines however you want in those). # The first spaces (not tabs) for indentation will be ignored, just to make the file look nice when writing. # This one is one of this cases that cares about multilines -settings-general-steamvr-description = - Enable or disable specific SteamVR trackers. +settings-general-output-description = + Enable or disable specific trackers to output. Useful for games or apps that only support certain trackers. settings-general-steamvr-trackers-waist = Waist settings-general-steamvr-trackers-chest = Chest @@ -522,10 +516,9 @@ settings-general-steamvr-trackers-right_elbow = Right elbow settings-general-steamvr-trackers-left_hand = Left hand settings-general-steamvr-trackers-right_hand = Right hand settings-general-steamvr-trackers-tracker_toggling = Automatic tracker assignment -settings-general-steamvr-trackers-tracker_toggling-description = Automatically handles toggling SteamVR trackers on or off depending on your current tracker assignments. +settings-general-output-trackers-tracker_toggling-description = Automatically handles toggling output trackers on or off depending on your current tracker assignments. settings-general-steamvr-trackers-tracker_toggling-label = Automatic tracker assignment -settings-general-steamvr-trackers-hands-warning = Warning: Enabling the SteamVR hand trackers will disable inputs from real controllers. - This should only be enabled if you are using SlimeVR for hand tracking. +settings-general-output-trackers-hands-warning = Warning: Enabling the hand trackers will disable inputs from real controllers in SteamVR. Are you sure you want to do this? settings-general-steamvr-trackers-hands-warning-cancel = Cancel @@ -547,25 +540,6 @@ settings-general-tracker_mechanics-filtering-type-prediction = Prediction settings-general-tracker_mechanics-filtering-type-prediction-description = Reduces latency and makes movements more snappy, but may increase jitter. settings-general-tracker_mechanics-filtering-amount = Amount settings-general-tracker_mechanics-yaw-reset-smooth-time = Yaw reset smooth time (0s disables smoothing) -settings-general-tracker_mechanics-drift_compensation = Drift compensation -# This cares about multilines -settings-general-tracker_mechanics-drift_compensation-description = - Compensates for IMU yaw drift by applying an inverse rotation. - Change the amount of compensation and the number of resets taken into account. - This should only be used if you need to reset very often! -settings-general-tracker_mechanics-drift_compensation-enabled-label = Drift compensation -settings-general-tracker_mechanics-drift_compensation-prediction = Drift compensation prediction -# This cares about multilines -settings-general-tracker_mechanics-drift_compensation-prediction-description = - Predicts yaw drift compensation beyond previously measured range. - Enable this if your trackers are continuously spinning on the yaw axis. -settings-general-tracker_mechanics-drift_compensation-prediction-label = Drift compensation prediction -settings-general-tracker_mechanics-drift_compensation_warning = - Warning: Only use drift compensation if you need to reset - very often (every ~5-10 minutes). - - Some IMUs prone to frequent resets include: - Joy-Cons, owoTrack, and MPUs (without recent firmware). settings-general-tracker_mechanics-drift_compensation_warning-cancel = Cancel settings-general-tracker_mechanics-drift_compensation_warning-done = I understand settings-general-tracker_mechanics-drift_compensation-amount-label = Compensation amount @@ -589,7 +563,6 @@ settings-stay_aligned = Stay Aligned settings-stay_aligned-description = Stay Aligned reduces drift by gradually adjusting your trackers to match your relaxed poses. settings-stay_aligned-setup-label = Setup Stay Aligned settings-stay_aligned-setup-description = You must complete "Setup Stay Aligned" to enable Stay Aligned. -settings-stay_aligned-warnings-drift_compensation = ⚠ Please turn off Drift Compensation! Drift Compensation will conflict with Stay Aligned. settings-stay_aligned-enabled-label = Adjust trackers settings-stay_aligned-hide_yaw_correction-label = Hide adjustment (to compare with no Stay Aligned) settings-stay_aligned-general-label = General @@ -653,17 +626,10 @@ settings-general-fk_settings-arm_fk-tpose_down = T-pose (down) settings-general-fk_settings-arm_fk-tpose_down-description = Expects your arms to be 90 degrees up to the sides during Full Reset, and down at your sides during Mounting Calibration. settings-general-fk_settings-arm_fk-forward = Forward settings-general-fk_settings-arm_fk-forward-description = Expects your arms to be raised forward at 90 degrees. Useful for VTubing. -settings-general-fk_settings-skeleton_settings-toggles = Skeleton toggles -settings-general-fk_settings-skeleton_settings-description = Toggle skeleton settings on or off. It is recommended to leave these on. -settings-general-fk_settings-skeleton_settings-extended_spine_model = Extended spine model -settings-general-fk_settings-skeleton_settings-extended_pelvis_model = Extended pelvis model -settings-general-fk_settings-skeleton_settings-extended_knees_model = Extended knee model settings-general-fk_settings-skeleton_settings-ratios = Skeleton ratios settings-general-fk_settings-skeleton_settings-ratios-description = Change the values of skeleton settings. You may need to adjust your proportions after changing these. -settings-general-fk_settings-skeleton_settings-impute_waist_from_chest_hip = Impute waist from chest to hip -settings-general-fk_settings-skeleton_settings-impute_waist_from_chest_legs = Impute waist from chest to legs -settings-general-fk_settings-skeleton_settings-impute_hip_from_chest_legs = Impute hip from chest to legs -settings-general-fk_settings-skeleton_settings-impute_hip_from_waist_legs = Impute hip from waist to legs +settings-general-fk_settings-skeleton_settings-impute_spine_from_top_down = Impute missing spine trackers from upper to lower +settings-general-fk_settings-skeleton_settings-impute_spine_curvature = How much to curve the spine with at least 2 adjacent spine trackers settings-general-fk_settings-skeleton_settings-interp_hip_legs = Average the hip's yaw and roll with the legs' settings-general-fk_settings-skeleton_settings-interp_knee_tracker_ankle = Average the knee trackers' yaw and roll with the ankles' settings-general-fk_settings-skeleton_settings-interp_knee_ankle = Average the knees' yaw and roll with the ankles' @@ -805,30 +771,6 @@ settings-serial-send_command-warning = settings-serial-send_command-warning-ok = I know what I'm doing settings-serial-send_command-warning-cancel = Cancel -## OSC router settings -settings-osc-router = OSC router -# This cares about multilines -settings-osc-router-description = - Forward OSC messages from another program. - Useful for using another OSC program with VRChat, for example. -settings-osc-router-enable = Enable -settings-osc-router-enable-description = Toggle the forwarding of messages. -settings-osc-router-enable-label = Enable -settings-osc-router-network = Network ports -# This cares about multilines -settings-osc-router-network-description = - Set the ports for listening and sending data. - These can be the same as other ports used in the SlimeVR server. -settings-osc-router-network-port_in = - .label = Port In - .placeholder = Port in (default: 9002) -settings-osc-router-network-port_out = - .label = Port Out - .placeholder = Port out (default: 9000) -settings-osc-router-network-address = Network address -settings-osc-router-network-address-description = Set the address to send out data at. -settings-osc-router-network-address-placeholder = IPV4 address - ## OSC VRChat settings settings-osc-vrchat = VRChat OSC Trackers # This cares about multilines @@ -857,13 +799,6 @@ settings-osc-vrchat-network-port_out = settings-osc-vrchat-network-address = Network address settings-osc-vrchat-network-address-description-v1 = Choose which address to send out data to. Can be left untouched for VRChat. settings-osc-vrchat-network-address-placeholder = VRChat ip address -settings-osc-vrchat-network-trackers = Trackers -settings-osc-vrchat-network-trackers-description = Toggle the sending of specific trackers via OSC. -settings-osc-vrchat-network-trackers-chest = Chest -settings-osc-vrchat-network-trackers-hip = Hip -settings-osc-vrchat-network-trackers-knees = Knees -settings-osc-vrchat-network-trackers-feet = Feet -settings-osc-vrchat-network-trackers-elbows = Elbows ## VRChat OSC status settings-osc-vrchat-status-title = Status @@ -931,7 +866,7 @@ settings-osc-vmc-mirror_tracking-description = Mirror the tracking horizontally. settings-osc-vmc-mirror_tracking-label = Mirror tracking ## Common OSC settings -settings-osc-common-network-ports_match_error = The OSC Router in and out ports can't be the same! +settings-osc-common-network-ports_match_error-v2 = The in and out ports can't be the same! settings-osc-common-network-port_banned_error = The port { $port } can't be used! ## Advanced settings diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 9674789d43..2d7524707d 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -28,7 +28,6 @@ import { SerialDetectionModal } from './components/SerialDetectionModal'; import { VRCOSCSettings } from './components/settings/pages/VRCOSCSettings'; import { TopBar } from './components/TopBar'; import { TrackerSettingsPage } from './components/tracker/TrackerSettings'; -import { OSCRouterSettings } from './components/settings/pages/OSCRouterSettings'; import { VMCSettings } from './components/settings/pages/VMCSettings'; import { MountingChoose } from './components/onboarding/pages/mounting/MountingChoose'; import { VersionUpdateModal } from './components/VersionUpdateModal'; @@ -139,7 +138,6 @@ function Layout() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/gui/src/components/commons/icon/HorizontalAlignIcon.tsx b/gui/src/components/commons/icon/HorizontalAlignIcon.tsx new file mode 100644 index 0000000000..508b334d45 --- /dev/null +++ b/gui/src/components/commons/icon/HorizontalAlignIcon.tsx @@ -0,0 +1,13 @@ +export function HorizontalAlignIcon({ size = 22 }) { + return ( + + + + ); +} diff --git a/gui/src/components/commons/icon/MartialArtsIcon.tsx b/gui/src/components/commons/icon/MartialArtsIcon.tsx new file mode 100644 index 0000000000..d89c53ecf1 --- /dev/null +++ b/gui/src/components/commons/icon/MartialArtsIcon.tsx @@ -0,0 +1,13 @@ +export function MartialArtsIcon({ size = 23 }) { + return ( + + + + ); +} diff --git a/gui/src/components/commons/icon/OutputIcon.tsx b/gui/src/components/commons/icon/OutputIcon.tsx new file mode 100644 index 0000000000..ea85ef0075 --- /dev/null +++ b/gui/src/components/commons/icon/OutputIcon.tsx @@ -0,0 +1,13 @@ +export function OutputIcon({ size = 22 }) { + return ( + + + + ); +} diff --git a/gui/src/components/commons/icon/TouchDoubleIcon.tsx b/gui/src/components/commons/icon/TouchDoubleIcon.tsx new file mode 100644 index 0000000000..fc5a097253 --- /dev/null +++ b/gui/src/components/commons/icon/TouchDoubleIcon.tsx @@ -0,0 +1,13 @@ +export function TouchDoubleIcon({ size = 22 }) { + return ( + + + + ); +} diff --git a/gui/src/components/commons/icon/WrenchIcons.tsx b/gui/src/components/commons/icon/WrenchIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/WrenchIcons.tsx rename to gui/src/components/commons/icon/WrenchIcon.tsx diff --git a/gui/src/components/onboarding/pages/mounting/ManualMounting.tsx b/gui/src/components/onboarding/pages/mounting/ManualMounting.tsx index fc23c2a857..08cefbb129 100644 --- a/gui/src/components/onboarding/pages/mounting/ManualMounting.tsx +++ b/gui/src/components/onboarding/pages/mounting/ManualMounting.tsx @@ -56,7 +56,6 @@ export function ManualMountingPage() { mountingOrientationDegrees ); assignreq.trackerId = td.tracker.trackerId; - assignreq.allowDriftCompensation = false; sendRPCPacket(RpcMessage.AssignTrackerRequest, assignreq); Sentry.metrics.count('manual_mounting_set', 1, { @@ -177,7 +176,6 @@ export function ManualMountingPageStayAligned({ mountingOrientationDegrees ); assignreq.trackerId = td.tracker.trackerId; - assignreq.allowDriftCompensation = false; sendRPCPacket(RpcMessage.AssignTrackerRequest, assignreq); Sentry.metrics.count('manual_mounting_set', 1, { diff --git a/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx b/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx index 5655585d37..3c209bfa5e 100644 --- a/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx +++ b/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx @@ -109,32 +109,14 @@ export function TrackersAssignPage() { sendRPCPacket( RpcMessage.ChangeSettingsRequest, - new ChangeSettingsRequestT( - null, - null, - null, - null, - null, - null, - null, - newTapSettings - ) + new ChangeSettingsRequestT(null, null, null, null, null, newTapSettings) ); return () => { newTapSettings.setupMode = false; sendRPCPacket( RpcMessage.ChangeSettingsRequest, - new ChangeSettingsRequestT( - null, - null, - null, - null, - null, - null, - null, - newTapSettings - ) + new ChangeSettingsRequestT(null, null, null, null, null, newTapSettings) ); }; }, [tapDetectionSettings]); @@ -220,7 +202,6 @@ export function TrackersAssignPage() { assignreq.bodyPosition = role; assignreq.mountingOrientation = rotation; assignreq.trackerId = trackerId; - assignreq.allowDriftCompensation = false; sendRPCPacket(RpcMessage.AssignTrackerRequest, assignreq); }; diff --git a/gui/src/components/settings/SettingsSidebar.tsx b/gui/src/components/settings/SettingsSidebar.tsx index 1095073394..094d741d97 100644 --- a/gui/src/components/settings/SettingsSidebar.tsx +++ b/gui/src/components/settings/SettingsSidebar.tsx @@ -50,8 +50,8 @@ export function SettingsSidebar() {

+
+
+
+ +
-
- OSC -
- - - -
+
+
+ OSC +
+ +
-
- -
- - - - {vrcConfigState?.isSupported && ( - - )} +
+
+ +
+ + + + {vrcConfigState?.isSupported && ( -
+ )} +
diff --git a/gui/src/components/settings/pages/GeneralSettings.tsx b/gui/src/components/settings/pages/GeneralSettings.tsx index dc9b361f2b..3b83feb38c 100644 --- a/gui/src/components/settings/pages/GeneralSettings.tsx +++ b/gui/src/components/settings/pages/GeneralSettings.tsx @@ -12,18 +12,17 @@ import { RpcMessage, SettingsRequestT, SettingsResponseT, - SteamVRTrackersSettingT, TapDetectionSettingsT, HIDSettingsT, VelocitySettingsT, BodyPart, + OutputTrackersSettingT, } from 'solarxr-protocol'; import { useConfig } from '@/hooks/config'; import { useWebsocketAPI } from '@/hooks/websocket-api'; import { useLocaleConfig } from '@/i18n/config'; import { CheckBox } from '@/components/commons/Checkbox'; -import { SteamIcon } from '@/components/commons/icon/SteamIcon'; -import { WrenchIcon } from '@/components/commons/icon/WrenchIcons'; +import { WrenchIcon } from '@/components/commons/icon/WrenchIcon'; import { NumberSelector } from '@/components/commons/NumberSelector'; import { Radio } from '@/components/commons/Radio'; import { Typography } from '@/components/commons/Typography'; @@ -50,12 +49,15 @@ import { isEqual } from '@react-hookz/deep-equal'; import { selectAtom } from 'jotai/utils'; import { Dropdown } from '@/components/commons/Dropdown'; import { ASSIGNMENT_MODES } from '@/components/onboarding/BodyAssignment'; +import { OutputIcon } from '@/components/commons/icon/OutputIcon'; +import { TouchDoubleIcon } from '@/components/commons/icon/TouchDoubleIcon'; +import { MartialArtsIcon } from '@/components/commons/icon/MartialArtsIcon'; export type SettingsForm = { trackers: { + automaticTrackerToggle: boolean; waist: boolean; chest: boolean; - automaticTrackerToggle: boolean; leftFoot: boolean; rightFoot: boolean; leftKnee: boolean; @@ -70,9 +72,6 @@ export type SettingsForm = { amount: number; }; toggles: { - extendedSpine: boolean; - extendedPelvis: boolean; - extendedKnee: boolean; forceArmsFromHmd: boolean; floorClip: boolean; skatingCorrection: boolean; @@ -84,10 +83,8 @@ export type SettingsForm = { correctConstraints: boolean; }; ratios: { - imputeWaistFromChestHip: number; - imputeWaistFromChestLegs: number; - imputeHipFromChestLegs: number; - imputeHipFromWaistLegs: number; + imputeSpineFromTopDown: number; + imputeSpineCurvature: number; interpHipLegs: number; interpKneeTrackerAnkle: number; interpKneeAnkle: number; @@ -122,9 +119,9 @@ export type SettingsForm = { const defaultValues: SettingsForm = { trackers: { + automaticTrackerToggle: true, waist: false, chest: false, - automaticTrackerToggle: true, leftFoot: false, rightFoot: false, leftElbow: false, @@ -135,9 +132,6 @@ const defaultValues: SettingsForm = { rightKnee: false, }, toggles: { - extendedSpine: true, - extendedPelvis: true, - extendedKnee: true, forceArmsFromHmd: false, floorClip: false, skatingCorrection: false, @@ -149,10 +143,8 @@ const defaultValues: SettingsForm = { correctConstraints: true, }, ratios: { - imputeWaistFromChestHip: 0.3, - imputeWaistFromChestLegs: 0.2, - imputeHipFromChestLegs: 0.45, - imputeHipFromWaistLegs: 0.4, + imputeSpineFromTopDown: 0.5, + imputeSpineCurvature: 0.5, interpHipLegs: 0.25, interpKneeTrackerAnkle: 0.85, interpKneeAnkle: 0.2, @@ -235,44 +227,58 @@ export function GeneralSettings() { const settingsReq = new ChangeSettingsRequestT(); if (values.trackers) { - const trackers = new SteamVRTrackersSettingT(); - trackers.waist = values.trackers.waist; - trackers.chest = values.trackers.chest; - trackers.leftFoot = values.trackers.leftFoot; - trackers.rightFoot = values.trackers.rightFoot; + const outputTrackers = new OutputTrackersSettingT(); - trackers.leftKnee = values.trackers.leftKnee; - trackers.rightKnee = values.trackers.rightKnee; - - trackers.leftElbow = values.trackers.leftElbow; - trackers.rightElbow = values.trackers.rightElbow; - - trackers.leftHand = values.trackers.leftHand; - trackers.rightHand = values.trackers.rightHand; + // The tracker is at the tail of the bone. + // Example, the waist tracker is at the tail of the hip bone so we use the hip BodyPart + const enabledBodyParts: [boolean, BodyPart][] = [ + [values.trackers.waist, BodyPart.HIP], + [values.trackers.chest, BodyPart.CHEST], + [values.trackers.leftFoot, BodyPart.LEFT_FOOT], + [values.trackers.rightFoot, BodyPart.RIGHT_FOOT], + [values.trackers.leftKnee, BodyPart.LEFT_UPPER_LEG], + [values.trackers.rightKnee, BodyPart.RIGHT_UPPER_LEG], + [values.trackers.leftElbow, BodyPart.LEFT_UPPER_ARM], + [values.trackers.rightElbow, BodyPart.RIGHT_UPPER_ARM], + [values.trackers.leftHand, BodyPart.LEFT_HAND], + [values.trackers.rightHand, BodyPart.RIGHT_HAND], + ]; + outputTrackers.trackers = enabledBodyParts + .filter(([enabled]) => enabled) + .map(([, part]) => part); + const leftHandEnabled = outputTrackers.trackers.includes( + BodyPart.LEFT_HAND + ); + const rightHandEnabled = outputTrackers.trackers.includes( + BodyPart.RIGHT_HAND + ); if ( !blockHandsWarning.current && !showHandsWarning && - !settings.steamVrTrackers?.leftHand && - !settings.steamVrTrackers?.rightHand && - (trackers.leftHand || trackers.rightHand) + !settings?.outputTrackers?.trackers.includes(BodyPart.LEFT_HAND) && + !settings?.outputTrackers?.trackers.includes(BodyPart.RIGHT_HAND) && + (leftHandEnabled || rightHandEnabled) ) { // We have just toggled on one of the hand trackers, show the user a warning - setShowHandsWarning([trackers.leftHand, trackers.rightHand]); - trackers.leftHand = false; - trackers.rightHand = false; + setShowHandsWarning([leftHandEnabled, rightHandEnabled]); + outputTrackers.trackers = outputTrackers.trackers.filter( + (tracker) => + tracker != BodyPart.LEFT_HAND && tracker != BodyPart.RIGHT_HAND + ); } else if ( blockHandsWarning.current && - !trackers.leftHand && - !trackers.rightHand + !outputTrackers.trackers.includes(BodyPart.LEFT_HAND) && + !outputTrackers.trackers.includes(BodyPart.RIGHT_HAND) ) { // Both hand trackers have just been disabled, make sure the warning shows up // again next time the user toggles one back on blockHandsWarning.current = false; } - trackers.automaticTrackerToggle = values.trackers.automaticTrackerToggle; - settingsReq.steamVrTrackers = trackers; + outputTrackers.automaticTrackerToggle = + values.trackers.automaticTrackerToggle; + settingsReq.outputTrackers = outputTrackers; } const modelSettings = new ModelSettingsT(); @@ -281,9 +287,6 @@ export function GeneralSettings() { const toggles = new ModelTogglesT(); toggles.floorClip = values.toggles.floorClip; toggles.skatingCorrection = values.toggles.skatingCorrection; - toggles.extendedKnee = values.toggles.extendedKnee; - toggles.extendedPelvis = values.toggles.extendedPelvis; - toggles.extendedSpine = values.toggles.extendedSpine; toggles.forceArmsFromHmd = values.toggles.forceArmsFromHmd; toggles.toeSnap = values.toggles.toeSnap; toggles.footPlant = values.toggles.footPlant; @@ -296,14 +299,9 @@ export function GeneralSettings() { if (values.ratios) { const ratios = new ModelRatiosT(); - ratios.imputeWaistFromChestHip = - values.ratios.imputeWaistFromChestHip || -1; - ratios.imputeWaistFromChestLegs = - values.ratios.imputeWaistFromChestLegs || -1; - ratios.imputeHipFromChestLegs = - values.ratios.imputeHipFromChestLegs || -1; - ratios.imputeHipFromWaistLegs = - values.ratios.imputeHipFromWaistLegs || -1; + ratios.imputeSpineFromTopDown = + values.ratios.imputeSpineFromTopDown || -1; + ratios.imputeSpineCurvature = values.ratios.imputeSpineCurvature || -1; ratios.interpHipLegs = values.ratios.interpHipLegs || -1; ratios.interpKneeTrackerAnkle = values.ratios.interpKneeTrackerAnkle || -1; @@ -383,12 +381,12 @@ export function GeneralSettings() { formData.filtering = settings.filtering; } - if (settings.steamVrTrackers) { - formData.trackers = settings.steamVrTrackers; + if (settings.outputTrackers) { + formData.trackers = settings.outputTrackers; if ( !blockHandsWarning.current && - (settings.steamVrTrackers.leftHand || - settings.steamVrTrackers.rightHand) + (settings.outputTrackers.trackers.includes(BodyPart.LEFT_HAND) || + settings.outputTrackers.trackers.includes(BodyPart.RIGHT_HAND)) ) { blockHandsWarning.current = true; } @@ -522,17 +520,17 @@ export function GeneralSettings() { }} /> - } id="steamvr"> + } id="output"> <> - {l10n.getString('settings-general-steamvr')} + {l10n.getString('settings-general-output')} - {l10n.getString('settings-general-steamvr-subtitle')} + {l10n.getString('settings-general-output-subtitle')}
{l10n - .getString('settings-general-steamvr-description') + .getString('settings-general-output-description') .split('\n') .map((line, i) => ( {line} @@ -547,7 +545,7 @@ export function GeneralSettings() {
{l10n .getString( - 'settings-general-steamvr-trackers-tracker_toggling-description' + 'settings-general-output-trackers-tracker_toggling-description' ) .split('\n') .map((line, i) => ( @@ -801,7 +799,7 @@ export function GeneralSettings() { /> - } id="fksettings"> + } id="fksettings"> <> {l10n.getString('settings-general-fk_settings')} @@ -1095,47 +1093,6 @@ export function GeneralSettings() { {config?.debug && ( <> -
- - {l10n.getString( - 'settings-general-fk_settings-skeleton_settings-toggles' - )} - - - {l10n.getString( - 'settings-general-fk_settings-skeleton_settings-description' - )} - -
-
- - - -
@@ -1152,35 +1109,9 @@ export function GeneralSettings() {
- percentageFormat.format(value) - } - min={0.0} - max={1.0} - step={0.05} - /> - - percentageFormat.format(value) - } - min={0.0} - max={1.0} - step={0.05} - /> - percentageFormat.format(value) @@ -1191,9 +1122,9 @@ export function GeneralSettings() { /> percentageFormat.format(value) @@ -1271,7 +1202,7 @@ export function GeneralSettings() { - } id="gestureControl"> + } id="gestureControl"> <> {l10n.getString('settings-general-gesture_control')} diff --git a/gui/src/components/settings/pages/OSCRouterSettings.tsx b/gui/src/components/settings/pages/OSCRouterSettings.tsx deleted file mode 100644 index 0e5a68ae2c..0000000000 --- a/gui/src/components/settings/pages/OSCRouterSettings.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { Localized, useLocalization } from '@fluent/react'; -import { useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import { - ChangeSettingsRequestT, - OSCRouterSettingsT, - OSCSettingsT, - RpcMessage, - SettingsRequestT, - SettingsResponseT, -} from 'solarxr-protocol'; -import { useWebsocketAPI } from '@/hooks/websocket-api'; -import { CheckBox } from '@/components/commons/Checkbox'; -import { RouterIcon } from '@/components/commons/icon/RouterIcon'; -import { Input } from '@/components/commons/Input'; -import { Typography } from '@/components/commons/Typography'; -import { - SettingsPageLayout, - SettingsPagePaneLayout, -} from '@/components/settings/SettingsPageLayout'; -import { yupResolver } from '@hookform/resolvers/yup'; -import { object } from 'yup'; -import { - OSCSettings, - useOscSettingsValidator, -} from '@/hooks/osc-setting-validator'; - -interface OSCRouterSettingsForm { - router: { - oscSettings: OSCSettings; - }; -} - -const defaultValues = { - router: { - oscSettings: { - enabled: false, - portIn: 9002, - portOut: 9000, - address: '127.0.0.1', - }, - }, -}; - -export function OSCRouterSettings() { - const { l10n } = useLocalization(); - const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); - const { oscValidator } = useOscSettingsValidator(); - - const { reset, control, watch, handleSubmit } = - useForm({ - defaultValues: defaultValues, - reValidateMode: 'onChange', - mode: 'onChange', - resolver: yupResolver( - object({ - router: object({ - oscSettings: oscValidator, - }), - }) - ), - }); - - const onSubmit = (values: OSCRouterSettingsForm) => { - const settings = new ChangeSettingsRequestT(); - - if (values.router) { - const router = new OSCRouterSettingsT(); - - router.oscSettings = Object.assign( - new OSCSettingsT(), - values.router.oscSettings - ); - - settings.oscRouter = router; - } - sendRPCPacket(RpcMessage.ChangeSettingsRequest, settings); - }; - - useEffect(() => { - const subscription = watch(() => handleSubmit(onSubmit)()); - return () => subscription.unsubscribe(); - }, []); - - useEffect(() => { - sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT()); - }, []); - - useRPCPacket(RpcMessage.SettingsResponse, (settings: SettingsResponseT) => { - const formData: OSCRouterSettingsForm = defaultValues; - if (settings.oscRouter) { - if (settings.oscRouter.oscSettings) { - formData.router.oscSettings.enabled = - settings.oscRouter.oscSettings.enabled; - if (settings.oscRouter.oscSettings.portIn) - formData.router.oscSettings.portIn = - settings.oscRouter.oscSettings.portIn; - if (settings.oscRouter.oscSettings.portOut) - formData.router.oscSettings.portOut = - settings.oscRouter.oscSettings.portOut; - if (settings.oscRouter.oscSettings.address) - formData.router.oscSettings.address = - settings.oscRouter.oscSettings.address.toString(); - } - - reset(formData); - } - }); - - return ( - - - } id="router"> - <> - - {l10n.getString('settings-osc-router')} - -
- <> - {l10n - .getString('settings-osc-router-description') - .split('\n') - .map((line, i) => ( - {line} - ))} - -
- - {l10n.getString('settings-osc-router-enable')} - -
- - {l10n.getString('settings-osc-router-enable-description')} - -
-
- -
- - {l10n.getString('settings-osc-router-network')} - -
- <> - {l10n - .getString('settings-osc-router-network-description') - .split('\n') - .map((line, i) => ( - {line} - ))} - -
-
- - - - - - -
- - {l10n.getString('settings-osc-router-network-address')} - -
- - {l10n.getString( - 'settings-osc-router-network-address-description' - )} - -
-
- -
- -
- -
- ); -} diff --git a/gui/src/components/settings/pages/VRCOSCSettings.tsx b/gui/src/components/settings/pages/VRCOSCSettings.tsx index 7b2ff6788e..b5c4e1d097 100644 --- a/gui/src/components/settings/pages/VRCOSCSettings.tsx +++ b/gui/src/components/settings/pages/VRCOSCSettings.tsx @@ -6,7 +6,6 @@ import { useForm } from 'react-hook-form'; import { boolean, number, object, string } from 'yup'; import { ChangeVRCOSCSettingsRequestT, - OSCTrackersSettingT, RpcMessage, VRCOSCInputState, VRCOSCNetworkSettingsT, @@ -40,15 +39,6 @@ interface VRCOSCSettingsForm { portOut: number; address: string; }; - trackers: { - head: boolean; - chest: boolean; - elbows: boolean; - feet: boolean; - knees: boolean; - hands: boolean; - waist: boolean; - }; }; } @@ -61,15 +51,6 @@ const createDefaultValues = (): VRCOSCSettingsForm => ({ portOut: 9000, address: '127.0.0.1', }, - trackers: { - head: false, - chest: false, - elbows: false, - feet: false, - knees: false, - hands: false, - waist: false, - }, }, }); @@ -385,7 +366,7 @@ export function VRCOSCSettings() { .required() .test( 'ports-dont-match', - l10n.getString('settings-osc-common-network-ports_match_error'), + l10n.getString('settings-osc-common-network-ports_match_error-v2'), (_port, context) => context.parent.portIn != context.parent.portOut ) .notOneOf(bannedPorts, (context) => @@ -416,15 +397,6 @@ export function VRCOSCSettings() { { message: ' ' } ), }), - trackers: object({ - head: boolean().required(), - chest: boolean().required(), - elbows: boolean().required(), - feet: boolean().required(), - knees: boolean().required(), - hands: boolean().required(), - waist: boolean().required(), - }), }), }) ), @@ -443,10 +415,6 @@ export function VRCOSCSettings() { const vrcOsc = new VRCOSCSettingsT(); vrcOsc.enabled = values.vrchat.enabled; - vrcOsc.trackers = Object.assign( - new OSCTrackersSettingT(), - values.vrchat.trackers - ); vrcOsc.manualNetwork = values.vrchat.useManualNetwork ? Object.assign(new VRCOSCNetworkSettingsT(), values.vrchat.manualNetwork) : null; @@ -475,7 +443,6 @@ export function VRCOSCSettings() { const formData = createDefaultValues(); if (settings) { formData.vrchat.enabled = settings.enabled; - if (settings.trackers) formData.vrchat.trackers = settings.trackers; if (settings.manualNetwork) { formData.vrchat.useManualNetwork = true; formData.vrchat.manualNetwork.portIn = settings.manualNetwork.portIn; @@ -562,64 +529,6 @@ export function VRCOSCSettings() { )} - - {l10n.getString('settings-osc-vrchat-network-trackers')} - -
- - {l10n.getString( - 'settings-osc-vrchat-network-trackers-description' - )} - -
-
- - - - - -
- {l10n.getString('settings-osc-vrchat-status-network-mode')} diff --git a/gui/src/components/settings/pages/components/StayAlignedSettings.tsx b/gui/src/components/settings/pages/components/StayAlignedSettings.tsx index 6867383901..33eaa76f71 100644 --- a/gui/src/components/settings/pages/components/StayAlignedSettings.tsx +++ b/gui/src/components/settings/pages/components/StayAlignedSettings.tsx @@ -3,7 +3,6 @@ import { StayAlignedSettingsT } from 'solarxr-protocol'; import { SettingsForm } from '@/components/settings/pages/GeneralSettings'; import { Button } from '@/components/commons/Button'; import { CheckBox } from '@/components/commons/Checkbox'; -import { WrenchIcon } from '@/components/commons/icon/WrenchIcons'; import { Typography } from '@/components/commons/Typography'; import { SettingsPagePaneLayout } from '@/components/settings/SettingsPageLayout'; import { Localized, useLocalization } from '@fluent/react'; @@ -17,6 +16,7 @@ import { StandingRelaxedPoseModal, } from './StayAlignedPoseModal'; import { useState } from 'react'; +import { HorizontalAlignIcon } from '@/components/commons/icon/HorizontalAlignIcon'; export type StayAlignedSettingsForm = { enabled: boolean; @@ -177,7 +177,7 @@ export function StayAlignedSettings({ return ( <> - } id="stayaligned"> + } id="stayaligned"> {l10n.getString('settings-stay_aligned')} diff --git a/gui/src/components/tracker/TrackerSettings.tsx b/gui/src/components/tracker/TrackerSettings.tsx index 7f6acd8fd7..ef3fda3669 100644 --- a/gui/src/components/tracker/TrackerSettings.tsx +++ b/gui/src/components/tracker/TrackerSettings.tsx @@ -85,7 +85,6 @@ export function TrackerSettingsPage() { ); assignreq.bodyPosition = tracker?.tracker.info?.bodyPart || BodyPart.NONE; assignreq.trackerId = tracker?.tracker.trackerId; - assignreq.allowDriftCompensation = false; sendRPCPacket(RpcMessage.AssignTrackerRequest, assignreq); setSelectRotation(false); }; @@ -96,7 +95,6 @@ export function TrackerSettingsPage() { const assignreq = new AssignTrackerRequestT(); assignreq.bodyPosition = role; assignreq.trackerId = tracker?.tracker.trackerId; - assignreq.allowDriftCompensation = false; sendRPCPacket(RpcMessage.AssignTrackerRequest, assignreq); setSelectBodypart(false); }; @@ -116,7 +114,6 @@ export function TrackerSettingsPage() { assignreq.displayName = trackerName ?? null; assignreq.trackerId = tracker?.tracker.trackerId; - assignreq.allowDriftCompensation = false; sendRPCPacket(RpcMessage.AssignTrackerRequest, assignreq); }; diff --git a/gui/src/components/tracking-checklist/TrackingChecklist.tsx b/gui/src/components/tracking-checklist/TrackingChecklist.tsx index 40b9d4f4da..bba9850beb 100644 --- a/gui/src/components/tracking-checklist/TrackingChecklist.tsx +++ b/gui/src/components/tracking-checklist/TrackingChecklist.tsx @@ -12,10 +12,11 @@ import { RpcMessage, SettingsRequestT, SettingsResponseT, - SteamVRTrackersSettingT, + OutputTrackersSettingT, TrackingChecklistPublicNetworksT, TrackingChecklistSteamVRDisconnectedT, TrackingChecklistStepId, + BodyPart, } from 'solarxr-protocol'; import { ReactNode, useEffect, useMemo, useState } from 'react'; import { CheckIcon } from '@/components/commons/icon/CheckIcon'; @@ -31,7 +32,7 @@ import { ArrowRightIcon, } from '@/components/commons/icon/ArrowIcons'; import { Localized, useLocalization } from '@fluent/react'; -import { WrenchIcon } from '@/components/commons/icon/WrenchIcons'; +import { WrenchIcon } from '@/components/commons/icon/WrenchIcon'; import { TrackingChecklistModal } from './TrackingChecklistModal'; import { NavLink, useNavigate } from 'react-router-dom'; import { useBreakpoint } from '@/hooks/breakpoint'; @@ -176,7 +177,7 @@ function SteamVRDisconnected({ function SteamVRHandsEnabled() { const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); const [steamVrTrackers, setSteamVrTrackers] = useState | null>(null); @@ -185,25 +186,19 @@ function SteamVRHandsEnabled() { }, []); useRPCPacket(RpcMessage.SettingsResponse, (settings: SettingsResponseT) => { - if (settings.steamVrTrackers) { - setSteamVrTrackers(settings.steamVrTrackers); + if (settings.outputTrackers) { + setSteamVrTrackers(settings.outputTrackers); } }); const disableHandTrackers = () => { const settings = new ChangeSettingsRequestT(); - settings.steamVrTrackers = new SteamVRTrackersSettingT( - steamVrTrackers?.waist, - steamVrTrackers?.chest, + settings.outputTrackers = new OutputTrackersSettingT( steamVrTrackers?.automaticTrackerToggle, - steamVrTrackers?.leftFoot, - steamVrTrackers?.rightFoot, - steamVrTrackers?.leftKnee, - steamVrTrackers?.rightKnee, - steamVrTrackers?.leftElbow, - steamVrTrackers?.rightElbow, - false, - false + steamVrTrackers?.trackers.filter( + (tracker) => + tracker != BodyPart.LEFT_HAND && tracker != BodyPart.RIGHT_HAND + ) ); sendRPCPacket(RpcMessage.ChangeSettingsRequest, settings); }; diff --git a/gui/src/hooks/osc-setting-validator.ts b/gui/src/hooks/osc-setting-validator.ts index e090c28ad8..4d2cd71b97 100644 --- a/gui/src/hooks/osc-setting-validator.ts +++ b/gui/src/hooks/osc-setting-validator.ts @@ -17,7 +17,7 @@ export function useOscSettingsValidator() { .required() .test( 'ports-dont-match', - l10n.getString('settings-osc-common-network-ports_match_error'), + l10n.getString('settings-osc-common-network-ports_match_error-v2'), (port, context) => context.parent.portIn != context.parent.portOut ) .notOneOf(bannedPorts, (context) => diff --git a/server/core/src/main/java/dev/slimevr/solarxr/settings.kt b/server/core/src/main/java/dev/slimevr/solarxr/settings.kt index e6a3b4261d..8625fc5fea 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/settings.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/settings.kt @@ -21,6 +21,7 @@ class SettingsBehaviour( resetsSettings = buildResetsSettings(settings.context.state.value.data.resetsConfig), tapDetectionSettings = buildTapDetectionSettings(settings.context.state.value.data.tapDetectionConfig), vmcOsc = buildVmcOscSettings(vmcManager.context.state.value.config), + vrm = buildVrmSettings(vmcManager.context.state.value.config), ), ) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt index 3bf7526ab9..e966b6c42f 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt @@ -6,6 +6,7 @@ import dev.slimevr.vmc.VMCManager import solarxr_protocol.rpc.ChangeSettingsRequest import solarxr_protocol.rpc.OSCSettings import solarxr_protocol.rpc.VMCOSCSettings +import solarxr_protocol.rpc.VRMSettings class VmcBehaviour( private val vmcManager: VMCManager, @@ -25,7 +26,7 @@ class VmcBehaviour( address = osc.address.orEmpty(), mirrorTracking = vmc.mirrorTracking == true, anchorAtHips = vmc.anchorHip == true, - vrmJson = vmc.vrmJson?.ifEmpty { null }, + vrmJson = req.vrm?.vrmJson?.ifEmpty { null }, ), ), ) @@ -40,7 +41,10 @@ fun buildVmcOscSettings(config: VMCConfig): VMCOSCSettings = VMCOSCSettings( portOut = config.portOut.toUShort(), address = config.address, ), - vrmJson = config.vrmJson, anchorHip = config.anchorAtHips, mirrorTracking = config.mirrorTracking, ) + +fun buildVrmSettings(config: VMCConfig): VRMSettings = VRMSettings( + vrmJson = config.vrmJson, +) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt index 68412ca7d3..9f1a4e83e9 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.sample import solarxr_protocol.rpc.ChangeVRCOSCSettingsRequest -import solarxr_protocol.rpc.OSCTrackersSetting import solarxr_protocol.rpc.VRCOSCNetworkSettings import solarxr_protocol.rpc.VRCOSCSettings import solarxr_protocol.rpc.VRCOSCSettingsRequest @@ -51,7 +50,6 @@ internal class VrcOscBehaviour( receiver.rpcDispatcher.on { req -> val vrcOsc = req.settings ?: return@on - val trackers = vrcOsc.trackers vrcOscManager.context.dispatch( VRCOSCActions.UpdateConfig( VRCOSCConfig( @@ -63,15 +61,6 @@ internal class VrcOscBehaviour( address = network.address ?: error("address should be set"), ) }, - trackers = VRCOSCTrackers( - head = trackers?.head ?: false, - chest = trackers?.chest ?: false, - waist = trackers?.waist ?: true, - knees = trackers?.knees ?: false, - feet = trackers?.feet ?: true, - elbows = trackers?.elbows ?: false, - hands = trackers?.hands ?: false, - ), ), ), ) @@ -100,15 +89,6 @@ internal fun buildStatusResponse(status: VRCOSCStatus, enabled: Boolean) = VRCOS ) internal fun buildVrcOscSettings(config: VRCOSCConfig): VRCOSCSettings = VRCOSCSettings( - trackers = OSCTrackersSetting( - head = config.trackers.head, - chest = config.trackers.chest, - waist = config.trackers.waist, - knees = config.trackers.knees, - feet = config.trackers.feet, - elbows = config.trackers.elbows, - hands = config.trackers.hands, - ), enabled = config.enabled, manualNetwork = config.manualNetwork?.let { manual -> VRCOSCNetworkSettings( diff --git a/solarxr-protocol b/solarxr-protocol index 473e08f137..b8160c4760 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 473e08f137dd4f97eea7be0b267c756ec7da5cb2 +Subproject commit b8160c4760ef9a6fe7e921066b1bf256c3109074 From 70eda4f70ceaa95021ede2dd34dd08be31d36d37 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 22 Jun 2026 19:41:00 -0500 Subject: [PATCH 199/350] Read mag vector from HID and send to the GUI --- .../main/java/dev/slimevr/hid/behaviours.kt | 26 +++++++------------ .../src/main/java/dev/slimevr/hid/packets.kt | 4 +++ .../main/java/dev/slimevr/solarxr/datafeed.kt | 2 ++ .../java/dev/slimevr/tracker/behaviours.kt | 3 +++ .../main/java/dev/slimevr/tracker/module.kt | 4 ++- 5 files changed, 21 insertions(+), 18 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 2594602945..abadfe27b4 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -75,6 +75,7 @@ class HIDDeviceInfoBehaviour : HIDReceiverBehaviour { override fun observe(receiver: HIDReceiver) { receiver.packetEvents.onPacket { packet -> val device = receiver.getDevice(packet.hidId) ?: return@onPacket + val deviceState = device.context.state.value device.context.dispatch( DeviceActions.Update { @@ -90,17 +91,9 @@ class HIDDeviceInfoBehaviour : HIDReceiverBehaviour { ) val tracker = receiver.getTracker(packet.hidId) - if (tracker == null) { - val deviceState = device.context.state.value - - val existingTracker = receiver.appContext.server.context.state.value.trackers.values + ?: receiver.appContext.server.context.state.value.trackers.values .find { it.context.state.value.hardwareId == deviceState.address && it.context.state.value.origin == DeviceOrigin.HID } - - if (existingTracker != null) { - receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, existingTracker.context.state.value.id)) - // HID does not have a rest calibration signal - existingTracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType, completedRestCalibration = true) }) - } else { + ?: run { val trackerId = receiver.appContext.server.nextHandle() val newTracker = Tracker.create( scope = receiver.appContext.server.context.scope, @@ -112,13 +105,12 @@ class HIDDeviceInfoBehaviour : HIDReceiverBehaviour { appContext = receiver.appContext, ) receiver.appContext.server.context.dispatch(VRServerActions.NewTracker(trackerId, newTracker)) - receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, trackerId)) + newTracker } - } else { - // HID does not have a rest calibration signal - tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType, completedRestCalibration = true) }) - tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) - } + receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, tracker.context.state.value.id)) + // HID does not have a rest calibration signal + tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType, completedRestCalibration = true, magStatus = packet.magStatus) }) + tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) } } } @@ -137,7 +129,7 @@ class HIDRotationBehaviour : HIDReceiverBehaviour { receiver.packetEvents.onPacket { packet -> val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket - tracker.context.dispatch(TrackerActions.SetRotation(rotation = packet.rotation)) + tracker.context.dispatch(TrackerActions.SetRotation(rotation = packet.rotation, magnetometer = packet.magnetometer)) } receiver.packetEvents.onPacket { packet -> diff --git a/server/core/src/main/java/dev/slimevr/hid/packets.kt b/server/core/src/main/java/dev/slimevr/hid/packets.kt index c9dda5d94a..c1cbe6c981 100644 --- a/server/core/src/main/java/dev/slimevr/hid/packets.kt +++ b/server/core/src/main/java/dev/slimevr/hid/packets.kt @@ -2,6 +2,7 @@ package dev.slimevr.hid import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 +import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.BoardType import solarxr_protocol.datatypes.hardware_info.ImuType @@ -28,6 +29,7 @@ data class HIDDeviceInfo( val batteryLevel: Float?, val batteryVoltage: Float, val rssi: Int, + val magStatus: MagnetometerStatus, ) : HIDPacket /** Full-precision Q15 quaternion + Q7 acceleration (type 1). */ @@ -162,6 +164,7 @@ private fun parseSingleHIDPacket(data: ByteArray, i: Int): HIDPacket? { val brdId = data[i + 5].toUByte().toInt() val mcuId = data[i + 6].toUByte().toInt() val imuId = data[i + 8].toUByte().toInt() + val magId = data[i + 9].toUByte() val fwDate = data[i + 11].toUByte().toInt() shl 8 or data[i + 10].toUByte().toInt() val fwMajor = data[i + 12].toUByte().toInt() val fwMinor = data[i + 13].toUByte().toInt() @@ -179,6 +182,7 @@ private fun parseSingleHIDPacket(data: ByteArray, i: Int): HIDPacket? { batteryLevel = decodeBattery(batt), batteryVoltage = decodeBatteryVoltage(battV), rssi = -rssi, + magStatus = MagnetometerStatus.fromValue(magId) ?: MagnetometerStatus.NOT_SUPPORTED, ) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 55d4546c33..89d87d378e 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -23,6 +23,7 @@ import solarxr_protocol.data_feed.tracker.TrackerDataMask import solarxr_protocol.data_feed.tracker.TrackerInfo import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.Ipv4Address +import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.datatypes.Temperature import solarxr_protocol.datatypes.TrackerId import solarxr_protocol.datatypes.hardware_info.HardwareInfo @@ -66,6 +67,7 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas linearAcceleration = if (trackerMask.linearAcceleration == true) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null, rotationReferenceAdjusted = if (trackerMask.rotationReferenceAdjusted == true) tracker.rotation.let { Quat(it.x, it.y, it.z, it.w) } else null, rotationIdentityAdjusted = if (trackerMask.rotationIdentityAdjusted == true) tracker.rotation.let { Quat(it.x, it.y, it.z, it.w) } else null, // FIXME: uses reference adjusted + rawMagneticVector = if (trackerMask.rawMagneticVector == true && tracker.magStatus == MagnetometerStatus.ENABLED) tracker.rawMagnetometer.let { Vec3f(it.x, it.y, it.z) } else null, ) private fun createDevice( diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 1d93ab3775..fd171ccba5 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -242,11 +242,14 @@ class TrackerBasicBehaviour : TrackerBehaviour { else -> rawAcceleration } + val rawMagnetometer = action.magnetometer ?: state.rawMagnetometer + state.copy( rawRotation = rawRotation, rotation = rotation, rawAcceleration = rawAcceleration, acceleration = acceleration, + rawMagnetometer = rawMagnetometer, ) } diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index f92036efc1..04b357e428 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -28,6 +28,7 @@ data class TrackerState( val rotation: CalibratedRotation, val rawAcceleration: RawAcceleration, val acceleration: CalibratedAcceleration, + val rawMagnetometer: Vector3, // TODO apply calibration val deviceId: Int, val origin: DeviceOrigin, val tps: UShort, @@ -43,7 +44,7 @@ sealed interface TrackerActions { data class Update(val transform: TrackerState.() -> TrackerState) : TrackerActions data class SetMagStatus(val status: MagnetometerStatus) : TrackerActions data class SetStatus(val status: TrackerStatus) : TrackerActions - data class SetRotation(val rotation: Quaternion? = null, val acceleration: Vector3? = null) : TrackerActions + data class SetRotation(val rotation: Quaternion? = null, val acceleration: Vector3? = null, val magnetometer: Vector3? = null) : TrackerActions data class FullReset(val referenceRotation: Quaternion) : TrackerActions data class YawReset(val referenceRotation: Quaternion) : TrackerActions data class MountingReset(val referenceRotation: Quaternion) : TrackerActions @@ -80,6 +81,7 @@ class Tracker( rotation = Quaternion.IDENTITY, rawAcceleration = Vector3.NULL, acceleration = Vector3.NULL, + rawMagnetometer = Vector3.NULL, bodyPart = null, mountingOrientation = null, origin = origin, From ce762ba2423c06e24deecd32dde8833d62287e8c Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 22 Jun 2026 19:41:11 -0500 Subject: [PATCH 200/350] Spotless --- server/android/build.gradle.kts | 3 ++- server/android/src/main/java/dev/slimevr/android/hid/hid.kt | 2 +- server/core/src/main/java/dev/slimevr/hid/whitelist.kt | 2 +- server/core/src/main/java/dev/slimevr/provisioning/phases.kt | 2 +- server/core/src/main/java/dev/slimevr/serial/module.kt | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/server/android/build.gradle.kts b/server/android/build.gradle.kts index da7a76f967..c78a7a8762 100644 --- a/server/android/build.gradle.kts +++ b/server/android/build.gradle.kts @@ -168,7 +168,8 @@ android { // Defines the version number of your app. versionCode = providers.exec { commandLine("git", "--no-pager", "tag", "-l") - }.standardOutput.asText.get().trim().split('\n').size + versionCodeOffset + }.standardOutput.asText.get().trim().split('\n').size + + versionCodeOffset versionName = providers.exec { commandLine("git", "describe", "--tags", "--abbrev=8", "--always") }.standardOutput.asText.get().trim() diff --git a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt index e4934fa1b3..60ae8c362d 100644 --- a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt +++ b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt @@ -15,8 +15,8 @@ import dev.slimevr.AppContextProvider import dev.slimevr.AppLogger import dev.slimevr.device.DeviceActions import dev.slimevr.hid.HIDReceiver -import dev.slimevr.hid.parseHIDPackets import dev.slimevr.hid.isCompatibleHidDevice +import dev.slimevr.hid.parseHIDPackets import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers diff --git a/server/core/src/main/java/dev/slimevr/hid/whitelist.kt b/server/core/src/main/java/dev/slimevr/hid/whitelist.kt index 7c0d276928..24fa6e9f29 100644 --- a/server/core/src/main/java/dev/slimevr/hid/whitelist.kt +++ b/server/core/src/main/java/dev/slimevr/hid/whitelist.kt @@ -7,7 +7,7 @@ private val RECEIVER_PRODUCT_RULES = listOf( HidProductRule(0x4E76, 0xD200, 0xFF00), // Gestures Inc. D2XX ) private val TRACKER_PRODUCT_RULES = listOf( - HidProductRule(0x1209, 0x7692) // SlimeVR tracker direct + HidProductRule(0x1209, 0x7692), // SlimeVR tracker direct ) private fun matchesRule(rule: HidProductRule, vid: Int, pid: Int) = vid == rule.vendorId && (pid and rule.productMask) == rule.productId diff --git a/server/core/src/main/java/dev/slimevr/provisioning/phases.kt b/server/core/src/main/java/dev/slimevr/provisioning/phases.kt index c625f6d313..310f8d5419 100644 --- a/server/core/src/main/java/dev/slimevr/provisioning/phases.kt +++ b/server/core/src/main/java/dev/slimevr/provisioning/phases.kt @@ -33,7 +33,7 @@ internal suspend fun selectAndOpenPort( state.availablePorts.entries.firstOrNull { (_, info) -> isCompatibleHidDevice( info.vendorId, - info.productId + info.productId, ) } } diff --git a/server/core/src/main/java/dev/slimevr/serial/module.kt b/server/core/src/main/java/dev/slimevr/serial/module.kt index ec38523c35..aa9287992e 100644 --- a/server/core/src/main/java/dev/slimevr/serial/module.kt +++ b/server/core/src/main/java/dev/slimevr/serial/module.kt @@ -35,7 +35,7 @@ data class SerialPortInfo( isCompatibleHidReceiver(vendorId, productId) -> SerialDeviceType.HID_RECEIVER isCompatibleHidTracker(vendorId, productId) -> SerialDeviceType.HID_TRACKER else -> SerialDeviceType.ESP_TRACKER - } + }, ) } From 67fbf0facaece1a34fb8445b78f6093abab58268 Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 23 Jun 2026 00:28:50 -0400 Subject: [PATCH 201/350] Improving GUI settings layout --- gui/public/i18n/en/translation.ftl | 8 +- .../settings/pages/GeneralSettings.tsx | 79 ++++++++++--------- 2 files changed, 45 insertions(+), 42 deletions(-) diff --git a/gui/public/i18n/en/translation.ftl b/gui/public/i18n/en/translation.ftl index e879ad765c..2fef026c66 100644 --- a/gui/public/i18n/en/translation.ftl +++ b/gui/public/i18n/en/translation.ftl @@ -497,12 +497,12 @@ settings-sidebar-advanced = Advanced ## Output settings # Some keys have steamvr in them as we switched to output but want to keep existing translations settings-general-output = Output -settings-general-output-subtitle = Output trackers +settings-general-output_trackers = Output trackers # Not all translation keys support multiline, only the ones that specify it will actually # split it in lines (that also means you can split in lines however you want in those). # The first spaces (not tabs) for indentation will be ignored, just to make the file look nice when writing. # This one is one of this cases that cares about multilines -settings-general-output-description = +settings-general-output_trackers-description = Enable or disable specific trackers to output. Useful for games or apps that only support certain trackers. settings-general-steamvr-trackers-waist = Waist @@ -564,7 +564,7 @@ settings-stay_aligned-description = Stay Aligned reduces drift by gradually adju settings-stay_aligned-setup-label = Setup Stay Aligned settings-stay_aligned-setup-description = You must complete "Setup Stay Aligned" to enable Stay Aligned. settings-stay_aligned-enabled-label = Adjust trackers -settings-stay_aligned-hide_yaw_correction-label = Hide adjustment (to compare with no Stay Aligned) +settings-stay_aligned-hide_yaw_correction-label = Hide adjustment (to compare without Stay Aligned) settings-stay_aligned-general-label = General settings-stay_aligned-relaxed_poses-label = Relaxed Poses settings-stay_aligned-relaxed_poses-description = Stay Aligned uses your relaxed poses to keep the trackers aligned. Use "Setup Stay Aligned" to update these poses. @@ -661,7 +661,7 @@ settings-general-gesture_control-fullResetEnabled = Enable tap to full reset settings-general-gesture_control-fullResetDelay = Full reset delay settings-general-gesture_control-fullResetTaps = Taps for full reset settings-general-gesture_control-fullResetTracker = Full Reset Tracker -settings-general-gesture_control-mountingResetEnabled = Enable tap to perform mounting calibration +settings-general-gesture_control-mountingResetEnabled = Enable tap to mounting reset settings-general-gesture_control-mountingResetDelay = Mounting calibration delay settings-general-gesture_control-mountingResetTaps = Taps for mounting calibration settings-general-gesture_control-mountingResetTracker = Mounting Reset Tracker diff --git a/gui/src/components/settings/pages/GeneralSettings.tsx b/gui/src/components/settings/pages/GeneralSettings.tsx index 3b83feb38c..981bc84f4f 100644 --- a/gui/src/components/settings/pages/GeneralSettings.tsx +++ b/gui/src/components/settings/pages/GeneralSettings.tsx @@ -525,24 +525,14 @@ export function GeneralSettings() { {l10n.getString('settings-general-output')} - - {l10n.getString('settings-general-output-subtitle')} - -
- {l10n - .getString('settings-general-output-description') - .split('\n') - .map((line, i) => ( - {line} - ))} -
+
{l10n.getString( 'settings-general-steamvr-trackers-tracker_toggling' )} -
+
{l10n .getString( 'settings-general-output-trackers-tracker_toggling-description' @@ -562,6 +552,18 @@ export function GeneralSettings() { )} />
+ + + {l10n.getString('settings-general-output_trackers')} + +
+ {l10n + .getString('settings-general-output_trackers-description') + .split('\n') + .map((line, i) => ( + {line} + ))} +
+
+ +
+ + {l10n.getString( + 'settings-general-fk_settings-velocity_settings' + )} + +
+ + {l10n.getString( + 'settings-general-fk_settings-velocity_settings-description' + )} + +
+
+
+ +
@@ -1065,32 +1094,6 @@ export function GeneralSettings() { />
-
- - {l10n.getString( - 'settings-general-fk_settings-velocity_settings' - )} - -
- - {l10n.getString( - 'settings-general-fk_settings-velocity_settings-description' - )} - -
-
-
- -
- {config?.debug && ( <>
From 8358ca14a8b63ee7ad9bc09834bd9707961039e8 Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 23 Jun 2026 16:38:55 -0400 Subject: [PATCH 202/350] Update packaging instructions --- CONTRIBUTING.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67c79f01f1..6c81cd7300 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,17 +28,18 @@ The Java code is built with `gradle`, a CLI tool that manages java projects and dependencies. - You can run the server by running `./gradlew run` in your IDE's terminal. - To compile the code, run `./gradlew shadowJar`. The result will -be at `server/build/libs/slimevr.jar` (you can ignore `server.jar`). +be at `server/desktop/build/libs/slimevr.jar` (you can ignore `server.jar`). (Note: Your IDE may be able to do all of the above for you.) ### Electron (gui) -- Activate corepack (included with Node.JS) via `corepack enable` (might require administrator permissions) +- Activate corepack (included with Node.js) via `corepack enable` +(might require launching Git Bash as administrator on Windows) - Run `pnpm i` in your IDE's terminal to download and install dependencies. - To launch the GUI in dev mode, run `pnpm gui`. -- Finally, to compile for production, run `pnpm package:build`. The result -will be at `dist/artifacts/` content will change depending of the platform. +- Finally, to compile for production, run `pnpm package:build`. +The result will be in `gui/dist/artifacts/`. Content will change depending on the platform. ## Code style From a2c4c0fb4f804dbac92442ecca37d513b51bacdd Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Wed, 24 Jun 2026 22:49:07 -0400 Subject: [PATCH 203/350] WIP leg tweaks --- .../dev/slimevr/skeleton/skatingcorrection.kt | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt diff --git a/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt b/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt new file mode 100644 index 0000000000..a2fb3c1948 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt @@ -0,0 +1,140 @@ +package dev.slimevr.skeleton + +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import solarxr_protocol.datatypes.BodyPart +import kotlin.time.ComparableTimeMark +import kotlin.time.DurationUnit +import kotlin.time.TimeSource + +// TODO We should probably just have globally available velocity on the whole skeleton, +// we could do that as a processing step +data class VelocityState( + val time: ComparableTimeMark, + val position: Vector3, + val rotation: Quaternion, + val horizontalDistance: Float, + val velocity: Vector3, + val angularVelocityMagnitude: Float, + val acceleration: Vector3 +) + +val VELOCITY_BODY_PARTS = arrayOf(BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT) + +val FOOT_VELOCITY_SENSITIVITY = 1f +val FOOT_ACCELERATION_SENSITIVITY = 1f + +val SKATING_LOCK_ENGAGE_PERCENT = 1.1f +val SKATING_DISTANCE_THRESHOLD = 0.5f +val SKATING_VELOCITY_THRESHOLD = 2.4f +val SKATING_ROTATIONAL_VELOCITY_THRESHOLD = 4.5f +val SKATING_ACCELERATION_THRESHOLD = 0.7f + +// TODO Floor level needs to be calibrated in some way +val FLOOR_LEVEL = 0f +val FLOOR_DISTANCE_THRESHOLD = 0.065f + +// TODO Where are these numbers from? +val BODY_PART_MASSES = mapOf( + BodyPart.HEAD to 0.0827f, + BodyPart.LEFT_UPPER_ARM to 0.0263f, + BodyPart.RIGHT_UPPER_ARM to 0.0263f, + BodyPart.LEFT_LOWER_ARM to 0.0224f, + BodyPart.RIGHT_LOWER_ARM to 0.0224f, + BodyPart.CHEST to 0.1870f, + BodyPart.WAIST to 0.1320f, + BodyPart.HIP to 0.1530f, + BodyPart.LEFT_UPPER_LEG to 0.1122f, + BodyPart.RIGHT_UPPER_LEG to 0.1122f, + BodyPart.LEFT_LOWER_LEG to 0.0620f, + BodyPart.RIGHT_LOWER_LEG to 0.0620f, +) + +fun shouldLock( + velocity: VelocityState, + thresholdMultiplier: Float, +): Boolean = + (velocity.horizontalDistance <= SKATING_DISTANCE_THRESHOLD * thresholdMultiplier) && + (velocity.velocity.len() <= SKATING_VELOCITY_THRESHOLD * thresholdMultiplier) && + (velocity.angularVelocityMagnitude <= SKATING_ROTATIONAL_VELOCITY_THRESHOLD * thresholdMultiplier) && + (velocity.position.y - FLOOR_LEVEL <= FLOOR_DISTANCE_THRESHOLD * thresholdMultiplier) && + (velocity.acceleration.len() <= SKATING_ACCELERATION_THRESHOLD * thresholdMultiplier) + +fun centerOfMass(bones: Map) { + // TODO Compute center of mass using BODY_PART_MASSES +} + +// Probably not a SkeletonProcessor, maybe computed processor or smth +class SkatingCorrectionProcessor : SkeletonProcessor { + override var enabled: Boolean = true + + // Placeholder + val curPositions: Map = emptyMap() + + // Do we need to store this or do we just want velocity? We can probably just pull + // the last state + val velocity: MutableMap = mutableMapOf() + val lockStates: MutableMap = mutableMapOf() + + override fun process(state: SkeletonState): SkeletonState { + + for (bodyPart in VELOCITY_BODY_PARTS) { + // Pull data + val curBone = curPositions[bodyPart] ?: continue + val curTime = TimeSource.Monotonic.markNow() + val lastVel = velocity.getOrElse(bodyPart) { + VelocityState( + curTime, + curBone.tailPosition, + curBone.rotation, + 0f, + Vector3.NULL, + 0f, + Vector3.NULL, + ) + } + + // Calculate velocity + val deltaP = curBone.tailPosition - lastVel.position + val deltaT = + (curTime - lastVel.time).toDouble(DurationUnit.SECONDS).toFloat() + val newVel = VelocityState( + curTime, + curBone.tailPosition, + curBone.rotation, + deltaP.len(), + deltaP / deltaT, + // May need to be `angleToR` while polarity tracking is not implemented + lastVel.rotation.angleToQ(curBone.rotation) / deltaT, + Vector3.NULL, + ) + velocity[bodyPart] = newVel + + // Consider locking + val wasLocked = lockStates.getOrDefault(bodyPart, false) + val isLocked = shouldLock( + newVel, + if (wasLocked) SKATING_LOCK_ENGAGE_PERCENT else 1f, + ) + lockStates[bodyPart] = isLocked + } + return state + } +} + +class FloorClipProcessor : SkeletonProcessor { + override var enabled: Boolean = true + + fun getDisplacement(footPos: Vector3): Vector3 { + return Vector3(0f, footPos.y.coerceAtMost(0f), 0f) + } + + override fun process(state: SkeletonState): SkeletonState { + val newBones = state.rawBones.mapValues { (bodyPart, bone) -> } + state.rawBones[BodyPart.LEFT_FOOT]?.let { + val correction = getDisplacement(it.offset) + // TODO Apply displacement onto skeleton + } + return state + } +} From c87b7ff003935b2082d61dc8397542ad3c3a5ccc Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 23 Jun 2026 22:04:47 -0400 Subject: [PATCH 204/350] Update solarxr-protocol --- solarxr-protocol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solarxr-protocol b/solarxr-protocol index b8160c4760..5786fd494b 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit b8160c4760ef9a6fe7e921066b1bf256c3109074 +Subproject commit 5786fd494b35b402bf0e8d6b2f8d107e0d3533d1 From 234dabe23e83f6e3374a259f203b8fd37c3e4ba5 Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 23 Jun 2026 22:05:42 -0400 Subject: [PATCH 205/350] Make server work for solarxr rewrite 2nd pass --- .../main/java/dev/slimevr/config/settings.kt | 1 - .../main/java/dev/slimevr/device/module.kt | 2 +- .../main/java/dev/slimevr/firmware/module.kt | 4 +- .../src/main/java/dev/slimevr/firmware/ota.kt | 3 +- .../java/dev/slimevr/firmware/reconnect.kt | 10 +-- .../dev/slimevr/heightcalibration/module.kt | 2 + .../src/main/java/dev/slimevr/hid/packets.kt | 4 +- .../java/dev/slimevr/resets/behaviours.kt | 10 +-- .../main/java/dev/slimevr/resets/module.kt | 8 +- .../main/java/dev/slimevr/solarxr/datafeed.kt | 56 ++++++++----- .../main/java/dev/slimevr/solarxr/firmware.kt | 8 +- .../dev/slimevr/solarxr/heightcalibration.kt | 2 +- .../dev/slimevr/solarxr/installed-info.kt | 2 +- .../src/main/java/dev/slimevr/solarxr/mag.kt | 8 +- .../main/java/dev/slimevr/solarxr/module.kt | 3 +- .../main/java/dev/slimevr/solarxr/resets.kt | 52 ++++++------ .../dev/slimevr/solarxr/sessioncalibration.kt | 2 +- .../main/java/dev/slimevr/solarxr/settings.kt | 29 ------- .../dev/slimevr/solarxr/skeletonconfig.kt | 25 +++--- .../java/dev/slimevr/solarxr/tapdetection.kt | 81 ++++++++++--------- .../dev/slimevr/solarxr/trackerassignment.kt | 4 +- .../src/main/java/dev/slimevr/solarxr/vmc.kt | 46 +++++------ .../main/java/dev/slimevr/solarxr/vrcosc.kt | 69 ++++++++-------- .../java/dev/slimevr/tracker/behaviours.kt | 15 ++-- .../main/java/dev/slimevr/tracker/module.kt | 4 +- .../slimevr/trackingchecklist/behaviours.kt | 10 +-- .../main/java/dev/slimevr/udp/behaviours.kt | 10 +-- .../main/java/dev/slimevr/udp/connection.kt | 11 ++- .../src/main/java/dev/slimevr/udp/packets.kt | 8 +- .../src/main/java/dev/slimevr/vrserver.kt | 4 + 30 files changed, 228 insertions(+), 265 deletions(-) delete mode 100644 server/core/src/main/java/dev/slimevr/solarxr/settings.kt diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index fb30acdec5..cacd8acf44 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -44,7 +44,6 @@ data class TapDetectionConfig( var fullResetTaps: Int = 3, var mountingResetTaps: Int = 3, var numberTrackersOverThreshold: Int = 1, - var setupMode: Boolean = false, ) @Serializable diff --git a/server/core/src/main/java/dev/slimevr/device/module.kt b/server/core/src/main/java/dev/slimevr/device/module.kt index 2146dac586..68eb66fa80 100644 --- a/server/core/src/main/java/dev/slimevr/device/module.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -79,7 +79,7 @@ class Device( ping = null, signalStrength = null, status = TrackerStatus.DISCONNECTED, - mcuType = McuType.Other, + mcuType = McuType.UNKNOWN, boardType = BoardType.UNKNOWN, firmware = null, packetsReceived = 0L, diff --git a/server/core/src/main/java/dev/slimevr/firmware/module.kt b/server/core/src/main/java/dev/slimevr/firmware/module.kt index 63bd389bc9..9cd1c04b3c 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/module.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/module.kt @@ -10,7 +10,7 @@ import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin -import solarxr_protocol.datatypes.DeviceIdTable +import solarxr_protocol.rpc.FirmwareDeviceIdTable import solarxr_protocol.rpc.FirmwarePart import solarxr_protocol.rpc.FirmwareUpdateDeviceId import solarxr_protocol.rpc.FirmwareUpdateStatus @@ -99,7 +99,7 @@ class FirmwareManager( runningJobs[deviceIp] = scope.safeLaunch { doOtaFlash( deviceIp = deviceIp, - deviceId = (firmwareDeviceId as? DeviceIdTable)?.id ?: error("device id should exist"), + deviceId = (firmwareDeviceId as? FirmwareDeviceIdTable)?.id ?: error("device id should exist"), part = part, server = server, onStatus = { status, progress -> diff --git a/server/core/src/main/java/dev/slimevr/firmware/ota.kt b/server/core/src/main/java/dev/slimevr/firmware/ota.kt index 9c44a3217d..ac5e42da56 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/ota.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/ota.kt @@ -22,7 +22,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.io.readByteArray -import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.rpc.FirmwarePart import solarxr_protocol.rpc.FirmwareUpdateStatus import java.security.MessageDigest @@ -115,7 +114,7 @@ internal suspend fun uploadFirmware( suspend fun doOtaFlash( deviceIp: String, - deviceId: DeviceId, + deviceId: UShort, part: FirmwarePart, server: VRServer, onStatus: suspend (FirmwareUpdateStatus, Int) -> Unit, diff --git a/server/core/src/main/java/dev/slimevr/firmware/reconnect.kt b/server/core/src/main/java/dev/slimevr/firmware/reconnect.kt index 281be86765..8d09f3a39e 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/reconnect.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/reconnect.kt @@ -3,13 +3,11 @@ package dev.slimevr.firmware import dev.slimevr.VRServer import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.withTimeoutOrNull -import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.TrackerStatus private fun isOnlineStatus(status: TrackerStatus): Boolean = when (status) { @@ -38,18 +36,16 @@ suspend fun waitForConnected( macAddress: String, timeoutMs: Long = 30_000, ): Boolean? = withTimeoutOrNull(timeoutMs) { - deviceStatusFlow(server) { _, deviceMac -> deviceMac?.uppercase() == macAddress } - .filter(::isOnlineStatus) - .first() + deviceStatusFlow(server) { _, deviceMac -> deviceMac?.uppercase() == macAddress }.first(::isOnlineStatus) true } suspend fun waitForReconnected( server: VRServer, - deviceId: DeviceId, + deviceId: UShort, timeoutMs: Long = 60_000, ): Boolean? = withTimeoutOrNull(timeoutMs) { - val statuses = deviceStatusFlow(server) { id, _ -> id.toUByte() == deviceId.id } + val statuses = deviceStatusFlow(server) { id, _ -> id.toUShort() == deviceId } .distinctUntilChanged() if (isOnlineStatus(statuses.first())) { diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt index 6509627412..ec0c3c0a9a 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt @@ -26,6 +26,7 @@ data class TrackerSnapshot(val position: Vector3, val rotation: Quaternion) data class HeightCalibrationState( val status: UserHeightCalibrationStatus, val currentHeight: Float, + val canDoUserHeightCalibration: Boolean, ) sealed interface HeightCalibrationActions { @@ -38,6 +39,7 @@ typealias HeightCalibrationBehaviourType = Behaviour { state.copy( - canDoYawReset = if (ResetType.Yaw in action.resetType) false else state.canDoYawReset, - canDoMountingReset = if (ResetType.Mounting in action.resetType) false else state.canDoMountingReset, + canDoYawReset = if (ResetType.YAW in action.resetType) false else state.canDoYawReset, + canDoMountingReset = if (ResetType.MOUNTING in action.resetType) false else state.canDoMountingReset, ) } // Whenever a reset is finished is ResetsActions.EndReset -> { - if (action.resetType == ResetType.Full) { + if (action.resetType == ResetType.FULL) { state.copy(canDoYawReset = true, canDoMountingReset = true, lastFullResetTime = System.nanoTime()) - } else if (action.resetType == ResetType.Mounting) { + } else if (action.resetType == ResetType.MOUNTING) { state.copy(config = state.config.copy(lastMountingMethod = MountingMethods.AUTOMATIC)) } else { state.copy() @@ -42,7 +42,7 @@ class ResetsMountingTimeoutBehaviour : ResetsBehaviour { .distinctUntilChangedBy { it.lastFullResetTime } .mapLatest { delay(MOUNTING_RESET_TIMEOUT) - receiver.context.dispatch(ResetsActions.ClearResets(listOf(ResetType.Mounting))) + receiver.context.dispatch(ResetsActions.ClearResets(listOf(ResetType.MOUNTING))) } .launchIn(receiver.context.scope) } diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index 14f0e6fa67..917eea7fbc 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -94,7 +94,7 @@ class ResetsManager(val context: ResetsContext, val server: VRServer) { } else { // Exclude feet trackers from mounting reset if resetMountingFeet = false allTrackers.filter { - resetType != ResetType.Mounting || + resetType != ResetType.MOUNTING || config.resetMountingFeet || it.context.state.value.bodyPart !in setOf(BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT) } @@ -109,9 +109,9 @@ class ResetsManager(val context: ResetsContext, val server: VRServer) { trackers.forEach { it.context.dispatch( when (resetType) { - ResetType.Yaw -> TrackerActions.YawReset(referenceRotation) - ResetType.Full -> TrackerActions.FullReset(referenceRotation) - ResetType.Mounting -> TrackerActions.MountingReset(referenceRotation) + ResetType.YAW -> TrackerActions.YawReset(referenceRotation) + ResetType.FULL -> TrackerActions.FullReset(referenceRotation) + ResetType.MOUNTING -> TrackerActions.MountingReset(referenceRotation) }, ) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 89d87d378e..5308d007d5 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -3,6 +3,7 @@ package dev.slimevr.solarxr import dev.slimevr.AppLogger import dev.slimevr.VRServer import dev.slimevr.device.DeviceState +import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.resets.ResetsManager import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.Skeleton @@ -18,14 +19,12 @@ import solarxr_protocol.data_feed.PollDataFeed import solarxr_protocol.data_feed.StartDataFeed import solarxr_protocol.data_feed.device_data.DeviceData import solarxr_protocol.data_feed.server.ServerGuards -import solarxr_protocol.data_feed.tracker.TrackerData -import solarxr_protocol.data_feed.tracker.TrackerDataMask -import solarxr_protocol.data_feed.tracker.TrackerInfo -import solarxr_protocol.datatypes.DeviceId +import solarxr_protocol.data_feed.tracker_data.TrackerData +import solarxr_protocol.data_feed.tracker_data.TrackerDataMask +import solarxr_protocol.data_feed.tracker_data.TrackerInfo import solarxr_protocol.datatypes.Ipv4Address import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.datatypes.Temperature -import solarxr_protocol.datatypes.TrackerId import solarxr_protocol.datatypes.hardware_info.HardwareInfo import solarxr_protocol.datatypes.hardware_info.HardwareStatus import solarxr_protocol.datatypes.math.Quat @@ -40,11 +39,9 @@ private fun ipv4AddressFromString(address: String): UInt { } } -private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMask: TrackerDataMask, datafeedConfig: DataFeedConfig): TrackerData = TrackerData( - trackerId = TrackerId( - trackerNum = tracker.id.toUByte(), - deviceId = DeviceId(device.id.toUByte()), - ), +private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMask: TrackerDataMask): TrackerData = TrackerData( + deviceId = device.id.toUShort(), + trackerId = tracker.id.toUShort(), status = if (trackerMask.status == true) tracker.status else null, rotation = if (trackerMask.rotation == true) tracker.rawRotation.let { Quat(it.x, it.y, it.z, it.w) } else null, position = if (trackerMask.position == true && tracker.position != null) tracker.position.let { Vec3f(it.x, it.y, it.z) } else null, @@ -78,7 +75,7 @@ private fun createDevice( val trackerMask = datafeedConfig.dataMask?.trackerData return DeviceData( - id = DeviceId(device.id.toUByte()), + id = device.id.toUShort(), hardwareStatus = HardwareStatus( batteryVoltage = device.batteryVoltage, batteryPctEstimate = device.batteryLevel?.let { (it * 100).toUInt().toUByte() }, @@ -100,7 +97,7 @@ private fun createDevice( ), trackers = if (trackerMask != null) { trackers.filter { it.deviceId == device.id } - .map { tracker -> createTracker(device, tracker, trackerMask, datafeedConfig) } + .map { tracker -> createTracker(device, tracker, trackerMask) } } else { null }, @@ -114,12 +111,13 @@ private fun createBone(bone: BoneState): solarxr_protocol.data_feed.Bone = solar headPositionG = bone.headPosition.let { Vec3f(it.x, it.y, it.z) }, ) -private fun createServerGuards(resetsManager: ResetsManager): ServerGuards { +private fun createServerGuards(resetsManager: ResetsManager, heightCalibrationManager: HeightCalibrationManager): ServerGuards { val resetsState = resetsManager.context.state.value + val heightCalibrationState = heightCalibrationManager.context.state.value return ServerGuards( - candomounting = resetsState.canDoMountingReset, - candoyawreset = resetsState.canDoYawReset, - // candouserheightcalibration = true, TODO + canDoMountingReset = resetsState.canDoMountingReset, + canDoYawReset = resetsState.canDoYawReset, + canDoUserHeightCalibration = heightCalibrationState.canDoUserHeightCalibration, ) } @@ -128,6 +126,7 @@ fun createDatafeedFrame( datafeedConfig: DataFeedConfig, skeleton: Skeleton, resetsManager: ResetsManager, + heightCalibrationManager: HeightCalibrationManager, index: Int = 0, ): DataFeedMessageHeader { val serverState = server.context.state.value @@ -140,7 +139,7 @@ fun createDatafeedFrame( null } val serverGuards = if (datafeedConfig.serverGuardsMask == true) { - createServerGuards(resetsManager) + createServerGuards(resetsManager, heightCalibrationManager) } else { null } @@ -161,16 +160,23 @@ class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : Sola override fun observe(receiver: SolarXRBridge) { receiver.dataFeedDispatcher.on { event -> - val datafeeds = event.dataFeeds ?: return@on + val dataFeeds = event.dataFeeds ?: return@on receiver.datafeedTimers.forEach { it.cancelAndJoin() } - val timers = datafeeds.mapIndexed { index, config -> + val timers = dataFeeds.mapIndexed { index, config -> receiver.context.scope.safeLaunch { val minTime = config.minimumTimeSinceLast.toLong() while (isActive) { try { - receiver.sendDataFeed(createDatafeedFrame(server = server, datafeedConfig = config, skeleton = skeleton, resetsManager = receiver.appContext.resetsManager, index = index)) + receiver.sendDataFeed(createDatafeedFrame( + server = server, + datafeedConfig = config, + skeleton = skeleton, + resetsManager = receiver.appContext.resetsManager, + heightCalibrationManager = receiver.appContext.heightCalibrationManager, + index = index + )) } catch (e: Exception) { AppLogger.solarxr.error(e, "Error sending data feed") } @@ -180,12 +186,18 @@ class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : Sola } receiver.datafeedTimers = timers - receiver.context.dispatch(SolarXRBridgeActions.SetConfig(datafeeds)) + receiver.context.dispatch(SolarXRBridgeActions.SetConfig(dataFeeds)) } receiver.dataFeedDispatcher.on { event -> val config = event.config ?: return@on - receiver.sendDataFeed(createDatafeedFrame(server = server, datafeedConfig = config, skeleton = skeleton, resetsManager = receiver.appContext.resetsManager)) + receiver.sendDataFeed(createDatafeedFrame( + server = server, + datafeedConfig = config, + skeleton = skeleton, + resetsManager = receiver.appContext.resetsManager, + heightCalibrationManager = receiver.appContext.heightCalibrationManager + )) } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt index 55c9428ed5..1744e186c1 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt @@ -7,7 +7,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import solarxr_protocol.datatypes.DeviceIdTable +import solarxr_protocol.rpc.FirmwareDeviceIdTable import solarxr_protocol.rpc.FirmwareUpdateRequest import solarxr_protocol.rpc.FirmwareUpdateStatusResponse import solarxr_protocol.rpc.FirmwareUpdateStopQueuesRequest @@ -47,7 +47,7 @@ class FirmwareBehaviour(private val server: VRServer, private val firmwareManage firmwareManager.flash( portLocation, parts, - method.needmanualreboot == true, + method.needManualReboot == true, method.ssid, method.password, server, @@ -57,9 +57,9 @@ class FirmwareBehaviour(private val server: VRServer, private val firmwareManage is OTAFirmwareUpdate -> { val deviceId = method.deviceId ?: return@on val part = method.firmwarePart ?: return@on - val device = server.getDevice(deviceId.id.toInt()) ?: return@on + val device = server.getDevice(deviceId.toInt()) ?: return@on val deviceIp = device.context.state.value.address - firmwareManager.otaFlash(deviceIp, DeviceIdTable(id = deviceId), part, server) + firmwareManager.otaFlash(deviceIp, FirmwareDeviceIdTable(id = deviceId), part, server) } else -> return@on diff --git a/server/core/src/main/java/dev/slimevr/solarxr/heightcalibration.kt b/server/core/src/main/java/dev/slimevr/solarxr/heightcalibration.kt index d44433b910..025fda15a3 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/heightcalibration.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/heightcalibration.kt @@ -24,7 +24,7 @@ class HeightCalibrationBehaviour( receiver.sendRpc( UserHeightRecordingStatusResponse( status = state.status, - hmdheight = state.currentHeight, + hmdHeight = state.currentHeight, ), ) }.launchIn(receiver.context.scope) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt b/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt index 498b0461d2..69eba579cd 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt @@ -7,7 +7,7 @@ class InstalledInfoBehaviour : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { val udevRulesInstalled = receiver.appContext.featureFlags.udevRulesInstalled ?: return@on - receiver.sendRpc(InstalledInfoResponse(isudevinstalled = udevRulesInstalled)) + receiver.sendRpc(InstalledInfoResponse(isUdevInstalled = udevRulesInstalled)) } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt index 14af477412..adf7709c06 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt @@ -20,7 +20,7 @@ class MagBehaviour( ) : SolarXRBridgeBehaviour { private fun setUDPTrackerMag(trackerId: Int, deviceId: Int, enable: Boolean): Boolean { val connection = appContext.udpServer.findConnectionForDevice(deviceId) ?: return false - val sensorId = connection.context.state.value.trackerIds.find { it.id == trackerId }?.trackerNum ?: return false + val sensorId = connection.context.state.value.trackerIds.find { it.trackerId == trackerId }?.sensorId ?: return false connection.context.dispatch( UDPConnectionActions.SetSensorConfig( sensorId = sensorId, @@ -50,8 +50,7 @@ class MagBehaviour( return@on } - val trackerNum = trackerId.trackerNum ?: return@on - val tracker = appContext.server.getTracker(trackerNum.toInt()) ?: return@on + val tracker = appContext.server.getTracker(trackerId.toInt()) ?: return@on val trackerState = tracker.context.state.value if (trackerState.magStatus == MagnetometerStatus.NOT_SUPPORTED) return@on @@ -93,8 +92,7 @@ class MagBehaviour( return@on } - val trackerNum = trackerId.trackerNum ?: return@on - val trackerState = appContext.server.getTracker(trackerNum.toInt())?.context?.state?.value ?: return@on + val trackerState = appContext.server.getTracker(trackerId.toInt())?.context?.state?.value ?: return@on receiver.sendRpc( MagToggleResponse( trackerId = trackerId, diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 5c9832f66a..dd13dbe5ad 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -72,11 +72,10 @@ class SolarXRBridge( appContext.vrcConfigManager?.let { vrc -> add(VrcBehaviour(vrc, appContext.server, userHeight = { appContext.skeleton.context.state.value.userHeight })) } - add(ResetsBehaviour(appContext.config.settings)) + add(ResetsBehaviour(appContext.resetsManager)) add(TapDetectionBehaviour(appContext.config.settings)) add(VrcOscBehaviour(appContext.vrcOscManager)) add(VmcBehaviour(appContext.vmcManager)) - add(SettingsBehaviour(appContext.config.settings, appContext.vmcManager)) add(HeightCalibrationBehaviour(appContext.heightCalibrationManager)) add(ProvisioningBehaviour(appContext.server, appContext.provisioningManager)) add(SkeletonBehaviour(appContext.config.userConfig, appContext.skeleton)) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/resets.kt b/server/core/src/main/java/dev/slimevr/solarxr/resets.kt index a86fb4f34b..6fb3c9b50e 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/resets.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/resets.kt @@ -1,38 +1,36 @@ package dev.slimevr.solarxr import dev.slimevr.config.ResetsConfig -import dev.slimevr.config.Settings -import dev.slimevr.config.SettingsActions -import dev.slimevr.config.TapDetectionConfig -import solarxr_protocol.rpc.ChangeSettingsRequest -import solarxr_protocol.rpc.ResetsSettings -import solarxr_protocol.rpc.TapDetectionSettings +import dev.slimevr.resets.ResetsActions +import dev.slimevr.resets.ResetsManager +import solarxr_protocol.rpc.ChangeResetsSettingsRequest +import solarxr_protocol.rpc.ResetsSettingsRequest +import solarxr_protocol.rpc.ResetsSettingsResponse class ResetsBehaviour( - private val settings: Settings, + private val resetsManager: ResetsManager, ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { - receiver.rpcDispatcher.on { req -> - val resetsSettings = req.resetsSettings ?: return@on - - val oldConfig = settings.context.state.value.data.resetsConfig - val newConfig = oldConfig.copy( - resetMountingFeet = resetsSettings.resetMountingFeet == true, - armsResetMode = resetsSettings.armsMountingResetMode ?: oldConfig.armsResetMode, - yawResetSmoothTime = resetsSettings.yawResetSmoothTime ?: oldConfig.yawResetSmoothTime, - saveMountingReset = resetsSettings.saveMountingReset == true, - resetHmdPitch = resetsSettings.resetHmdPitch == true, - ) + receiver.rpcDispatcher.on { + receiver.sendRpc(buildResetsSettings(resetsManager.context.state.value.config)) + } - settings.context.dispatch(SettingsActions.Update { copy(resetsConfig = newConfig) }) + receiver.rpcDispatcher.on { req -> + resetsManager.context.dispatch(ResetsActions.UpdateConfig(ResetsConfig( + resetMountingFeet = req.resetMountingFeet == true, + armsResetMode = req.armsMountingResetMode ?: error("armsMountingResetMode should be set"), + yawResetSmoothTime = req.yawResetSmoothTime ?: error("yawResetSmoothTime should be set"), + saveMountingReset = req.saveMountingReset == true, + resetHmdPitch = req.resetHmdPitch == true, + ))) } } -} -fun buildResetsSettings(config: ResetsConfig): ResetsSettings = ResetsSettings( - resetMountingFeet = config.resetMountingFeet, - armsMountingResetMode = config.armsResetMode, - yawResetSmoothTime = config.yawResetSmoothTime, - saveMountingReset = config.saveMountingReset, - resetHmdPitch = config.resetHmdPitch, -) + private fun buildResetsSettings(config: ResetsConfig): ResetsSettingsResponse = ResetsSettingsResponse( + resetMountingFeet = config.resetMountingFeet, + armsMountingResetMode = config.armsResetMode, + yawResetSmoothTime = config.yawResetSmoothTime, + saveMountingReset = config.saveMountingReset, + resetHmdPitch = config.resetHmdPitch, + ) +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt index 52a17a64f2..08de3e1bd3 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt @@ -13,7 +13,7 @@ class SessionCalibrationBehaviour( ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { req -> - resetsManager.scheduleReset("SolarXRBridge", req.resetType ?: ResetType.Yaw, req.delay ?: 0f, req.bodyParts) + resetsManager.scheduleReset("SolarXRBridge", req.resetType ?: ResetType.YAW, req.delay ?: 0f, req.bodyParts) } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/settings.kt b/server/core/src/main/java/dev/slimevr/solarxr/settings.kt deleted file mode 100644 index 8625fc5fea..0000000000 --- a/server/core/src/main/java/dev/slimevr/solarxr/settings.kt +++ /dev/null @@ -1,29 +0,0 @@ -package dev.slimevr.solarxr - -import dev.slimevr.config.Settings -import dev.slimevr.vmc.VMCManager -import solarxr_protocol.rpc.SettingsRequest -import solarxr_protocol.rpc.SettingsResponse - -// Central handler for SettingsRequest. Aggregates per-feature settings into one SettingsResponse -// so the GUI receives a single response. Each feature builder lives next to its behaviour -// (e.g. buildVmcOscSettings in vmc.kt). -// TODO: Would be nice to split settings based on their solarxr behaviour. -// TODO: Almost all build functions could be their own request. making it easier to work with -class SettingsBehaviour( - private val settings: Settings, - private val vmcManager: VMCManager, -) : SolarXRBridgeBehaviour { - override fun observe(receiver: SolarXRBridge) { - receiver.rpcDispatcher.on { - receiver.sendRpc( - SettingsResponse( - resetsSettings = buildResetsSettings(settings.context.state.value.data.resetsConfig), - tapDetectionSettings = buildTapDetectionSettings(settings.context.state.value.data.tapDetectionConfig), - vmcOsc = buildVmcOscSettings(vmcManager.context.state.value.config), - vrm = buildVrmSettings(vmcManager.context.state.value.config), - ), - ) - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt index 9ea26575b2..fe00b615f9 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt @@ -2,19 +2,18 @@ package dev.slimevr.solarxr import dev.slimevr.config.UserConfig import dev.slimevr.config.UserConfigActions -import dev.slimevr.skeleton.SKELETON_BONE_TO_BODY_PARTS import dev.slimevr.skeleton.Skeleton import dev.slimevr.skeleton.computeAllDefaultProportionsByBone import dev.slimevr.skeleton.computeDefaultProportionsByBone import dev.slimevr.skeleton.configToBoneValues import dev.slimevr.skeleton.height import dev.slimevr.skeleton.toBoneValues -import solarxr_protocol.rpc.ChangeSettingsRequest -import solarxr_protocol.rpc.ChangeSkeletonConfigRequest -import solarxr_protocol.rpc.SkeletonConfigRequest -import solarxr_protocol.rpc.SkeletonConfigResponse +import solarxr_protocol.rpc.ChangeSkeletonProportionsRequest +import solarxr_protocol.rpc.ChangeSkeletonSettingsRequest import solarxr_protocol.rpc.SkeletonPart -import solarxr_protocol.rpc.SkeletonResetAllRequest +import solarxr_protocol.rpc.SkeletonProportionsRequest +import solarxr_protocol.rpc.SkeletonProportionsResetAllRequest +import solarxr_protocol.rpc.SkeletonProportionsResponse private const val MIN_HEIGHT = 1.0f @@ -23,7 +22,7 @@ class SkeletonBehaviour( private val skeleton: Skeleton, ) : SolarXRBridgeBehaviour { - private fun buildConfigResponse(): SkeletonConfigResponse { + private fun buildConfigResponse(): SkeletonProportionsResponse { val proportions = userConfig.context.state.value.data.proportions val bones = skeleton.context.state.value.rawBones val skeletonParts = bones.mapValues { it.value.offset }.toBoneValues().map { (offset, bone) -> SkeletonPart(offset, bone) } @@ -33,16 +32,16 @@ class SkeletonBehaviour( } else { skeleton.context.state.value.userHeight } - return SkeletonConfigResponse(skeletonParts = skeletonParts, userHeight = userHeight) + return SkeletonProportionsResponse(skeletonParts = skeletonParts, userHeight = userHeight) } override fun observe(receiver: SolarXRBridge) { - receiver.rpcDispatcher.on { + receiver.rpcDispatcher.on { receiver.sendRpc(buildConfigResponse()) } - receiver.rpcDispatcher.on { req -> - req.modelSettings?.skeletonHeight?.let { skeletonHeight -> + receiver.rpcDispatcher.on { req -> + req.skeletonHeight?.let { skeletonHeight -> val hmdHeight = skeletonHeight.hmdHeight ?: return@let val floorHeight = skeletonHeight.floorHeight ?: 0f val height = hmdHeight - floorHeight @@ -56,7 +55,7 @@ class SkeletonBehaviour( } } - receiver.rpcDispatcher.on { + receiver.rpcDispatcher.on { val height = userConfig.context.state.value.data.userHeight if (height >= MIN_HEIGHT) { val defaults = computeAllDefaultProportionsByBone(height) @@ -65,7 +64,7 @@ class SkeletonBehaviour( receiver.sendRpc(buildConfigResponse()) } - receiver.rpcDispatcher.on { req -> + receiver.rpcDispatcher.on< ChangeSkeletonProportionsRequest> { req -> val bone = req.bone ?: return@on val value = req.value ?: return@on diff --git a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt index 74a77999ef..ee10de04f2 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt @@ -3,51 +3,56 @@ package dev.slimevr.solarxr import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions import dev.slimevr.config.TapDetectionConfig -import solarxr_protocol.rpc.ChangeSettingsRequest -import solarxr_protocol.rpc.TapDetectionSettings +import solarxr_protocol.rpc.ChangeTapDetectionSettingsRequest +import solarxr_protocol.rpc.TapDetectionSettingsRequest +import solarxr_protocol.rpc.TapDetectionSettingsResponse +import solarxr_protocol.rpc.TapDetectionSetupModeRequest +import solarxr_protocol.rpc.VMCOSCSettingsRequest class TapDetectionBehaviour( private val settings: Settings, ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { - receiver.rpcDispatcher.on { req -> - val tapDetectionSettings = req.tapDetectionSettings ?: return@on + receiver.rpcDispatcher.on { + receiver.sendRpc(buildTapDetectionSettings(settings.context.state.value.data.tapDetectionConfig)) + } - val oldConfig = settings.context.state.value.data.tapDetectionConfig - val newConfig = oldConfig.copy( - yawResetDelay = tapDetectionSettings.yawResetDelay ?: oldConfig.yawResetDelay, - fullResetDelay = tapDetectionSettings.fullResetDelay ?: oldConfig.fullResetDelay, - mountingResetDelay = tapDetectionSettings.mountingResetDelay ?: oldConfig.mountingResetDelay, - yawResetEnabled = tapDetectionSettings.yawResetEnabled == true, - fullResetEnabled = tapDetectionSettings.fullResetEnabled == true, - mountingResetEnabled = tapDetectionSettings.mountingResetEnabled == true, - yawResetTaps = tapDetectionSettings.yawResetTaps?.toInt() ?: oldConfig.yawResetTaps, - fullResetTaps = tapDetectionSettings.fullResetTaps?.toInt() ?: oldConfig.fullResetTaps, - mountingResetTaps = tapDetectionSettings.mountingResetTaps?.toInt() ?: oldConfig.mountingResetTaps, - yawResetBodyPart = tapDetectionSettings.yawResetTracker ?: oldConfig.yawResetBodyPart, - fullResetBodyPart = tapDetectionSettings.fullResetTracker ?: oldConfig.fullResetBodyPart, - mountingResetBodyPart = tapDetectionSettings.mountingResetTracker ?: oldConfig.mountingResetBodyPart, - numberTrackersOverThreshold = tapDetectionSettings.numberTrackersOverThreshold?.toInt() ?: oldConfig.numberTrackersOverThreshold, - setupMode = tapDetectionSettings.setupMode == true, - ) + receiver.rpcDispatcher.on { req -> + println(req.setupMode) // TODO + } - settings.context.dispatch(SettingsActions.Update { copy(tapDetectionConfig = newConfig) }) + receiver.rpcDispatcher.on { req -> + settings.context.dispatch(SettingsActions.Update { copy(tapDetectionConfig = TapDetectionConfig( + yawResetDelay = req.yawResetDelay ?: error("yawResetDelay should be set"), + fullResetDelay = req.fullResetDelay ?: error("fullResetDelay should be set"), + mountingResetDelay = req.mountingResetDelay ?: error("mountingResetDelay should be set"), + yawResetEnabled = req.yawResetEnabled == true, + fullResetEnabled = req.fullResetEnabled == true, + mountingResetEnabled = req.mountingResetEnabled == true, + yawResetTaps = req.yawResetTaps?.toInt() ?: error("yawResetTaps should be set"), + fullResetTaps = req.fullResetTaps?.toInt() ?: error("fullResetTaps should be set"), + mountingResetTaps = req.mountingResetTaps?.toInt() ?: error("mountingResetTaps should be set"), + yawResetBodyPart = req.yawResetTracker ?: error("yawResetTracker should be set"), + fullResetBodyPart = req.fullResetTracker ?: error("fullResetTracker should be set"), + mountingResetBodyPart = req.mountingResetTracker ?: error("mountingResetTracker should be set"), + numberTrackersOverThreshold = req.numberTrackersOverThreshold?.toInt() ?: error("numberTrackersOverThreshold should be set"), + ))}) } } -} -fun buildTapDetectionSettings(config: TapDetectionConfig): TapDetectionSettings = TapDetectionSettings( - yawResetDelay = config.yawResetDelay, - fullResetDelay = config.fullResetDelay, - mountingResetDelay = config.mountingResetDelay, - yawResetEnabled = config.yawResetEnabled, - fullResetEnabled = config.fullResetEnabled, - mountingResetEnabled = config.mountingResetEnabled, - yawResetTaps = config.yawResetTaps.toUByte(), - fullResetTaps = config.fullResetTaps.toUByte(), - mountingResetTaps = config.mountingResetTaps.toUByte(), - yawResetTracker = config.yawResetBodyPart, - fullResetTracker = config.fullResetBodyPart, - mountingResetTracker = config.mountingResetBodyPart, - numberTrackersOverThreshold = config.numberTrackersOverThreshold.toUByte(), -) + private fun buildTapDetectionSettings(config: TapDetectionConfig): TapDetectionSettingsResponse = TapDetectionSettingsResponse( + yawResetDelay = config.yawResetDelay, + fullResetDelay = config.fullResetDelay, + mountingResetDelay = config.mountingResetDelay, + yawResetEnabled = config.yawResetEnabled, + fullResetEnabled = config.fullResetEnabled, + mountingResetEnabled = config.mountingResetEnabled, + yawResetTaps = config.yawResetTaps.toUByte(), + fullResetTaps = config.fullResetTaps.toUByte(), + mountingResetTaps = config.mountingResetTaps.toUByte(), + yawResetTracker = config.yawResetBodyPart, + fullResetTracker = config.fullResetBodyPart, + mountingResetTracker = config.mountingResetBodyPart, + numberTrackersOverThreshold = config.numberTrackersOverThreshold.toUByte(), + ) +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt b/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt index 7b78e396de..cb90c5ae89 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt @@ -11,8 +11,8 @@ class AssignTrackerBehaviour( ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { req -> - val trackerNum = req.trackerId?.trackerNum ?: return@on - val tracker = server.getTracker(trackerNum.toInt()) + val id = req.trackerId ?: return@on + val tracker = server.getTracker(id.toInt()) ?: return@on val bodyPart = req.bodyPosition?.takeIf { it != BodyPart.NONE } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt index e966b6c42f..0176b67751 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt @@ -3,48 +3,40 @@ package dev.slimevr.solarxr import dev.slimevr.config.VMCConfig import dev.slimevr.vmc.VMCActions import dev.slimevr.vmc.VMCManager -import solarxr_protocol.rpc.ChangeSettingsRequest -import solarxr_protocol.rpc.OSCSettings -import solarxr_protocol.rpc.VMCOSCSettings -import solarxr_protocol.rpc.VRMSettings +import solarxr_protocol.rpc.ChangeVMCOSCSettingsRequest +import solarxr_protocol.rpc.VMCOSCSettingsRequest +import solarxr_protocol.rpc.VMCOSCSettingsResponse class VmcBehaviour( private val vmcManager: VMCManager, ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { - receiver.rpcDispatcher.on { req -> - val vmc = req.vmcOsc ?: return@on - val osc = vmc.oscSettings ?: return@on - val portIn = osc.portIn?.toInt() ?: return@on - val portOut = osc.portOut?.toInt() ?: return@on + receiver.rpcDispatcher.on { + receiver.sendRpc(buildVmcOscSettings(vmcManager.context.state.value.config)) + } + + receiver.rpcDispatcher.on { req -> vmcManager.context.dispatch( VMCActions.UpdateConfig( VMCConfig( - enabled = osc.enabled == true, - portIn = portIn, - portOut = portOut, - address = osc.address.orEmpty(), - mirrorTracking = vmc.mirrorTracking == true, - anchorAtHips = vmc.anchorHip == true, - vrmJson = req.vrm?.vrmJson?.ifEmpty { null }, + enabled = req.enabled == true, + portIn = req.portIn?.toInt() ?: error("portIn should be set"), + portOut = req.portOut?.toInt() ?: error("portOut should be set"), + address = req.address ?: error("address should be set"), + mirrorTracking = req.mirrorTracking == true, + anchorAtHips = req.anchorHip == true, ), ), ) } } -} -fun buildVmcOscSettings(config: VMCConfig): VMCOSCSettings = VMCOSCSettings( - oscSettings = OSCSettings( + private fun buildVmcOscSettings(config: VMCConfig): VMCOSCSettingsResponse = VMCOSCSettingsResponse( enabled = config.enabled, portIn = config.portIn.toUShort(), portOut = config.portOut.toUShort(), address = config.address, - ), - anchorHip = config.anchorAtHips, - mirrorTracking = config.mirrorTracking, -) - -fun buildVrmSettings(config: VMCConfig): VRMSettings = VRMSettings( - vrmJson = config.vrmJson, -) + anchorHip = config.anchorAtHips, + mirrorTracking = config.mirrorTracking, + ) +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt index 9f1a4e83e9..5d5b9b6f22 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt @@ -2,7 +2,6 @@ package dev.slimevr.solarxr import dev.slimevr.config.VRCOSCConfig import dev.slimevr.config.VRCOSCManualNetworkConfig -import dev.slimevr.config.VRCOSCTrackers import dev.slimevr.vrcosc.VRCOSCActions import dev.slimevr.vrcosc.VRCOSCManager import dev.slimevr.vrcosc.VRCOSCStatus @@ -14,7 +13,6 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.sample import solarxr_protocol.rpc.ChangeVRCOSCSettingsRequest import solarxr_protocol.rpc.VRCOSCNetworkSettings -import solarxr_protocol.rpc.VRCOSCSettings import solarxr_protocol.rpc.VRCOSCSettingsRequest import solarxr_protocol.rpc.VRCOSCSettingsResponse import solarxr_protocol.rpc.VRCOSCStatusChangeResponse @@ -40,7 +38,7 @@ internal class VrcOscBehaviour( .launchIn(receiver.context.scope) receiver.rpcDispatcher.on { - receiver.sendRpc(VRCOSCSettingsResponse(settings = buildVrcOscSettings(vrcOscManager.context.state.value.config))) + receiver.sendRpc(buildVrcOscSettings(vrcOscManager.context.state.value.config)) } receiver.rpcDispatcher.on { @@ -49,12 +47,11 @@ internal class VrcOscBehaviour( } receiver.rpcDispatcher.on { req -> - val vrcOsc = req.settings ?: return@on vrcOscManager.context.dispatch( VRCOSCActions.UpdateConfig( VRCOSCConfig( - enabled = vrcOsc.enabled == true, - manualNetwork = vrcOsc.manualNetwork?.takeIf { it.portIn !== null && it.portOut !== null && it.address !== null }?.let { network -> + enabled = req.enabled == true, + manualNetwork = req.manualNetwork?.takeIf { it.portIn !== null && it.portOut !== null && it.address !== null }?.let { network -> VRCOSCManualNetworkConfig( portIn = network.portIn?.toInt() ?: error("portIn should be set"), portOut = network.portOut?.toInt() ?: error("portOut should be set"), @@ -66,35 +63,35 @@ internal class VrcOscBehaviour( ) } } -} -internal fun buildStatusResponse(status: VRCOSCStatus, enabled: Boolean) = VRCOSCStatusChangeResponse( - enabled = enabled, - inputState = status.inputState, - inputPort = status.inputPort?.toUShort(), - inputError = status.inputError, - lastReceivedInputMillis = status.lastReceivedInputMillis?.toULong(), - outputState = status.outputState, - outputError = status.outputError, - targetAddress = status.targetAddress, - targetPort = status.targetPort?.toUShort(), - targetSource = status.targetSource, - lastFrameSentMillis = status.lastFrameSentMillis?.toULong(), - oscqueryState = status.oscQueryState, - oscqueryAdvertisedPort = status.oscQueryAdvertisedPort?.toUShort(), - oscqueryError = status.oscQueryError, - discoveredTargets = status.discoveredTargets.map { target -> - RpcVRCOSCDiscoveredTarget(name = target.name, address = target.address, portOut = target.portOut.toUShort()) - }, -) + private fun buildStatusResponse(status: VRCOSCStatus, enabled: Boolean) = VRCOSCStatusChangeResponse( + enabled = enabled, + inputState = status.inputState, + inputPort = status.inputPort?.toUShort(), + inputError = status.inputError, + lastReceivedInputMillis = status.lastReceivedInputMillis?.toULong(), + outputState = status.outputState, + outputError = status.outputError, + targetAddress = status.targetAddress, + targetPort = status.targetPort?.toUShort(), + targetSource = status.targetSource, + lastFrameSentMillis = status.lastFrameSentMillis?.toULong(), + oscqueryState = status.oscQueryState, + oscqueryAdvertisedPort = status.oscQueryAdvertisedPort?.toUShort(), + oscqueryError = status.oscQueryError, + discoveredTargets = status.discoveredTargets.map { target -> + RpcVRCOSCDiscoveredTarget(name = target.name, address = target.address, portOut = target.portOut.toUShort()) + }, + ) -internal fun buildVrcOscSettings(config: VRCOSCConfig): VRCOSCSettings = VRCOSCSettings( - enabled = config.enabled, - manualNetwork = config.manualNetwork?.let { manual -> - VRCOSCNetworkSettings( - portIn = manual.portIn.toUShort(), - portOut = manual.portOut.toUShort(), - address = manual.address, - ) - }, -) + private fun buildVrcOscSettings(config: VRCOSCConfig): VRCOSCSettingsResponse = VRCOSCSettingsResponse( + enabled = config.enabled, + manualNetwork = config.manualNetwork?.let { manual -> + VRCOSCNetworkSettings( + portIn = manual.portIn.toUShort(), + portOut = manual.portOut.toUShort(), + address = manual.address, + ) + }, + ) +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index fd171ccba5..f5bb7b29c6 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -17,8 +17,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import solarxr_protocol.datatypes.BodyPart -import solarxr_protocol.datatypes.DeviceId -import solarxr_protocol.datatypes.TrackerId import solarxr_protocol.rpc.ResetType import solarxr_protocol.rpc.TapDetectionSetupNotification import kotlin.concurrent.atomics.AtomicInt @@ -87,7 +85,8 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { resetTapDetection() // If setupMode is true, double tap to assign - if (tapDetectionConfig.setupMode) { + // TODO setupMode + if (false) { setupModeAssign = true resetType = null tapsNeeded = 2 @@ -111,19 +110,19 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { // BodyParts above could be null yawResetBodyPart if tapDetectionConfig.yawResetEnabled -> { - resetType = ResetType.Yaw + resetType = ResetType.YAW tapsNeeded = tapDetectionConfig.yawResetTaps actionDelay = tapDetectionConfig.yawResetDelay } fullResetBodyPart if tapDetectionConfig.fullResetEnabled -> { - resetType = ResetType.Full + resetType = ResetType.FULL tapsNeeded = tapDetectionConfig.fullResetTaps actionDelay = tapDetectionConfig.fullResetDelay } mountingResetBodyPart if tapDetectionConfig.mountingResetEnabled -> { - resetType = ResetType.Mounting + resetType = ResetType.MOUNTING tapsNeeded = tapDetectionConfig.mountingResetTaps actionDelay = tapDetectionConfig.mountingResetDelay } @@ -178,9 +177,7 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { // If it's in setup mode, tap to assign if (setupModeAssign) { receiver.appContext.server.sendSolarxrRpc( - TapDetectionSetupNotification( - TrackerId(DeviceId(currentTracker.deviceId.toUByte()), currentTracker.id.toUByte()), - ), + TapDetectionSetupNotification(currentTracker.id.toUShort()), ) } diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 04b357e428..d890ec0f67 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -14,10 +14,10 @@ import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.ImuType -data class TrackerIdNum(val id: Int, val trackerNum: Int) +data class TrackerSensorIds(val trackerId: Int, val sensorId: Int) data class TrackerState( - val id: Int, // trackerNum + val id: Int, val name: String, val hardwareId: String, val sensorType: ImuType?, diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index 7e2239743c..d3da818ca0 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -23,8 +23,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.BodyPart -import solarxr_protocol.datatypes.DeviceId -import solarxr_protocol.datatypes.TrackerId import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.TrackingChecklistNeedCalibration import solarxr_protocol.rpc.TrackingChecklistPublicNetworks @@ -50,8 +48,6 @@ private fun trackerStatesFlow(server: VRServer): Flow> = allC private fun deviceStatesFlow(server: VRServer): Flow> = allContextStates(server, { state -> state.devices.values }) { device -> device.context.state } -private fun trackerIdOf(tracker: TrackerState): TrackerId = TrackerId(trackerNum = tracker.id.toUByte(), deviceId = DeviceId(tracker.deviceId.toUByte())) - class SteamVRCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { override fun observe(receiver: TrackingChecklist) { server.context.state @@ -82,7 +78,7 @@ class HMDCheckBehaviour(private val server: VRServer) : TrackingChecklistBehavio ignorable = true, extraData = if (!isAssigned) { TrackingChecklistUnassignedHMD( - trackerId = hmdTracker?.let { tracker -> trackerIdOf(tracker) }, + trackerId = hmdTracker?.id?.toUShort(), ) } else { null @@ -111,7 +107,7 @@ class TrackerRestCheckBehaviour(private val server: VRServer) : TrackingChecklis enabled = trackers.isNotEmpty(), extraData = if (!uncalibratedTrackers.isEmpty()) { TrackingChecklistNeedCalibration( - trackersId = uncalibratedTrackers.map { tracker -> trackerIdOf(tracker) }, + trackersId = uncalibratedTrackers.map { tracker -> tracker.id.toUShort() }, ) } else { null @@ -138,7 +134,7 @@ class TrackerErrorCheckBehaviour(private val server: VRServer) : TrackingCheckli enabled = trackers.isNotEmpty(), extraData = if (errorTrackers.isNotEmpty()) { TrackingChecklistTrackerError( - trackersId = errorTrackers.map { tracker -> trackerIdOf(tracker) }, + trackersId = errorTrackers.map { tracker -> tracker.id.toUShort() }, ) } else { null diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 0bbe5aeee4..badc276bea 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -8,7 +8,7 @@ import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions -import dev.slimevr.tracker.TrackerIdNum +import dev.slimevr.tracker.TrackerSensorIds import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.Job @@ -225,7 +225,7 @@ class TimeoutBehaviour : UDPConnectionBehaviour { receiver.getDevice()?.context?.dispatch( DeviceActions.Update { copy(status = TrackerStatus.TIMED_OUT) }, ) - state.trackerIds.mapNotNull { receiver.appContext.server.getTracker(it.id) }.forEach { tracker -> + state.trackerIds.mapNotNull { receiver.appContext.server.getTracker(it.trackerId) }.forEach { tracker -> tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.TIMED_OUT)) } } else { @@ -251,7 +251,7 @@ class DisconnectBehaviour : UDPConnectionBehaviour { receiver.getDevice()?.context?.dispatch( DeviceActions.Update { copy(status = TrackerStatus.DISCONNECTED) }, ) - currentState.trackerIds.mapNotNull { receiver.appContext.server.getTracker(it.id) }.forEach { tracker -> + currentState.trackerIds.mapNotNull { receiver.appContext.server.getTracker(it.trackerId) }.forEach { tracker -> tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.DISCONNECTED)) } } @@ -303,7 +303,7 @@ class SensorInfoBehaviour : UDPConnectionBehaviour { if (existingTracker != null) { receiver.context.dispatch( UDPConnectionActions.AssignTracker( - trackerId = TrackerIdNum(id = existingTracker.context.state.value.id, trackerNum = event.data.sensorId), + trackerId = TrackerSensorIds(trackerId = existingTracker.context.state.value.id, sensorId = event.data.sensorId), ), ) return existingTracker to false @@ -321,7 +321,7 @@ class SensorInfoBehaviour : UDPConnectionBehaviour { ) receiver.appContext.server.context.dispatch(VRServerActions.NewTracker(trackerId = trackerId, context = newTracker)) receiver.context.dispatch( - UDPConnectionActions.AssignTracker(trackerId = TrackerIdNum(id = trackerId, trackerNum = event.data.sensorId)), + UDPConnectionActions.AssignTracker(trackerId = TrackerSensorIds(trackerId = trackerId, sensorId = event.data.sensorId)), ) return newTracker to true } diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 5bfb369ed7..7925d2c58d 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -6,7 +6,7 @@ import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.device.Device import dev.slimevr.tracker.Tracker -import dev.slimevr.tracker.TrackerIdNum +import dev.slimevr.tracker.TrackerSensorIds import dev.slimevr.util.safeLaunch import io.ktor.network.sockets.BoundDatagramSocket import io.ktor.network.sockets.Datagram @@ -14,7 +14,6 @@ import io.ktor.network.sockets.InetSocketAddress import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel import kotlinx.io.Buffer -import kotlinx.io.readByteArray import solarxr_protocol.datatypes.MagnetometerStatus data class LastPing( @@ -36,7 +35,7 @@ data class UDPConnectionState( val lastPing: LastPing, val didHandshake: Boolean, val deviceId: Int?, - val trackerIds: List, + val trackerIds: List, val features: FirmwareFeatures?, val sensorConfigFlags: Map, ) @@ -45,7 +44,7 @@ sealed interface UDPConnectionActions { data class StartPing(val startTime: Long, val pingId: Int) : UDPConnectionActions data class Handshake(val deviceId: Int) : UDPConnectionActions data class LastPacket(val packetNum: Long? = null, val time: Long) : UDPConnectionActions - data class AssignTracker(val trackerId: TrackerIdNum) : UDPConnectionActions + data class AssignTracker(val trackerId: TrackerSensorIds) : UDPConnectionActions data class FirmwareFeatures(val features: dev.slimevr.udp.FirmwareFeatures) : UDPConnectionActions data class SetSensorConfig(val sensorId: Int, val flags: SensorConfigFlags) : UDPConnectionActions data object TimedOut : UDPConnectionActions @@ -79,8 +78,8 @@ class UDPConnection( } fun getTracker(id: Int): Tracker? { - val trackerId = context.state.value.trackerIds.find { it.trackerNum == id } - return if (trackerId != null) appContext.server.getTracker(trackerId.id) else null + val trackerId = context.state.value.trackerIds.find { it.sensorId == id } + return if (trackerId != null) appContext.server.getTracker(trackerId.trackerId) else null } companion object { diff --git a/server/core/src/main/java/dev/slimevr/udp/packets.kt b/server/core/src/main/java/dev/slimevr/udp/packets.kt index 28aca1a69d..8f15246d0c 100644 --- a/server/core/src/main/java/dev/slimevr/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/udp/packets.kt @@ -151,7 +151,7 @@ data object Heartbeat : UDPPacket data class Handshake( val boardType: BoardType = BoardType.UNKNOWN, val imuType: Int = 0, - val mcuType: McuType = McuType.Other, + val mcuType: McuType = McuType.UNKNOWN, val protocolVersion: Int = 0, val firmware: String? = null, val macString: String? = null, @@ -166,7 +166,7 @@ data class Handshake( if (remaining == 0L) return Handshake() val b = if (remaining >= 4) BoardType.fromValue(readInt().toUShort()) ?: BoardType.UNKNOWN else BoardType.UNKNOWN val i = if (remaining >= 4) readInt() else 0 - val m = if (remaining >= 4) McuType.fromValue(readInt().toUShort()) ?: McuType.Other else McuType.Other + val m = if (remaining >= 4) McuType.fromValue(readInt().toUShort()) ?: McuType.UNKNOWN else McuType.UNKNOWN if (remaining >= 12) { readInt() readInt() @@ -296,7 +296,7 @@ data class ErrorPacket(override val sensorId: Int = 0, val errorNumber: Int = 0) data class SensorInfo( override val sensorId: Int = 0, val status: TrackerStatus = TrackerStatus.DISCONNECTED, - val imuType: ImuType = ImuType.Other, + val imuType: ImuType = ImuType.UNKNOWN, val sensorConfig: SensorConfig? = null, val hasCompletedRestCalibration: Boolean? = null, val trackerPosition: Int? = null, @@ -337,7 +337,7 @@ data class SensorInfo( fun read(src: Source) = with(src) { val id = readU8() val stat = statusFromUDP(readUByte()) - val imu = if (remaining > 0) ImuType.fromValue(readUByte().toUShort()) ?: ImuType.Other else ImuType.Other + val imu = if (remaining > 0) ImuType.fromValue(readUByte().toUShort()) ?: ImuType.UNKNOWN else ImuType.UNKNOWN val conf = if (remaining >= 2) SensorConfig.fromUDP(readShort().toUShort()) else null val calib = if (remaining > 0) readU8() != 0 else null val pos = if (remaining > 0) readU8() else null diff --git a/server/core/src/main/java/dev/slimevr/vrserver.kt b/server/core/src/main/java/dev/slimevr/vrserver.kt index 735912ac7b..99620a4fbc 100644 --- a/server/core/src/main/java/dev/slimevr/vrserver.kt +++ b/server/core/src/main/java/dev/slimevr/vrserver.kt @@ -42,6 +42,10 @@ class VRServer( fun nextHandle() = handleCounter.incrementAndFetch() fun getTracker(id: Int) = context.state.value.trackers[id] fun getDevice(id: Int) = context.state.value.devices[id] + fun getDeviceFromTrackerId(id: Int): Device? { + val deviceId = getTracker(id)?.context?.state?.value?.deviceId ?: return null + return getDevice(deviceId) + } suspend fun sendSolarxrRpc(message: RpcMessage) = context.state.value.solarxr.values.forEach { it.sendRpc(message) } From 068a7a85a6688b475321d64f661705f8641a7c3e Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 23 Jun 2026 22:38:12 -0400 Subject: [PATCH 206/350] Nuke pub sub --- gui/src/components/widgets/OverlayWidget.tsx | 89 -------------------- gui/src/hooks/pubSub.ts | 86 ------------------- gui/src/hooks/websocket-api.ts | 43 ---------- 3 files changed, 218 deletions(-) delete mode 100644 gui/src/components/widgets/OverlayWidget.tsx delete mode 100644 gui/src/hooks/pubSub.ts diff --git a/gui/src/components/widgets/OverlayWidget.tsx b/gui/src/components/widgets/OverlayWidget.tsx deleted file mode 100644 index 6890277dbd..0000000000 --- a/gui/src/components/widgets/OverlayWidget.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useLocalization } from '@fluent/react'; -import { useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { KeyValuesT, MessageT, Payload, Topic } from 'solarxr-protocol'; -import { - OVERLAY_DISPLAY_SETTINGS_TOPIC, - PayloadData, - usePubSub, -} from '@/hooks/pubSub'; -import { CheckBox } from '@/components/commons/Checkbox'; -import { Typography } from '@/components/commons/Typography'; - -export function OverlayWidget() { - const { l10n } = useLocalization(); - const { publish, subscribe, keyValues } = usePubSub(); - const [loading, setLoading] = useState(true); - const { reset, control, handleSubmit, watch } = useForm<{ - isVisible: boolean; - isMirrored: boolean; - }>({ - defaultValues: { - isVisible: false, - isMirrored: false, - }, - }); - - subscribe( - OVERLAY_DISPLAY_SETTINGS_TOPIC, - (payload: PayloadData, type: Payload) => { - setLoading(false); - if (type !== Payload.KeyValues) throw new Error('Invalid payload'); - const obj = keyValues(payload); - reset({ - isMirrored: obj['is_mirrored'] === 'true', - isVisible: obj['is_visible'] === 'true', - }); - } - ); - - useEffect(() => { - const message = new MessageT(); - message.topic = OVERLAY_DISPLAY_SETTINGS_TOPIC; - message.topicType = Topic.TopicId; - message.payloadType = Payload.NONE; - publish(message); - }, []); - - useEffect(() => { - const subscription = watch(() => handleSubmit(onSubmit)()); - return () => subscription.unsubscribe(); - }, []); - - const onSubmit = (val: { isVisible: boolean; isMirrored: boolean }) => { - const message = new MessageT(); - - message.topic = OVERLAY_DISPLAY_SETTINGS_TOPIC; - message.topicType = Topic.TopicId; - - message.payloadType = Payload.KeyValues; - const keyValues = new KeyValuesT(); - keyValues.keys = ['is_mirrored', 'is_visible']; - keyValues.values = [`${val.isMirrored}`, `${val.isVisible}`]; - message.payload = keyValues; - - publish(message); - }; - - return !loading ? ( -
-
- {l10n.getString('widget-overlay')} -
- - - - ) : ( - <> - ); -} diff --git a/gui/src/hooks/pubSub.ts b/gui/src/hooks/pubSub.ts deleted file mode 100644 index f9126ed10e..0000000000 --- a/gui/src/hooks/pubSub.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { useEffect, useState } from 'react'; -import { - KeyValuesT, - MessageT, - PubSubUnion, - SubscriptionRequestT, - Topic, - TopicHandleT, - TopicIdT, - TopicMappingT, -} from 'solarxr-protocol'; -import { useWebsocketAPI } from './websocket-api'; - -export type PayloadData = MessageT['payload']; -export type PayloadType = MessageT['payloadType']; - -export function usePubSub() { - const { sendPubSubPacket, usePubSubPacket } = useWebsocketAPI(); - const [handleTopics, setHandleTopics] = useState<{ [key: number]: TopicIdT }>({}); - - const subscribe = ( - topicId: TopicIdT, - callback: (data: PayloadData, type: PayloadType) => void - ): void => { - useEffect(() => { - const subRequest = new SubscriptionRequestT(); - subRequest.topicType = Topic.TopicId; - subRequest.topic = topicId; - sendPubSubPacket(PubSubUnion.SubscriptionRequest, subRequest); - }, [sendPubSubPacket, usePubSubPacket]); - - usePubSubPacket(PubSubUnion.Message, (message: MessageT) => { - if (message.topicType == Topic.TopicHandle) { - const messageHandleId = message.topic as TopicHandleT; - if (handleTopics[messageHandleId.id]) { - callback(message.payload, message.payloadType); - } - } - if (message.topicType == Topic.TopicId) { - const messageTopicId = message.topic as TopicIdT; - if ( - messageTopicId.appName === topicId.appName && - messageTopicId.organization === topicId.organization && - messageTopicId.topic === topicId.topic - ) { - callback(message.payload, message.payloadType); - } - } - }); - }; - - usePubSubPacket(PubSubUnion.TopicMapping, (data: TopicMappingT) => { - setHandleTopics((handleTopics) => ({ - ...handleTopics, - ...(data.handle?.id && data.id ? { [data.handle?.id]: data.id } : {}), - })); - }); - - const publish = (message: MessageT) => { - sendPubSubPacket(PubSubUnion.Message, message); - }; - - return { - subscribe, - publish, - keyValues: (payload: PayloadData): Record => { - const keyValuesPayload = payload as KeyValuesT; - return keyValuesPayload.keys.reduce( - (curr, _, index) => ({ - ...curr, - [keyValuesPayload.keys[index]]: keyValuesPayload.values[index], - }), - {} - ); - }, - }; -} - -export const topic = ({ appName, organization, topic }: Omit) => - Object.assign(new TopicIdT(), { appName, organization, topic }); - -export const OVERLAY_DISPLAY_SETTINGS_TOPIC = topic({ - appName: 'overlay', - organization: 'slimevr.dev', - topic: 'display_settings', -}); diff --git a/gui/src/hooks/websocket-api.ts b/gui/src/hooks/websocket-api.ts index 39fb832fcd..33cab72d82 100644 --- a/gui/src/hooks/websocket-api.ts +++ b/gui/src/hooks/websocket-api.ts @@ -5,8 +5,6 @@ import { DataFeedMessageHeaderT, MessageBundle, MessageBundleT, - PubSubHeaderT, - PubSubUnion, RpcMessage, RpcMessageHeaderT, } from 'solarxr-protocol'; @@ -24,22 +22,17 @@ export interface WebSocketApi { useDataFeedPacket: (type: DataFeedMessage, callback: (packet: T) => void) => void; sendRPCPacket: (type: RpcMessage, data: RPCPacketType) => void; sendDataFeedPacket: (type: DataFeedMessage, data: DataFeedPacketType) => void; - usePubSubPacket: (type: PubSubUnion, callback: (packet: T) => void) => void; - sendPubSubPacket: (type: PubSubUnion, data: PubSubPacketType) => void; } export const WebSocketApiContext = createContext(undefined as never); export type RPCPacketType = RpcMessageHeaderT['message']; -export type PubSubPacketType = PubSubHeaderT['u']; export type DataFeedPacketType = DataFeedMessageHeaderT['message']; -// export type OutboundPacketType = OutboundPacketT['packet']; export function useProvideWebsocketApi(): WebSocketApi { const rpcPacketCounterRef = useRef(0); const webSocketRef = useRef(null); const rpclistenerRef = useRef(new EventTarget()); - const pubsublistenerRef = useRef(new EventTarget()); const datafeedlistenerRef = useRef(new EventTarget()); const [isFirstConnection, setFirstConnection] = useState(true); const [timedOut, setTimedOut] = useState(false); @@ -91,14 +84,6 @@ export function useProvideWebsocketApi(): WebSocketApi { }) ); }); - - message.pubSubMsgs.forEach((pubSubHeader) => { - pubsublistenerRef.current?.dispatchEvent( - new CustomEvent(PubSubUnion[pubSubHeader.uType], { - detail: pubSubHeader.u, - }) - ); - }); }; const sendRPCPacket = (type: RpcMessage, data: RPCPacketType): void => { @@ -138,22 +123,6 @@ export function useProvideWebsocketApi(): WebSocketApi { webSocketRef.current.send(fbb.asUint8Array()); }; - const sendPubSubPacket = (type: PubSubUnion, data: PubSubPacketType): void => { - if (webSocketRef?.current?.readyState !== WebSocket.OPEN) return; - const fbb = new Builder(1); - - const message = new MessageBundleT(); - - const pubSubHeader = new PubSubHeaderT(); - pubSubHeader.uType = type; - pubSubHeader.u = data; - - message.pubSubMsgs = [pubSubHeader]; - fbb.finish(message.pack(fbb)); - - webSocketRef.current.send(fbb.asUint8Array()); - }; - const connect = () => { webSocketRef.current = new WebSocket(`ws://${targetIp}:${targetPort}`); @@ -222,20 +191,8 @@ export function useProvideWebsocketApi(): WebSocketApi { }; }, [callback, type]); }, - usePubSubPacket: (type: PubSubUnion, callback: (packet: T) => void) => { - useEffect(() => { - const onEvent = (event: CustomEventInit) => { - callback(event.detail); - }; - pubsublistenerRef.current.addEventListener(PubSubUnion[type], onEvent); - return () => { - pubsublistenerRef.current.removeEventListener(PubSubUnion[type], onEvent); - }; - }, [callback, type]); - }, sendRPCPacket, sendDataFeedPacket, - sendPubSubPacket, }; } From d256506a1a77f9e8b143ad10ac3bb208e890dc3a Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 23 Jun 2026 23:22:22 -0400 Subject: [PATCH 207/350] ArmsResetMode --- server/core/src/main/java/dev/slimevr/config/resets.kt | 3 ++- server/core/src/main/java/dev/slimevr/solarxr/resets.kt | 5 +++-- solarxr-protocol | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/config/resets.kt b/server/core/src/main/java/dev/slimevr/config/resets.kt index 0459b94d22..0dc63edb5c 100644 --- a/server/core/src/main/java/dev/slimevr/config/resets.kt +++ b/server/core/src/main/java/dev/slimevr/config/resets.kt @@ -2,6 +2,7 @@ package dev.slimevr.config import kotlinx.serialization.Serializable import solarxr_protocol.rpc.ArmsMountingResetMode +import solarxr_protocol.rpc.ArmsResetMode enum class MountingMethods(val id: Int) { MANUAL(0), @@ -25,7 +26,7 @@ data class ResetsConfig( /** Always reset mounting for feet */ val resetMountingFeet: Boolean = false, /** Reset mode used for the arms */ - val armsResetMode: ArmsMountingResetMode = ArmsMountingResetMode.BACK, + val armsResetMode: ArmsResetMode = ArmsResetMode.BACK, /** Yaw reset smoothing time in seconds */ val yawResetSmoothTime: Float = 0.0f, /** Save automatic mounting reset calibration */ diff --git a/server/core/src/main/java/dev/slimevr/solarxr/resets.kt b/server/core/src/main/java/dev/slimevr/solarxr/resets.kt index 6fb3c9b50e..2e4c313b1e 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/resets.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/resets.kt @@ -15,11 +15,12 @@ class ResetsBehaviour( receiver.sendRpc(buildResetsSettings(resetsManager.context.state.value.config)) } + val oldConfig = resetsManager.context.state.value.config receiver.rpcDispatcher.on { req -> resetsManager.context.dispatch(ResetsActions.UpdateConfig(ResetsConfig( resetMountingFeet = req.resetMountingFeet == true, - armsResetMode = req.armsMountingResetMode ?: error("armsMountingResetMode should be set"), - yawResetSmoothTime = req.yawResetSmoothTime ?: error("yawResetSmoothTime should be set"), + armsResetMode = req.armsResetMode ?: oldConfig.armsResetMode, + yawResetSmoothTime = req.yawResetSmoothTime ?: oldConfig.yawResetSmoothTime, saveMountingReset = req.saveMountingReset == true, resetHmdPitch = req.resetHmdPitch == true, ))) diff --git a/solarxr-protocol b/solarxr-protocol index 5786fd494b..17da40a966 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 5786fd494b35b402bf0e8d6b2f8d107e0d3533d1 +Subproject commit 17da40a96612e788f9b10b001ad97ac0495cd828 From 33d54b2609cfe8d69a6e2e872c099f7b29bf46f7 Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 23 Jun 2026 23:25:59 -0400 Subject: [PATCH 208/350] Fall back to config for solarxr null fields --- .../java/dev/slimevr/solarxr/tapdetection.kt | 21 ++++++++++--------- .../src/main/java/dev/slimevr/solarxr/vmc.kt | 7 ++++--- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt index ee10de04f2..5bb36999b4 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt @@ -21,21 +21,22 @@ class TapDetectionBehaviour( println(req.setupMode) // TODO } + val oldConfig = settings.context.state.value.data.tapDetectionConfig receiver.rpcDispatcher.on { req -> settings.context.dispatch(SettingsActions.Update { copy(tapDetectionConfig = TapDetectionConfig( - yawResetDelay = req.yawResetDelay ?: error("yawResetDelay should be set"), - fullResetDelay = req.fullResetDelay ?: error("fullResetDelay should be set"), - mountingResetDelay = req.mountingResetDelay ?: error("mountingResetDelay should be set"), + yawResetDelay = req.yawResetDelay ?: oldConfig.yawResetDelay, + fullResetDelay = req.fullResetDelay ?: oldConfig.fullResetDelay, + mountingResetDelay = req.mountingResetDelay ?: oldConfig.mountingResetDelay, yawResetEnabled = req.yawResetEnabled == true, fullResetEnabled = req.fullResetEnabled == true, mountingResetEnabled = req.mountingResetEnabled == true, - yawResetTaps = req.yawResetTaps?.toInt() ?: error("yawResetTaps should be set"), - fullResetTaps = req.fullResetTaps?.toInt() ?: error("fullResetTaps should be set"), - mountingResetTaps = req.mountingResetTaps?.toInt() ?: error("mountingResetTaps should be set"), - yawResetBodyPart = req.yawResetTracker ?: error("yawResetTracker should be set"), - fullResetBodyPart = req.fullResetTracker ?: error("fullResetTracker should be set"), - mountingResetBodyPart = req.mountingResetTracker ?: error("mountingResetTracker should be set"), - numberTrackersOverThreshold = req.numberTrackersOverThreshold?.toInt() ?: error("numberTrackersOverThreshold should be set"), + yawResetTaps = req.yawResetTaps?.toInt() ?: oldConfig.yawResetTaps, + fullResetTaps = req.fullResetTaps?.toInt() ?: oldConfig.fullResetTaps, + mountingResetTaps = req.mountingResetTaps?.toInt() ?: oldConfig.mountingResetTaps, + yawResetBodyPart = req.yawResetTracker ?: oldConfig.yawResetBodyPart, + fullResetBodyPart = req.fullResetTracker ?: oldConfig.fullResetBodyPart, + mountingResetBodyPart = req.mountingResetTracker ?: oldConfig.mountingResetBodyPart, + numberTrackersOverThreshold = req.numberTrackersOverThreshold?.toInt() ?: oldConfig.numberTrackersOverThreshold, ))}) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt index 0176b67751..03bba277de 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt @@ -15,14 +15,15 @@ class VmcBehaviour( receiver.sendRpc(buildVmcOscSettings(vmcManager.context.state.value.config)) } + val oldConfig = vmcManager.context.state.value.config receiver.rpcDispatcher.on { req -> vmcManager.context.dispatch( VMCActions.UpdateConfig( VMCConfig( enabled = req.enabled == true, - portIn = req.portIn?.toInt() ?: error("portIn should be set"), - portOut = req.portOut?.toInt() ?: error("portOut should be set"), - address = req.address ?: error("address should be set"), + portIn = req.portIn?.toInt() ?: oldConfig.portIn, + portOut = req.portOut?.toInt() ?: oldConfig.portOut, + address = req.address ?: oldConfig.address, mirrorTracking = req.mirrorTracking == true, anchorAtHips = req.anchorHip == true, ), From 9961cb41149a1295fc1741b000e196a46fa9b60d Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 1 Jul 2026 20:12:32 -0400 Subject: [PATCH 209/350] Migrating the GUI for solarxr rewrite 2nd pass --- gui/public/i18n/en/translation.ftl | 3 +- gui/src/components/MainLayout.tsx | 5 - gui/src/components/Sidebar.tsx | 12 +- gui/src/components/Toolbar.tsx | 12 +- .../steps/FlashingMethodStep.tsx | 8 +- .../firmware-tool/steps/FlashingStep.tsx | 6 +- .../firmware-update/FirmwareUpdate.tsx | 14 +- gui/src/components/home/Home.tsx | 12 +- gui/src/components/home/ResetButton.tsx | 10 +- .../onboarding/pages/ResetTutorial.tsx | 19 +- .../body-proportions/ManualProportions.tsx | 39 +- .../body-proportions/ScaledProportions.tsx | 41 +- .../autobone-steps/Preparation.tsx | 2 +- .../mounting/mounting-steps/MountingReset.tsx | 2 +- .../mounting/mounting-steps/Preparation.tsx | 2 +- .../pages/quiz/MocapPreferencesQuestions.tsx | 4 +- .../stay-aligned-steps/PreparationStep.tsx | 2 +- .../stay-aligned-steps/VerifyMounting.tsx | 2 +- .../trackers-assign/TrackerAssignment.tsx | 54 +- .../settings/pages/GeneralSettings.tsx | 1415 +---------------- .../settings/pages/HomeScreenSettings.tsx | 2 +- .../components/settings/pages/VMCSettings.tsx | 173 +- .../settings/pages/VRCOSCSettings.tsx | 130 +- .../settings/pages/components/HIDSettings.tsx | 108 ++ .../components}/HandsWarningModal.tsx | 0 .../MagnetometerToggleSetting.tsx | 35 +- .../pages/components/OutputSettings.tsx | 388 +++++ .../pages/components/ResetsSettings.tsx | 240 +++ .../pages/components/SkeletonSettings.tsx | 520 ++++++ .../pages/components/StayAlignedSettings.tsx | 386 +++-- .../pages/components/TapDetectionSettings.tsx | 382 +++++ .../components/tracker/TrackerSettings.tsx | 5 +- gui/src/components/tracker/TrackersTable.tsx | 7 +- .../tracking-checklist/TrackingChecklist.tsx | 52 +- .../widgets/IMUVisualizerWidget.tsx | 5 +- gui/src/hooks/firmware-tool.ts | 4 +- gui/src/hooks/height.ts | 26 +- gui/src/hooks/manual-proportions.ts | 43 +- gui/src/hooks/onboarding.ts | 71 +- gui/src/hooks/osc-setting-validator.ts | 8 +- gui/src/hooks/reset-settings.ts | 29 +- gui/src/hooks/reset.ts | 28 +- gui/src/hooks/tracking-checklist.ts | 3 +- gui/src/sounds/sounds.ts | 6 +- gui/src/store/app-store.ts | 4 +- gui/src/utils/sentry.ts | 12 +- gui/vite.config.ts | 1 - .../main/java/dev/slimevr/config/resets.kt | 1 - .../main/java/dev/slimevr/solarxr/resets.kt | 2 +- .../java/dev/slimevr/solarxr/tapdetection.kt | 2 +- 50 files changed, 2276 insertions(+), 2061 deletions(-) create mode 100644 gui/src/components/settings/pages/components/HIDSettings.tsx rename gui/src/components/settings/{ => pages/components}/HandsWarningModal.tsx (100%) rename gui/src/components/settings/pages/{ => components}/MagnetometerToggleSetting.tsx (84%) create mode 100644 gui/src/components/settings/pages/components/OutputSettings.tsx create mode 100644 gui/src/components/settings/pages/components/ResetsSettings.tsx create mode 100644 gui/src/components/settings/pages/components/SkeletonSettings.tsx create mode 100644 gui/src/components/settings/pages/components/TapDetectionSettings.tsx diff --git a/gui/public/i18n/en/translation.ftl b/gui/public/i18n/en/translation.ftl index 2fef026c66..2c163afda9 100644 --- a/gui/public/i18n/en/translation.ftl +++ b/gui/public/i18n/en/translation.ftl @@ -518,7 +518,8 @@ settings-general-steamvr-trackers-right_hand = Right hand settings-general-steamvr-trackers-tracker_toggling = Automatic tracker assignment settings-general-output-trackers-tracker_toggling-description = Automatically handles toggling output trackers on or off depending on your current tracker assignments. settings-general-steamvr-trackers-tracker_toggling-label = Automatic tracker assignment -settings-general-output-trackers-hands-warning = Warning: Enabling the hand trackers will disable inputs from real controllers in SteamVR. +settings-general-steamvr-trackers-hands-warning = Warning: Enabling the SteamVR hand trackers will disable inputs from real controllers. + This should only be enabled if you are using SlimeVR for hand tracking. Are you sure you want to do this? settings-general-steamvr-trackers-hands-warning-cancel = Cancel diff --git a/gui/src/components/MainLayout.tsx b/gui/src/components/MainLayout.tsx index c3ca4dde08..70696eedfa 100644 --- a/gui/src/components/MainLayout.tsx +++ b/gui/src/components/MainLayout.tsx @@ -4,7 +4,6 @@ import { LegTweaksTmpChangeT, LegTweaksTmpClearT, RpcMessage, - SettingsRequestT, } from 'solarxr-protocol'; import { Navbar } from './Navbar'; import { TopBar } from './TopBar'; @@ -31,10 +30,6 @@ export function MainLayout({ const { sendRPCPacket } = useWebsocketAPI(); const [ProportionsLastPageOpen, setProportionsLastPageOpen] = useState(true); - useEffect(() => { - sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT()); - }, []); - function usePageChanged(callback: () => void) { useEffect(() => { callback(); diff --git a/gui/src/components/Sidebar.tsx b/gui/src/components/Sidebar.tsx index 97b45e08eb..7c4456d512 100644 --- a/gui/src/components/Sidebar.tsx +++ b/gui/src/components/Sidebar.tsx @@ -8,8 +8,8 @@ import { useLocaleConfig } from '@/i18n/config'; import { useWebsocketAPI } from '@/hooks/websocket-api'; import { RpcMessage, - SkeletonConfigRequestT, - SkeletonConfigResponseT, + SkeletonProportionsRequestT, + SkeletonProportionsResponseT, } from 'solarxr-protocol'; import { Tooltip } from './commons/Tooltip'; import { Vector3 } from 'three'; @@ -43,8 +43,8 @@ export function PreviewControls({ open }: { open: boolean }) { return { cmFormat }; }, [currentLocales]); useRPCPacket( - RpcMessage.SkeletonConfigResponse, - (data: SkeletonConfigResponseT) => { + RpcMessage.SkeletonProportionsResponse, + (data: SkeletonProportionsResponseT) => { if (data.userHeight) setUserHeight(cmFormat.format((data.userHeight * 100) / 0.936)); } @@ -52,8 +52,8 @@ export function PreviewControls({ open }: { open: boolean }) { useEffect(() => { sendRPCPacket( - RpcMessage.SkeletonConfigRequest, - new SkeletonConfigRequestT() + RpcMessage.SkeletonProportionsRequest, + new SkeletonProportionsRequestT() ); }, []); diff --git a/gui/src/components/Toolbar.tsx b/gui/src/components/Toolbar.tsx index d643dd1f30..9a5c8813d0 100644 --- a/gui/src/components/Toolbar.tsx +++ b/gui/src/components/Toolbar.tsx @@ -66,7 +66,7 @@ function BasicResetButton(options: UseResetOptions & { customName?: string }) { const name = options.customName || resetName; const skiReset = - options.type === ResetType.Mounting && options.group === 'default'; + options.type === ResetType.MOUNTING && options.group === 'default'; return (
- - + +
@@ -191,18 +191,18 @@ export function Toolbar() { }} > {groupVisibility['fingers'] && ( diff --git a/gui/src/components/firmware-tool/steps/FlashingMethodStep.tsx b/gui/src/components/firmware-tool/steps/FlashingMethodStep.tsx index b276d2a964..014e7a5cd4 100644 --- a/gui/src/components/firmware-tool/steps/FlashingMethodStep.tsx +++ b/gui/src/components/firmware-tool/steps/FlashingMethodStep.tsx @@ -259,7 +259,7 @@ function OTADevicesList({ flashingMethod: FirmwareUpdateMethod.OTAFirmwareUpdate.toString(), ota: { selectedDevices: devices.reduce( - (curr, { id }) => ({ ...curr, [id?.id ?? 0]: false }), + (curr, { id }) => ({ ...curr, [id]: false }), {} ), }, @@ -276,7 +276,7 @@ function OTADevicesList({ if (!selectedDevices[id]) return curr; const deviceId = id.substring('id-'.length); const device = devices.find( - ({ id: dId }) => deviceId === dId?.id.toString() + ({ id: dId }) => deviceId === dId.toString() ); if (!device) return curr; return [ @@ -312,8 +312,8 @@ function OTADevicesList({ {devices.map((device) => ( diff --git a/gui/src/components/firmware-tool/steps/FlashingStep.tsx b/gui/src/components/firmware-tool/steps/FlashingStep.tsx index 1a19f517bf..52fe706a64 100644 --- a/gui/src/components/firmware-tool/steps/FlashingStep.tsx +++ b/gui/src/components/firmware-tool/steps/FlashingStep.tsx @@ -9,7 +9,7 @@ import { import { useEffect, useMemo, useState } from 'react'; import { useWebsocketAPI } from '@/hooks/websocket-api'; import { - DeviceIdTableT, + FirmwareDeviceIdTableT, FirmwareUpdateMethod, FirmwareUpdateStatus, FirmwareUpdateStatusResponseT, @@ -87,8 +87,8 @@ export function FlashingStep({ (data: FirmwareUpdateStatusResponseT) => { if (!data.deviceId) throw new Error('no device id'); const id = - data.deviceId instanceof DeviceIdTableT - ? data.deviceId.id?.id + data.deviceId instanceof FirmwareDeviceIdTableT + ? data.deviceId.id : data.deviceId.port; if (!id) throw new Error('invalid device id'); diff --git a/gui/src/components/firmware-update/FirmwareUpdate.tsx b/gui/src/components/firmware-update/FirmwareUpdate.tsx index a61346f475..503f2cf291 100644 --- a/gui/src/components/firmware-update/FirmwareUpdate.tsx +++ b/gui/src/components/firmware-update/FirmwareUpdate.tsx @@ -5,7 +5,7 @@ import { ComponentProps, useEffect, useMemo, useRef, useState } from 'react'; import { BoardType, DeviceDataT, - DeviceIdTableT, + FirmwareDeviceIdTableT, FirmwareUpdateMethod, FirmwareUpdateStatus, FirmwareUpdateStatusResponseT, @@ -67,7 +67,7 @@ const DeviceList = ({ )); @@ -82,7 +82,7 @@ const StatusList = ({ status }: { status: Record }) => { if (!val) throw new Error('there should always be a val'); - const device = devices.find(({ id: dId }) => id === dId?.id.toString()); + const device = devices.find(({ id: dId }) => id === dId.toString()); return ( { if (!data.deviceId) throw new Error('no device id'); const id = - data.deviceId instanceof DeviceIdTableT - ? data.deviceId.id?.id + data.deviceId instanceof FirmwareDeviceIdTableT + ? data.deviceId.id : data.deviceId.port; if (!id) throw new Error('invalid device id'); @@ -161,7 +161,7 @@ export function FirmwareUpdate() { mode: 'onChange', defaultValues: { selectedDevices: devices.reduce( - (curr, { id }) => ({ ...curr, [id?.id ?? 0]: false }), + (curr, { id }) => ({ ...curr, [id]: false }), {} ), }, @@ -298,7 +298,7 @@ export function FirmwareUpdate() { const selectedDevices = Object.keys(selectedDevicesForm).reduce( (curr, id) => { if (!selectedDevicesForm[id]) return curr; - const device = devices.find(({ id: dId }) => id === dId?.id.toString()); + const device = devices.find(({ id: dId }) => id === dId?.toString()); if (!device) return curr; return [ ...curr, diff --git a/gui/src/components/home/Home.tsx b/gui/src/components/home/Home.tsx index 1243811b87..4b28f5cdd7 100644 --- a/gui/src/components/home/Home.tsx +++ b/gui/src/components/home/Home.tsx @@ -26,9 +26,7 @@ export function Home() { const navigate = useNavigate(); const sendToSettings = (tracker: TrackerDataT) => { - navigate( - `/tracker/${tracker.trackerId?.trackerNum}/${tracker.trackerId?.deviceId?.id}` - ); + navigate(`/tracker/${tracker.trackerId}/${tracker.deviceId}`); }; const settingsOpenState = useState(false); @@ -85,9 +83,7 @@ export function Home() { interactable warning={ !!highlightedTrackers?.trackers.find( - (t) => - t?.deviceId?.id === tracker.trackerId?.deviceId?.id && - t?.trackerNum === tracker.trackerId?.trackerNum + (t) => t === tracker.trackerId ) && highlightedTrackers.step } /> @@ -127,9 +123,7 @@ export function Home() { interactable warning={ !!highlightedTrackers?.trackers.find( - (t) => - t?.deviceId?.id === tracker.trackerId?.deviceId?.id && - t?.trackerNum === tracker.trackerId?.trackerNum + (t) => t === tracker.trackerId ) && highlightedTrackers.step } /> diff --git a/gui/src/components/home/ResetButton.tsx b/gui/src/components/home/ResetButton.tsx index 18da3c5c5f..ebe4018d64 100644 --- a/gui/src/components/home/ResetButton.tsx +++ b/gui/src/components/home/ResetButton.tsx @@ -15,12 +15,12 @@ import { Tooltip } from '@/components/commons/Tooltip'; import { Typography } from '@/components/commons/Typography'; export function ResetButtonIcon(options: UseResetOptions) { - if (options.type === ResetType.Mounting && !options.group) + if (options.type === ResetType.MOUNTING && !options.group) options.group = 'default'; - if (options.type === ResetType.Yaw) return ; - if (options.type === ResetType.Full) return ; - if (options.type === ResetType.Mounting) { + if (options.type === ResetType.YAW) return ; + if (options.type === ResetType.FULL) return ; + if (options.type === ResetType.MOUNTING) { if (options.group === 'default') return ; if (options.group === 'feet') return ; if (options.group === 'fingers') return ; @@ -83,7 +83,7 @@ export function ResetButton({
{children || }
- {status !== 'counting' || options.type === ResetType.Yaw + {status !== 'counting' || options.type === ResetType.YAW ? children || : String(timer)}
diff --git a/gui/src/components/onboarding/pages/ResetTutorial.tsx b/gui/src/components/onboarding/pages/ResetTutorial.tsx index 4b2f867098..ddc035e9e3 100644 --- a/gui/src/components/onboarding/pages/ResetTutorial.tsx +++ b/gui/src/components/onboarding/pages/ResetTutorial.tsx @@ -9,8 +9,8 @@ import { ResetStatus, ResetType, RpcMessage, - SettingsRequestT, - SettingsResponseT, + TapDetectionSettingsRequestT, + TapDetectionSettingsResponseT, } from 'solarxr-protocol'; import { BodyDisplay } from '@/components/commons/BodyDisplay'; import { useWebsocketAPI } from '@/hooks/websocket-api'; @@ -83,12 +83,15 @@ export function ResetTutorialPage() { ); useEffect(() => { - sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT()); + sendRPCPacket( + RpcMessage.TapDetectionSettingsRequest, + new TapDetectionSettingsRequestT() + ); }, []); useRPCPacket( - RpcMessage.SettingsResponse, - ({ tapDetectionSettings }: SettingsResponseT) => { + RpcMessage.TapDetectionSettingsResponse, + (tapDetectionSettings: TapDetectionSettingsResponseT) => { if ( !tapDetectionSettings || !tapDetectionSettings.yawResetTaps || @@ -200,7 +203,7 @@ export const RIGHT_LEG_PARTS = [ ]; export const RESET_TYPE_ORDER = [ - ResetType.Yaw, - ResetType.Full, - ResetType.Mounting, + ResetType.YAW, + ResetType.FULL, + ResetType.MOUNTING, ]; diff --git a/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx b/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx index a80b2a6373..2d5c4d4b0c 100644 --- a/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx +++ b/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx @@ -1,12 +1,12 @@ import { Control, Controller, useForm } from 'react-hook-form'; import { - ChangeSkeletonConfigRequestT, + ChangeSkeletonProportionsRequestT, ResetType, RpcMessage, SkeletonBone, - SkeletonConfigRequestT, - SkeletonConfigResponseT, - SkeletonResetAllRequestT, + SkeletonProportionsRequestT, + SkeletonProportionsResetAllRequestT, + SkeletonProportionsResponseT, } from 'solarxr-protocol'; import { useOnboarding } from '@/hooks/onboarding'; import { useWebsocketAPI } from '@/hooks/websocket-api'; @@ -85,7 +85,7 @@ function IconButton({ function parseConfigImport( config: SkeletonConfigExport -): ChangeSkeletonConfigRequestT[] { +): ChangeSkeletonProportionsRequestT[] { if (!config.version) config.version = 1; if (config.version < 1) { // Add config migration stuff here, this one is just an example. @@ -95,7 +95,7 @@ function parseConfigImport( const bone = typeof part.bone === 'string' ? SkeletonBone[part.bone] : part.bone; - return new ChangeSkeletonConfigRequestT(bone, part.value); + return new ChangeSkeletonProportionsRequestT(bone, part.value); }); } @@ -121,7 +121,7 @@ function ImportExportButtons() { const exporting = useRef(false); useRPCPacket( - RpcMessage.SkeletonConfigResponse, + RpcMessage.SkeletonProportionsResponse, (data: SkeletonConfigExport) => { if (!exporting.current) return; exporting.current = false; @@ -183,11 +183,11 @@ function ImportExportButtons() { } parseConfigImport(config).forEach((req) => - sendRPCPacket(RpcMessage.ChangeSkeletonConfigRequest, req) + sendRPCPacket(RpcMessage.ChangeSkeletonProportionsRequest, req) ); sendRPCPacket( - RpcMessage.SkeletonConfigRequest, - new SkeletonConfigRequestT() + RpcMessage.SkeletonProportionsRequest, + new SkeletonProportionsRequestT() ); setImportState(ImportStatus.SUCCESS); setTimeout(() => { @@ -219,8 +219,8 @@ function ImportExportButtons() { exporting.current = true; sendRPCPacket( - RpcMessage.SkeletonConfigRequest, - new SkeletonConfigRequestT() + RpcMessage.SkeletonProportionsRequest, + new SkeletonProportionsRequestT() ); }} > @@ -311,16 +311,17 @@ function ButtonsControl({ control }: { control: ManualProportionControls }) { const [showWarning, setShowWarning] = useState(false); const resetAll = () => { sendRPCPacket( - RpcMessage.SkeletonResetAllRequest, - new SkeletonResetAllRequestT() + RpcMessage.SkeletonProportionsResetAllRequest, + new SkeletonProportionsResetAllRequestT() ); }; const beneathFloor = useMemo(() => { + // TODO I think this needs to be rewritten with rewrite but it also just may be unneeded const hmd = computedTrackers.find( (tracker) => - tracker.tracker.trackerId?.trackerNum === 1 && - tracker.tracker.trackerId.deviceId?.id === undefined + tracker.tracker.trackerId === 1 && + tracker.tracker.deviceId === undefined ); return !(hmd?.tracker.position && hmd.tracker.position.y >= MIN_HEIGHT); }, [computedTrackers]); @@ -418,8 +419,8 @@ export function ManualProportionsPage() { }, [ratio]); useRPCPacket( - RpcMessage.SkeletonConfigResponse, - (data: SkeletonConfigResponseT) => { + RpcMessage.SkeletonProportionsResponse, + (data: SkeletonProportionsResponseT) => { if (data.userHeight) setUserHeight(data.userHeight); } ); @@ -470,7 +471,7 @@ export function ManualProportionsPage() {
diff --git a/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx b/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx index 78ec3b1a1b..14771ea30f 100644 --- a/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx +++ b/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx @@ -7,14 +7,13 @@ import { useWebsocketAPI } from '@/hooks/websocket-api'; import { useEffect, useState } from 'react'; import { CancelUserHeightCalibrationT, - ChangeSettingsRequestT, - ModelSettingsT, + ChangeSkeletonSettingsRequestT, ResetType, RpcMessage, - SkeletonConfigRequestT, - SkeletonConfigResponseT, SkeletonHeightT, - SkeletonResetAllRequestT, + SkeletonProportionsRequestT, + SkeletonProportionsResetAllRequestT, + SkeletonProportionsResponseT, StartUserHeightCalibrationT, UserHeightCalibrationStatus, UserHeightRecordingStatusResponseT, @@ -265,7 +264,7 @@ export function ScaledProportionsPage() { ); }; - // Makes it so you dont get spammed by sounds if multiple status complete at once + // Makes it so you don't get spammed by sounds if multiple status complete at once useDebouncedEffect( () => { if (!config || !config.feedbackSound) return; @@ -286,17 +285,15 @@ export function ScaledProportionsPage() { const applyHeight = (newHeight: number) => { setHmdHeight(newHeight); - const settingsRequest = new ChangeSettingsRequestT(); - settingsRequest.modelSettings = new ModelSettingsT( - null, - null, - null, - new SkeletonHeightT(newHeight, 0) + const skeletonSettingsRequest = new ChangeSkeletonSettingsRequestT(); + skeletonSettingsRequest.skeletonHeight = new SkeletonHeightT(newHeight, 0); + sendRPCPacket( + RpcMessage.ChangeSkeletonSettingsRequest, + skeletonSettingsRequest ); - sendRPCPacket(RpcMessage.ChangeSettingsRequest, settingsRequest); sendRPCPacket( - RpcMessage.SkeletonResetAllRequest, - new SkeletonResetAllRequestT() + RpcMessage.SkeletonProportionsResetAllRequest, + new SkeletonProportionsResetAllRequestT() ); setConfig({ lastUsedProportions: 'scaled' }); setLastUsed('manual'); @@ -320,8 +317,8 @@ export function ScaledProportionsPage() { ); useRPCPacket( - RpcMessage.SkeletonConfigResponse, - (res: SkeletonConfigResponseT) => { + RpcMessage.SkeletonProportionsResponse, + (res: SkeletonProportionsResponseT) => { setHmdHeight(res.userHeight); } ); @@ -336,8 +333,8 @@ export function ScaledProportionsPage() { useEffect(() => { sendRPCPacket( - RpcMessage.SkeletonConfigRequest, - new SkeletonConfigRequestT() + RpcMessage.SkeletonProportionsRequest, + new SkeletonProportionsRequestT() ); return () => { @@ -356,8 +353,8 @@ export function ScaledProportionsPage() { if (status && checkNotAuto(status.status)) { setAuto(false); sendRPCPacket( - RpcMessage.SkeletonConfigRequest, - new SkeletonConfigRequestT() + RpcMessage.SkeletonProportionsRequest, + new SkeletonProportionsRequestT() ); // Re ask the user height so it resets back to the correct value } }, @@ -469,7 +466,7 @@ export function ScaledProportionsPage() { state={{ alonePage: state.alonePage }} /> diff --git a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx b/gui/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx index 7dd87b6ae2..45216dc52d 100644 --- a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx +++ b/gui/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx @@ -69,7 +69,7 @@ export function PreparationStep({ > {l10n.getString('onboarding-automatic_mounting-prev_step')} - +
diff --git a/gui/src/components/onboarding/pages/mounting/mounting-steps/MountingReset.tsx b/gui/src/components/onboarding/pages/mounting/mounting-steps/MountingReset.tsx index 1943d757e9..b60ebe487e 100644 --- a/gui/src/components/onboarding/pages/mounting/mounting-steps/MountingReset.tsx +++ b/gui/src/components/onboarding/pages/mounting/mounting-steps/MountingReset.tsx @@ -58,7 +58,7 @@ export function MountingResetStep({ {l10n.getString('onboarding-automatic_mounting-prev_step')} diff --git a/gui/src/components/onboarding/pages/mounting/mounting-steps/Preparation.tsx b/gui/src/components/onboarding/pages/mounting/mounting-steps/Preparation.tsx index bc9ca3b7c9..3eb02aeeae 100644 --- a/gui/src/components/onboarding/pages/mounting/mounting-steps/Preparation.tsx +++ b/gui/src/components/onboarding/pages/mounting/mounting-steps/Preparation.tsx @@ -69,7 +69,7 @@ export function PreparationStep({ > {l10n.getString('onboarding-automatic_mounting-prev_step')} - +
diff --git a/gui/src/components/onboarding/pages/quiz/MocapPreferencesQuestions.tsx b/gui/src/components/onboarding/pages/quiz/MocapPreferencesQuestions.tsx index 5f24842339..520a38d31b 100644 --- a/gui/src/components/onboarding/pages/quiz/MocapPreferencesQuestions.tsx +++ b/gui/src/components/onboarding/pages/quiz/MocapPreferencesQuestions.tsx @@ -5,7 +5,7 @@ import { HumanIcon } from '@/components/commons/icon/HumanIcon'; import { HeadsetIcon } from '@/components/commons/icon/HeadsetIcon'; import { useState } from 'react'; import { SittingIcon } from '@/components/commons/icon/SittingIcon'; -import { VMCFileUpload } from '@/components/settings/pages/VMCSettings'; +import { VRMFileUpload } from '@/components/settings/pages/VMCSettings'; import { QuizButton } from './SlimeSetQuestion'; import { useNavigate } from 'react-router-dom'; @@ -81,7 +81,7 @@ export function QuizMocapPosQuestion() { />
- +
diff --git a/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx b/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx index f1b3286340..7cccc3bbff 100644 --- a/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx +++ b/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx @@ -55,7 +55,7 @@ export function PreparationStep({ { if (isActive) { nextStep(); diff --git a/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/VerifyMounting.tsx b/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/VerifyMounting.tsx index 6c5d72862f..7685c53ab8 100644 --- a/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/VerifyMounting.tsx +++ b/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/VerifyMounting.tsx @@ -78,7 +78,7 @@ export function VerifyMountingStep({ setDisableMounting(true)} - type={ResetType.Mounting} + type={ResetType.MOUNTING} group="default" onReseted={() => { if (isActive) { diff --git a/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx b/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx index 3c209bfa5e..79fcbc32b9 100644 --- a/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx +++ b/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx @@ -7,11 +7,7 @@ import { BodyPart, QuatT, RpcMessage, - TrackerIdT, - SettingsRequestT, - SettingsResponseT, - TapDetectionSettingsT, - ChangeSettingsRequestT, + TapDetectionSetupModeRequestT, TapDetectionSetupNotificationT, } from 'solarxr-protocol'; import { useChokerWarning } from '@/hooks/choker-warning'; @@ -46,7 +42,7 @@ export type BodyPartError = { interface FlatDeviceTrackerDummy { tracker: { - trackerId: TrackerIdT; + trackerId: number; info: undefined; }; } @@ -73,53 +69,19 @@ export function TrackersAssignPage() { setConfig({ mirrorView }); }, [mirrorView]); - const [tapDetectionSettings, setTapDetectionSettings] = useState | null>(null); - useEffect(() => { - sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT()); - }, []); - - useRPCPacket(RpcMessage.SettingsResponse, (settings: SettingsResponseT) => { - if (settings.tapDetectionSettings) { - setTapDetectionSettings(settings.tapDetectionSettings); - } - }); - - useEffect(() => { - if (!tapDetectionSettings) return; - const newTapSettings = new TapDetectionSettingsT( - tapDetectionSettings.fullResetDelay, - tapDetectionSettings.fullResetEnabled, - tapDetectionSettings.fullResetTaps, - tapDetectionSettings.yawResetDelay, - tapDetectionSettings.yawResetEnabled, - tapDetectionSettings.yawResetTaps, - tapDetectionSettings.mountingResetDelay, - tapDetectionSettings.mountingResetEnabled, - tapDetectionSettings.mountingResetTaps, - true, - null, - tapDetectionSettings.yawResetTracker, - tapDetectionSettings.fullResetTracker, - tapDetectionSettings.mountingResetTracker - ); - sendRPCPacket( - RpcMessage.ChangeSettingsRequest, - new ChangeSettingsRequestT(null, null, null, null, null, newTapSettings) + RpcMessage.TapDetectionSetupModeRequest, + new TapDetectionSetupModeRequestT(true) ); return () => { - newTapSettings.setupMode = false; sendRPCPacket( - RpcMessage.ChangeSettingsRequest, - new ChangeSettingsRequestT(null, null, null, null, null, newTapSettings) + RpcMessage.TapDetectionSetupModeRequest, + new TapDetectionSetupModeRequestT(false) ); }; - }, [tapDetectionSettings]); + }); const trackerPartGrouped = useMemo( () => @@ -195,7 +157,7 @@ export function TrackersAssignPage() { const assign = ( role: BodyPart, rotation: QuatT | null, - trackerId: TrackerIdT | null + trackerId: number ) => { const assignreq = new AssignTrackerRequestT(); diff --git a/gui/src/components/settings/pages/GeneralSettings.tsx b/gui/src/components/settings/pages/GeneralSettings.tsx index 981bc84f4f..d80462688d 100644 --- a/gui/src/components/settings/pages/GeneralSettings.tsx +++ b/gui/src/components/settings/pages/GeneralSettings.tsx @@ -1,1410 +1,21 @@ -import { Localized, useLocalization } from '@fluent/react'; -import { useEffect, useRef, useState } from 'react'; -import { DefaultValues, useForm } from 'react-hook-form'; -import { - ChangeSettingsRequestT, - FilteringSettingsT, - FilteringType, - LegTweaksSettingsT, - ModelRatiosT, - ModelSettingsT, - ModelTogglesT, - RpcMessage, - SettingsRequestT, - SettingsResponseT, - TapDetectionSettingsT, - HIDSettingsT, - VelocitySettingsT, - BodyPart, - OutputTrackersSettingT, -} from 'solarxr-protocol'; -import { useConfig } from '@/hooks/config'; -import { useWebsocketAPI } from '@/hooks/websocket-api'; -import { useLocaleConfig } from '@/i18n/config'; -import { CheckBox } from '@/components/commons/Checkbox'; -import { WrenchIcon } from '@/components/commons/icon/WrenchIcon'; -import { NumberSelector } from '@/components/commons/NumberSelector'; -import { Radio } from '@/components/commons/Radio'; -import { Typography } from '@/components/commons/Typography'; -import { - SettingsPageLayout, - SettingsPagePaneLayout, -} from '@/components/settings/SettingsPageLayout'; -import { HandsWarningModal } from '@/components/settings/HandsWarningModal'; -import { MagnetometerToggleSetting } from './MagnetometerToggleSetting'; -import { - defaultStayAlignedSettings, - StayAlignedSettings, - StayAlignedSettingsForm, - serializeStayAlignedSettings, - deserializeStayAlignedSettings, -} from './components/StayAlignedSettings'; -import { - defaultResetSettings, - loadResetSettings, - ResetSettingsForm, -} from '@/hooks/reset-settings'; -import { atom, useAtomValue, useSetAtom } from 'jotai'; -import { isEqual } from '@react-hookz/deep-equal'; -import { selectAtom } from 'jotai/utils'; -import { Dropdown } from '@/components/commons/Dropdown'; -import { ASSIGNMENT_MODES } from '@/components/onboarding/BodyAssignment'; -import { OutputIcon } from '@/components/commons/icon/OutputIcon'; -import { TouchDoubleIcon } from '@/components/commons/icon/TouchDoubleIcon'; -import { MartialArtsIcon } from '@/components/commons/icon/MartialArtsIcon'; - -export type SettingsForm = { - trackers: { - automaticTrackerToggle: boolean; - waist: boolean; - chest: boolean; - leftFoot: boolean; - rightFoot: boolean; - leftKnee: boolean; - rightKnee: boolean; - leftElbow: boolean; - rightElbow: boolean; - leftHand: boolean; - rightHand: boolean; - }; - filtering: { - type: number; - amount: number; - }; - toggles: { - forceArmsFromHmd: boolean; - floorClip: boolean; - skatingCorrection: boolean; - toeSnap: boolean; - footPlant: boolean; - selfLocalization: boolean; - usePosition: boolean; - enforceConstraints: boolean; - correctConstraints: boolean; - }; - ratios: { - imputeSpineFromTopDown: number; - imputeSpineCurvature: number; - interpHipLegs: number; - interpKneeTrackerAnkle: number; - interpKneeAnkle: number; - }; - tapDetection: { - mountingResetEnabled: boolean; - yawResetEnabled: boolean; - fullResetEnabled: boolean; - yawResetDelay: number; - fullResetDelay: number; - mountingResetDelay: number; - yawResetTaps: number; - fullResetTaps: number; - mountingResetTaps: number; - numberTrackersOverThreshold: number; - yawResetTracker: string; - mountingResetTracker: string; - fullResetTracker: string; - }; - legTweaks: { - correctionStrength: number; - }; - resetsSettings: ResetSettingsForm; - stayAligned: StayAlignedSettingsForm; - hidSettings: { - trackersOverHID: boolean; - }; - velocitySettings: { - sendDerivedVelocity: boolean; - }; -}; - -const defaultValues: SettingsForm = { - trackers: { - automaticTrackerToggle: true, - waist: false, - chest: false, - leftFoot: false, - rightFoot: false, - leftElbow: false, - rightElbow: false, - leftHand: false, - rightHand: false, - leftKnee: false, - rightKnee: false, - }, - toggles: { - forceArmsFromHmd: false, - floorClip: false, - skatingCorrection: false, - toeSnap: false, - footPlant: true, - selfLocalization: false, - usePosition: true, - enforceConstraints: true, - correctConstraints: true, - }, - ratios: { - imputeSpineFromTopDown: 0.5, - imputeSpineCurvature: 0.5, - interpHipLegs: 0.25, - interpKneeTrackerAnkle: 0.85, - interpKneeAnkle: 0.2, - }, - filtering: { amount: 0.1, type: FilteringType.NONE }, - tapDetection: { - mountingResetEnabled: false, - yawResetEnabled: false, - fullResetEnabled: false, - yawResetDelay: 0.2, - fullResetDelay: 1.0, - mountingResetDelay: 1.0, - yawResetTaps: 2, - fullResetTaps: 3, - mountingResetTaps: 3, - numberTrackersOverThreshold: 1, - yawResetTracker: String(BodyPart.CHEST), - mountingResetTracker: String(BodyPart.RIGHT_UPPER_LEG), - fullResetTracker: String(BodyPart.LEFT_UPPER_LEG), - }, - legTweaks: { correctionStrength: 0.3 }, - resetsSettings: defaultResetSettings, - stayAligned: defaultStayAlignedSettings, - hidSettings: { trackersOverHID: false }, - velocitySettings: { sendDerivedVelocity: false }, -}; - -const settingsAtom = atom(new SettingsResponseT()); -const settingsValueAtom = selectAtom( - settingsAtom, - (settings) => settings, - isEqual -); +import { SettingsPageLayout } from '@/components/settings/SettingsPageLayout'; +import { OutputTrackersSettings } from './components/OutputSettings'; +import { StayAlignedSettings } from './components/StayAlignedSettings'; +import { ResetsSettings } from './components/ResetsSettings'; +import { HIDSettings } from './components/HIDSettings'; +import { SkeletonSettings } from './components/SkeletonSettings'; +import { TapDetectionSettings } from './components/TapDetectionSettings'; export function GeneralSettings() { - const setSettings = useSetAtom(settingsAtom); - const settings = useAtomValue(settingsValueAtom); - const { l10n } = useLocalization(); - const { config } = useConfig(); - const { currentLocales } = useLocaleConfig(); - - const bodyParts: { value: string; label: string }[] = Object.values(BodyPart) - .filter((v): v is BodyPart => typeof v === 'number') - .filter((v) => ASSIGNMENT_MODES['full-body'].includes(v as BodyPart)) - .map((value) => ({ - value: String(value), - label: l10n.getString(`body_part-${BodyPart[value]}`), - })); - const blockHandsWarning = useRef(false); - // If not null, warning will be shown, and showHandsWarning will - // hold which hands should be toggled ([leftHand, rightHand]) - const [showHandsWarning, setShowHandsWarning] = useState< - [boolean, boolean] | null - >(null); - - const percentageFormat = new Intl.NumberFormat(currentLocales, { - style: 'percent', - maximumFractionDigits: 0, - }); - const secondsFormat = new Intl.NumberFormat(currentLocales, { - style: 'unit', - unit: 'second', - unitDisplay: 'narrow', - maximumFractionDigits: 2, - }); - - const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); - const { reset, control, watch, handleSubmit, getValues, setValue } = - useForm({ - defaultValues, - mode: 'onChange', - reValidateMode: 'onChange', - }); - - const { - trackers: { automaticTrackerToggle }, - } = watch(); - - const onSubmit = (values: SettingsForm) => { - const settingsReq = new ChangeSettingsRequestT(); - - if (values.trackers) { - const outputTrackers = new OutputTrackersSettingT(); - - // The tracker is at the tail of the bone. - // Example, the waist tracker is at the tail of the hip bone so we use the hip BodyPart - const enabledBodyParts: [boolean, BodyPart][] = [ - [values.trackers.waist, BodyPart.HIP], - [values.trackers.chest, BodyPart.CHEST], - [values.trackers.leftFoot, BodyPart.LEFT_FOOT], - [values.trackers.rightFoot, BodyPart.RIGHT_FOOT], - [values.trackers.leftKnee, BodyPart.LEFT_UPPER_LEG], - [values.trackers.rightKnee, BodyPart.RIGHT_UPPER_LEG], - [values.trackers.leftElbow, BodyPart.LEFT_UPPER_ARM], - [values.trackers.rightElbow, BodyPart.RIGHT_UPPER_ARM], - [values.trackers.leftHand, BodyPart.LEFT_HAND], - [values.trackers.rightHand, BodyPart.RIGHT_HAND], - ]; - outputTrackers.trackers = enabledBodyParts - .filter(([enabled]) => enabled) - .map(([, part]) => part); - - const leftHandEnabled = outputTrackers.trackers.includes( - BodyPart.LEFT_HAND - ); - const rightHandEnabled = outputTrackers.trackers.includes( - BodyPart.RIGHT_HAND - ); - if ( - !blockHandsWarning.current && - !showHandsWarning && - !settings?.outputTrackers?.trackers.includes(BodyPart.LEFT_HAND) && - !settings?.outputTrackers?.trackers.includes(BodyPart.RIGHT_HAND) && - (leftHandEnabled || rightHandEnabled) - ) { - // We have just toggled on one of the hand trackers, show the user a warning - setShowHandsWarning([leftHandEnabled, rightHandEnabled]); - outputTrackers.trackers = outputTrackers.trackers.filter( - (tracker) => - tracker != BodyPart.LEFT_HAND && tracker != BodyPart.RIGHT_HAND - ); - } else if ( - blockHandsWarning.current && - !outputTrackers.trackers.includes(BodyPart.LEFT_HAND) && - !outputTrackers.trackers.includes(BodyPart.RIGHT_HAND) - ) { - // Both hand trackers have just been disabled, make sure the warning shows up - // again next time the user toggles one back on - blockHandsWarning.current = false; - } - - outputTrackers.automaticTrackerToggle = - values.trackers.automaticTrackerToggle; - settingsReq.outputTrackers = outputTrackers; - } - - const modelSettings = new ModelSettingsT(); - - if (values.toggles) { - const toggles = new ModelTogglesT(); - toggles.floorClip = values.toggles.floorClip; - toggles.skatingCorrection = values.toggles.skatingCorrection; - toggles.forceArmsFromHmd = values.toggles.forceArmsFromHmd; - toggles.toeSnap = values.toggles.toeSnap; - toggles.footPlant = values.toggles.footPlant; - toggles.selfLocalization = values.toggles.selfLocalization; - toggles.usePosition = values.toggles.usePosition; - toggles.enforceConstraints = values.toggles.enforceConstraints; - toggles.correctConstraints = values.toggles.correctConstraints; - modelSettings.toggles = toggles; - } - - if (values.ratios) { - const ratios = new ModelRatiosT(); - ratios.imputeSpineFromTopDown = - values.ratios.imputeSpineFromTopDown || -1; - ratios.imputeSpineCurvature = values.ratios.imputeSpineCurvature || -1; - ratios.interpHipLegs = values.ratios.interpHipLegs || -1; - ratios.interpKneeTrackerAnkle = - values.ratios.interpKneeTrackerAnkle || -1; - ratios.interpKneeAnkle = values.ratios.interpKneeAnkle || -1; - modelSettings.ratios = ratios; - } - - if (values.legTweaks) { - const legTweaks = new LegTweaksSettingsT(); - legTweaks.correctionStrength = values.legTweaks.correctionStrength; - modelSettings.legTweaks = legTweaks; - } - - settingsReq.modelSettings = modelSettings; - - const tapDetection = new TapDetectionSettingsT(); - tapDetection.fullResetDelay = values.tapDetection.fullResetDelay; - tapDetection.fullResetEnabled = values.tapDetection.fullResetEnabled; - tapDetection.fullResetTaps = values.tapDetection.fullResetTaps; - tapDetection.yawResetDelay = values.tapDetection.yawResetDelay; - tapDetection.yawResetEnabled = values.tapDetection.yawResetEnabled; - tapDetection.yawResetTaps = values.tapDetection.yawResetTaps; - tapDetection.yawResetTracker = Number(values.tapDetection.yawResetTracker); - tapDetection.mountingResetTracker = Number( - values.tapDetection.mountingResetTracker - ); - tapDetection.fullResetTracker = Number( - values.tapDetection.fullResetTracker - ); - tapDetection.mountingResetEnabled = - values.tapDetection.mountingResetEnabled; - tapDetection.mountingResetDelay = values.tapDetection.mountingResetDelay; - tapDetection.mountingResetTaps = values.tapDetection.mountingResetTaps; - tapDetection.numberTrackersOverThreshold = - values.tapDetection.numberTrackersOverThreshold; - tapDetection.setupMode = false; - settingsReq.tapDetectionSettings = tapDetection; - - const filtering = new FilteringSettingsT(); - filtering.type = values.filtering.type; - filtering.amount = values.filtering.amount; - settingsReq.filtering = filtering; - - settingsReq.stayAligned = serializeStayAlignedSettings(values.stayAligned); - - const hidSettings = new HIDSettingsT(); - hidSettings.trackersOverHid = values.hidSettings.trackersOverHID; - settingsReq.hidSettings = hidSettings; - - const velocitySettings = new VelocitySettingsT(); - velocitySettings.sendDerivedVelocity = - values.velocitySettings.sendDerivedVelocity; - settingsReq.velocitySettings = velocitySettings; - - if (values.resetsSettings) { - settingsReq.resetsSettings = loadResetSettings(values.resetsSettings); - } - - sendRPCPacket(RpcMessage.ChangeSettingsRequest, settingsReq); - }; - - useEffect(() => { - const subscription = watch((value, { type }) => { - if (type === 'change') handleSubmit(onSubmit)(); - }); - return () => subscription.unsubscribe(); - }, []); - - useEffect(() => { - sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT()); - }, []); - - useEffect(() => { - const formData: DefaultValues = {}; - - if (settings.filtering) { - formData.filtering = settings.filtering; - } - - if (settings.outputTrackers) { - formData.trackers = settings.outputTrackers; - if ( - !blockHandsWarning.current && - (settings.outputTrackers.trackers.includes(BodyPart.LEFT_HAND) || - settings.outputTrackers.trackers.includes(BodyPart.RIGHT_HAND)) - ) { - blockHandsWarning.current = true; - } - } - - if (settings.modelSettings?.toggles) { - formData.toggles = Object.keys(settings.modelSettings.toggles).reduce( - (curr, key: string) => ({ - ...curr, - [key]: - (settings.modelSettings?.toggles && - (settings.modelSettings.toggles as any)[key]) || - false, - }), - {} - ); - } - - if (settings.modelSettings?.ratios) { - formData.ratios = Object.keys(settings.modelSettings.ratios).reduce( - (curr, key: string) => ({ - ...curr, - [key]: - (settings.modelSettings?.ratios && - (settings.modelSettings.ratios as any)[key]) || - 0.0, - }), - {} - ); - } - - if (settings.tapDetectionSettings) { - formData.tapDetection = { - yawResetEnabled: - settings.tapDetectionSettings.yawResetEnabled || - defaultValues.tapDetection.yawResetEnabled, - fullResetEnabled: - settings.tapDetectionSettings.fullResetEnabled || - defaultValues.tapDetection.fullResetEnabled, - mountingResetEnabled: - settings.tapDetectionSettings.mountingResetEnabled || - defaultValues.tapDetection.mountingResetEnabled, - yawResetDelay: - settings.tapDetectionSettings.yawResetDelay || - defaultValues.tapDetection.yawResetDelay, - fullResetDelay: - settings.tapDetectionSettings.fullResetDelay || - defaultValues.tapDetection.fullResetDelay, - mountingResetDelay: - settings.tapDetectionSettings.mountingResetDelay || - defaultValues.tapDetection.mountingResetDelay, - yawResetTaps: - settings.tapDetectionSettings.yawResetTaps || - defaultValues.tapDetection.yawResetTaps, - fullResetTaps: - settings.tapDetectionSettings.fullResetTaps || - defaultValues.tapDetection.fullResetTaps, - mountingResetTaps: - settings.tapDetectionSettings.mountingResetTaps || - defaultValues.tapDetection.mountingResetTaps, - yawResetTracker: String( - settings.tapDetectionSettings.yawResetTracker || - defaultValues.tapDetection.yawResetTracker - ), - fullResetTracker: String( - settings.tapDetectionSettings.fullResetTracker || - defaultValues.tapDetection.fullResetTracker - ), - mountingResetTracker: String( - settings.tapDetectionSettings.mountingResetTracker || - defaultValues.tapDetection.mountingResetTracker - ), - numberTrackersOverThreshold: - settings.tapDetectionSettings.numberTrackersOverThreshold || - defaultValues.tapDetection.numberTrackersOverThreshold, - }; - } - - if (settings.modelSettings?.legTweaks) { - formData.legTweaks = { - correctionStrength: - settings.modelSettings.legTweaks.correctionStrength || - defaultValues.legTweaks.correctionStrength, - }; - } - - if (settings.resetsSettings) { - formData.resetsSettings = settings.resetsSettings; - } - - if (settings.stayAligned) { - formData.stayAligned = deserializeStayAlignedSettings( - settings.stayAligned - ); - } - - if (settings.hidSettings) { - formData.hidSettings = { - trackersOverHID: settings.hidSettings.trackersOverHid, - }; - } - - if (settings.velocitySettings) { - formData.velocitySettings = { - sendDerivedVelocity: settings.velocitySettings.sendDerivedVelocity, - }; - } - - reset({ ...getValues(), ...formData }); - }, [settings]); - - useRPCPacket(RpcMessage.SettingsResponse, (settings: SettingsResponseT) => { - setSettings(settings); - }); - return ( - { - setValue('trackers.leftHand', false); - setValue('trackers.rightHand', false); - setShowHandsWarning(null); - }} - accept={() => { - const [leftHand, rightHand] = showHandsWarning!; - blockHandsWarning.current = true; - setValue('trackers.leftHand', leftHand); - setValue('trackers.rightHand', rightHand); - setShowHandsWarning(null); - }} - />
- } id="output"> - <> - - {l10n.getString('settings-general-output')} - - -
- - {l10n.getString( - 'settings-general-steamvr-trackers-tracker_toggling' - )} - -
- {l10n - .getString( - 'settings-general-output-trackers-tracker_toggling-description' - ) - .split('\n') - .map((line, i) => ( - {line} - ))} -
- -
- - - {l10n.getString('settings-general-output_trackers')} - -
- {l10n - .getString('settings-general-output_trackers-description') - .split('\n') - .map((line, i) => ( - {line} - ))} -
-
- - - - - - - - - - -
-
- -
- - {l10n.getString( - 'settings-general-fk_settings-velocity_settings' - )} - -
- - {l10n.getString( - 'settings-general-fk_settings-velocity_settings-description' - )} - -
-
-
- -
- - - - } id="mechanics"> - <> - - {l10n.getString('settings-general-tracker_mechanics')} - - - {l10n.getString('settings-general-tracker_mechanics-filtering')} - -
- {l10n - .getString( - 'settings-general-tracker_mechanics-filtering-description' - ) - .split('\n') - .map((line, i) => ( - {line} - ))} -
- - {l10n.getString( - 'settings-general-tracker_mechanics-filtering-type' - )} - -
- - - -
-
- percentageFormat.format(value)} - min={0.1} - max={1.0} - step={0.1} - /> -
-
- secondsFormat.format(value)} - min={0.0} - max={0.5} - step={0.05} - /> -
-
- - {l10n.getString( - 'settings-general-tracker_mechanics-save_mounting_reset' - )} - - }} - > - - -
- - -
- - {l10n.getString( - 'settings-general-tracker_mechanics-trackers_over_usb' - )} - - }} - > - - -
- - -
- } id="fksettings"> - <> - - {l10n.getString('settings-general-fk_settings')} - -
- - {l10n.getString( - 'settings-general-fk_settings-leg_tweak-skating_correction' - )} - - - {l10n.getString( - 'settings-general-fk_settings-leg_tweak-skating_correction-description' - )} - -
-
- - percentageFormat.format(value)} - min={0.1} - max={1.0} - step={0.1} - /> -
- -
- - {l10n.getString('settings-general-fk_settings-leg_fk')} - -
-
- - {l10n.getString( - 'settings-general-fk_settings-leg_tweak-floor_clip-description' - )} - -
-
- -
-
- - {l10n.getString( - 'settings-general-fk_settings-leg_tweak-foot_plant-description' - )} - -
-
- -
-
- - {l10n.getString( - 'settings-general-fk_settings-leg_tweak-toe_snap-description' - )} - -
-
- -
- -
- - {l10n.getString('settings-general-fk_settings-arm_fk')} - - - {l10n.getString( - 'settings-general-fk_settings-arm_fk-description' - )} - -
-
- -
- -
- - {l10n.getString('settings-general-fk_settings-reset_settings')} - -
-
-
-
- - {l10n.getString( - 'settings-general-fk_settings-reset_settings-reset_hmd_pitch-description' - )} - - -
-
- - {l10n.getString( - 'settings-general-fk_settings-leg_fk-reset_mounting_feet-description-v1' - )} - - -
-
-
- -
- - {l10n.getString( - 'settings-general-fk_settings-arm_fk-reset_mode-description' - )} - -
- - - - -
-
- -
- - {l10n.getString( - 'settings-general-fk_settings-enforce_joint_constraints' - )} - -
- - {l10n.getString( - 'settings-general-fk_settings-enforce_joint_constraints-enforce_constraints-description' - )} - -
-
-
- -
- -
- - {l10n.getString('settings-general-fk_settings-ik')} - - - {l10n.getString( - 'settings-general-fk_settings-ik-use_position-description' - )} - -
-
- -
- - {config?.debug && ( - <> -
-
- - {l10n.getString( - 'settings-general-fk_settings-skeleton_settings-ratios' - )} - - - {l10n.getString( - 'settings-general-fk_settings-skeleton_settings-ratios-description' - )} - -
-
- - percentageFormat.format(value) - } - min={0.0} - max={1.0} - step={0.05} - /> - - percentageFormat.format(value) - } - min={0.0} - max={1.0} - step={0.05} - /> - - percentageFormat.format(value) - } - min={0.0} - max={1.0} - step={0.05} - /> - - percentageFormat.format(value) - } - min={0.0} - max={1.0} - step={0.05} - /> - - percentageFormat.format(value) - } - min={0.0} - max={1.0} - step={0.05} - /> -
-
- - )} -
- - {l10n.getString( - 'settings-general-fk_settings-self_localization-title' - )} - - - {l10n.getString( - 'settings-general-fk_settings-self_localization-description' - )} - -
-
- -
- -
- - } id="gestureControl"> - <> - - {l10n.getString('settings-general-gesture_control')} - - -
- - {l10n.getString('settings-general-gesture_control-subtitle')} - - - {l10n.getString('settings-general-gesture_control-description')} - -
-
-
- - - -
-
-
- - {l10n.getString( - 'settings-general-gesture_control-yawResetTracker' - )} - - -
-
- - {l10n.getString( - 'settings-general-gesture_control-mountingResetTracker' - )} - - -
-
- - {l10n.getString( - 'settings-general-gesture_control-fullResetTracker' - )} - - -
-
-
-
- secondsFormat.format(value)} - min={0.2} - max={3.0} - step={0.2} - /> - secondsFormat.format(value)} - min={0.2} - max={3.0} - step={0.2} - /> - secondsFormat.format(value)} - min={0.2} - max={3.0} - step={0.2} - /> -
-
- - l10n.getString('settings-general-gesture_control-taps', { - amount: Math.round(value), - }) - } - min={2} - max={10} - step={1} - /> - - l10n.getString('settings-general-gesture_control-taps', { - amount: Math.round(value), - }) - } - min={2} - max={10} - step={1} - /> - - l10n.getString('settings-general-gesture_control-taps', { - amount: Math.round(value), - }) - } - min={2} - max={10} - step={1} - /> -
-
- - {l10n.getString( - 'settings-general-gesture_control-numberTrackersOverThreshold' - )} - - - {l10n.getString( - 'settings-general-gesture_control-numberTrackersOverThreshold-description' - )} - - - l10n.getString('settings-general-gesture_control-trackers', { - amount: Math.round(value), - }) - } - min={1} - max={20} - step={1} - /> -
- -
+ + + + + + ); diff --git a/gui/src/components/settings/pages/HomeScreenSettings.tsx b/gui/src/components/settings/pages/HomeScreenSettings.tsx index 9a00a45345..282f658a2b 100644 --- a/gui/src/components/settings/pages/HomeScreenSettings.tsx +++ b/gui/src/components/settings/pages/HomeScreenSettings.tsx @@ -52,7 +52,7 @@ export function TrackingChecklistSettings({ if (!stepId) continue; // doing it this way prevents calling ignore step for every step. - // that prevent sending a packet for steps that didnt change + // that prevent sending a packet for steps that didn't change if (!value && !ignoredSteps.includes(stepId)) { ignoreStep(stepId, true, false); } diff --git a/gui/src/components/settings/pages/VMCSettings.tsx b/gui/src/components/settings/pages/VMCSettings.tsx index f13af7de4b..65e0782e9a 100644 --- a/gui/src/components/settings/pages/VMCSettings.tsx +++ b/gui/src/components/settings/pages/VMCSettings.tsx @@ -2,13 +2,13 @@ import { Localized, useLocalization } from '@fluent/react'; import { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { - ChangeSettingsRequestT, + ChangeVMCOSCSettingsRequestT, + ChangeVRMSettingsRequestT, RpcMessage, - SettingsRequestT, - SettingsResponseT, - OSCSettingsT, - VMCOSCSettingsT, - VRMSettingsT, + VMCOSCSettingsRequestT, + VMCOSCSettingsResponseT, + VRMSettingsRequestT, + VRMSettingsResponseT, } from 'solarxr-protocol'; import { useWebsocketAPI } from '@/hooks/websocket-api'; import { CheckBox } from '@/components/commons/Checkbox'; @@ -23,34 +23,31 @@ import { } from '@/components/settings/SettingsPageLayout'; import { error } from '@/utils/logging'; import { - OSCSettings, - useOscSettingsValidator, + OSCPortsAddress, + useOscPortsAddressValidator, } from '@/hooks/osc-setting-validator'; import { yupResolver } from '@hookform/resolvers/yup'; import { boolean, object } from 'yup'; interface VMCSettingsForm { - vmc: { - oscSettings: OSCSettings; - anchorHip: boolean; - mirrorTracking: boolean; - }; + enabled: boolean; + portsAddress: OSCPortsAddress; + anchorHip: boolean; + mirrorTracking: boolean; } -const defaultValues = { - vmc: { - oscSettings: { - enabled: false, - portIn: 39540, - portOut: 39539, - address: '127.0.0.1', - }, - anchorHip: true, - mirrorTracking: true, +const defaultVMCSettings: VMCSettingsForm = { + enabled: false, + portsAddress: { + portIn: 39540, + portOut: 39539, + address: '127.0.0.1', }, + anchorHip: true, + mirrorTracking: true, }; -export function VMCFileUpload() { +export function VRMFileUpload() { const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); const { l10n } = useLocalization(); const [modelName, setModelName] = useState(null); @@ -66,22 +63,20 @@ export function VMCFileUpload() { const vrmJson = watch('vrmJson'); const updateVRMJson = async () => { - const req = new ChangeSettingsRequestT(); - const vrm = new VRMSettingsT(); + const req = new ChangeVRMSettingsRequestT(); if (vrmJson !== undefined) { if (vrmJson.length > 0) { const file = await parseVRMFile(vrmJson[0]); if (file) { - vrm.vrmJson = file.json; + req.vrmJson = file.json; setModelName(file.name); } } else { - vrm.vrmJson = ''; + req.vrmJson = ''; setModelName(null); } } - req.vrm = vrm; - sendRPCPacket(RpcMessage.ChangeSettingsRequest, req); + sendRPCPacket(RpcMessage.ChangeVRMSettingsRequest, req); }; useEffect(() => { @@ -89,21 +84,28 @@ export function VMCFileUpload() { }, [vrmJson]); useEffect(() => { - sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT()); + sendRPCPacket( + RpcMessage.VMCOSCSettingsRequest, + new VMCOSCSettingsRequestT() + ); + sendRPCPacket(RpcMessage.VRMSettingsRequest, new VRMSettingsRequestT()); }, []); - useRPCPacket(RpcMessage.SettingsResponse, (settings: SettingsResponseT) => { - const vrmJson = settings.vrm?.vrmJson?.toString(); - if (vrmJson) { - let data: any; - try { - data = JSON.parse(vrmJson); - setModelName(getVRMName(data) || ''); - } catch (e) { - error('Failed to fetch VRM name: ' + e); + useRPCPacket( + RpcMessage.VRMSettingsResponse, + (settings: VRMSettingsResponseT) => { + const vrmJson = settings.vrmJson?.toString(); + if (vrmJson) { + let data: any; + try { + data = JSON.parse(vrmJson); + setModelName(getVRMName(data) || ''); + } catch (e) { + error('Failed to fetch VRM name: ' + e); + } } } - }); + ); return ( ({ - defaultValues, + defaultValues: defaultVMCSettings, reValidateMode: 'onChange', mode: 'onChange', resolver: yupResolver( object({ - vmc: object({ - oscSettings: oscValidator, - anchorHip: boolean().required(), - mirrorTracking: boolean().required(), - }), + enabled: boolean().required(), + portsAddress: oscValidator, + anchorHip: boolean().required(), + mirrorTracking: boolean().required(), }) ), }); const onSubmit = async (values: VMCSettingsForm) => { - const settings = new ChangeSettingsRequestT(); + const req = new ChangeVMCOSCSettingsRequestT(); - if (values.vmc) { - const vmcOsc = new VMCOSCSettingsT(); + req.enabled = values.enabled; + req.portIn = values.portsAddress.portIn; + req.portOut = values.portsAddress.portOut; + req.address = values.portsAddress.address; + req.anchorHip = values.anchorHip; + req.mirrorTracking = values.mirrorTracking; - vmcOsc.oscSettings = Object.assign( - new OSCSettingsT(), - values.vmc.oscSettings - ); - vmcOsc.anchorHip = values.vmc.anchorHip; - vmcOsc.mirrorTracking = values.vmc.mirrorTracking; - - settings.vmcOsc = vmcOsc; - } - sendRPCPacket(RpcMessage.ChangeSettingsRequest, settings); + sendRPCPacket(RpcMessage.ChangeVMCOSCSettingsRequest, req); }; useEffect(() => { @@ -168,31 +164,24 @@ export function VMCSettings() { return () => subscription.unsubscribe(); }, []); - useEffect(() => { - sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT()); - }, []); - - useRPCPacket(RpcMessage.SettingsResponse, (settings: SettingsResponseT) => { - const formData: VMCSettingsForm = defaultValues; - if (settings.vmcOsc) { - if (settings.vmcOsc.oscSettings) { - formData.vmc.oscSettings.enabled = settings.vmcOsc.oscSettings.enabled; - if (settings.vmcOsc.oscSettings.portIn) - formData.vmc.oscSettings.portIn = settings.vmcOsc.oscSettings.portIn; - if (settings.vmcOsc.oscSettings.portOut) - formData.vmc.oscSettings.portOut = - settings.vmcOsc.oscSettings.portOut; - if (settings.vmcOsc.oscSettings.address) - formData.vmc.oscSettings.address = - settings.vmcOsc.oscSettings.address.toString(); + useRPCPacket( + RpcMessage.VMCOSCSettingsResponse, + (settings: VMCOSCSettingsResponseT) => { + const formData: VMCSettingsForm = defaultVMCSettings; + if (settings) { + formData.enabled = settings.enabled; + if (settings.portIn) formData.portsAddress.portIn = settings.portIn; + if (settings.portOut) formData.portsAddress.portOut = settings.portOut; + if (settings.address) + formData.portsAddress.address = settings.address.toString(); + + formData.anchorHip = settings.anchorHip; + formData.mirrorTracking = settings.mirrorTracking; + + reset(formData); } - - formData.vmc.anchorHip = settings.vmcOsc.anchorHip; - formData.vmc.mirrorTracking = settings.vmcOsc.mirrorTracking; - - reset(formData); } - }); + ); return ( @@ -225,7 +214,7 @@ export function VMCSettings() { variant="toggle" outlined control={control} - name="vmc.oscSettings.enabled" + name="enabled" label={l10n.getString('settings-osc-vmc-enable-label')} />
@@ -250,7 +239,7 @@ export function VMCSettings() { @@ -262,7 +251,7 @@ export function VMCSettings() { @@ -280,7 +269,7 @@ export function VMCSettings() {
- +
{l10n.getString('settings-osc-vmc-anchor_hip')} @@ -311,7 +300,7 @@ export function VMCSettings() { variant="toggle" outlined control={control} - name="vmc.anchorHip" + name="anchorHip" label={l10n.getString('settings-osc-vmc-anchor_hip-label')} />
@@ -328,7 +317,7 @@ export function VMCSettings() { variant="toggle" outlined control={control} - name="vmc.mirrorTracking" + name="mirrorTracking" label={l10n.getString('settings-osc-vmc-mirror_tracking-label')} />
diff --git a/gui/src/components/settings/pages/VRCOSCSettings.tsx b/gui/src/components/settings/pages/VRCOSCSettings.tsx index b5c4e1d097..53e06a7c26 100644 --- a/gui/src/components/settings/pages/VRCOSCSettings.tsx +++ b/gui/src/components/settings/pages/VRCOSCSettings.tsx @@ -1,9 +1,9 @@ import { Localized, useLocalization } from '@fluent/react'; import { yupResolver } from '@hookform/resolvers/yup'; import classNames from 'classnames'; -import { useEffect, useMemo, useState } from 'react'; +import { ReactNode, useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; -import { boolean, number, object, string } from 'yup'; +import { boolean, object } from 'yup'; import { ChangeVRCOSCSettingsRequestT, RpcMessage, @@ -13,7 +13,6 @@ import { VRCOSCOutputState, VRCOSCSettingsRequestT, VRCOSCSettingsResponseT, - VRCOSCSettingsT, VRCOSCStatusChangeResponseT, VRCOSCStatusRequestT, VRCOSCTargetSource, @@ -29,30 +28,26 @@ import { SettingsPagePaneLayout, } from '@/components/settings/SettingsPageLayout'; import { useWebsocketAPI } from '@/hooks/websocket-api'; +import { + OSCPortsAddress, + useOscPortsAddressValidator, +} from '@/hooks/osc-setting-validator'; interface VRCOSCSettingsForm { - vrchat: { - enabled: boolean; - useManualNetwork: boolean; - manualNetwork: { - portIn: number; - portOut: number; - address: string; - }; - }; + enabled: boolean; + useManualNetwork: boolean; + manualNetwork: OSCPortsAddress; } -const createDefaultValues = (): VRCOSCSettingsForm => ({ - vrchat: { - enabled: false, - useManualNetwork: false, - manualNetwork: { - portIn: 9001, - portOut: 9000, - address: '127.0.0.1', - }, +const defaultVRCOSCSettings: VRCOSCSettingsForm = { + enabled: false, + useManualNetwork: false, + manualNetwork: { + portIn: 9001, + portOut: 9000, + address: '127.0.0.1', }, -}); +}; function asString(value: string | Uint8Array | null | undefined): string { return typeof value === 'string' ? value : ''; @@ -126,7 +121,7 @@ function StatusRow({ }: { label: string; badge: BadgeKind; - children?: React.ReactNode; + children?: ReactNode; }) { return (
@@ -358,52 +353,23 @@ export function VRCOSCSettings() { ); const [now, setNow] = useState(() => Date.now()); - const bannedPorts = [6969, 21110]; - const portValidator = useMemo( - () => - number() - .typeError(' ') - .required() - .test( - 'ports-dont-match', - l10n.getString('settings-osc-common-network-ports_match_error-v2'), - (_port, context) => context.parent.portIn != context.parent.portOut - ) - .notOneOf(bannedPorts, (context) => - l10n.getString('settings-osc-common-network-port_banned_error', { - port: context.originalValue, - }) - ), - [l10n] - ); - + const { oscValidator } = useOscPortsAddressValidator(); const { reset, control, watch, handleSubmit, setValue } = useForm({ - defaultValues: createDefaultValues(), + defaultValues: defaultVRCOSCSettings, reValidateMode: 'onChange', mode: 'onChange', resolver: yupResolver( object({ - vrchat: object({ - enabled: boolean().required(), - useManualNetwork: boolean().required(), - manualNetwork: object({ - portIn: portValidator, - portOut: portValidator, - address: string() - .required(' ') - .matches( - /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|$)){4}$/i, - { message: ' ' } - ), - }), - }), + enabled: boolean().required(), + useManualNetwork: boolean().required(), + manualNetwork: oscValidator, }) ), }); - const enabled = watch('vrchat.enabled'); - const useManualNetwork = watch('vrchat.useManualNetwork'); + const enabled = watch('enabled'); + const useManualNetwork = watch('useManualNetwork'); useEffect(() => { const interval = window.setInterval(() => setNow(Date.now()), 1_000); @@ -412,14 +378,12 @@ export function VRCOSCSettings() { const onSubmit = (values: VRCOSCSettingsForm) => { const req = new ChangeVRCOSCSettingsRequestT(); - const vrcOsc = new VRCOSCSettingsT(); - vrcOsc.enabled = values.vrchat.enabled; - vrcOsc.manualNetwork = values.vrchat.useManualNetwork - ? Object.assign(new VRCOSCNetworkSettingsT(), values.vrchat.manualNetwork) + req.enabled = values.enabled; + req.manualNetwork = values.useManualNetwork + ? Object.assign(new VRCOSCNetworkSettingsT(), values.manualNetwork) : null; - req.settings = vrcOsc; sendRPCPacket(RpcMessage.ChangeVRCOSCSettingsRequest, req); }; @@ -439,18 +403,16 @@ export function VRCOSCSettings() { useRPCPacket( RpcMessage.VRCOSCSettingsResponse, (response: VRCOSCSettingsResponseT) => { - const settings = response.settings; - const formData = createDefaultValues(); - if (settings) { - formData.vrchat.enabled = settings.enabled; - if (settings.manualNetwork) { - formData.vrchat.useManualNetwork = true; - formData.vrchat.manualNetwork.portIn = settings.manualNetwork.portIn; - formData.vrchat.manualNetwork.portOut = - settings.manualNetwork.portOut; - if (settings.manualNetwork.address) { - formData.vrchat.manualNetwork.address = asString( - settings.manualNetwork.address + const formData = defaultVRCOSCSettings; + if (response) { + formData.enabled = response.enabled; + if (response.manualNetwork) { + formData.useManualNetwork = true; + formData.manualNetwork.portIn = response.manualNetwork.portIn; + formData.manualNetwork.portOut = response.manualNetwork.portOut; + if (response.manualNetwork.address) { + formData.manualNetwork.address = asString( + response.manualNetwork.address ); } } @@ -499,7 +461,7 @@ export function VRCOSCSettings() { variant="toggle" outlined control={control} - name="vrchat.enabled" + name="enabled" label={l10n.getString('settings-osc-vrchat-enable-label')} />
@@ -513,15 +475,15 @@ export function VRCOSCSettings() { status={status} now={now} onSwitchToTarget={(target) => { - setValue('vrchat.useManualNetwork', true, { + setValue('useManualNetwork', true, { shouldDirty: true, }); setValue( - 'vrchat.manualNetwork.address', + 'manualNetwork.address', asString(target.address), { shouldDirty: true } ); - setValue('vrchat.manualNetwork.portOut', target.portOut, { + setValue('manualNetwork.portOut', target.portOut, { shouldDirty: true, }); }} @@ -544,7 +506,7 @@ export function VRCOSCSettings() { variant="toggle" outlined control={control} - name="vrchat.useManualNetwork" + name="useManualNetwork" label={l10n.getString( 'settings-osc-vrchat-status-network-mode-toggle' )} @@ -571,7 +533,7 @@ export function VRCOSCSettings() { @@ -583,7 +545,7 @@ export function VRCOSCSettings() { @@ -603,7 +565,7 @@ export function VRCOSCSettings() { settings, + isEqual +); + +export function HIDSettings() { + const setSettings = useSetAtom(hidSettingsAtom); + const settings = useAtomValue(hidSettingsValueAtom); + const { l10n } = useLocalization(); + const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); + + const { control, watch, handleSubmit, getValues, reset } = useForm({ + defaultValues, + mode: 'onChange', + reValidateMode: 'onChange', + }); + + const onSubmit = (values: HIDForm) => { + const settingsReq = new ChangeHIDSettingsRequestT(); + settingsReq.trackersOverHid = values.hidSettings.trackersOverHID; + sendRPCPacket(RpcMessage.ChangeHIDSettingsRequest, settingsReq); + }; + + useEffect(() => { + const subscription = watch((_, { type }) => { + if (type === 'change') handleSubmit(onSubmit)(); + }); + return () => subscription.unsubscribe(); + }, []); + + useEffect(() => { + sendRPCPacket(RpcMessage.HIDSettingsRequest, new HIDSettingsRequestT()); + }, []); + + useEffect(() => { + const formData: DefaultValues = {}; + formData.hidSettings = { + trackersOverHID: settings.trackersOverHid, + }; + reset({ ...getValues(), ...formData }); + }, [settings]); + + useRPCPacket( + RpcMessage.HIDSettingsResponse, + (settings: HIDSettingsResponseT) => { + setSettings(settings); + } + ); + + return ( + } id="mechanics-hid"> + <> +
+ + {l10n.getString( + 'settings-general-tracker_mechanics-trackers_over_usb' + )} + + }} + > + + +
+ + +
+ ); +} diff --git a/gui/src/components/settings/HandsWarningModal.tsx b/gui/src/components/settings/pages/components/HandsWarningModal.tsx similarity index 100% rename from gui/src/components/settings/HandsWarningModal.tsx rename to gui/src/components/settings/pages/components/HandsWarningModal.tsx diff --git a/gui/src/components/settings/pages/MagnetometerToggleSetting.tsx b/gui/src/components/settings/pages/components/MagnetometerToggleSetting.tsx similarity index 84% rename from gui/src/components/settings/pages/MagnetometerToggleSetting.tsx rename to gui/src/components/settings/pages/components/MagnetometerToggleSetting.tsx index efd84a5e93..c381a8f644 100644 --- a/gui/src/components/settings/pages/MagnetometerToggleSetting.tsx +++ b/gui/src/components/settings/pages/components/MagnetometerToggleSetting.tsx @@ -6,11 +6,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { ChangeMagToggleRequestT, - DeviceIdT, MagToggleRequestT, MagToggleResponseT, RpcMessage, - TrackerIdT, } from 'solarxr-protocol'; import { Link } from 'react-router-dom'; @@ -19,13 +17,11 @@ interface MagnetometerToggleForm { } export function MagnetometerToggleSetting({ - trackerNum, - deviceId, + trackerId, settingType, id, }: { - trackerNum?: number; - deviceId?: number; + trackerId?: number; settingType: 'general' | 'tracker'; id?: string; }) { @@ -45,18 +41,14 @@ export function MagnetometerToggleSetting({ if (originalValue.current === values.magToggle) return; setWaitingMag(true); const req = new ChangeMagToggleRequestT(); - if (trackerNum !== undefined) { - const id = new TrackerIdT( - deviceId ? new DeviceIdT(deviceId) : undefined, - trackerNum - ); - req.trackerId = id; + if (trackerId !== undefined) { + req.trackerId = trackerId; } req.enable = values.magToggle; sendRPCPacket(RpcMessage.ChangeMagToggleRequest, req); }, - [trackerNum, deviceId] + [trackerId] ); useEffect(() => { @@ -66,25 +58,18 @@ export function MagnetometerToggleSetting({ useEffect(() => { const req = new MagToggleRequestT(); - if (trackerNum !== undefined) { - const id = new TrackerIdT( - deviceId ? new DeviceIdT(deviceId) : undefined, - trackerNum - ); - req.trackerId = id; + if (trackerId !== undefined) { + req.trackerId = trackerId; sendRPCPacket(RpcMessage.MagToggleRequest, new MagToggleRequestT()); } sendRPCPacket(RpcMessage.MagToggleRequest, req); - }, [trackerNum, deviceId]); + }, [trackerId]); useRPCPacket(RpcMessage.MagToggleResponse, (mag: MagToggleResponseT) => { - if (trackerNum !== undefined && mag.trackerId?.trackerNum === undefined) { + if (trackerId !== undefined && mag.trackerId === undefined) { setGlobalToggle(mag.enable); } - if ( - mag.trackerId?.trackerNum !== trackerNum || - mag.trackerId?.deviceId?.id !== deviceId - ) { + if (mag.trackerId !== trackerId) { return; } originalValue.current = mag.enable; diff --git a/gui/src/components/settings/pages/components/OutputSettings.tsx b/gui/src/components/settings/pages/components/OutputSettings.tsx new file mode 100644 index 0000000000..64e2056ffc --- /dev/null +++ b/gui/src/components/settings/pages/components/OutputSettings.tsx @@ -0,0 +1,388 @@ +import { useLocalization } from '@fluent/react'; +import { useEffect, useRef, useState } from 'react'; +import { DefaultValues, useForm } from 'react-hook-form'; +import { + BodyPart, + ChangeOutputTrackersSettingsRequestT, + OutputTrackersSettingsRequestT, + OutputTrackersSettingsResponseT, + RpcMessage, +} from 'solarxr-protocol'; +import { useWebsocketAPI } from '@/hooks/websocket-api'; +import { CheckBox } from '@/components/commons/Checkbox'; +import { SteamIcon } from '@/components/commons/icon/SteamIcon'; +import { Typography } from '@/components/commons/Typography'; +import { SettingsPagePaneLayout } from '@/components/settings/SettingsPageLayout'; +import { atom, useAtomValue, useSetAtom } from 'jotai'; +import { isEqual } from '@react-hookz/deep-equal'; +import { selectAtom } from 'jotai/utils'; +import { HandsWarningModal } from './HandsWarningModal'; +import {OutputIcon} from "@/components/commons/icon/OutputIcon"; + +type OutputTrackersForm = { + automaticTrackerToggle: boolean; + trackers: { + waist: boolean; + chest: boolean; + leftFoot: boolean; + rightFoot: boolean; + leftKnee: boolean; + rightKnee: boolean; + leftElbow: boolean; + rightElbow: boolean; + leftHand: boolean; + rightHand: boolean; + }; + sendDerivedVelocity: boolean; +}; + +const defaultValues: OutputTrackersForm = { + automaticTrackerToggle: true, + trackers: { + waist: false, + chest: false, + leftFoot: false, + rightFoot: false, + leftElbow: false, + rightElbow: false, + leftHand: false, + rightHand: false, + leftKnee: false, + rightKnee: false, + }, + sendDerivedVelocity: false, +}; + +// The tracker is at the tail of the bone. +// Example, the waist tracker is at the tail of the hip bone so we use the hip BodyPart +function trackersToBodyPartList( + trackers: OutputTrackersForm['trackers'] +): BodyPart[] { + const enabledBodyParts: [boolean, BodyPart][] = [ + [trackers.waist, BodyPart.HIP], + [trackers.chest, BodyPart.CHEST], + [trackers.leftFoot, BodyPart.LEFT_FOOT], + [trackers.rightFoot, BodyPart.RIGHT_FOOT], + [trackers.leftKnee, BodyPart.LEFT_UPPER_LEG], + [trackers.rightKnee, BodyPart.RIGHT_UPPER_LEG], + [trackers.leftElbow, BodyPart.LEFT_UPPER_ARM], + [trackers.rightElbow, BodyPart.RIGHT_UPPER_ARM], + [trackers.leftHand, BodyPart.LEFT_HAND], + [trackers.rightHand, BodyPart.RIGHT_HAND], + ]; + + return enabledBodyParts + .filter(([enabled]) => enabled) + .map(([, part]) => part); +} + +function bodyPartListToTrackers( + bodyParts: BodyPart[] +): Omit { + const set = new Set(bodyParts); + return { + waist: set.has(BodyPart.HIP), + chest: set.has(BodyPart.CHEST), + leftFoot: set.has(BodyPart.LEFT_FOOT), + rightFoot: set.has(BodyPart.RIGHT_FOOT), + leftKnee: set.has(BodyPart.LEFT_UPPER_LEG), + rightKnee: set.has(BodyPart.RIGHT_UPPER_LEG), + leftElbow: set.has(BodyPart.LEFT_UPPER_ARM), + rightElbow: set.has(BodyPart.RIGHT_UPPER_ARM), + leftHand: set.has(BodyPart.LEFT_HAND), + rightHand: set.has(BodyPart.RIGHT_HAND), + }; +} + +const outputTrackersSettingsAtom = atom(new OutputTrackersSettingsResponseT()); +const outputTrackersSettingsValueAtom = selectAtom( + outputTrackersSettingsAtom, + (settings) => settings, + isEqual +); + +export function OutputTrackersSettings() { + const setSettings = useSetAtom(outputTrackersSettingsAtom); + const settings = useAtomValue(outputTrackersSettingsValueAtom); + const { l10n } = useLocalization(); + const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); + + const blockHandsWarning = useRef(false); + + // If not null, warning will be shown, and showHandsWarning will + // hold which hands should be toggled ([leftHand, rightHand]) + const [showHandsWarning, setShowHandsWarning] = useState< + [boolean, boolean] | null + >(null); + + const { control, watch, handleSubmit, getValues, setValue, reset } = + useForm({ + defaultValues, + mode: 'onChange', + reValidateMode: 'onChange', + }); + + const { automaticTrackerToggle } = watch(); + + const onSubmit = (values: OutputTrackersForm) => { + const settingsReq = new ChangeOutputTrackersSettingsRequestT(); + + let leftHand = values.trackers.leftHand; + let rightHand = values.trackers.rightHand; + + const hadHandTracker = + settings.trackers?.includes(BodyPart.LEFT_HAND) || + settings.trackers?.includes(BodyPart.RIGHT_HAND); + + if ( + !blockHandsWarning.current && + !showHandsWarning && + !hadHandTracker && + (leftHand || rightHand) + ) { + setShowHandsWarning([leftHand, rightHand]); + leftHand = false; + rightHand = false; + } else if (blockHandsWarning.current && !leftHand && !rightHand) { + blockHandsWarning.current = false; + } + + settingsReq.trackers = trackersToBodyPartList({ + ...values.trackers, + leftHand, + rightHand, + }); + settingsReq.automaticTrackerToggle = values.automaticTrackerToggle; + settingsReq.sendDerivedVelocity = values.sendDerivedVelocity; + + sendRPCPacket(RpcMessage.ChangeOutputTrackersSettingsRequest, settingsReq); + }; + + useEffect(() => { + const subscription = watch((_, { type }) => { + if (type === 'change') handleSubmit(onSubmit)(); + }); + return () => subscription.unsubscribe(); + }, []); + + useEffect(() => { + sendRPCPacket( + RpcMessage.OutputTrackersSettingsRequest, + new OutputTrackersSettingsRequestT() + ); + }, []); + + useEffect(() => { + let formData: DefaultValues = {}; + + if (settings.trackers) { + formData = { + ...bodyPartListToTrackers(settings.trackers), + automaticTrackerToggle: settings.automaticTrackerToggle, + sendDerivedVelocity: settings.sendDerivedVelocity, + }; + if ( + !blockHandsWarning.current && + (settings.trackers.includes(BodyPart.LEFT_HAND) || + settings.trackers.includes(BodyPart.RIGHT_HAND)) + ) { + blockHandsWarning.current = true; + } + } + + reset({ ...getValues(), ...formData }); + }, [settings]); + + useRPCPacket( + RpcMessage.OutputTrackersSettingsResponse, + (settings: OutputTrackersSettingsResponseT) => { + setSettings(settings); + } + ); + + return ( + <> + { + setValue('trackers.leftHand', false); + setValue('trackers.rightHand', false); + setShowHandsWarning(null); + }} + accept={() => { + const [leftHand, rightHand] = showHandsWarning!; + blockHandsWarning.current = true; + setValue('trackers.leftHand', leftHand); + setValue('trackers.rightHand', rightHand); + setShowHandsWarning(null); + }} + /> + } id="output"> + <> + + {l10n.getString('settings-general-output')} +
+ + + {l10n.getString( + 'settings-general-steamvr-trackers-tracker_toggling' + )} + +
+ {l10n + .getString( + 'settings-general-output-trackers-tracker_toggling-description' + ) + .split('\n') + .map((line, i) => ( + {line} + ))} +
+ + + +
+ + {l10n.getString('settings-general-output_trackers')} + +
+ {l10n + .getString('settings-general-output_trackers-description') + .split('\n') + .map((line, i) => ( + {line} + ))} +
+ +
+ + + + + + + + + + +
+ +
+ + {l10n.getString('settings-general-fk_settings-velocity_settings')} + +
+ + {l10n.getString( + 'settings-general-fk_settings-velocity_settings-description' + )} + +
+
+ +
+ + + + ); +} diff --git a/gui/src/components/settings/pages/components/ResetsSettings.tsx b/gui/src/components/settings/pages/components/ResetsSettings.tsx new file mode 100644 index 0000000000..4951f40745 --- /dev/null +++ b/gui/src/components/settings/pages/components/ResetsSettings.tsx @@ -0,0 +1,240 @@ +import { useLocalization, Localized } from '@fluent/react'; +import { useEffect } from 'react'; +import { DefaultValues, useForm } from 'react-hook-form'; +import { + ResetsSettingsRequestT, + ResetsSettingsResponseT, + RpcMessage, +} from 'solarxr-protocol'; +import { useWebsocketAPI } from '@/hooks/websocket-api'; +import { useLocaleConfig } from '@/i18n/config'; +import { CheckBox } from '@/components/commons/Checkbox'; +import { WrenchIcon } from '@/components/commons/icon/WrenchIcon'; +import { NumberSelector } from '@/components/commons/NumberSelector'; +import { Radio } from '@/components/commons/Radio'; +import { Typography } from '@/components/commons/Typography'; +import { SettingsPagePaneLayout } from '@/components/settings/SettingsPageLayout'; +import { MagnetometerToggleSetting } from './MagnetometerToggleSetting'; +import { + defaultResetSettings, + loadResetSettings, + ResetSettingsForm, +} from '@/hooks/reset-settings'; +import { atom, useAtomValue, useSetAtom } from 'jotai'; +import { isEqual } from '@react-hookz/deep-equal'; +import { selectAtom } from 'jotai/utils'; + +type ResetsForm = { + resetsSettings: ResetSettingsForm; +}; + +const defaultValues: ResetsForm = { + resetsSettings: defaultResetSettings, +}; + +const resetsSettingsAtom = atom(new ResetsSettingsResponseT()); +const resetsSettingsValueAtom = selectAtom( + resetsSettingsAtom, + (settings) => settings, + isEqual +); + +export function ResetsSettings() { + const setSettings = useSetAtom(resetsSettingsAtom); + const settings = useAtomValue(resetsSettingsValueAtom); + const { l10n } = useLocalization(); + const { currentLocales } = useLocaleConfig(); + const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); + + const secondsFormat = new Intl.NumberFormat(currentLocales, { + style: 'unit', + unit: 'second', + unitDisplay: 'narrow', + maximumFractionDigits: 2, + }); + + const { control, watch, handleSubmit, getValues, reset } = + useForm({ + defaultValues, + mode: 'onChange', + reValidateMode: 'onChange', + }); + + const onSubmit = (values: ResetsForm) => { + const settingsReq = loadResetSettings(values.resetsSettings); + sendRPCPacket(RpcMessage.ChangeResetsSettingsRequest, settingsReq); + }; + + useEffect(() => { + const subscription = watch((_, { type }) => { + if (type === 'change') handleSubmit(onSubmit)(); + }); + return () => subscription.unsubscribe(); + }, []); + + useEffect(() => { + sendRPCPacket( + RpcMessage.ResetsSettingsRequest, + new ResetsSettingsRequestT() + ); + }, []); + + useEffect(() => { + const formData: DefaultValues = {}; + formData.resetsSettings = settings; + reset({ ...getValues(), ...formData }); + }, [settings]); + + useRPCPacket( + RpcMessage.ResetsSettingsResponse, + (settings: ResetsSettingsResponseT) => { + setSettings(settings); + } + ); + + return ( + <> + } id="mechanics-resets"> + <> +
+ secondsFormat.format(value)} + min={0.0} + max={0.5} + step={0.05} + /> +
+
+ + {l10n.getString( + 'settings-general-tracker_mechanics-save_mounting_reset' + )} + + }} + > + + +
+ + + +
+ } id="fksettings-resets"> + <> +
+ + {l10n.getString('settings-general-fk_settings-reset_settings')} + +
+
+
+
+ + {l10n.getString( + 'settings-general-fk_settings-reset_settings-reset_hmd_pitch-description' + )} + + +
+
+ + {l10n.getString( + 'settings-general-fk_settings-leg_fk-reset_mounting_feet-description-v1' + )} + + +
+
+
+
+ + {l10n.getString( + 'settings-general-fk_settings-arm_fk-reset_mode-description' + )} + +
+ + + + +
+
+ +
+ + ); +} diff --git a/gui/src/components/settings/pages/components/SkeletonSettings.tsx b/gui/src/components/settings/pages/components/SkeletonSettings.tsx new file mode 100644 index 0000000000..1f10d3fcbc --- /dev/null +++ b/gui/src/components/settings/pages/components/SkeletonSettings.tsx @@ -0,0 +1,520 @@ +import { useLocalization } from '@fluent/react'; +import { useEffect } from 'react'; +import { DefaultValues, useForm } from 'react-hook-form'; +import { + ChangeSkeletonSettingsRequestT, + FilteringType, + RpcMessage, + SkeletonFilteringT, + SkeletonRatiosT, + SkeletonSettingsRequestT, + SkeletonSettingsResponseT, + SkeletonTogglesT, +} from 'solarxr-protocol'; +import { useWebsocketAPI } from '@/hooks/websocket-api'; +import { useLocaleConfig } from '@/i18n/config'; +import { CheckBox } from '@/components/commons/Checkbox'; +import { WrenchIcon } from '@/components/commons/icon/WrenchIcon'; +import { NumberSelector } from '@/components/commons/NumberSelector'; +import { Radio } from '@/components/commons/Radio'; +import { Typography } from '@/components/commons/Typography'; +import { SettingsPagePaneLayout } from '@/components/settings/SettingsPageLayout'; +import { atom, useAtomValue, useSetAtom } from 'jotai'; +import { isEqual } from '@react-hookz/deep-equal'; +import { selectAtom } from 'jotai/utils'; + +type SkeletonForm = { + toggles: { + forceArmsFromHmd: boolean; + floorClip: boolean; + skatingCorrection: boolean; + toeSnap: boolean; + footPlant: boolean; + selfLocalization: boolean; + usePosition: boolean; + enforceConstraints: boolean; + correctConstraints: boolean; + }; + ratios: { + imputeSpineFromUpperLower: number; + imputeSpineCurvature: number; + interpHipLegs: number; + interpKneeTrackerAnkle: number; + interpKneeAnkle: number; + skatingCorrectionStrength: number; + }; + filtering: { + type: number; + amount: number; + }; +}; + +// Exported so other components (e.g. StayAlignedSettings' debug copy) can +// read the current skeleton toggles/filtering without re-requesting them. +export const skeletonSettingsAtom = atom(new SkeletonSettingsResponseT()); +const skeletonSettingsValueAtom = selectAtom( + skeletonSettingsAtom, + (settings) => settings, + isEqual +); + +const defaultValues: SkeletonForm = { + toggles: { + forceArmsFromHmd: false, + floorClip: false, + skatingCorrection: false, + toeSnap: false, + footPlant: true, + selfLocalization: false, + usePosition: true, + enforceConstraints: true, + correctConstraints: true, + }, + ratios: { + imputeSpineFromUpperLower: 0.5, + imputeSpineCurvature: 0.5, + interpHipLegs: 0.25, + interpKneeTrackerAnkle: 0.85, + interpKneeAnkle: 0.2, + skatingCorrectionStrength: 0.3, + }, + filtering: { amount: 0.1, type: FilteringType.NONE }, +}; + +export function SkeletonSettings() { + const setSettings = useSetAtom(skeletonSettingsAtom); + const settings = useAtomValue(skeletonSettingsValueAtom); + const { l10n } = useLocalization(); + const { currentLocales } = useLocaleConfig(); + const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); + + const percentageFormat = new Intl.NumberFormat(currentLocales, { + style: 'percent', + maximumFractionDigits: 0, + }); + + const { control, watch, handleSubmit, getValues, reset } = + useForm({ + defaultValues, + mode: 'onChange', + reValidateMode: 'onChange', + }); + + const onSubmit = (values: SkeletonForm) => { + const settingsReq = new ChangeSkeletonSettingsRequestT(); + + const toggles = new SkeletonTogglesT(); + toggles.floorClip = values.toggles.floorClip; + toggles.skatingCorrection = values.toggles.skatingCorrection; + toggles.forceArmsFromHmd = values.toggles.forceArmsFromHmd; + toggles.toeSnap = values.toggles.toeSnap; + toggles.footPlant = values.toggles.footPlant; + toggles.selfLocalization = values.toggles.selfLocalization; + toggles.usePosition = values.toggles.usePosition; + toggles.enforceConstraints = values.toggles.enforceConstraints; + toggles.correctConstraints = values.toggles.correctConstraints; + settingsReq.toggles = toggles; + + const ratios = new SkeletonRatiosT(); + ratios.imputeSpineFromUpperLower = values.ratios.imputeSpineFromUpperLower; + ratios.imputeSpineCurvature = values.ratios.imputeSpineCurvature; + ratios.interpHipLegs = values.ratios.interpHipLegs; + ratios.interpKneeTrackerAnkle = values.ratios.interpKneeTrackerAnkle; + ratios.interpKneeAnkle = values.ratios.interpKneeAnkle; + ratios.skatingCorrectionStrength = values.ratios.skatingCorrectionStrength; + settingsReq.ratios = ratios; + + const filtering = new SkeletonFilteringT(); + filtering.type = values.filtering.type; + filtering.amount = values.filtering.amount; + settingsReq.filtering = filtering; + + sendRPCPacket(RpcMessage.ChangeSkeletonSettingsRequest, settingsReq); + }; + + useEffect(() => { + const subscription = watch((_, { type }) => { + if (type === 'change') handleSubmit(onSubmit)(); + }); + return () => subscription.unsubscribe(); + }, []); + + useEffect(() => { + sendRPCPacket( + RpcMessage.SkeletonSettingsRequest, + new SkeletonSettingsRequestT() + ); + }, []); + + useEffect(() => { + const formData: DefaultValues = {}; + + if (settings.toggles) { + formData.toggles = Object.keys(settings.toggles).reduce( + (curr, key: string) => ({ + ...curr, + [key]: (settings.toggles && (settings.toggles as any)[key]) || false, + }), + {} + ); + } + + if (settings.ratios) { + formData.ratios = Object.keys(settings.ratios).reduce( + (curr, key: string) => ({ + ...curr, + [key]: (settings.ratios && (settings.ratios as any)[key]) || 0.0, + }), + {} + ); + } + + if (settings.filtering) { + formData.filtering = { + type: settings.filtering.type ?? defaultValues.filtering.type, + amount: settings.filtering.amount ?? defaultValues.filtering.amount, + }; + } + + reset({ ...getValues(), ...formData }); + }, [settings]); + + useRPCPacket( + RpcMessage.SkeletonSettingsResponse, + (settings: SkeletonSettingsResponseT) => { + setSettings(settings); + } + ); + + return ( + } id="fksettings"> + <> + + {l10n.getString('settings-general-fk_settings')} + + + + {l10n.getString('settings-general-tracker_mechanics-filtering')} + +
+ {l10n + .getString( + 'settings-general-tracker_mechanics-filtering-description' + ) + .split('\n') + .map((line, i) => ( + {line} + ))} +
+ + {l10n.getString('settings-general-tracker_mechanics-filtering-type')} + +
+ + + +
+
+ percentageFormat.format(value)} + min={0.1} + max={1.0} + step={0.1} + /> +
+ +
+ + {l10n.getString( + 'settings-general-fk_settings-leg_tweak-skating_correction' + )} + + + {l10n.getString( + 'settings-general-fk_settings-leg_tweak-skating_correction-description' + )} + +
+
+ + percentageFormat.format(value)} + min={0.1} + max={1.0} + step={0.1} + /> +
+ +
+ + {l10n.getString('settings-general-fk_settings-leg_fk')} + +
+
+ + {l10n.getString( + 'settings-general-fk_settings-leg_tweak-floor_clip-description' + )} + +
+
+ +
+
+ + {l10n.getString( + 'settings-general-fk_settings-leg_tweak-foot_plant-description' + )} + +
+
+ +
+
+ + {l10n.getString( + 'settings-general-fk_settings-leg_tweak-toe_snap-description' + )} + +
+
+ +
+ +
+ + {l10n.getString('settings-general-fk_settings-arm_fk')} + + + {l10n.getString('settings-general-fk_settings-arm_fk-description')} + +
+
+ +
+ +
+ + {l10n.getString( + 'settings-general-fk_settings-enforce_joint_constraints' + )} + +
+ + {l10n.getString( + 'settings-general-fk_settings-enforce_joint_constraints-enforce_constraints-description' + )} + +
+
+
+ +
+ +
+ + {l10n.getString('settings-general-fk_settings-ik')} + + + {l10n.getString( + 'settings-general-fk_settings-ik-use_position-description' + )} + +
+
+ +
+ +
+ + {l10n.getString( + 'settings-general-fk_settings-skeleton_settings-ratios' + )} + + + {l10n.getString( + 'settings-general-fk_settings-skeleton_settings-ratios-description' + )} + +
+
+ percentageFormat.format(value)} + min={0.0} + max={1.0} + step={0.05} + /> + percentageFormat.format(value)} + min={0.0} + max={1.0} + step={0.05} + /> + percentageFormat.format(value)} + min={0.0} + max={1.0} + step={0.05} + /> + percentageFormat.format(value)} + min={0.0} + max={1.0} + step={0.05} + /> + percentageFormat.format(value)} + min={0.0} + max={1.0} + step={0.05} + /> +
+ +
+ + {l10n.getString( + 'settings-general-fk_settings-self_localization-title' + )} + + + {l10n.getString( + 'settings-general-fk_settings-self_localization-description' + )} + +
+
+ +
+ +
+ ); +} diff --git a/gui/src/components/settings/pages/components/StayAlignedSettings.tsx b/gui/src/components/settings/pages/components/StayAlignedSettings.tsx index 33eaa76f71..36719f7633 100644 --- a/gui/src/components/settings/pages/components/StayAlignedSettings.tsx +++ b/gui/src/components/settings/pages/components/StayAlignedSettings.tsx @@ -1,26 +1,32 @@ -import { Control } from 'react-hook-form'; -import { StayAlignedSettingsT } from 'solarxr-protocol'; -import { SettingsForm } from '@/components/settings/pages/GeneralSettings'; +import { Localized, useLocalization } from '@fluent/react'; +import { useEffect, useState } from 'react'; +import { DefaultValues, useForm } from 'react-hook-form'; +import { + ChangeStayAlignedSettingsRequestT, + RpcMessage, + StayAlignedSettingsRequestT, + StayAlignedSettingsResponseT, +} from 'solarxr-protocol'; import { Button } from '@/components/commons/Button'; import { CheckBox } from '@/components/commons/Checkbox'; import { Typography } from '@/components/commons/Typography'; import { SettingsPagePaneLayout } from '@/components/settings/SettingsPageLayout'; -import { Localized, useLocalization } from '@fluent/react'; -import { useAtomValue } from 'jotai'; +import { HorizontalAlignIcon } from '@/components/commons/icon/HorizontalAlignIcon'; +import { useAtomValue, atom, useSetAtom } from 'jotai'; +import { selectAtom } from 'jotai/utils'; +import { isEqual } from '@react-hookz/deep-equal'; import { connectedIMUTrackersAtom } from '@/store/app-store'; import { bodypartToString } from '@/utils/formatting'; import { useLocaleConfig } from '@/i18n/config'; +import { useWebsocketAPI } from '@/hooks/websocket-api'; import { FlatRelaxedPoseModal, SittingRelaxedPoseModal, StandingRelaxedPoseModal, } from './StayAlignedPoseModal'; -import { useState } from 'react'; -import { HorizontalAlignIcon } from '@/components/commons/icon/HorizontalAlignIcon'; export type StayAlignedSettingsForm = { enabled: boolean; - extraYawCorrection: boolean; hideYawCorrection: boolean; standingEnabled: boolean; standingUpperLegAngle: number; @@ -34,12 +40,13 @@ export type StayAlignedSettingsForm = { flatUpperLegAngle: number; flatLowerLegAngle: number; flatFootAngle: number; + // Not part of StayAlignedSettingsResponse — tracked client-side/derived + // from whether setup has produced non-zero relaxed pose angles. setupComplete: boolean; }; export const defaultStayAlignedSettings: StayAlignedSettingsForm = { enabled: false, - extraYawCorrection: false, hideYawCorrection: false, standingEnabled: false, standingUpperLegAngle: 0.0, @@ -56,35 +63,14 @@ export const defaultStayAlignedSettings: StayAlignedSettingsForm = { setupComplete: false, }; -export function serializeStayAlignedSettings( - settings: StayAlignedSettingsForm -): StayAlignedSettingsT { - const serialized = new StayAlignedSettingsT(); - serialized.enabled = settings.enabled; - serialized.extraYawCorrection = settings.extraYawCorrection; - serialized.hideYawCorrection = settings.hideYawCorrection; - serialized.standingEnabled = settings.standingEnabled; - serialized.standingUpperLegAngle = settings.standingUpperLegAngle; - serialized.standingLowerLegAngle = settings.standingLowerLegAngle; - serialized.standingFootAngle = settings.standingFootAngle; - serialized.sittingEnabled = settings.sittingEnabled; - serialized.sittingUpperLegAngle = settings.sittingUpperLegAngle; - serialized.sittingLowerLegAngle = settings.sittingLowerLegAngle; - serialized.sittingFootAngle = settings.sittingFootAngle; - serialized.flatEnabled = settings.flatEnabled; - serialized.flatUpperLegAngle = settings.flatUpperLegAngle; - serialized.flatLowerLegAngle = settings.flatLowerLegAngle; - serialized.flatFootAngle = settings.flatFootAngle; - return serialized; -} - -export function deserializeStayAlignedSettings( - serialized: StayAlignedSettingsT -): StayAlignedSettingsForm { - return serialized; -} +const stayAlignedSettingsAtom = atom(new StayAlignedSettingsResponseT()); +const stayAlignedSettingsValueAtom = selectAtom( + stayAlignedSettingsAtom, + (settings) => settings, + isEqual +); -function CopySettingsButton({ values }: { values: SettingsForm }) { +function CopySettingsButton({ values }: { values: StayAlignedSettingsForm }) { const { l10n } = useLocalization(); const { currentLocales } = useLocaleConfig(); const numberFormat = new Intl.NumberFormat(currentLocales, { @@ -99,7 +85,7 @@ function CopySettingsButton({ values }: { values: SettingsForm }) { } const copySettings = () => { - const config = values.stayAligned; + const config = values; const debug = ` Stay Aligned @@ -107,7 +93,6 @@ Stay Aligned GENERAL ======= Enabled: ${config.enabled ? 'true' : 'false'} -Extra yaw correction: ${boolify(config.extraYawCorrection)} Setup complete: ${boolify(config.setupComplete)} RELAXED POSES @@ -127,12 +112,6 @@ ${trackers } }) .join('\n')} - -OTHER -===== -Filtering: type=${values.filtering.type} amount=${numberFormat.format(values.filtering.amount)} -Enforce constraints: ${boolify(values.toggles.enforceConstraints)} -Skating correction: ${boolify(values.toggles.skatingCorrection)} `; navigator.clipboard.writeText(debug); @@ -145,16 +124,91 @@ Skating correction: ${boolify(values.toggles.skatingCorrection)} ); } -export function StayAlignedSettings({ - values, - control, -}: { - values: SettingsForm; - control: Control; -}) { +export function StayAlignedSettings() { + const setSettings = useSetAtom(stayAlignedSettingsAtom); + const settings = useAtomValue(stayAlignedSettingsValueAtom); const { l10n } = useLocalization(); + const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); + + const { control, watch, handleSubmit, getValues, reset } = + useForm({ + defaultValues: defaultStayAlignedSettings, + mode: 'onChange', + reValidateMode: 'onChange', + }); + + const values = watch(); + + const onSubmit = (values: StayAlignedSettingsForm) => { + const settingsReq = new ChangeStayAlignedSettingsRequestT(); + settingsReq.enabled = values.enabled; + settingsReq.standingEnabled = values.standingEnabled; + settingsReq.standingUpperLegAngle = values.standingUpperLegAngle; + settingsReq.standingLowerLegAngle = values.standingLowerLegAngle; + settingsReq.standingFootAngle = values.standingFootAngle; + settingsReq.sittingEnabled = values.sittingEnabled; + settingsReq.sittingUpperLegAngle = values.sittingUpperLegAngle; + settingsReq.sittingLowerLegAngle = values.sittingLowerLegAngle; + settingsReq.sittingFootAngle = values.sittingFootAngle; + settingsReq.flatEnabled = values.flatEnabled; + settingsReq.flatUpperLegAngle = values.flatUpperLegAngle; + settingsReq.flatLowerLegAngle = values.flatLowerLegAngle; + settingsReq.flatFootAngle = values.flatFootAngle; + + sendRPCPacket(RpcMessage.ChangeStayAlignedSettingsRequest, settingsReq); + }; + + useEffect(() => { + const subscription = watch((_, { type }) => { + if (type === 'change') handleSubmit(onSubmit)(); + }); + return () => subscription.unsubscribe(); + }, []); - const config = values.stayAligned; + useEffect(() => { + sendRPCPacket( + RpcMessage.StayAlignedSettingsRequest, + new StayAlignedSettingsRequestT() + ); + }, []); + + useEffect(() => { + const formData: DefaultValues = {}; + + if (settings.enabled !== undefined) formData.enabled = settings.enabled; + // TODO hideYawCorrection + + formData.standingEnabled = settings.standingEnabled ?? false; + formData.standingUpperLegAngle = settings.standingUpperLegAngle ?? 0.0; + formData.standingLowerLegAngle = settings.standingLowerLegAngle ?? 0.0; + formData.standingFootAngle = settings.standingFootAngle ?? 0.0; + + formData.sittingEnabled = settings.sittingEnabled ?? false; + formData.sittingUpperLegAngle = settings.sittingUpperLegAngle ?? 0.0; + formData.sittingLowerLegAngle = settings.sittingLowerLegAngle ?? 0.0; + formData.sittingFootAngle = settings.sittingFootAngle ?? 0.0; + + formData.flatEnabled = settings.flatEnabled ?? false; + formData.flatUpperLegAngle = settings.flatUpperLegAngle ?? 0.0; + formData.flatLowerLegAngle = settings.flatLowerLegAngle ?? 0.0; + formData.flatFootAngle = settings.flatFootAngle ?? 0.0; + + formData.setupComplete = + (formData.standingUpperLegAngle ?? 0) !== 0 || + (formData.sittingUpperLegAngle ?? 0) !== 0 || + (formData.flatUpperLegAngle ?? 0) !== 0; + + reset({ ...getValues(), ...formData }); + }, [settings]); + + useRPCPacket( + RpcMessage.StayAlignedSettingsResponse, + (settings: StayAlignedSettingsResponseT) => { + setSettings(settings); + } + ); + + const config = values; const hasStandingPose = config.standingEnabled || config.standingUpperLegAngle !== 0.0 || @@ -176,144 +230,138 @@ export function StayAlignedSettings({ const openFlat = useState(false); return ( - <> - } id="stayaligned"> - - {l10n.getString('settings-stay_aligned')} + } id="stayaligned"> + + {l10n.getString('settings-stay_aligned')} + +
+ + {l10n.getString('settings-stay_aligned-description')} + + + {l10n.getString('settings-stay_aligned-setup-description')} + +
+ +
+
+
+ + {l10n.getString('settings-stay_aligned-general-label')} + +
+ + +
+
+
+ + {l10n.getString('settings-stay_aligned-relaxed_poses-label')}
- {l10n.getString('settings-stay_aligned-description')} - - - {l10n.getString('settings-stay_aligned-setup-description')} + {l10n.getString('settings-stay_aligned-relaxed_poses-description')} -
- -
-
- - {l10n.getString('settings-stay_aligned-general-label')} - -
+
+
+ + +
+
-
-
-
- - {l10n.getString('settings-stay_aligned-relaxed_poses-label')} - -
- - {l10n.getString( - 'settings-stay_aligned-relaxed_poses-description' - )} - -
-
-
- + +
-
- - - -
-
- +
+
+ + + +
+
-
- - {l10n.getString('settings-stay_aligned-debug-label')} +
+
+ + {l10n.getString('settings-stay_aligned-debug-label')} + +
+ + {l10n.getString('settings-stay_aligned-debug-description')} -
- - {l10n.getString('settings-stay_aligned-debug-description')} - -
-
- -
- - +
+ +
+
+ ); } diff --git a/gui/src/components/settings/pages/components/TapDetectionSettings.tsx b/gui/src/components/settings/pages/components/TapDetectionSettings.tsx new file mode 100644 index 0000000000..b33914276d --- /dev/null +++ b/gui/src/components/settings/pages/components/TapDetectionSettings.tsx @@ -0,0 +1,382 @@ +import { useLocalization } from '@fluent/react'; +import { useEffect } from 'react'; +import { DefaultValues, useForm } from 'react-hook-form'; +import { + BodyPart, + ChangeTapDetectionSettingsRequestT, + RpcMessage, + TapDetectionSettingsRequestT, + TapDetectionSettingsResponseT, +} from 'solarxr-protocol'; +import { useWebsocketAPI } from '@/hooks/websocket-api'; +import { WrenchIcon } from '@/components/commons/icon/WrenchIcon'; +import { NumberSelector } from '@/components/commons/NumberSelector'; +import { Typography } from '@/components/commons/Typography'; +import { CheckBox } from '@/components/commons/Checkbox'; +import { Dropdown } from '@/components/commons/Dropdown'; +import { SettingsPagePaneLayout } from '@/components/settings/SettingsPageLayout'; +import { ASSIGNMENT_MODES } from '@/components/onboarding/BodyAssignment'; +import { atom, useAtomValue, useSetAtom } from 'jotai'; +import { isEqual } from '@react-hookz/deep-equal'; +import { selectAtom } from 'jotai/utils'; +import { useLocaleConfig } from '@/i18n/config'; + +type TapDetectionForm = { + tapDetection: { + mountingResetEnabled: boolean; + yawResetEnabled: boolean; + fullResetEnabled: boolean; + yawResetDelay: number; + fullResetDelay: number; + mountingResetDelay: number; + yawResetTaps: number; + fullResetTaps: number; + mountingResetTaps: number; + numberTrackersOverThreshold: number; + yawResetTracker: string; + mountingResetTracker: string; + fullResetTracker: string; + }; +}; + +const defaultValues: TapDetectionForm = { + tapDetection: { + mountingResetEnabled: false, + yawResetEnabled: false, + fullResetEnabled: false, + yawResetDelay: 0.2, + fullResetDelay: 1.0, + mountingResetDelay: 1.0, + yawResetTaps: 2, + fullResetTaps: 3, + mountingResetTaps: 3, + numberTrackersOverThreshold: 1, + yawResetTracker: String(BodyPart.CHEST), + mountingResetTracker: String(BodyPart.RIGHT_UPPER_LEG), + fullResetTracker: String(BodyPart.LEFT_UPPER_LEG), + }, +}; + +const tapDetectionSettingsAtom = atom(new TapDetectionSettingsResponseT()); +const tapDetectionSettingsValueAtom = selectAtom( + tapDetectionSettingsAtom, + (settings) => settings, + isEqual +); + +export function TapDetectionSettings() { + const setSettings = useSetAtom(tapDetectionSettingsAtom); + const settings = useAtomValue(tapDetectionSettingsValueAtom); + const { l10n } = useLocalization(); + const { currentLocales } = useLocaleConfig(); + const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); + + const secondsFormat = new Intl.NumberFormat(currentLocales, { + style: 'unit', + unit: 'second', + unitDisplay: 'narrow', + maximumFractionDigits: 2, + }); + + const bodyParts: { value: string; label: string }[] = Object.values(BodyPart) + .filter((v): v is BodyPart => typeof v === 'number') + .filter((v) => ASSIGNMENT_MODES['full-body'].includes(v as BodyPart)) + .map((value) => ({ + value: String(value), + label: l10n.getString(`body_part-${BodyPart[value]}`), + })); + + const { control, watch, handleSubmit, getValues, reset } = + useForm({ + defaultValues, + mode: 'onChange', + reValidateMode: 'onChange', + }); + + const onSubmit = (values: TapDetectionForm) => { + const settingsReq = new ChangeTapDetectionSettingsRequestT(); + settingsReq.fullResetDelay = values.tapDetection.fullResetDelay; + settingsReq.fullResetEnabled = values.tapDetection.fullResetEnabled; + settingsReq.fullResetTaps = values.tapDetection.fullResetTaps; + settingsReq.yawResetDelay = values.tapDetection.yawResetDelay; + settingsReq.yawResetEnabled = values.tapDetection.yawResetEnabled; + settingsReq.yawResetTaps = values.tapDetection.yawResetTaps; + settingsReq.yawResetTracker = Number(values.tapDetection.yawResetTracker); + settingsReq.mountingResetTracker = Number( + values.tapDetection.mountingResetTracker + ); + settingsReq.fullResetTracker = Number(values.tapDetection.fullResetTracker); + settingsReq.mountingResetEnabled = values.tapDetection.mountingResetEnabled; + settingsReq.mountingResetDelay = values.tapDetection.mountingResetDelay; + settingsReq.mountingResetTaps = values.tapDetection.mountingResetTaps; + settingsReq.numberTrackersOverThreshold = + values.tapDetection.numberTrackersOverThreshold; + + sendRPCPacket(RpcMessage.ChangeTapDetectionSettingsRequest, settingsReq); + }; + + useEffect(() => { + const subscription = watch((_, { type }) => { + if (type === 'change') handleSubmit(onSubmit)(); + }); + return () => subscription.unsubscribe(); + }, []); + + useEffect(() => { + sendRPCPacket( + RpcMessage.TapDetectionSettingsRequest, + new TapDetectionSettingsRequestT() + ); + }, []); + + useEffect(() => { + const formData: DefaultValues = {}; + formData.tapDetection = { + yawResetEnabled: + settings.yawResetEnabled || defaultValues.tapDetection.yawResetEnabled, + fullResetEnabled: + settings.fullResetEnabled || + defaultValues.tapDetection.fullResetEnabled, + mountingResetEnabled: + settings.mountingResetEnabled || + defaultValues.tapDetection.mountingResetEnabled, + yawResetDelay: + settings.yawResetDelay || defaultValues.tapDetection.yawResetDelay, + fullResetDelay: + settings.fullResetDelay || defaultValues.tapDetection.fullResetDelay, + mountingResetDelay: + settings.mountingResetDelay || + defaultValues.tapDetection.mountingResetDelay, + yawResetTaps: + settings.yawResetTaps || defaultValues.tapDetection.yawResetTaps, + fullResetTaps: + settings.fullResetTaps || defaultValues.tapDetection.fullResetTaps, + mountingResetTaps: + settings.mountingResetTaps || + defaultValues.tapDetection.mountingResetTaps, + yawResetTracker: String( + settings.yawResetTracker || defaultValues.tapDetection.yawResetTracker + ), + fullResetTracker: String( + settings.fullResetTracker || defaultValues.tapDetection.fullResetTracker + ), + mountingResetTracker: String( + settings.mountingResetTracker || + defaultValues.tapDetection.mountingResetTracker + ), + numberTrackersOverThreshold: + settings.numberTrackersOverThreshold || + defaultValues.tapDetection.numberTrackersOverThreshold, + }; + reset({ ...getValues(), ...formData }); + }, [settings]); + + useRPCPacket( + RpcMessage.TapDetectionSettingsResponse, + (settings: TapDetectionSettingsResponseT) => { + setSettings(settings); + } + ); + + return ( + } id="gestureControl"> + <> + + {l10n.getString('settings-general-gesture_control')} + +
+ + {l10n.getString('settings-general-gesture_control-subtitle')} + + + {l10n.getString('settings-general-gesture_control-description')} + +
+
+
+ + + +
+
+
+ + {l10n.getString( + 'settings-general-gesture_control-yawResetTracker' + )} + + +
+
+ + {l10n.getString( + 'settings-general-gesture_control-mountingResetTracker' + )} + + +
+
+ + {l10n.getString( + 'settings-general-gesture_control-fullResetTracker' + )} + + +
+
+
+
+ secondsFormat.format(value)} + min={0.2} + max={3.0} + step={0.2} + /> + secondsFormat.format(value)} + min={0.2} + max={3.0} + step={0.2} + /> + secondsFormat.format(value)} + min={0.2} + max={3.0} + step={0.2} + /> +
+
+ + l10n.getString('settings-general-gesture_control-taps', { + amount: Math.round(value), + }) + } + min={2} + max={10} + step={1} + /> + + l10n.getString('settings-general-gesture_control-taps', { + amount: Math.round(value), + }) + } + min={2} + max={10} + step={1} + /> + + l10n.getString('settings-general-gesture_control-taps', { + amount: Math.round(value), + }) + } + min={2} + max={10} + step={1} + /> +
+
+ + {l10n.getString( + 'settings-general-gesture_control-numberTrackersOverThreshold' + )} + + + {l10n.getString( + 'settings-general-gesture_control-numberTrackersOverThreshold-description' + )} + + + l10n.getString('settings-general-gesture_control-trackers', { + amount: Math.round(value), + }) + } + min={1} + max={20} + step={1} + /> +
+ +
+ ); +} diff --git a/gui/src/components/tracker/TrackerSettings.tsx b/gui/src/components/tracker/TrackerSettings.tsx index ef3fda3669..b2b2e7f339 100644 --- a/gui/src/components/tracker/TrackerSettings.tsx +++ b/gui/src/components/tracker/TrackerSettings.tsx @@ -35,7 +35,7 @@ import { SingleTrackerBodyAssignmentMenu } from './SingleTrackerBodyAssignmentMe import { TrackerCard } from './TrackerCard'; import { Quaternion } from 'three'; import { useAppContext } from '@/hooks/app'; -import { MagnetometerToggleSetting } from '@/components/settings/pages/MagnetometerToggleSetting'; +import { MagnetometerToggleSetting } from '@/components/settings/pages/components/MagnetometerToggleSetting'; import { useSetAtom } from 'jotai'; import { ignoredTrackersAtom } from '@/store/app-store'; import { checkForUpdate } from '@/hooks/firmware-update'; @@ -506,8 +506,7 @@ export function TrackerSettingsPage() { MagnetometerStatus.NOT_SUPPORTED && ( )}
diff --git a/gui/src/components/tracker/TrackersTable.tsx b/gui/src/components/tracker/TrackersTable.tsx index 3092e953f7..bbd109de74 100644 --- a/gui/src/components/tracker/TrackersTable.tsx +++ b/gui/src/components/tracker/TrackersTable.tsx @@ -184,11 +184,8 @@ function Row({ const { tracker, device } = data; const warning = - !!highlightedTrackers?.trackers.find( - (t) => - t?.deviceId?.id === tracker.trackerId?.deviceId?.id && - t?.trackerNum === tracker.trackerId?.trackerNum - ) && highlightedTrackers.step; + !!highlightedTrackers?.trackers.find((t) => t === tracker.trackerId) && + highlightedTrackers.step; return ( diff --git a/gui/src/components/tracking-checklist/TrackingChecklist.tsx b/gui/src/components/tracking-checklist/TrackingChecklist.tsx index bba9850beb..d3084d1232 100644 --- a/gui/src/components/tracking-checklist/TrackingChecklist.tsx +++ b/gui/src/components/tracking-checklist/TrackingChecklist.tsx @@ -6,17 +6,16 @@ import { } from '@/hooks/tracking-checklist'; import classNames from 'classnames'; import { - ChangeSettingsRequestT, EnableSteamVRDriverRequestT, ResetType, RpcMessage, - SettingsRequestT, - SettingsResponseT, - OutputTrackersSettingT, TrackingChecklistPublicNetworksT, TrackingChecklistSteamVRDisconnectedT, TrackingChecklistStepId, BodyPart, + ChangeOutputTrackersSettingsRequestT, + OutputTrackersSettingsResponseT, + OutputTrackersSettingsRequestT, } from 'solarxr-protocol'; import { ReactNode, useEffect, useMemo, useState } from 'react'; import { CheckIcon } from '@/components/commons/icon/CheckIcon'; @@ -177,30 +176,41 @@ function SteamVRDisconnected({ function SteamVRHandsEnabled() { const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); const [steamVrTrackers, setSteamVrTrackers] = useState | null>(null); useEffect(() => { - sendRPCPacket(RpcMessage.SettingsRequest, new SettingsRequestT()); + sendRPCPacket( + RpcMessage.OutputTrackersSettingsRequest, + new OutputTrackersSettingsRequestT() + ); }, []); - useRPCPacket(RpcMessage.SettingsResponse, (settings: SettingsResponseT) => { - if (settings.outputTrackers) { - setSteamVrTrackers(settings.outputTrackers); + useRPCPacket( + RpcMessage.OutputTrackersSettingsResponse, + (settings: OutputTrackersSettingsResponseT) => { + if (settings.trackers) { + setSteamVrTrackers(settings); + } } - }); + ); const disableHandTrackers = () => { - const settings = new ChangeSettingsRequestT(); - settings.outputTrackers = new OutputTrackersSettingT( - steamVrTrackers?.automaticTrackerToggle, - steamVrTrackers?.trackers.filter( - (tracker) => - tracker != BodyPart.LEFT_HAND && tracker != BodyPart.RIGHT_HAND - ) + const req = new ChangeOutputTrackersSettingsRequestT(); + + const newTrackers = steamVrTrackers?.trackers.filter( + (tracker) => + tracker != BodyPart.LEFT_HAND && tracker != BodyPart.RIGHT_HAND ); - sendRPCPacket(RpcMessage.ChangeSettingsRequest, settings); + if (newTrackers !== undefined) { + req.trackers = newTrackers; + } + req.automaticTrackerToggle = + steamVrTrackers?.automaticTrackerToggle === true; + req.sendDerivedVelocity = steamVrTrackers?.sendDerivedVelocity === true; + + sendRPCPacket(RpcMessage.ChangeOutputTrackersSettingsRequest, req); }; return ( @@ -294,7 +304,7 @@ const stepContentLookup: Record<
- +
); @@ -386,7 +396,7 @@ const stepContentLookup: Record< />
- + {step.ignorable && (
- + {step.ignorable && (
diff --git a/gui/src/components/settings/pages/GeneralSettings.tsx b/gui/src/components/settings/pages/GeneralSettings.tsx index d80462688d..ba270a3f97 100644 --- a/gui/src/components/settings/pages/GeneralSettings.tsx +++ b/gui/src/components/settings/pages/GeneralSettings.tsx @@ -1,10 +1,16 @@ -import { SettingsPageLayout } from '@/components/settings/SettingsPageLayout'; +import {SettingsPageLayout, SettingsPagePaneLayout} from '@/components/settings/SettingsPageLayout'; import { OutputTrackersSettings } from './components/OutputSettings'; import { StayAlignedSettings } from './components/StayAlignedSettings'; import { ResetsSettings } from './components/ResetsSettings'; import { HIDSettings } from './components/HIDSettings'; -import { SkeletonSettings } from './components/SkeletonSettings'; +import { TrackingSettings } from './components/TrackingSettings'; import { TapDetectionSettings } from './components/TapDetectionSettings'; +import {WrenchIcon} from "@/components/commons/icon/WrenchIcon"; +import {MagnetometerToggleSetting} from "@/components/settings/pages/components/MagnetometerToggleSetting"; +import {Typography} from "@/components/commons/Typography"; +import {useLocalization} from "@fluent/react"; +import {ResetButtonIcon} from "@/components/home/ResetButton"; +import {FullResetIcon} from "@/components/commons/icon/ResetIcon"; export function GeneralSettings() { return ( @@ -12,11 +18,51 @@ export function GeneralSettings() {
- - - - + + + ); } + +export function GroupedResetsSettings() { + const { l10n } = useLocalization(); + + return ( + } id="resets"> + <> +
+ + {l10n.getString('settings-general-fk_settings-resets_settings')} + +
+ + + + +
+ ); +} + +export function GroupedTrackersSettings() { + const { l10n } = useLocalization(); + + return ( + } id="trackers"> + <> +
+ + {l10n.getString('settings-general-trackers_settings')} + +
+ + + + +
+ ); +} diff --git a/gui/src/components/settings/pages/components/HIDSettings.tsx b/gui/src/components/settings/pages/components/HIDSettings.tsx index e71954949e..71b9cf4b58 100644 --- a/gui/src/components/settings/pages/components/HIDSettings.tsx +++ b/gui/src/components/settings/pages/components/HIDSettings.tsx @@ -78,9 +78,8 @@ export function HIDSettings() { ); return ( - } id="mechanics-hid"> <> -
+
{l10n.getString( 'settings-general-tracker_mechanics-trackers_over_usb' @@ -103,6 +102,5 @@ export function HIDSettings() { )} /> - ); } diff --git a/gui/src/components/settings/pages/components/MagnetometerToggleSetting.tsx b/gui/src/components/settings/pages/components/MagnetometerToggleSetting.tsx index c381a8f644..13188c69d2 100644 --- a/gui/src/components/settings/pages/components/MagnetometerToggleSetting.tsx +++ b/gui/src/components/settings/pages/components/MagnetometerToggleSetting.tsx @@ -80,7 +80,7 @@ export function MagnetometerToggleSetting({ return settingType === 'general' ? ( <>
diff --git a/gui/src/components/settings/pages/components/OutputSettings.tsx b/gui/src/components/settings/pages/components/OutputSettings.tsx index 64e2056ffc..352e262b56 100644 --- a/gui/src/components/settings/pages/components/OutputSettings.tsx +++ b/gui/src/components/settings/pages/components/OutputSettings.tsx @@ -221,14 +221,14 @@ export function OutputTrackersSettings() { <> {l10n.getString('settings-general-output')} -
+
{l10n.getString( 'settings-general-steamvr-trackers-tracker_toggling' )} -
+
{l10n .getString( 'settings-general-output-trackers-tracker_toggling-description' @@ -249,11 +249,11 @@ export function OutputTrackersSettings() { /> -
+
{l10n.getString('settings-general-output_trackers')} -
+
{l10n .getString('settings-general-output_trackers-description') .split('\n') @@ -359,18 +359,18 @@ export function OutputTrackersSettings() { />
-
+
{l10n.getString('settings-general-fk_settings-velocity_settings')} -
+
{l10n.getString( 'settings-general-fk_settings-velocity_settings-description' )}
-
+
- } id="mechanics-resets"> - <> -
- + + {l10n.getString( + 'settings-general-tracker_mechanics-yaw-reset-smooth-time' )} - valueLabelFormat={(value) => secondsFormat.format(value)} - min={0.0} - max={0.5} - step={0.05} - /> +
-
+ + secondsFormat.format(value)} + min={0.0} + max={0.5} + step={0.05} + /> + +
{l10n.getString( 'settings-general-tracker_mechanics-save_mounting_reset' @@ -131,20 +133,8 @@ export function ResetsSettings() { 'settings-general-tracker_mechanics-save_mounting_reset-enabled-label' )} /> - - - - } id="fksettings-resets"> - <> -
- - {l10n.getString('settings-general-fk_settings-reset_settings')} - -
-
+ +
@@ -180,7 +170,8 @@ export function ResetsSettings() {
-
+ +
{l10n.getString( 'settings-general-fk_settings-arm_fk-reset_mode-description' @@ -233,8 +224,6 @@ export function ResetsSettings() { />
- - ); } diff --git a/gui/src/components/settings/pages/components/StayAlignedSettings.tsx b/gui/src/components/settings/pages/components/StayAlignedSettings.tsx index 36719f7633..d321222488 100644 --- a/gui/src/components/settings/pages/components/StayAlignedSettings.tsx +++ b/gui/src/components/settings/pages/components/StayAlignedSettings.tsx @@ -230,18 +230,18 @@ export function StayAlignedSettings() { const openFlat = useState(false); return ( - } id="stayaligned"> + } id="stayAligned"> {l10n.getString('settings-stay_aligned')} -
+
{l10n.getString('settings-stay_aligned-description')} {l10n.getString('settings-stay_aligned-setup-description')} -
+
-
+
{l10n.getString('settings-stay_aligned-general-label')} -
+
-
+
{l10n.getString('settings-stay_aligned-relaxed_poses-label')} -
+
{l10n.getString('settings-stay_aligned-relaxed_poses-description')}
-
+
-
+
{l10n.getString('settings-stay_aligned-debug-label')} -
+
{l10n.getString('settings-stay_aligned-debug-description')}
-
+
diff --git a/gui/src/components/settings/pages/components/TapDetectionSettings.tsx b/gui/src/components/settings/pages/components/TapDetectionSettings.tsx index b33914276d..9c3f72d509 100644 --- a/gui/src/components/settings/pages/components/TapDetectionSettings.tsx +++ b/gui/src/components/settings/pages/components/TapDetectionSettings.tsx @@ -179,12 +179,8 @@ export function TapDetectionSettings() { ); return ( - } id="gestureControl"> <> - - {l10n.getString('settings-general-gesture_control')} - -
+
{l10n.getString('settings-general-gesture_control-subtitle')} @@ -302,7 +298,7 @@ export function TapDetectionSettings() { step={0.2} />
-
+
-
+ +
{l10n.getString( 'settings-general-gesture_control-numberTrackersOverThreshold' @@ -360,23 +357,22 @@ export function TapDetectionSettings() { 'settings-general-gesture_control-numberTrackersOverThreshold-description' )} - - l10n.getString('settings-general-gesture_control-trackers', { - amount: Math.round(value), - }) - } - min={1} - max={20} - step={1} - /> + +
+ + l10n.getString('settings-general-gesture_control-trackers', { + amount: Math.round(value), + }) + } + min={1} + max={20} + step={1} + /> +
- ); } diff --git a/gui/src/components/settings/pages/components/SkeletonSettings.tsx b/gui/src/components/settings/pages/components/TrackingSettings.tsx similarity index 87% rename from gui/src/components/settings/pages/components/SkeletonSettings.tsx rename to gui/src/components/settings/pages/components/TrackingSettings.tsx index 1f10d3fcbc..f9b6b4b1fa 100644 --- a/gui/src/components/settings/pages/components/SkeletonSettings.tsx +++ b/gui/src/components/settings/pages/components/TrackingSettings.tsx @@ -22,6 +22,7 @@ import { SettingsPagePaneLayout } from '@/components/settings/SettingsPageLayout import { atom, useAtomValue, useSetAtom } from 'jotai'; import { isEqual } from '@react-hookz/deep-equal'; import { selectAtom } from 'jotai/utils'; +import {MartialArtsIcon} from "@/components/commons/icon/MartialArtsIcon"; type SkeletonForm = { toggles: { @@ -30,7 +31,7 @@ type SkeletonForm = { skatingCorrection: boolean; toeSnap: boolean; footPlant: boolean; - selfLocalization: boolean; + mocapMode: boolean; usePosition: boolean; enforceConstraints: boolean; correctConstraints: boolean; @@ -65,7 +66,7 @@ const defaultValues: SkeletonForm = { skatingCorrection: false, toeSnap: false, footPlant: true, - selfLocalization: false, + mocapMode: false, usePosition: true, enforceConstraints: true, correctConstraints: true, @@ -81,7 +82,7 @@ const defaultValues: SkeletonForm = { filtering: { amount: 0.1, type: FilteringType.NONE }, }; -export function SkeletonSettings() { +export function TrackingSettings() { const setSettings = useSetAtom(skeletonSettingsAtom); const settings = useAtomValue(skeletonSettingsValueAtom); const { l10n } = useLocalization(); @@ -109,7 +110,7 @@ export function SkeletonSettings() { toggles.forceArmsFromHmd = values.toggles.forceArmsFromHmd; toggles.toeSnap = values.toggles.toeSnap; toggles.footPlant = values.toggles.footPlant; - toggles.selfLocalization = values.toggles.selfLocalization; + toggles.mocapMode = values.toggles.mocapMode; toggles.usePosition = values.toggles.usePosition; toggles.enforceConstraints = values.toggles.enforceConstraints; toggles.correctConstraints = values.toggles.correctConstraints; @@ -187,16 +188,19 @@ export function SkeletonSettings() { ); return ( - } id="fksettings"> + } id="tracking"> <> {l10n.getString('settings-general-fk_settings')} - - {l10n.getString('settings-general-tracker_mechanics-filtering')} - -
+
+ + {l10n.getString('settings-general-tracker_mechanics-filtering')} + +
+ +
{l10n .getString( 'settings-general-tracker_mechanics-filtering-description' @@ -206,7 +210,7 @@ export function SkeletonSettings() { {line} ))}
- + {l10n.getString('settings-general-tracker_mechanics-filtering-type')}
@@ -244,7 +248,7 @@ export function SkeletonSettings() { value={FilteringType.PREDICTION.toString()} />
-
+
-
+
{l10n.getString( 'settings-general-fk_settings-leg_tweak-skating_correction' @@ -270,7 +274,7 @@ export function SkeletonSettings() { )}
-
+
-
+
{l10n.getString('settings-general-fk_settings-leg_fk')}
-
+
{l10n.getString( 'settings-general-fk_settings-leg_tweak-floor_clip-description' )}
-
+
-
+
{l10n.getString( 'settings-general-fk_settings-leg_tweak-foot_plant-description' )}
-
+
-
+
{l10n.getString( 'settings-general-fk_settings-leg_tweak-toe_snap-description' )}
-
+
-
+
{l10n.getString('settings-general-fk_settings-arm_fk')} @@ -361,7 +365,7 @@ export function SkeletonSettings() { {l10n.getString('settings-general-fk_settings-arm_fk-description')}
-
+
-
+
{l10n.getString( 'settings-general-fk_settings-enforce_joint_constraints' )} -
- - {l10n.getString( + + {l10n.getString( 'settings-general-fk_settings-enforce_joint_constraints-enforce_constraints-description' - )} - -
+ )} +
-
+
-
- +
+ {l10n.getString('settings-general-fk_settings-ik')} - + {l10n.getString( 'settings-general-fk_settings-ik-use_position-description' )}
-
+
-
+
+ + {l10n.getString( + 'settings-general-fk_settings-self_localization-title' + )} + + + {l10n.getString( + 'settings-general-fk_settings-self_localization-description' + )} + +
+
+ +
+ +
{l10n.getString( 'settings-general-fk_settings-skeleton_settings-ratios' @@ -433,7 +459,7 @@ export function SkeletonSettings() { )}
-
+
- -
- - {l10n.getString( - 'settings-general-fk_settings-self_localization-title' - )} - - - {l10n.getString( - 'settings-general-fk_settings-self_localization-description' - )} - -
-
- -
); diff --git a/gui/vite.config.ts b/gui/vite.config.ts index 4973749dd8..b2f86c21ea 100644 --- a/gui/vite.config.ts +++ b/gui/vite.config.ts @@ -64,6 +64,7 @@ export default defineConfig({ esbuildOptions: { target: 'es2022', }, + needsInterop: ['solarxr-protocol'], include: ['solarxr-protocol'], }, resolve: { diff --git a/solarxr-protocol b/solarxr-protocol index 17da40a966..25118f5371 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 17da40a96612e788f9b10b001ad97ac0495cd828 +Subproject commit 25118f5371630ff7c19033cd14b5e573b9e40cca From d9edf9fb697a8d68f5891910fa2780aacb1d6cb9 Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 1 Jul 2026 23:09:52 -0400 Subject: [PATCH 211/350] lint & format --- .../components/settings/SettingsSidebar.tsx | 6 +- .../settings/pages/GeneralSettings.tsx | 70 ++-- .../settings/pages/components/HIDSettings.tsx | 48 ++- .../pages/components/OutputSettings.tsx | 3 +- .../pages/components/ResetsSettings.tsx | 235 ++++++------ .../pages/components/TapDetectionSettings.tsx | 346 +++++++++--------- .../pages/components/TrackingSettings.tsx | 23 +- gui/src/hooks/onboarding.ts | 2 +- 8 files changed, 362 insertions(+), 371 deletions(-) diff --git a/gui/src/components/settings/SettingsSidebar.tsx b/gui/src/components/settings/SettingsSidebar.tsx index 460909224a..d501795692 100644 --- a/gui/src/components/settings/SettingsSidebar.tsx +++ b/gui/src/components/settings/SettingsSidebar.tsx @@ -69,9 +69,9 @@ export function SettingsSidebar() { id="settings-sidebar-resets" />
diff --git a/gui/src/components/settings/pages/GeneralSettings.tsx b/gui/src/components/settings/pages/GeneralSettings.tsx index ba270a3f97..69c616827c 100644 --- a/gui/src/components/settings/pages/GeneralSettings.tsx +++ b/gui/src/components/settings/pages/GeneralSettings.tsx @@ -1,16 +1,18 @@ -import {SettingsPageLayout, SettingsPagePaneLayout} from '@/components/settings/SettingsPageLayout'; +import { + SettingsPageLayout, + SettingsPagePaneLayout, +} from '@/components/settings/SettingsPageLayout'; import { OutputTrackersSettings } from './components/OutputSettings'; import { StayAlignedSettings } from './components/StayAlignedSettings'; import { ResetsSettings } from './components/ResetsSettings'; import { HIDSettings } from './components/HIDSettings'; import { TrackingSettings } from './components/TrackingSettings'; import { TapDetectionSettings } from './components/TapDetectionSettings'; -import {WrenchIcon} from "@/components/commons/icon/WrenchIcon"; -import {MagnetometerToggleSetting} from "@/components/settings/pages/components/MagnetometerToggleSetting"; -import {Typography} from "@/components/commons/Typography"; -import {useLocalization} from "@fluent/react"; -import {ResetButtonIcon} from "@/components/home/ResetButton"; -import {FullResetIcon} from "@/components/commons/icon/ResetIcon"; +import { WrenchIcon } from '@/components/commons/icon/WrenchIcon'; +import { MagnetometerToggleSetting } from './components/MagnetometerToggleSetting'; +import { Typography } from '@/components/commons/Typography'; +import { useLocalization } from '@fluent/react'; +import { FullResetIcon } from '@/components/commons/icon/ResetIcon'; export function GeneralSettings() { return ( @@ -27,42 +29,42 @@ export function GeneralSettings() { } export function GroupedResetsSettings() { - const { l10n } = useLocalization(); + const { l10n } = useLocalization(); return ( - } id="resets"> - <> -
- - {l10n.getString('settings-general-fk_settings-resets_settings')} - -
+ } id="resets"> + <> +
+ + {l10n.getString('settings-general-fk_settings-resets_settings')} + +
- - - -
+ + + +
); } export function GroupedTrackersSettings() { - const { l10n } = useLocalization(); + const { l10n } = useLocalization(); return ( - } id="trackers"> - <> -
- - {l10n.getString('settings-general-trackers_settings')} - -
+ } id="trackers"> + <> +
+ + {l10n.getString('settings-general-trackers_settings')} + +
- - - -
+ + + +
); } diff --git a/gui/src/components/settings/pages/components/HIDSettings.tsx b/gui/src/components/settings/pages/components/HIDSettings.tsx index 71b9cf4b58..371af50f3b 100644 --- a/gui/src/components/settings/pages/components/HIDSettings.tsx +++ b/gui/src/components/settings/pages/components/HIDSettings.tsx @@ -10,11 +10,9 @@ import { import { useWebsocketAPI } from '@/hooks/websocket-api'; import { CheckBox } from '@/components/commons/Checkbox'; import { Typography } from '@/components/commons/Typography'; -import { SettingsPagePaneLayout } from '@/components/settings/SettingsPageLayout'; import { atom, useAtomValue, useSetAtom } from 'jotai'; import { isEqual } from '@react-hookz/deep-equal'; import { selectAtom } from 'jotai/utils'; -import { WrenchIcon } from '@/components/commons/icon/WrenchIcon'; type HIDForm = { hidSettings: { @@ -78,29 +76,29 @@ export function HIDSettings() { ); return ( - <> -
- - {l10n.getString( - 'settings-general-tracker_mechanics-trackers_over_usb' - )} - - }} - > - - -
- +
+ + {l10n.getString( + 'settings-general-tracker_mechanics-trackers_over_usb' )} - /> - + + }} + > + + +
+ + ); } diff --git a/gui/src/components/settings/pages/components/OutputSettings.tsx b/gui/src/components/settings/pages/components/OutputSettings.tsx index 352e262b56..6f31a8b512 100644 --- a/gui/src/components/settings/pages/components/OutputSettings.tsx +++ b/gui/src/components/settings/pages/components/OutputSettings.tsx @@ -10,14 +10,13 @@ import { } from 'solarxr-protocol'; import { useWebsocketAPI } from '@/hooks/websocket-api'; import { CheckBox } from '@/components/commons/Checkbox'; -import { SteamIcon } from '@/components/commons/icon/SteamIcon'; import { Typography } from '@/components/commons/Typography'; import { SettingsPagePaneLayout } from '@/components/settings/SettingsPageLayout'; import { atom, useAtomValue, useSetAtom } from 'jotai'; import { isEqual } from '@react-hookz/deep-equal'; import { selectAtom } from 'jotai/utils'; import { HandsWarningModal } from './HandsWarningModal'; -import {OutputIcon} from "@/components/commons/icon/OutputIcon"; +import { OutputIcon } from '@/components/commons/icon/OutputIcon'; type OutputTrackersForm = { automaticTrackerToggle: boolean; diff --git a/gui/src/components/settings/pages/components/ResetsSettings.tsx b/gui/src/components/settings/pages/components/ResetsSettings.tsx index e6f0000a07..05ca835c91 100644 --- a/gui/src/components/settings/pages/components/ResetsSettings.tsx +++ b/gui/src/components/settings/pages/components/ResetsSettings.tsx @@ -9,12 +9,9 @@ import { import { useWebsocketAPI } from '@/hooks/websocket-api'; import { useLocaleConfig } from '@/i18n/config'; import { CheckBox } from '@/components/commons/Checkbox'; -import { WrenchIcon } from '@/components/commons/icon/WrenchIcon'; import { NumberSelector } from '@/components/commons/NumberSelector'; import { Radio } from '@/components/commons/Radio'; import { Typography } from '@/components/commons/Typography'; -import { SettingsPagePaneLayout } from '@/components/settings/SettingsPageLayout'; -import { MagnetometerToggleSetting } from './MagnetometerToggleSetting'; import { defaultResetSettings, loadResetSettings, @@ -94,136 +91,134 @@ export function ResetsSettings() { return ( <> -
- - {l10n.getString( - 'settings-general-tracker_mechanics-yaw-reset-smooth-time' - )} - -
+
+ + {l10n.getString( + 'settings-general-tracker_mechanics-yaw-reset-smooth-time' + )} + +
secondsFormat.format(value)} - min={0.0} - max={0.5} - step={0.05} + control={control} + name="resetsSettings.yawResetSmoothTime" + valueLabelFormat={(value) => secondsFormat.format(value)} + min={0.0} + max={0.5} + step={0.05} + /> + +
+ + {l10n.getString( + 'settings-general-tracker_mechanics-save_mounting_reset' + )} + + }} + > + + +
+ -
- +
+
+
+ {l10n.getString( - 'settings-general-tracker_mechanics-save_mounting_reset' + 'settings-general-fk_settings-reset_settings-reset_hmd_pitch-description' )} - }} - > - - -
- - -
-
-
- - {l10n.getString( - 'settings-general-fk_settings-reset_settings-reset_hmd_pitch-description' - )} - - -
-
- - {l10n.getString( - 'settings-general-fk_settings-leg_fk-reset_mounting_feet-description-v1' - )} - - -
-
+
- -
+
{l10n.getString( - 'settings-general-fk_settings-arm_fk-reset_mode-description' + 'settings-general-fk_settings-leg_fk-reset_mounting_feet-description-v1' )} -
- - - - -
+
+
+
+ +
+ + {l10n.getString( + 'settings-general-fk_settings-arm_fk-reset_mode-description' + )} + +
+ + + + +
+
); } diff --git a/gui/src/components/settings/pages/components/TapDetectionSettings.tsx b/gui/src/components/settings/pages/components/TapDetectionSettings.tsx index 9c3f72d509..a2945e1bfa 100644 --- a/gui/src/components/settings/pages/components/TapDetectionSettings.tsx +++ b/gui/src/components/settings/pages/components/TapDetectionSettings.tsx @@ -9,12 +9,10 @@ import { TapDetectionSettingsResponseT, } from 'solarxr-protocol'; import { useWebsocketAPI } from '@/hooks/websocket-api'; -import { WrenchIcon } from '@/components/commons/icon/WrenchIcon'; import { NumberSelector } from '@/components/commons/NumberSelector'; import { Typography } from '@/components/commons/Typography'; import { CheckBox } from '@/components/commons/Checkbox'; import { Dropdown } from '@/components/commons/Dropdown'; -import { SettingsPagePaneLayout } from '@/components/settings/SettingsPageLayout'; import { ASSIGNMENT_MODES } from '@/components/onboarding/BodyAssignment'; import { atom, useAtomValue, useSetAtom } from 'jotai'; import { isEqual } from '@react-hookz/deep-equal'; @@ -179,200 +177,200 @@ export function TapDetectionSettings() { ); return ( - <> -
- - {l10n.getString('settings-general-gesture_control-subtitle')} - - - {l10n.getString('settings-general-gesture_control-description')} - -
-
-
- - - -
-
-
- - {l10n.getString( - 'settings-general-gesture_control-yawResetTracker' - )} - - -
-
- - {l10n.getString( - 'settings-general-gesture_control-mountingResetTracker' - )} - - -
-
- - {l10n.getString( - 'settings-general-gesture_control-fullResetTracker' - )} - - -
-
-
+ <> +
+ + {l10n.getString('settings-general-gesture_control-subtitle')} + + + {l10n.getString('settings-general-gesture_control-description')} + +
+
- secondsFormat.format(value)} - min={0.2} - max={3.0} - step={0.2} /> - secondsFormat.format(value)} - min={0.2} - max={3.0} - step={0.2} /> - secondsFormat.format(value)} - min={0.2} - max={3.0} - step={0.2} />
-
- - l10n.getString('settings-general-gesture_control-taps', { - amount: Math.round(value), - }) - } - min={2} - max={10} - step={1} - /> - - l10n.getString('settings-general-gesture_control-taps', { - amount: Math.round(value), - }) - } - min={2} - max={10} - step={1} - /> +
+
+ + {l10n.getString( + 'settings-general-gesture_control-yawResetTracker' + )} + + +
+
+ + {l10n.getString( + 'settings-general-gesture_control-mountingResetTracker' + )} + + +
+
+ + {l10n.getString( + 'settings-general-gesture_control-fullResetTracker' + )} + + +
+
+
+
+ secondsFormat.format(value)} + min={0.2} + max={3.0} + step={0.2} + /> + secondsFormat.format(value)} + min={0.2} + max={3.0} + step={0.2} + /> + secondsFormat.format(value)} + min={0.2} + max={3.0} + step={0.2} + /> +
+
+ + l10n.getString('settings-general-gesture_control-taps', { + amount: Math.round(value), + }) + } + min={2} + max={10} + step={1} + /> + + l10n.getString('settings-general-gesture_control-taps', { + amount: Math.round(value), + }) + } + min={2} + max={10} + step={1} + /> + + l10n.getString('settings-general-gesture_control-taps', { + amount: Math.round(value), + }) + } + min={2} + max={10} + step={1} + /> +
+ +
+ + {l10n.getString( + 'settings-general-gesture_control-numberTrackersOverThreshold' + )} + + + {l10n.getString( + 'settings-general-gesture_control-numberTrackersOverThreshold-description' + )} + + +
- l10n.getString('settings-general-gesture_control-taps', { + l10n.getString('settings-general-gesture_control-trackers', { amount: Math.round(value), }) } - min={2} - max={10} + min={1} + max={20} step={1} />
- -
- - {l10n.getString( - 'settings-general-gesture_control-numberTrackersOverThreshold' - )} - - - {l10n.getString( - 'settings-general-gesture_control-numberTrackersOverThreshold-description' - )} - - -
- - l10n.getString('settings-general-gesture_control-trackers', { - amount: Math.round(value), - }) - } - min={1} - max={20} - step={1} - /> -
-
- +
+ ); } diff --git a/gui/src/components/settings/pages/components/TrackingSettings.tsx b/gui/src/components/settings/pages/components/TrackingSettings.tsx index f9b6b4b1fa..3e4f657520 100644 --- a/gui/src/components/settings/pages/components/TrackingSettings.tsx +++ b/gui/src/components/settings/pages/components/TrackingSettings.tsx @@ -14,7 +14,6 @@ import { import { useWebsocketAPI } from '@/hooks/websocket-api'; import { useLocaleConfig } from '@/i18n/config'; import { CheckBox } from '@/components/commons/Checkbox'; -import { WrenchIcon } from '@/components/commons/icon/WrenchIcon'; import { NumberSelector } from '@/components/commons/NumberSelector'; import { Radio } from '@/components/commons/Radio'; import { Typography } from '@/components/commons/Typography'; @@ -22,7 +21,7 @@ import { SettingsPagePaneLayout } from '@/components/settings/SettingsPageLayout import { atom, useAtomValue, useSetAtom } from 'jotai'; import { isEqual } from '@react-hookz/deep-equal'; import { selectAtom } from 'jotai/utils'; -import {MartialArtsIcon} from "@/components/commons/icon/MartialArtsIcon"; +import { MartialArtsIcon } from '@/components/commons/icon/MartialArtsIcon'; type SkeletonForm = { toggles: { @@ -385,7 +384,7 @@ export function TrackingSettings() { {l10n.getString( - 'settings-general-fk_settings-enforce_joint_constraints-enforce_constraints-description' + 'settings-general-fk_settings-enforce_joint_constraints-enforce_constraints-description' )}
@@ -426,24 +425,24 @@ export function TrackingSettings() {
{l10n.getString( - 'settings-general-fk_settings-self_localization-title' + 'settings-general-fk_settings-self_localization-title' )} {l10n.getString( - 'settings-general-fk_settings-self_localization-description' + 'settings-general-fk_settings-self_localization-description' )}
diff --git a/gui/src/hooks/onboarding.ts b/gui/src/hooks/onboarding.ts index a2b168504f..b0f7e1fe75 100644 --- a/gui/src/hooks/onboarding.ts +++ b/gui/src/hooks/onboarding.ts @@ -127,7 +127,7 @@ export function useProvideOnboarding() { const mocap = usage === 'mocap' || usage === 'vtubing'; const toggles = Object.assign(new SkeletonTogglesT(), skeletonSettings.toggles); - toggles.selfLocalization = mocap && playspace === 'standing'; + toggles.mocapMode = mocap && playspace === 'standing'; const skeletonReq = new ChangeSkeletonSettingsRequestT(); skeletonReq.toggles = toggles; From 67e5880739fb9496fb15f29b52757b850824892d Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 2 Jul 2026 17:31:07 -0400 Subject: [PATCH 212/350] Fix steamvr -> output scrollTo --- gui/src/components/Navbar.tsx | 2 +- gui/src/components/TopBar.tsx | 2 +- .../settings/pages/GeneralSettings.tsx | 28 +++++++++---------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/gui/src/components/Navbar.tsx b/gui/src/components/Navbar.tsx index 5b5e6c4c4d..20e47d6124 100644 --- a/gui/src/components/Navbar.tsx +++ b/gui/src/components/Navbar.tsx @@ -121,7 +121,7 @@ export function Navbar() { } > {l10n.getString('navbar-settings')} diff --git a/gui/src/components/TopBar.tsx b/gui/src/components/TopBar.tsx index c87b500c10..5f0fdfb426 100644 --- a/gui/src/components/TopBar.tsx +++ b/gui/src/components/TopBar.tsx @@ -208,7 +208,7 @@ export function TopBar({ diff --git a/gui/src/components/settings/pages/GeneralSettings.tsx b/gui/src/components/settings/pages/GeneralSettings.tsx index 69c616827c..f8c3a784ba 100644 --- a/gui/src/components/settings/pages/GeneralSettings.tsx +++ b/gui/src/components/settings/pages/GeneralSettings.tsx @@ -14,20 +14,6 @@ import { Typography } from '@/components/commons/Typography'; import { useLocalization } from '@fluent/react'; import { FullResetIcon } from '@/components/commons/icon/ResetIcon'; -export function GeneralSettings() { - return ( - -
- - - - - - -
- ); -} - export function GroupedResetsSettings() { const { l10n } = useLocalization(); @@ -68,3 +54,17 @@ export function GroupedTrackersSettings() { ); } + +export function GeneralSettings() { + return ( + +
+ + + + + +
+
+ ); +} \ No newline at end of file From 4ea067731bba8c11e8eb2f011c62a539914c22ae Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 2 Jul 2026 18:28:48 -0400 Subject: [PATCH 213/350] UserHeight/SkeletonHeight --- gui/src/components/Sidebar.tsx | 4 +- .../onboarding/pages/ResetTutorial.tsx | 209 ------------------ .../body-proportions/ManualProportions.tsx | 2 +- .../body-proportions/ScaledProportions.tsx | 10 +- .../settings/pages/GeneralSettings.tsx | 20 +- .../settings/pages/components/HIDSettings.tsx | 13 +- .../pages/components/OutputSettings.tsx | 2 +- .../pages/components/ResetsSettings.tsx | 57 ++--- .../pages/components/StayAlignedSettings.tsx | 2 +- gui/src/hooks/height.ts | 21 +- gui/src/hooks/onboarding.ts | 1 - gui/src/hooks/reset-settings.ts | 57 ----- .../main/java/dev/slimevr/config/settings.kt | 2 +- .../java/dev/slimevr/firmware/reconnect.kt | 2 +- .../java/dev/slimevr/skeleton/behaviours.kt | 8 +- .../main/java/dev/slimevr/skeleton/module.kt | 4 +- .../dev/slimevr/skeleton/skatingcorrection.kt | 18 +- .../main/java/dev/slimevr/solarxr/datafeed.kt | 34 +-- .../main/java/dev/slimevr/solarxr/module.kt | 3 +- .../main/java/dev/slimevr/solarxr/resets.kt | 18 +- .../dev/slimevr/solarxr/skeletonconfig.kt | 30 ++- .../java/dev/slimevr/solarxr/tapdetection.kt | 36 +-- .../slimevr/trackingchecklist/behaviours.kt | 2 +- solarxr-protocol | 2 +- 24 files changed, 149 insertions(+), 408 deletions(-) delete mode 100644 gui/src/components/onboarding/pages/ResetTutorial.tsx delete mode 100644 gui/src/hooks/reset-settings.ts diff --git a/gui/src/components/Sidebar.tsx b/gui/src/components/Sidebar.tsx index 7c4456d512..d5dcd1307e 100644 --- a/gui/src/components/Sidebar.tsx +++ b/gui/src/components/Sidebar.tsx @@ -45,8 +45,8 @@ export function PreviewControls({ open }: { open: boolean }) { useRPCPacket( RpcMessage.SkeletonProportionsResponse, (data: SkeletonProportionsResponseT) => { - if (data.userHeight) - setUserHeight(cmFormat.format((data.userHeight * 100) / 0.936)); + if (data.skeletonHeight) + setUserHeight(cmFormat.format((data.skeletonHeight * 100) / 0.936)); } ); diff --git a/gui/src/components/onboarding/pages/ResetTutorial.tsx b/gui/src/components/onboarding/pages/ResetTutorial.tsx deleted file mode 100644 index ddc035e9e3..0000000000 --- a/gui/src/components/onboarding/pages/ResetTutorial.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { useLocalization } from '@fluent/react'; -import { useOnboarding } from '@/hooks/onboarding'; -import { Button } from '@/components/commons/Button'; -import { Typography } from '@/components/commons/Typography'; -import { useState, useMemo, useEffect } from 'react'; -import { - BodyPart, - ResetResponseT, - ResetStatus, - ResetType, - RpcMessage, - TapDetectionSettingsRequestT, - TapDetectionSettingsResponseT, -} from 'solarxr-protocol'; -import { BodyDisplay } from '@/components/commons/BodyDisplay'; -import { useWebsocketAPI } from '@/hooks/websocket-api'; -import classNames from 'classnames'; -import { log } from '@/utils/logging'; -import { useAtomValue } from 'jotai'; -import { assignedTrackersAtom } from '@/store/app-store'; - -export function ResetTutorialPage() { - const { l10n } = useLocalization(); - const { applyProgress } = useOnboarding(); - const { useRPCPacket, sendRPCPacket } = useWebsocketAPI(); - const [curIndex, setCurIndex] = useState(0); - const [tapSettings, setTapSettings] = useState([]); - applyProgress(0.8); - - const assignedTrackers = useAtomValue(assignedTrackersAtom); - - const highestTorsoTracker = useMemo( - () => - assignedTrackers - .filter((x) => - TORSO_PARTS.includes(x.tracker.info?.bodyPart ?? BodyPart.NONE) - ) - .sort( - (a, b) => - TORSO_PARTS.indexOf(a.tracker.info!.bodyPart)! - - TORSO_PARTS.indexOf(b.tracker.info!.bodyPart)! - ), - [assignedTrackers] - ); - - const highestRightLegTracker = useMemo( - () => - assignedTrackers - .filter((x) => - RIGHT_LEG_PARTS.includes(x.tracker.info?.bodyPart ?? BodyPart.NONE) - ) - .sort( - (a, b) => - RIGHT_LEG_PARTS.indexOf(a.tracker.info!.bodyPart)! - - RIGHT_LEG_PARTS.indexOf(b.tracker.info!.bodyPart)! - ), - [assignedTrackers] - ); - - const highestLeftLegTracker = useMemo( - () => - assignedTrackers - .filter((x) => - LEFT_LEG_PARTS.includes(x.tracker.info?.bodyPart ?? BodyPart.NONE) - ) - .sort( - (a, b) => - LEFT_LEG_PARTS.indexOf(a.tracker.info!.bodyPart)! - - LEFT_LEG_PARTS.indexOf(b.tracker.info!.bodyPart)! - ), - [assignedTrackers] - ); - - useRPCPacket( - RpcMessage.ResetResponse, - ({ status, resetType }: ResetResponseT) => { - if (status !== ResetStatus.STARTED) return; - log(status); - if (resetType === RESET_TYPE_ORDER[curIndex]) { - setCurIndex(curIndex + 1); - } - } - ); - - useEffect(() => { - sendRPCPacket( - RpcMessage.TapDetectionSettingsRequest, - new TapDetectionSettingsRequestT() - ); - }, []); - - useRPCPacket( - RpcMessage.TapDetectionSettingsResponse, - (tapDetectionSettings: TapDetectionSettingsResponseT) => { - if ( - !tapDetectionSettings || - !tapDetectionSettings.yawResetTaps || - !tapDetectionSettings.fullResetTaps || - !tapDetectionSettings.mountingResetTaps - ) - return; - setTapSettings([ - tapDetectionSettings.yawResetTaps, - tapDetectionSettings.fullResetTaps, - tapDetectionSettings.mountingResetTaps, - ]); - } - ); - - const order = [ - highestTorsoTracker[0], - highestLeftLegTracker[0], - highestRightLegTracker[0], - ]; - - return ( -
-
-
- - {l10n.getString('onboarding-reset_tutorial')} - - - {l10n.getString('onboarding-reset_tutorial-explanation')} - -
- - - - - -
-
= order.length && 'hidden' - )} - > - - {l10n.getString(`onboarding-reset_tutorial-${curIndex}`, { - taps: tapSettings[curIndex], - })} - -
-
-
- -
= order.length && 'hidden' - )} - > - - {l10n.getString(`onboarding-reset_tutorial-${curIndex}`, { - taps: tapSettings[curIndex], - })} - -
-
-
-
- ); -} - -export const TORSO_PARTS = [ - BodyPart.UPPER_CHEST, - BodyPart.CHEST, - BodyPart.WAIST, - BodyPart.HIP, -]; -export const LEFT_LEG_PARTS = [ - BodyPart.LEFT_UPPER_LEG, - BodyPart.LEFT_LOWER_LEG, -]; -export const RIGHT_LEG_PARTS = [ - BodyPart.RIGHT_UPPER_LEG, - BodyPart.RIGHT_LOWER_LEG, -]; - -export const RESET_TYPE_ORDER = [ - ResetType.YAW, - ResetType.FULL, - ResetType.MOUNTING, -]; diff --git a/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx b/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx index 2d5c4d4b0c..44a2c44dca 100644 --- a/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx +++ b/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx @@ -421,7 +421,7 @@ export function ManualProportionsPage() { useRPCPacket( RpcMessage.SkeletonProportionsResponse, (data: SkeletonProportionsResponseT) => { - if (data.userHeight) setUserHeight(data.userHeight); + if (data.skeletonHeight) setUserHeight(data.skeletonHeight); } ); diff --git a/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx b/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx index 14771ea30f..a389010daa 100644 --- a/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx +++ b/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx @@ -7,10 +7,9 @@ import { useWebsocketAPI } from '@/hooks/websocket-api'; import { useEffect, useState } from 'react'; import { CancelUserHeightCalibrationT, - ChangeSkeletonSettingsRequestT, + ChangeUserHeightRequestT, ResetType, RpcMessage, - SkeletonHeightT, SkeletonProportionsRequestT, SkeletonProportionsResetAllRequestT, SkeletonProportionsResponseT, @@ -285,8 +284,9 @@ export function ScaledProportionsPage() { const applyHeight = (newHeight: number) => { setHmdHeight(newHeight); - const skeletonSettingsRequest = new ChangeSkeletonSettingsRequestT(); - skeletonSettingsRequest.skeletonHeight = new SkeletonHeightT(newHeight, 0); + const skeletonSettingsRequest = new ChangeUserHeightRequestT(); + skeletonSettingsRequest.hmdHeight = newHeight; + skeletonSettingsRequest.floorHeight = 0; sendRPCPacket( RpcMessage.ChangeSkeletonSettingsRequest, skeletonSettingsRequest @@ -319,7 +319,7 @@ export function ScaledProportionsPage() { useRPCPacket( RpcMessage.SkeletonProportionsResponse, (res: SkeletonProportionsResponseT) => { - setHmdHeight(res.userHeight); + setHmdHeight(res.skeletonHeight); } ); diff --git a/gui/src/components/settings/pages/GeneralSettings.tsx b/gui/src/components/settings/pages/GeneralSettings.tsx index f8c3a784ba..fab287fe42 100644 --- a/gui/src/components/settings/pages/GeneralSettings.tsx +++ b/gui/src/components/settings/pages/GeneralSettings.tsx @@ -57,14 +57,14 @@ export function GroupedTrackersSettings() { export function GeneralSettings() { return ( - -
- - - - - -
-
+ +
+ + + + + +
+
); -} \ No newline at end of file +} diff --git a/gui/src/components/settings/pages/components/HIDSettings.tsx b/gui/src/components/settings/pages/components/HIDSettings.tsx index 371af50f3b..4d83c02443 100644 --- a/gui/src/components/settings/pages/components/HIDSettings.tsx +++ b/gui/src/components/settings/pages/components/HIDSettings.tsx @@ -15,13 +15,11 @@ import { isEqual } from '@react-hookz/deep-equal'; import { selectAtom } from 'jotai/utils'; type HIDForm = { - hidSettings: { - trackersOverHID: boolean; - }; + trackersOverHID: boolean; }; const defaultValues: HIDForm = { - hidSettings: { trackersOverHID: false }, + trackersOverHID: false, }; const hidSettingsAtom = atom(new HIDSettingsResponseT()); @@ -45,7 +43,7 @@ export function HIDSettings() { const onSubmit = (values: HIDForm) => { const settingsReq = new ChangeHIDSettingsRequestT(); - settingsReq.trackersOverHid = values.hidSettings.trackersOverHID; + settingsReq.trackersOverHid = values.trackersOverHID; sendRPCPacket(RpcMessage.ChangeHIDSettingsRequest, settingsReq); }; @@ -61,8 +59,7 @@ export function HIDSettings() { }, []); useEffect(() => { - const formData: DefaultValues = {}; - formData.hidSettings = { + const formData: DefaultValues = { trackersOverHID: settings.trackersOverHid, }; reset({ ...getValues(), ...formData }); @@ -94,7 +91,7 @@ export function HIDSettings() { variant="toggle" outlined control={control} - name="hidSettings.trackersOverHID" + name="trackersOverHID" label={l10n.getString( 'settings-general-tracker_mechanics-trackers_over_usb-enabled-label' )} diff --git a/gui/src/components/settings/pages/components/OutputSettings.tsx b/gui/src/components/settings/pages/components/OutputSettings.tsx index 6f31a8b512..acddb28cea 100644 --- a/gui/src/components/settings/pages/components/OutputSettings.tsx +++ b/gui/src/components/settings/pages/components/OutputSettings.tsx @@ -77,7 +77,7 @@ function trackersToBodyPartList( function bodyPartListToTrackers( bodyParts: BodyPart[] -): Omit { +): OutputTrackersForm['trackers'] { const set = new Set(bodyParts); return { waist: set.has(BodyPart.HIP), diff --git a/gui/src/components/settings/pages/components/ResetsSettings.tsx b/gui/src/components/settings/pages/components/ResetsSettings.tsx index 05ca835c91..bc50ebf3a9 100644 --- a/gui/src/components/settings/pages/components/ResetsSettings.tsx +++ b/gui/src/components/settings/pages/components/ResetsSettings.tsx @@ -1,6 +1,6 @@ import { useLocalization, Localized } from '@fluent/react'; import { useEffect } from 'react'; -import { DefaultValues, useForm } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; import { ResetsSettingsRequestT, ResetsSettingsResponseT, @@ -12,21 +12,24 @@ import { CheckBox } from '@/components/commons/Checkbox'; import { NumberSelector } from '@/components/commons/NumberSelector'; import { Radio } from '@/components/commons/Radio'; import { Typography } from '@/components/commons/Typography'; -import { - defaultResetSettings, - loadResetSettings, - ResetSettingsForm, -} from '@/hooks/reset-settings'; import { atom, useAtomValue, useSetAtom } from 'jotai'; import { isEqual } from '@react-hookz/deep-equal'; import { selectAtom } from 'jotai/utils'; -type ResetsForm = { - resetsSettings: ResetSettingsForm; +type ResetsSettingsForm = { + resetMountingFeet: boolean; + armsResetMode: number; + yawResetSmoothTime: number; + saveMountingReset: boolean; + resetHmdPitch: boolean; }; -const defaultValues: ResetsForm = { - resetsSettings: defaultResetSettings, +const defaultValues: ResetsSettingsForm = { + resetMountingFeet: false, + armsResetMode: 0, + yawResetSmoothTime: 0.0, + saveMountingReset: false, + resetHmdPitch: false, }; const resetsSettingsAtom = atom(new ResetsSettingsResponseT()); @@ -51,15 +54,21 @@ export function ResetsSettings() { }); const { control, watch, handleSubmit, getValues, reset } = - useForm({ + useForm({ defaultValues, mode: 'onChange', reValidateMode: 'onChange', }); - const onSubmit = (values: ResetsForm) => { - const settingsReq = loadResetSettings(values.resetsSettings); - sendRPCPacket(RpcMessage.ChangeResetsSettingsRequest, settingsReq); + const onSubmit = (values: ResetsSettingsForm) => { + const resetsSettings = new ResetsSettingsResponseT(); + resetsSettings.resetMountingFeet = values.resetMountingFeet; + resetsSettings.armsResetMode = values.armsResetMode; + resetsSettings.yawResetSmoothTime = values.yawResetSmoothTime; + resetsSettings.saveMountingReset = values.saveMountingReset; + resetsSettings.resetHmdPitch = values.resetHmdPitch; + + sendRPCPacket(RpcMessage.ChangeResetsSettingsRequest, resetsSettings); }; useEffect(() => { @@ -77,9 +86,7 @@ export function ResetsSettings() { }, []); useEffect(() => { - const formData: DefaultValues = {}; - formData.resetsSettings = settings; - reset({ ...getValues(), ...formData }); + reset({ ...getValues(), ...settings }); }, [settings]); useRPCPacket( @@ -101,7 +108,7 @@ export function ResetsSettings() { secondsFormat.format(value)} min={0.0} max={0.5} @@ -125,7 +132,7 @@ export function ResetsSettings() { variant="toggle" outlined control={control} - name="resetsSettings.saveMountingReset" + name="saveMountingReset" label={l10n.getString( 'settings-general-tracker_mechanics-save_mounting_reset-enabled-label' )} @@ -143,7 +150,7 @@ export function ResetsSettings() { variant="toggle" outlined control={control} - name="resetsSettings.resetHmdPitch" + name="resetHmdPitch" label={l10n.getString( 'settings-general-fk_settings-reset_settings-reset_hmd_pitch' )} @@ -159,7 +166,7 @@ export function ResetsSettings() { variant="toggle" outlined control={control} - name="resetsSettings.resetMountingFeet" + name="resetMountingFeet" label={l10n.getString( 'settings-general-fk_settings-leg_fk-reset_mounting_feet-v1' )} @@ -177,7 +184,7 @@ export function ResetsSettings() {
= {}; if (settings.enabled !== undefined) formData.enabled = settings.enabled; - // TODO hideYawCorrection + // TODO hide_yaw_correction + setup_complete formData.standingEnabled = settings.standingEnabled ?? false; formData.standingUpperLegAngle = settings.standingUpperLegAngle ?? 0.0; diff --git a/gui/src/hooks/height.ts b/gui/src/hooks/height.ts index bbc64b00e8..23c44ac889 100644 --- a/gui/src/hooks/height.ts +++ b/gui/src/hooks/height.ts @@ -1,9 +1,9 @@ -import { createContext, useContext, useEffect, useMemo, useState } from 'react'; +import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { useWebsocketAPI } from './websocket-api'; import { RpcMessage, SkeletonSettingsRequestT, - SkeletonSettingsResponseT, + UserHeightResponseT, } from 'solarxr-protocol'; import { MIN_HEIGHT } from './manual-proportions'; @@ -25,18 +25,15 @@ export function useProvideHeightContext(): HeightContext { sendRPCPacket(RpcMessage.SkeletonSettingsRequest, new SkeletonSettingsRequestT()), [] ); - useRPCPacket( - RpcMessage.SkeletonSettingsResponse, - (res: SkeletonSettingsResponseT) => { - const hmd = res.skeletonHeight?.hmdHeight; - const floor = res.skeletonHeight?.floorHeight; + useRPCPacket(RpcMessage.UserHeightResponse, (res: UserHeightResponseT) => { + const hmd = res.hmdHeight; + const floor = res.floorHeight; - if (validateHeight(hmd, floor)) { - setHmdHeight(hmd ?? null); - setFloorHeight(floor ?? null); - } + if (validateHeight(hmd, floor)) { + setHmdHeight(hmd ?? null); + setFloorHeight(floor ?? null); } - ); + }); const currentHeight = useMemo( () => computeHeight(hmdHeight, floorHeight), diff --git a/gui/src/hooks/onboarding.ts b/gui/src/hooks/onboarding.ts index b0f7e1fe75..76dfab0f20 100644 --- a/gui/src/hooks/onboarding.ts +++ b/gui/src/hooks/onboarding.ts @@ -133,7 +133,6 @@ export function useProvideOnboarding() { skeletonReq.toggles = toggles; skeletonReq.ratios = skeletonSettings.ratios; skeletonReq.filtering = skeletonSettings.filtering; - skeletonReq.skeletonHeight = skeletonSettings.skeletonHeight; sendRPCPacket(RpcMessage.ChangeSkeletonSettingsRequest, skeletonReq); const resetsReq = Object.assign(new ChangeResetsSettingsRequestT(), resetsSettings); diff --git a/gui/src/hooks/reset-settings.ts b/gui/src/hooks/reset-settings.ts deleted file mode 100644 index b6da38a941..0000000000 --- a/gui/src/hooks/reset-settings.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { - ResetsSettingsResponseT, - RpcMessage, - SettingsResetRequestT, -} from 'solarxr-protocol'; -import { useWebsocketAPI } from './websocket-api'; -import { useEffect, useState } from 'react'; - -export interface ResetSettingsForm { - resetMountingFeet: boolean; - armsResetMode: number; - yawResetSmoothTime: number; - saveMountingReset: boolean; - resetHmdPitch: boolean; -} - -export const defaultResetSettings = { - resetMountingFeet: false, - armsResetMode: 0, - yawResetSmoothTime: 0.0, - saveMountingReset: false, - resetHmdPitch: false, -}; - -export function loadResetSettings(resetSettingsForm: ResetSettingsForm) { - const resetsSettings = new ResetsSettingsResponseT(); - resetsSettings.resetMountingFeet = resetSettingsForm.resetMountingFeet; - resetsSettings.armsResetMode = resetSettingsForm.armsResetMode; - resetsSettings.yawResetSmoothTime = resetSettingsForm.yawResetSmoothTime; - resetsSettings.saveMountingReset = resetSettingsForm.saveMountingReset; - resetsSettings.resetHmdPitch = resetSettingsForm.resetHmdPitch; - - return resetsSettings; -} - -export function useResetSettings() { - const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); - const [settings, setSettings] = useState(defaultResetSettings); - - useEffect(() => - sendRPCPacket(RpcMessage.ResetsSettingsRequest, new SettingsResetRequestT()) - ); - - useRPCPacket( - RpcMessage.ResetsSettingsResponse, - (settings: ResetsSettingsResponseT) => { - if (settings) setSettings(settings); - } - ); - - return { - update: (resetSettingsForm: Partial) => { - const req = loadResetSettings({ ...settings, ...resetSettingsForm }); - sendRPCPacket(RpcMessage.ChangeResetsSettingsRequest, req); - }, - }; -} diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index cacd8acf44..eccac3ac83 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -51,7 +51,7 @@ data class VMCConfig( val enabled: Boolean = true, val portOut: Int = 39539, val portIn: Int = 39540, - val address: String = "172.17.0.1", // TODO why not 127.0.0.1? + val address: String = "127.0.0.1", /** Mirror the tracking before sending it (turn left <=> turn right, left leg <=> right leg) */ val mirrorTracking: Boolean = false, /** Anchor the tracking at the hip (sitting down)? */ diff --git a/server/core/src/main/java/dev/slimevr/firmware/reconnect.kt b/server/core/src/main/java/dev/slimevr/firmware/reconnect.kt index 8d09f3a39e..ae3398cfb1 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/reconnect.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/reconnect.kt @@ -36,7 +36,7 @@ suspend fun waitForConnected( macAddress: String, timeoutMs: Long = 30_000, ): Boolean? = withTimeoutOrNull(timeoutMs) { - deviceStatusFlow(server) { _, deviceMac -> deviceMac?.uppercase() == macAddress }.first(::isOnlineStatus) + deviceStatusFlow(server) { _, deviceMac -> deviceMac?.uppercase() == macAddress }.first(::isOnlineStatus) true } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 5375308142..6147a2d60d 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -41,7 +41,7 @@ class ProportionsBehaviour : SkeletonBehaviour { val newBones = state.rawBones.mapValues { (bodyPart, bone) -> bone.copy(offset = bones[bodyPart] ?: bone.offset) } - state.copy(rawBones = newBones, userHeight = action.lengths.height()) + state.copy(rawBones = newBones, skeletonHeight = action.lengths.height()) } else -> state @@ -66,7 +66,7 @@ class HeightLogBehaviour : SkeletonBehaviour { override fun observe(receiver: Skeleton) { receiver.context.scope.safeLaunch { receiver.context.state - .map { state -> state.userHeight } + .map { state -> state.skeletonHeight } .distinctUntilChanged() .collect { height -> println("User height changed: ${"%.2f".format(height)}m") } } @@ -103,7 +103,7 @@ class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour receiver.context.dispatch( SkeletonActions.SetBonePosition( BodyPart.HEAD, - Vector3(circleX, state.userHeight + jumpHeight, circleZ), + Vector3(circleX, state.skeletonHeight + jumpHeight, circleZ), ), ) } @@ -125,7 +125,7 @@ class ComputedSkeletonBehaviour( val processed = processors .filter { processor -> processor.enabled } .fold(targetState) { state, processor -> processor.process(state) } - val rootHead = Vector3(0f, targetState.userHeight, 0f) // FIXME WRONG + val rootHead = Vector3(0f, targetState.skeletonHeight, 0f) // FIXME WRONG receiver.computed.value = buildBones(processed, rootHead = rootHead) } catch (e: CancellationException) { throw e diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 198aa32963..b9a3048f1e 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -34,7 +34,7 @@ data class BoneState( // get() = tailPosition - headPosition } -data class SkeletonState(val rawBones: Map, val userHeight: Float) +data class SkeletonState(val rawBones: Map, val skeletonHeight: Float) val DEFAULT_SKELETON_STATE: SkeletonState = run { val bones = DEFAULT_BONE_OFFSETS.entries.associate { (bodyPart, tailOffset) -> @@ -44,7 +44,7 @@ val DEFAULT_SKELETON_STATE: SkeletonState = run { } bodyPart to BoneState(bodyPart = bodyPart, offset = tailOffset, rotation = restRotation) } - SkeletonState(rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, offset = bone.offset, rawPosition = Vector3.NULL) }, userHeight = DEFAULT_HEIGHT) + SkeletonState(rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, offset = bone.offset, rawPosition = Vector3.NULL) }, skeletonHeight = DEFAULT_HEIGHT) } fun buildBones( diff --git a/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt b/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt index a2fb3c1948..67506a2c0c 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt @@ -16,7 +16,7 @@ data class VelocityState( val horizontalDistance: Float, val velocity: Vector3, val angularVelocityMagnitude: Float, - val acceleration: Vector3 + val acceleration: Vector3, ) val VELOCITY_BODY_PARTS = arrayOf(BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT) @@ -53,12 +53,11 @@ val BODY_PART_MASSES = mapOf( fun shouldLock( velocity: VelocityState, thresholdMultiplier: Float, -): Boolean = - (velocity.horizontalDistance <= SKATING_DISTANCE_THRESHOLD * thresholdMultiplier) && - (velocity.velocity.len() <= SKATING_VELOCITY_THRESHOLD * thresholdMultiplier) && - (velocity.angularVelocityMagnitude <= SKATING_ROTATIONAL_VELOCITY_THRESHOLD * thresholdMultiplier) && - (velocity.position.y - FLOOR_LEVEL <= FLOOR_DISTANCE_THRESHOLD * thresholdMultiplier) && - (velocity.acceleration.len() <= SKATING_ACCELERATION_THRESHOLD * thresholdMultiplier) +): Boolean = (velocity.horizontalDistance <= SKATING_DISTANCE_THRESHOLD * thresholdMultiplier) && + (velocity.velocity.len() <= SKATING_VELOCITY_THRESHOLD * thresholdMultiplier) && + (velocity.angularVelocityMagnitude <= SKATING_ROTATIONAL_VELOCITY_THRESHOLD * thresholdMultiplier) && + (velocity.position.y - FLOOR_LEVEL <= FLOOR_DISTANCE_THRESHOLD * thresholdMultiplier) && + (velocity.acceleration.len() <= SKATING_ACCELERATION_THRESHOLD * thresholdMultiplier) fun centerOfMass(bones: Map) { // TODO Compute center of mass using BODY_PART_MASSES @@ -77,7 +76,6 @@ class SkatingCorrectionProcessor : SkeletonProcessor { val lockStates: MutableMap = mutableMapOf() override fun process(state: SkeletonState): SkeletonState { - for (bodyPart in VELOCITY_BODY_PARTS) { // Pull data val curBone = curPositions[bodyPart] ?: continue @@ -125,9 +123,7 @@ class SkatingCorrectionProcessor : SkeletonProcessor { class FloorClipProcessor : SkeletonProcessor { override var enabled: Boolean = true - fun getDisplacement(footPos: Vector3): Vector3 { - return Vector3(0f, footPos.y.coerceAtMost(0f), 0f) - } + fun getDisplacement(footPos: Vector3): Vector3 = Vector3(0f, footPos.y.coerceAtMost(0f), 0f) override fun process(state: SkeletonState): SkeletonState { val newBones = state.rawBones.mapValues { (bodyPart, bone) -> } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 5308d007d5..31304cbb62 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -169,14 +169,16 @@ class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : Sola val minTime = config.minimumTimeSinceLast.toLong() while (isActive) { try { - receiver.sendDataFeed(createDatafeedFrame( - server = server, - datafeedConfig = config, - skeleton = skeleton, - resetsManager = receiver.appContext.resetsManager, - heightCalibrationManager = receiver.appContext.heightCalibrationManager, - index = index - )) + receiver.sendDataFeed( + createDatafeedFrame( + server = server, + datafeedConfig = config, + skeleton = skeleton, + resetsManager = receiver.appContext.resetsManager, + heightCalibrationManager = receiver.appContext.heightCalibrationManager, + index = index, + ), + ) } catch (e: Exception) { AppLogger.solarxr.error(e, "Error sending data feed") } @@ -191,13 +193,15 @@ class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : Sola receiver.dataFeedDispatcher.on { event -> val config = event.config ?: return@on - receiver.sendDataFeed(createDatafeedFrame( - server = server, - datafeedConfig = config, - skeleton = skeleton, - resetsManager = receiver.appContext.resetsManager, - heightCalibrationManager = receiver.appContext.heightCalibrationManager - )) + receiver.sendDataFeed( + createDatafeedFrame( + server = server, + datafeedConfig = config, + skeleton = skeleton, + resetsManager = receiver.appContext.resetsManager, + heightCalibrationManager = receiver.appContext.heightCalibrationManager, + ), + ) } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index dd13dbe5ad..36a1224c28 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -6,7 +6,6 @@ import dev.slimevr.VRServerActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.context.ManagedContext -import dev.slimevr.tracker.TrackerTapDetectionBehaviour import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import solarxr_protocol.MessageBundle @@ -70,7 +69,7 @@ class SolarXRBridge( add(SerialBehaviour(appContext.serialServer)) add(FirmwareBehaviour(appContext.server, appContext.firmwareManager)) appContext.vrcConfigManager?.let { vrc -> - add(VrcBehaviour(vrc, appContext.server, userHeight = { appContext.skeleton.context.state.value.userHeight })) + add(VrcBehaviour(vrc, appContext.server, userHeight = { appContext.skeleton.context.state.value.skeletonHeight })) } add(ResetsBehaviour(appContext.resetsManager)) add(TapDetectionBehaviour(appContext.config.settings)) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/resets.kt b/server/core/src/main/java/dev/slimevr/solarxr/resets.kt index b3f5c4fd13..876c693442 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/resets.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/resets.kt @@ -17,13 +17,17 @@ class ResetsBehaviour( val oldConfig = resetsManager.context.state.value.config receiver.rpcDispatcher.on { req -> - resetsManager.context.dispatch(ResetsActions.UpdateConfig(ResetsConfig( - resetMountingFeet = req.resetMountingFeet == true, - armsResetMode = req.armsResetMode ?: oldConfig.armsResetMode, - yawResetSmoothTime = req.yawResetSmoothTime ?: oldConfig.yawResetSmoothTime, - saveMountingReset = req.saveMountingReset == true, - resetHmdPitch = req.resetHmdPitch == true, - ))) + resetsManager.context.dispatch( + ResetsActions.UpdateConfig( + ResetsConfig( + resetMountingFeet = req.resetMountingFeet == true, + armsResetMode = req.armsResetMode ?: oldConfig.armsResetMode, + yawResetSmoothTime = req.yawResetSmoothTime ?: oldConfig.yawResetSmoothTime, + saveMountingReset = req.saveMountingReset == true, + resetHmdPitch = req.resetHmdPitch == true, + ), + ), + ) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt index fe00b615f9..838eeea6cc 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt @@ -9,7 +9,7 @@ import dev.slimevr.skeleton.configToBoneValues import dev.slimevr.skeleton.height import dev.slimevr.skeleton.toBoneValues import solarxr_protocol.rpc.ChangeSkeletonProportionsRequest -import solarxr_protocol.rpc.ChangeSkeletonSettingsRequest +import solarxr_protocol.rpc.ChangeUserHeightRequest import solarxr_protocol.rpc.SkeletonPart import solarxr_protocol.rpc.SkeletonProportionsRequest import solarxr_protocol.rpc.SkeletonProportionsResetAllRequest @@ -30,9 +30,9 @@ class SkeletonBehaviour( val userHeight = if (expanded.isNotEmpty()) { expanded.height() } else { - skeleton.context.state.value.userHeight + skeleton.context.state.value.skeletonHeight } - return SkeletonProportionsResponse(skeletonParts = skeletonParts, userHeight = userHeight) + return SkeletonProportionsResponse(skeletonParts = skeletonParts, skeletonHeight = userHeight) } override fun observe(receiver: SolarXRBridge) { @@ -40,18 +40,16 @@ class SkeletonBehaviour( receiver.sendRpc(buildConfigResponse()) } - receiver.rpcDispatcher.on { req -> - req.skeletonHeight?.let { skeletonHeight -> - val hmdHeight = skeletonHeight.hmdHeight ?: return@let - val floorHeight = skeletonHeight.floorHeight ?: 0f - val height = hmdHeight - floorHeight - if (height >= MIN_HEIGHT) { - userConfig.context.dispatch( - UserConfigActions.Update { - copy(userHeight = height, proportions = computeDefaultProportionsByBone(height)) - }, - ) - } + receiver.rpcDispatcher.on { req -> + val hmdHeight = req.hmdHeight ?: return@on + val floorHeight = req.floorHeight ?: 0f + val height = hmdHeight - floorHeight + if (height >= MIN_HEIGHT) { + userConfig.context.dispatch( + UserConfigActions.Update { + copy(userHeight = height, proportions = computeDefaultProportionsByBone(height)) + }, + ) } } @@ -64,7 +62,7 @@ class SkeletonBehaviour( receiver.sendRpc(buildConfigResponse()) } - receiver.rpcDispatcher.on< ChangeSkeletonProportionsRequest> { req -> + receiver.rpcDispatcher.on { req -> val bone = req.bone ?: return@on val value = req.value ?: return@on diff --git a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt index db7896c5c1..d1f115c8e2 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt @@ -23,21 +23,27 @@ class TapDetectionBehaviour( val oldConfig = settings.context.state.value.data.tapDetectionConfig receiver.rpcDispatcher.on { req -> - settings.context.dispatch(SettingsActions.Update { copy(tapDetectionConfig = TapDetectionConfig( - yawResetDelay = req.yawResetDelay ?: oldConfig.yawResetDelay, - fullResetDelay = req.fullResetDelay ?: oldConfig.fullResetDelay, - mountingResetDelay = req.mountingResetDelay ?: oldConfig.mountingResetDelay, - yawResetEnabled = req.yawResetEnabled == true, - fullResetEnabled = req.fullResetEnabled == true, - mountingResetEnabled = req.mountingResetEnabled == true, - yawResetTaps = req.yawResetTaps?.toInt() ?: oldConfig.yawResetTaps, - fullResetTaps = req.fullResetTaps?.toInt() ?: oldConfig.fullResetTaps, - mountingResetTaps = req.mountingResetTaps?.toInt() ?: oldConfig.mountingResetTaps, - yawResetBodyPart = req.yawResetTracker ?: oldConfig.yawResetBodyPart, - fullResetBodyPart = req.fullResetTracker ?: oldConfig.fullResetBodyPart, - mountingResetBodyPart = req.mountingResetTracker ?: oldConfig.mountingResetBodyPart, - numberTrackersOverThreshold = req.numberTrackersOverThreshold?.toInt() ?: oldConfig.numberTrackersOverThreshold, - ))}) + settings.context.dispatch( + SettingsActions.Update { + copy( + tapDetectionConfig = TapDetectionConfig( + yawResetDelay = req.yawResetDelay ?: oldConfig.yawResetDelay, + fullResetDelay = req.fullResetDelay ?: oldConfig.fullResetDelay, + mountingResetDelay = req.mountingResetDelay ?: oldConfig.mountingResetDelay, + yawResetEnabled = req.yawResetEnabled == true, + fullResetEnabled = req.fullResetEnabled == true, + mountingResetEnabled = req.mountingResetEnabled == true, + yawResetTaps = req.yawResetTaps?.toInt() ?: oldConfig.yawResetTaps, + fullResetTaps = req.fullResetTaps?.toInt() ?: oldConfig.fullResetTaps, + mountingResetTaps = req.mountingResetTaps?.toInt() ?: oldConfig.mountingResetTaps, + yawResetBodyPart = req.yawResetTracker ?: oldConfig.yawResetBodyPart, + fullResetBodyPart = req.fullResetTracker ?: oldConfig.fullResetBodyPart, + mountingResetBodyPart = req.mountingResetTracker ?: oldConfig.mountingResetBodyPart, + numberTrackersOverThreshold = req.numberTrackersOverThreshold?.toInt() ?: oldConfig.numberTrackersOverThreshold, + ), + ) + }, + ) } } diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index d3da818ca0..ac57d1a6d6 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -166,7 +166,7 @@ class VRChatSettingsCheckBehaviour( override fun observe(receiver: TrackingChecklist) { combine( - skeleton.context.state.map { state -> state.userHeight }, + skeleton.context.state.map { state -> state.skeletonHeight }, vrcConfigManager.context.state, ) { userHeight, vrc -> computeStep(vrc, userHeight) } .distinctUntilChanged() diff --git a/solarxr-protocol b/solarxr-protocol index 25118f5371..9948881a63 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 25118f5371630ff7c19033cd14b5e573b9e40cca +Subproject commit 9948881a630c42f336694c7b494a27e11fe350d3 From 396a049809557318715212453d1495ac5af9069e Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 2 Jul 2026 18:39:12 -0400 Subject: [PATCH 214/350] Small GUI fixups --- .../steps/FlashingMethodStep.tsx | 4 +- .../firmware-tool/steps/FlashingStep.tsx | 2 +- .../firmware-update/FirmwareUpdate.tsx | 4 +- .../pages/components/StayAlignedSettings.tsx | 4 +- .../pages/components/TapDetectionSettings.tsx | 149 +++++++++--------- gui/src/hooks/firmware-tool.ts | 6 +- 6 files changed, 81 insertions(+), 88 deletions(-) diff --git a/gui/src/components/firmware-tool/steps/FlashingMethodStep.tsx b/gui/src/components/firmware-tool/steps/FlashingMethodStep.tsx index 014e7a5cd4..49661d8695 100644 --- a/gui/src/components/firmware-tool/steps/FlashingMethodStep.tsx +++ b/gui/src/components/firmware-tool/steps/FlashingMethodStep.tsx @@ -119,7 +119,7 @@ function SerialDevicesList({ selectDevices([ { type: FirmwareUpdateMethod.SerialFirmwareUpdate, - deviceId: serialValues.selectedDevicePort, + deviceIdPort: serialValues.selectedDevicePort, deviceNames: [ devices[serialValues.selectedDevicePort].name?.toString() ?? 'unknown', @@ -283,7 +283,7 @@ function OTADevicesList({ ...curr, { type: FirmwareUpdateMethod.OTAFirmwareUpdate, - deviceId, + deviceIdPort: deviceId, deviceNames: deviceNames(device), }, ]; diff --git a/gui/src/components/firmware-tool/steps/FlashingStep.tsx b/gui/src/components/firmware-tool/steps/FlashingStep.tsx index 52fe706a64..0d2443488c 100644 --- a/gui/src/components/firmware-tool/steps/FlashingStep.tsx +++ b/gui/src/components/firmware-tool/steps/FlashingStep.tsx @@ -93,7 +93,7 @@ export function FlashingStep({ if (!id) throw new Error('invalid device id'); const selectedDevice = selectedDevices?.find( - ({ deviceId }) => deviceId == id.toString() + ({ deviceIdPort }) => deviceIdPort == id.toString() ); // We skip the status as it can be old trackers still sending status diff --git a/gui/src/components/firmware-update/FirmwareUpdate.tsx b/gui/src/components/firmware-update/FirmwareUpdate.tsx index 503f2cf291..6871ac4f40 100644 --- a/gui/src/components/firmware-update/FirmwareUpdate.tsx +++ b/gui/src/components/firmware-update/FirmwareUpdate.tsx @@ -133,7 +133,7 @@ export function FirmwareUpdate() { if (!id) throw new Error('invalid device id'); const selectedDevice = pendingDevicesRef.current.find( - ({ deviceId }) => deviceId === id.toString() + ({ deviceIdPort }) => deviceIdPort === id.toString() ); // We skip the status as it can be old trackers still sending status @@ -304,7 +304,7 @@ export function FirmwareUpdate() { ...curr, { type: FirmwareUpdateMethod.OTAFirmwareUpdate, - deviceId: id, + deviceIdPort: id, board: device.hardwareInfo?.officialBoardType ?? BoardType.UNKNOWN, deviceNames: deviceNames(device, l10n), }, diff --git a/gui/src/components/settings/pages/components/StayAlignedSettings.tsx b/gui/src/components/settings/pages/components/StayAlignedSettings.tsx index d23037ffb6..92007f01f4 100644 --- a/gui/src/components/settings/pages/components/StayAlignedSettings.tsx +++ b/gui/src/components/settings/pages/components/StayAlignedSettings.tsx @@ -25,6 +25,7 @@ import { StandingRelaxedPoseModal, } from './StayAlignedPoseModal'; +// TODO hide_yaw_correction + setup_complete are now in their own requests export type StayAlignedSettingsForm = { enabled: boolean; hideYawCorrection: boolean; @@ -40,8 +41,6 @@ export type StayAlignedSettingsForm = { flatUpperLegAngle: number; flatLowerLegAngle: number; flatFootAngle: number; - // Not part of StayAlignedSettingsResponse — tracked client-side/derived - // from whether setup has produced non-zero relaxed pose angles. setupComplete: boolean; }; @@ -176,7 +175,6 @@ export function StayAlignedSettings() { const formData: DefaultValues = {}; if (settings.enabled !== undefined) formData.enabled = settings.enabled; - // TODO hide_yaw_correction + setup_complete formData.standingEnabled = settings.standingEnabled ?? false; formData.standingUpperLegAngle = settings.standingUpperLegAngle ?? 0.0; diff --git a/gui/src/components/settings/pages/components/TapDetectionSettings.tsx b/gui/src/components/settings/pages/components/TapDetectionSettings.tsx index a2945e1bfa..13f43d8bd0 100644 --- a/gui/src/components/settings/pages/components/TapDetectionSettings.tsx +++ b/gui/src/components/settings/pages/components/TapDetectionSettings.tsx @@ -20,39 +20,35 @@ import { selectAtom } from 'jotai/utils'; import { useLocaleConfig } from '@/i18n/config'; type TapDetectionForm = { - tapDetection: { - mountingResetEnabled: boolean; - yawResetEnabled: boolean; - fullResetEnabled: boolean; - yawResetDelay: number; - fullResetDelay: number; - mountingResetDelay: number; - yawResetTaps: number; - fullResetTaps: number; - mountingResetTaps: number; - numberTrackersOverThreshold: number; - yawResetTracker: string; - mountingResetTracker: string; - fullResetTracker: string; - }; + mountingResetEnabled: boolean; + yawResetEnabled: boolean; + fullResetEnabled: boolean; + yawResetDelay: number; + fullResetDelay: number; + mountingResetDelay: number; + yawResetTaps: number; + fullResetTaps: number; + mountingResetTaps: number; + numberTrackersOverThreshold: number; + yawResetTracker: string; + mountingResetTracker: string; + fullResetTracker: string; }; const defaultValues: TapDetectionForm = { - tapDetection: { - mountingResetEnabled: false, - yawResetEnabled: false, - fullResetEnabled: false, - yawResetDelay: 0.2, - fullResetDelay: 1.0, - mountingResetDelay: 1.0, - yawResetTaps: 2, - fullResetTaps: 3, - mountingResetTaps: 3, - numberTrackersOverThreshold: 1, - yawResetTracker: String(BodyPart.CHEST), - mountingResetTracker: String(BodyPart.RIGHT_UPPER_LEG), - fullResetTracker: String(BodyPart.LEFT_UPPER_LEG), - }, + mountingResetEnabled: false, + yawResetEnabled: false, + fullResetEnabled: false, + yawResetDelay: 0.2, + fullResetDelay: 1.0, + mountingResetDelay: 1.0, + yawResetTaps: 2, + fullResetTaps: 3, + mountingResetTaps: 3, + numberTrackersOverThreshold: 1, + yawResetTracker: String(BodyPart.CHEST), + mountingResetTracker: String(BodyPart.RIGHT_UPPER_LEG), + fullResetTracker: String(BodyPart.LEFT_UPPER_LEG), }; const tapDetectionSettingsAtom = atom(new TapDetectionSettingsResponseT()); @@ -93,22 +89,22 @@ export function TapDetectionSettings() { const onSubmit = (values: TapDetectionForm) => { const settingsReq = new ChangeTapDetectionSettingsRequestT(); - settingsReq.fullResetDelay = values.tapDetection.fullResetDelay; - settingsReq.fullResetEnabled = values.tapDetection.fullResetEnabled; - settingsReq.fullResetTaps = values.tapDetection.fullResetTaps; - settingsReq.yawResetDelay = values.tapDetection.yawResetDelay; - settingsReq.yawResetEnabled = values.tapDetection.yawResetEnabled; - settingsReq.yawResetTaps = values.tapDetection.yawResetTaps; - settingsReq.yawResetTracker = Number(values.tapDetection.yawResetTracker); + settingsReq.fullResetDelay = values.fullResetDelay; + settingsReq.fullResetEnabled = values.fullResetEnabled; + settingsReq.fullResetTaps = values.fullResetTaps; + settingsReq.yawResetDelay = values.yawResetDelay; + settingsReq.yawResetEnabled = values.yawResetEnabled; + settingsReq.yawResetTaps = values.yawResetTaps; + settingsReq.yawResetTracker = Number(values.yawResetTracker); settingsReq.mountingResetTracker = Number( - values.tapDetection.mountingResetTracker + values.mountingResetTracker ); - settingsReq.fullResetTracker = Number(values.tapDetection.fullResetTracker); - settingsReq.mountingResetEnabled = values.tapDetection.mountingResetEnabled; - settingsReq.mountingResetDelay = values.tapDetection.mountingResetDelay; - settingsReq.mountingResetTaps = values.tapDetection.mountingResetTaps; + settingsReq.fullResetTracker = Number(values.fullResetTracker); + settingsReq.mountingResetEnabled = values.mountingResetEnabled; + settingsReq.mountingResetDelay = values.mountingResetDelay; + settingsReq.mountingResetTaps = values.mountingResetTaps; settingsReq.numberTrackersOverThreshold = - values.tapDetection.numberTrackersOverThreshold; + values.numberTrackersOverThreshold; sendRPCPacket(RpcMessage.ChangeTapDetectionSettingsRequest, settingsReq); }; @@ -128,43 +124,42 @@ export function TapDetectionSettings() { }, []); useEffect(() => { - const formData: DefaultValues = {}; - formData.tapDetection = { + const formData: DefaultValues = { yawResetEnabled: - settings.yawResetEnabled || defaultValues.tapDetection.yawResetEnabled, + settings.yawResetEnabled || defaultValues.yawResetEnabled, fullResetEnabled: - settings.fullResetEnabled || - defaultValues.tapDetection.fullResetEnabled, + settings.fullResetEnabled || + defaultValues.fullResetEnabled, mountingResetEnabled: - settings.mountingResetEnabled || - defaultValues.tapDetection.mountingResetEnabled, + settings.mountingResetEnabled || + defaultValues.mountingResetEnabled, yawResetDelay: - settings.yawResetDelay || defaultValues.tapDetection.yawResetDelay, + settings.yawResetDelay || defaultValues.yawResetDelay, fullResetDelay: - settings.fullResetDelay || defaultValues.tapDetection.fullResetDelay, + settings.fullResetDelay || defaultValues.fullResetDelay, mountingResetDelay: - settings.mountingResetDelay || - defaultValues.tapDetection.mountingResetDelay, + settings.mountingResetDelay || + defaultValues.mountingResetDelay, yawResetTaps: - settings.yawResetTaps || defaultValues.tapDetection.yawResetTaps, + settings.yawResetTaps || defaultValues.yawResetTaps, fullResetTaps: - settings.fullResetTaps || defaultValues.tapDetection.fullResetTaps, + settings.fullResetTaps || defaultValues.fullResetTaps, mountingResetTaps: - settings.mountingResetTaps || - defaultValues.tapDetection.mountingResetTaps, + settings.mountingResetTaps || + defaultValues.mountingResetTaps, yawResetTracker: String( - settings.yawResetTracker || defaultValues.tapDetection.yawResetTracker + settings.yawResetTracker || defaultValues.yawResetTracker ), fullResetTracker: String( - settings.fullResetTracker || defaultValues.tapDetection.fullResetTracker + settings.fullResetTracker || defaultValues.fullResetTracker ), mountingResetTracker: String( - settings.mountingResetTracker || - defaultValues.tapDetection.mountingResetTracker + settings.mountingResetTracker || + defaultValues.mountingResetTracker ), numberTrackersOverThreshold: - settings.numberTrackersOverThreshold || - defaultValues.tapDetection.numberTrackersOverThreshold, + settings.numberTrackersOverThreshold || + defaultValues.numberTrackersOverThreshold, }; reset({ ...getValues(), ...formData }); }, [settings]); @@ -192,7 +187,7 @@ export function TapDetectionSettings() { variant="toggle" outlined control={control} - name="tapDetection.yawResetEnabled" + name="yawResetEnabled" label={l10n.getString( 'settings-general-gesture_control-yawResetEnabled' )} @@ -201,7 +196,7 @@ export function TapDetectionSettings() { variant="toggle" outlined control={control} - name="tapDetection.fullResetEnabled" + name="fullResetEnabled" label={l10n.getString( 'settings-general-gesture_control-fullResetEnabled' )} @@ -210,7 +205,7 @@ export function TapDetectionSettings() { variant="toggle" outlined control={control} - name="tapDetection.mountingResetEnabled" + name="mountingResetEnabled" label={l10n.getString( 'settings-general-gesture_control-mountingResetEnabled' )} @@ -227,7 +222,7 @@ export function TapDetectionSettings() { display="block" control={control} placeholder={''} - name="tapDetection.yawResetTracker" + name="yawResetTracker" items={bodyParts} />
@@ -241,7 +236,7 @@ export function TapDetectionSettings() { display="block" control={control} placeholder={''} - name="tapDetection.mountingResetTracker" + name="mountingResetTracker" items={bodyParts} />
@@ -255,7 +250,7 @@ export function TapDetectionSettings() { display="block" control={control} placeholder={''} - name="tapDetection.fullResetTracker" + name="fullResetTracker" items={bodyParts} />
@@ -264,7 +259,7 @@ export function TapDetectionSettings() {
l10n.getString('settings-general-gesture_control-trackers', { amount: Math.round(value), diff --git a/gui/src/hooks/firmware-tool.ts b/gui/src/hooks/firmware-tool.ts index e1d5d87142..d27876ccc1 100644 --- a/gui/src/hooks/firmware-tool.ts +++ b/gui/src/hooks/firmware-tool.ts @@ -25,7 +25,7 @@ export type SelectedSouce = { export type SelectedDevice = { type: FirmwareUpdateMethod; - deviceId: string | number; + deviceIdPort: string | number; deviceNames: string[]; }; @@ -115,7 +115,7 @@ export const getFlashingRequests = ( for (const device of devices) { switch (device.type) { case FirmwareUpdateMethod.OTAFirmwareUpdate: { - const dId = +device.deviceId; + const dId = +device.deviceIdPort; const part = new FirmwarePartT(); part.offset = 0; @@ -134,7 +134,7 @@ export const getFlashingRequests = ( } case FirmwareUpdateMethod.SerialFirmwareUpdate: { const id = new SerialDevicePortT(); - id.port = device.deviceId.toString(); + id.port = device.deviceIdPort.toString(); if (!onboardingState.wifi?.ssid) throw new Error('invalid state, wifi should be set'); From 853db3fd0f99bd0aa55b8f09cc566e1d3d4f0e5a Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 2 Jul 2026 19:21:21 -0400 Subject: [PATCH 215/350] TapDetectionSetupModeRequest missing [] --- .../onboarding/pages/trackers-assign/TrackerAssignment.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx b/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx index 79fcbc32b9..69a17c881b 100644 --- a/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx +++ b/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx @@ -81,7 +81,7 @@ export function TrackersAssignPage() { new TapDetectionSetupModeRequestT(false) ); }; - }); + }, [] ); const trackerPartGrouped = useMemo( () => From 9efc2b2b3c69a809b3466a81f251cc04b336e13b Mon Sep 17 00:00:00 2001 From: loucass003 Date: Fri, 3 Jul 2026 02:19:06 +0200 Subject: [PATCH 216/350] Rework electron build pipeline --- .github/workflows/build.yml | 20 +- flake.nix | 129 ++++-- gui/.gitignore | 14 +- gui/.lintstagedrc.mjs | 4 +- gui/{ => app}/.env | 0 gui/{ => app}/eslint.config.js | 0 gui/{ => app}/index.html | 0 gui/{ => app}/openapi-codegen.config.ts | 0 gui/{ => app}/package.json | 117 +++-- gui/{ => app}/postcss.config.cjs | 0 gui/{ => app}/public/favicon.ico | Bin .../public/fonts/Lexend[HEXP,wght].woff2 | Bin .../fonts/OpenDyslexic-Bold-Italic.woff | Bin .../public/fonts/OpenDyslexic-Bold.woff | Bin .../public/fonts/OpenDyslexic-Italic.woff | Bin .../public/fonts/OpenDyslexic-Regular.woff | Bin gui/{ => app}/public/fonts/Ubuntu-R.woff2 | Bin .../fonts/noto-sans-v42-latin-regular.woff2 | Bin gui/{ => app}/public/i18n/ar/translation.ftl | 0 gui/{ => app}/public/i18n/cs/translation.ftl | 0 gui/{ => app}/public/i18n/da/translation.ftl | 0 gui/{ => app}/public/i18n/de/translation.ftl | 0 gui/{ => app}/public/i18n/el/translation.ftl | 0 .../public/i18n/en-x-owo/translation.ftl | 0 gui/{ => app}/public/i18n/en/translation.ftl | 0 .../public/i18n/es-419/translation.ftl | 0 .../public/i18n/es-ES/translation.ftl | 0 gui/{ => app}/public/i18n/et/translation.ftl | 0 gui/{ => app}/public/i18n/fi/translation.ftl | 0 gui/{ => app}/public/i18n/fr/translation.ftl | 0 gui/{ => app}/public/i18n/he/translation.ftl | 0 gui/{ => app}/public/i18n/it/translation.ftl | 0 gui/{ => app}/public/i18n/ja/translation.ftl | 0 gui/{ => app}/public/i18n/ko/translation.ftl | 0 gui/{ => app}/public/i18n/lt/translation.ftl | 0 .../public/i18n/nb-NO/translation.ftl | 0 gui/{ => app}/public/i18n/nl/translation.ftl | 0 gui/{ => app}/public/i18n/pl/translation.ftl | 0 .../public/i18n/pt-BR/translation.ftl | 0 gui/{ => app}/public/i18n/ru/translation.ftl | 0 .../public/i18n/sv-SE/translation.ftl | 0 gui/{ => app}/public/i18n/th/translation.ftl | 0 gui/{ => app}/public/i18n/tr/translation.ftl | 0 gui/{ => app}/public/i18n/uk/translation.ftl | 0 gui/{ => app}/public/i18n/vi/translation.ftl | 0 .../public/i18n/zh-Hans/translation.ftl | 0 .../public/i18n/zh-Hant/translation.ftl | 0 .../public/images/R11_board_reset.webp | Bin .../public/images/R12_board_reset.webp | Bin .../public/images/R14_board_reset_sw.webp | Bin .../public/images/assignment-pose.webp | Bin .../public/images/autobone-poster.webp | Bin gui/{ => app}/public/images/boxslime.webp | Bin gui/{ => app}/public/images/curious-slime.gif | Bin .../public/images/front-standing-pose.webp | Bin gui/{ => app}/public/images/happy-slime.gif | Bin gui/{ => app}/public/images/jumping-slime.gif | Bin .../public/images/mounting-reset-pose.webp | Bin .../public/images/mounting/MountingFeets.webp | Bin .../images/mounting/MountingFeetsSide.webp | Bin .../images/quiz/quiz_mocap-pos_face.webp | Bin .../images/quiz/quiz_mocap-pos_forehead.webp | Bin gui/{ => app}/public/images/reset-pose.webp | Bin .../public/images/reset/FullResetPose.webp | Bin .../images/reset/FullResetPoseSide.webp | Bin .../images/reset/FullResetPoseWrong.webp | Bin gui/{ => app}/public/images/sad-slime.gif | Bin gui/{ => app}/public/images/slime-girl.webp | Bin gui/{ => app}/public/images/slimes.webp | Bin gui/{ => app}/public/images/slimetower.webp | Bin .../images/stay-aligned/StayAlignedFloor.webp | Bin .../stay-aligned/StayAlignedSitting.webp | Bin .../stay-aligned/StayAlignedStanding.webp | Bin .../images/trackers/butterfly_slime.webp | Bin .../public/images/trackers/v1_2_slime.webp | Bin .../images/user-height/controller-ok.webp | Bin .../user-height/controller-wrong-1.webp | Bin .../user-height/controller-wrong-2.webp | Bin .../public/images/user-height/done.webp | Bin .../images/user-height/look-forward-high.webp | Bin .../images/user-height/look-forward-low.webp | Bin .../images/user-height/look-forward-ok.webp | Bin .../images/user-height/stand-still.webp | Bin .../public/images/user-height/timeout.webp | Bin .../images/user-height/touch-floor.webp | Bin .../images/user-height/wrong-height.webp | Bin gui/{ => app}/public/logo.svg | 0 gui/{ => app}/public/logo192.png | Bin gui/{ => app}/public/logo512.png | Bin gui/{ => app}/public/manifest.json | 0 gui/{ => app}/public/models/extension.gltf | 0 gui/{ => app}/public/models/tracker.gltf | 0 gui/{ => app}/public/robots.txt | 0 .../full-reset/end-full-reset-with-tail.ogg | Bin .../public/sounds/full-reset/full-click-1.ogg | Bin .../public/sounds/full-reset/full-click-2.ogg | Bin .../public/sounds/full-reset/full-click-3.ogg | Bin .../full-reset/init-full-reset-with-tail.ogg | Bin gui/{ => app}/public/sounds/mew.ogg | Bin .../end-mounting-reset-with-tail.ogg | Bin .../init-mounting-reset-with-tail.ogg | Bin .../sounds/mounting-reset/mount-click-1.ogg | Bin .../sounds/mounting-reset/mount-click-2.ogg | Bin .../sounds/mounting-reset/mount-click-3.ogg | Bin .../public/sounds/tracking/pause.ogg | Bin gui/{ => app}/public/sounds/tracking/play.ogg | Bin .../public/sounds/yaw-reset/yaw-reset.ogg | Bin gui/{ => app}/public/videos/autobone.webm | Bin .../public/videos/turn-on-tracker.webm | Bin gui/{ => app}/scripts/check-missing.js | 0 gui/{ => app}/scripts/convert-fluent.js | 0 gui/{ => app}/scripts/gitversion.mjs | 0 gui/{ => app}/scripts/license-list.mjs | 0 gui/{ => app}/src/App.tsx | 0 gui/{ => app}/src/AppLayout.tsx | 0 gui/{ => app}/src/components/BVHButton.tsx | 0 gui/{ => app}/src/components/EmptyLayout.scss | 0 gui/{ => app}/src/components/EmptyLayout.tsx | 0 gui/{ => app}/src/components/MainLayout.scss | 0 gui/{ => app}/src/components/MainLayout.tsx | 0 gui/{ => app}/src/components/Navbar.tsx | 0 gui/{ => app}/src/components/Preload.tsx | 0 .../src/components/SerialDetectionModal.tsx | 0 gui/{ => app}/src/components/Sidebar.tsx | 0 gui/{ => app}/src/components/Toolbar.tsx | 0 gui/{ => app}/src/components/TopBar.tsx | 0 .../src/components/TrackersStillOnModal.tsx | 0 .../src/components/TrackingPauseButton.tsx | 0 .../src/components/TrayOrExitModal.tsx | 0 .../src/components/UnknownDeviceModal.tsx | 0 .../src/components/VersionUpdateModal.tsx | 0 gui/{ => app}/src/components/commons/A.tsx | 0 .../src/components/commons/ArrowLink.tsx | 0 .../src/components/commons/BaseModal.tsx | 0 .../src/components/commons/BigButton.tsx | 0 .../src/components/commons/BodyDisplay.tsx | 0 .../components/commons/BodyInteractions.tsx | 0 .../src/components/commons/BodyPartIcon.tsx | 0 .../src/components/commons/Button.tsx | 0 .../components/commons/ButtonConfirmModal.tsx | 0 .../src/components/commons/Checkbox.tsx | 0 .../src/components/commons/Dropdown.tsx | 0 .../src/components/commons/FileInput.tsx | 0 .../src/components/commons/FirmwareIcon.tsx | 0 .../src/components/commons/Input.tsx | 0 .../src/components/commons/LangSelector.tsx | 0 .../src/components/commons/Modal.tsx | 0 .../src/components/commons/NumberSelector.tsx | 0 .../components/commons/PersonFrontIcon.tsx | 0 .../src/components/commons/ProgressBar.tsx | 0 .../src/components/commons/Radio.tsx | 0 .../src/components/commons/Range.tsx | 0 .../components/commons/SystemFileInput.tsx | 0 .../src/components/commons/ThemeSelector.tsx | 0 .../src/components/commons/TipBox.tsx | 0 .../src/components/commons/Tooltip.tsx | 0 .../src/components/commons/Typography.tsx | 0 .../components/commons/VerticalStepper.tsx | 0 .../src/components/commons/icon/AnkleIcon.tsx | 0 .../components/commons/icon/ArrowIcons.tsx | 0 .../components/commons/icon/BatteryIcon.tsx | 0 .../src/components/commons/icon/BellIcon.tsx | 0 .../src/components/commons/icon/BugIcon.tsx | 0 .../src/components/commons/icon/BulbIcon.tsx | 0 .../src/components/commons/icon/CheckIcon.tsx | 0 .../components/commons/icon/ChecklistIcon.tsx | 0 .../src/components/commons/icon/ChestIcon.tsx | 0 .../components/commons/icon/CircleIcon.tsx | 0 .../src/components/commons/icon/ClearIcon.tsx | 0 .../src/components/commons/icon/CloseIcon.tsx | 0 .../commons/icon/ControllerIcon.tsx | 0 .../src/components/commons/icon/CrossIcon.tsx | 0 .../src/components/commons/icon/CubeIcon.tsx | 0 .../components/commons/icon/DownloadIcon.tsx | 0 .../components/commons/icon/EscapeIcon.tsx | 0 .../src/components/commons/icon/EyeIcon.tsx | 0 .../src/components/commons/icon/FileIcon.tsx | 0 .../components/commons/icon/FingersIcon.tsx | 0 .../components/commons/icon/FolderIcon.tsx | 0 .../src/components/commons/icon/FootIcon.tsx | 0 .../components/commons/icon/FrontOfChair.tsx | 0 .../src/components/commons/icon/GearIcon.tsx | 0 .../src/components/commons/icon/HeadIcon.tsx | 0 .../components/commons/icon/HeadsetIcon.tsx | 0 .../src/components/commons/icon/HipIcon.tsx | 0 .../src/components/commons/icon/HomeIcon.tsx | 0 .../commons/icon/HorizontalAlignIcon.tsx | 0 .../src/components/commons/icon/HumanIcon.tsx | 0 .../components/commons/icon/ImportIcon.tsx | 0 .../components/commons/icon/LayoutIcon.tsx | 0 .../components/commons/icon/LoaderIcon.tsx | 0 .../components/commons/icon/LowerArmIcon.tsx | 0 .../commons/icon/MartialArtsIcon.tsx | 0 .../components/commons/icon/MaximiseIcon.tsx | 0 .../components/commons/icon/MinimiseIcon.tsx | 0 .../src/components/commons/icon/NeckIcon.tsx | 0 .../components/commons/icon/OutputIcon.tsx | 0 .../src/components/commons/icon/PauseIcon.tsx | 0 .../src/components/commons/icon/PawIcon.tsx | 0 .../components/commons/icon/PercentIcon.tsx | 0 .../src/components/commons/icon/PlayIcon.tsx | 0 .../components/commons/icon/QuestionIcon.tsx | 0 .../components/commons/icon/RecordIcon.tsx | 0 .../src/components/commons/icon/ResetIcon.tsx | 0 .../components/commons/icon/RouterIcon.tsx | 0 .../src/components/commons/icon/RulerIcon.tsx | 0 .../components/commons/icon/ShoulderIcon.tsx | 0 .../components/commons/icon/SimevrIcon.tsx | 0 .../components/commons/icon/SittingIcon.tsx | 0 .../src/components/commons/icon/SkiIcon.tsx | 0 .../components/commons/icon/SlimeUpIcon.tsx | 0 .../components/commons/icon/SlimeVRIcon.tsx | 0 .../components/commons/icon/SparkleIcon.tsx | 0 .../src/components/commons/icon/SpinIcon.tsx | 0 .../components/commons/icon/SquaresIcon.tsx | 0 .../src/components/commons/icon/SteamIcon.tsx | 0 .../components/commons/icon/TaybolIcon.tsx | 0 .../src/components/commons/icon/TrashIcon.tsx | 0 .../commons/icon/UploadFileIcon.tsx | 0 .../commons/icon/UploadFolderIcon.tsx | 0 .../components/commons/icon/UpperArmIcon.tsx | 0 .../commons/icon/UpperChestIcon.tsx | 0 .../components/commons/icon/UpperLegIcon.tsx | 0 .../src/components/commons/icon/UsbIcon.tsx | 0 .../src/components/commons/icon/VMCIcon.tsx | 0 .../src/components/commons/icon/VRCIcon.tsx | 0 .../src/components/commons/icon/WaistIcon.tsx | 0 .../components/commons/icon/WarningIcon.tsx | 0 .../src/components/commons/icon/WifiIcon.tsx | 0 .../components/commons/icon/WrenchIcon.tsx | 0 .../components/firmware-tool/DeviceCard.tsx | 0 .../components/firmware-tool/FirmwareTool.tsx | 0 .../firmware-tool/steps/BoardDefaultsStep.tsx | 0 .../firmware-tool/steps/BuildStep.tsx | 0 .../firmware-tool/steps/FlashBtnStep.tsx | 0 .../steps/FlashingMethodStep.tsx | 0 .../firmware-tool/steps/FlashingStep.tsx | 0 .../firmware-tool/steps/SelectSourceStep.tsx | 0 .../firmware-update/FirmwareUpdate.tsx | 0 gui/{ => app}/src/components/home/Home.tsx | 0 .../src/components/home/HomeSettingsModal.tsx | 0 .../src/components/home/ResetButton.tsx | 0 .../components/onboarding/BodyAssignment.tsx | 0 .../onboarding/NeckWarningModal.tsx | 0 .../onboarding/OnboardingContextProvider.tsx | 0 .../onboarding/OnboardingLayout.scss | 0 .../onboarding/OnboardingLayout.tsx | 0 .../components/onboarding/SkipSetupButton.tsx | 0 .../onboarding/SkipSetupWarningModal.tsx | 0 .../components/onboarding/StepperSlider.tsx | 0 .../components/onboarding/UdevRulesModal.tsx | 0 .../onboarding/pages/ConnectTracker.scss | 0 .../onboarding/pages/ConnectTracker.tsx | 0 .../onboarding/pages/ConnectionLost.tsx | 0 .../components/onboarding/pages/Dongle.tsx | 0 .../pages/ErrorCollectingConsent.tsx | 0 .../src/components/onboarding/pages/Home.tsx | 0 .../components/onboarding/pages/WifiCreds.tsx | 0 .../assignment-preparation/ExtensionArrow.tsx | 0 .../assignment-preparation/StickerSlime.tsx | 0 .../assignment-preparation/TrackerArrow.tsx | 0 .../body-proportions/AutomaticProportions.tsx | 0 .../body-proportions/BodyProportions.tsx | 0 .../pages/body-proportions/HeightInput.tsx | 0 .../body-proportions/ManualProportions.tsx | 0 .../ProportionsResetModal.tsx | 0 .../body-proportions/ScaledProportions.tsx | 0 .../autobone-steps/AutoboneErrorModal.tsx | 0 .../body-proportions/autobone-steps/Done.tsx | 0 .../autobone-steps/Preparation.tsx | 0 .../autobone-steps/PutTrackersOn.tsx | 0 .../autobone-steps/Recording.tsx | 0 .../autobone-steps/Requirements.tsx | 0 .../autobone-steps/StartRecording.tsx | 0 .../autobone-steps/TooSmolModal.tsx | 0 .../autobone-steps/VerifyResults.tsx | 0 .../pages/mounting/AutomaticMounting.tsx | 0 .../pages/mounting/ManualMounting.tsx | 0 .../pages/mounting/MountingChoose.tsx | 0 .../pages/mounting/MountingSelectionMenu.tsx | 0 .../pages/mounting/mounting-steps/Done.tsx | 0 .../mounting/mounting-steps/MountingReset.tsx | 0 .../mounting/mounting-steps/Preparation.tsx | 0 .../mounting/mounting-steps/PutTrackersOn.tsx | 0 .../pages/quiz/MocapPreferencesQuestions.tsx | 0 .../onboarding/pages/quiz/MoreSetsConfirm.tsx | 0 .../onboarding/pages/quiz/RuntimeQuestion.tsx | 0 .../pages/quiz/SlimeSetQuestion.tsx | 0 .../onboarding/pages/quiz/UsageQuestion.tsx | 0 .../pages/stay-aligned/StayAlignedSetup.tsx | 0 .../stay-aligned/stay-aligned-steps/Done.tsx | 0 .../stay-aligned-steps/PreparationStep.tsx | 0 .../stay-aligned-steps/PutTrackersOnStep.tsx | 0 .../stay-aligned-steps/RelaxedPoseSteps.tsx | 0 .../stay-aligned-steps/VerifyMounting.tsx | 0 .../trackers-assign/TrackerAssignOptions.tsx | 0 .../trackers-assign/TrackerAssignment.tsx | 0 .../trackers-assign/TrackerSelectionMenu.tsx | 0 .../src/components/providers/AppContext.tsx | 0 .../components/providers/ConfigContext.tsx | 0 .../components/settings/SettingsLayout.scss | 0 .../components/settings/SettingsLayout.tsx | 0 .../settings/SettingsPageLayout.tsx | 0 .../settings/SettingsResetModal.tsx | 0 .../components/settings/SettingsSidebar.tsx | 0 .../settings/pages/AdvancedSettings.tsx | 0 .../settings/pages/GeneralSettings.tsx | 0 .../settings/pages/HomeScreenSettings.tsx | 0 .../settings/pages/InterfaceSettings.tsx | 0 .../src/components/settings/pages/Serial.tsx | 0 .../components/settings/pages/VMCSettings.tsx | 0 .../settings/pages/VRCOSCSettings.tsx | 0 .../settings/pages/components/HIDSettings.tsx | 0 .../pages/components/HandsWarningModal.tsx | 0 .../components/MagnetometerToggleSetting.tsx | 0 .../pages/components/OutputSettings.tsx | 0 .../pages/components/ResetsSettings.tsx | 0 .../pages/components/StayAlignedPoseModal.tsx | 0 .../pages/components/StayAlignedSettings.tsx | 0 .../pages/components/TapDetectionSettings.tsx | 0 .../pages/components/TrackingSettings.tsx | 0 .../components/stay-aligned/RelaxedPose.tsx | 0 .../stay-aligned/StayAlignedInfo.tsx | 0 .../SingleTrackerBodyAssignmentMenu.tsx | 0 .../src/components/tracker/TrackerBattery.tsx | 0 .../src/components/tracker/TrackerCard.tsx | 0 .../components/tracker/TrackerPartCard.tsx | 0 .../components/tracker/TrackerSettings.tsx | 0 .../src/components/tracker/TrackerStatus.tsx | 0 .../src/components/tracker/TrackerWifi.tsx | 0 .../src/components/tracker/TrackersTable.tsx | 0 .../tracking-checklist/TrackingChecklist.tsx | 0 .../TrackingChecklistModal.tsx | 0 .../TrackingChecklistProvider.tsx | 0 .../src/components/vr-mode/VRModePage.tsx | 0 .../src/components/vrc/VRCWarningsPage.tsx | 0 .../widgets/IMUVisualizerWidget.tsx | 0 .../widgets/SkeletonVisualizerWidget.tsx | 0 .../firmwareToolComponents.ts | 0 .../firmware-tool-api/firmwareToolContext.ts | 0 .../firmware-tool-api/firmwareToolFetcher.ts | 0 .../firmware-tool-api/firmwareToolSchemas.ts | 0 .../firmware-tool-api/firmwareToolUtils.ts | 0 gui/{ => app}/src/hooks/app.ts | 0 gui/{ => app}/src/hooks/autobone.ts | 0 gui/{ => app}/src/hooks/body-parts.ts | 0 gui/{ => app}/src/hooks/breakpoint.ts | 0 gui/{ => app}/src/hooks/bvh.ts | 0 gui/{ => app}/src/hooks/cache.ts | 2 +- gui/{ => app}/src/hooks/choker-warning.ts | 0 gui/{ => app}/src/hooks/config.ts | 2 +- gui/{ => app}/src/hooks/countdown.ts | 0 gui/{ => app}/src/hooks/crossplatform.ts | 0 gui/{ => app}/src/hooks/crypto.ts | 0 gui/{ => app}/src/hooks/datafeed-config.ts | 0 gui/{ => app}/src/hooks/discord-presence.ts | 0 gui/{ => app}/src/hooks/electron.ts | 2 +- gui/{ => app}/src/hooks/firmware-tool.ts | 0 gui/{ => app}/src/hooks/firmware-update.ts | 0 gui/{ => app}/src/hooks/height.ts | 0 gui/{ => app}/src/hooks/imu-logic.ts | 0 gui/{ => app}/src/hooks/layout.ts | 0 gui/{ => app}/src/hooks/manual-proportions.ts | 0 gui/{ => app}/src/hooks/onboarding.ts | 0 .../src/hooks/osc-setting-validator.ts | 0 gui/{ => app}/src/hooks/pause-tracking.ts | 0 gui/{ => app}/src/hooks/previous.ts | 0 gui/{ => app}/src/hooks/reset.ts | 0 gui/{ => app}/src/hooks/timeout.ts | 0 gui/{ => app}/src/hooks/tracker.ts | 0 gui/{ => app}/src/hooks/tracking-checklist.ts | 0 gui/{ => app}/src/hooks/vrc-config.ts | 0 gui/{ => app}/src/hooks/websocket-api.ts | 0 gui/{ => app}/src/hooks/wifi-form.tsx | 0 gui/{ => app}/src/i18n/config.tsx | 0 gui/{ => app}/src/i18n/names.ts | 0 gui/{ => app}/src/index.scss | 0 gui/{ => app}/src/index.tsx | 0 gui/{ => app}/src/logo.svg | 0 gui/{ => app}/src/maths/angle.ts | 0 gui/{ => app}/src/maths/quaternion.ts | 0 gui/{ => app}/src/maths/vector3.ts | 0 gui/{ => app}/src/setupTests.ts | 0 gui/{ => app}/src/sounds/sounds.ts | 0 gui/{ => app}/src/sounds/xylophone.ts | 0 gui/{ => app}/src/store/app-store.ts | 0 gui/{ => app}/src/utils/a11y.ts | 0 gui/{ => app}/src/utils/formatting.ts | 0 gui/{ => app}/src/utils/logging.ts | 0 gui/{ => app}/src/utils/sentry.ts | 0 gui/{ => app}/src/utils/skeletonHelper.ts | 0 gui/{ => app}/src/vite-env.d.ts | 0 gui/{ => app}/tailwind.config.ts | 0 gui/{ => app}/tsconfig.json | 2 +- gui/{ => app}/tsconfig.node.json | 0 gui/{ => app}/vite.config.ts | 7 - gui/electron-builder.yml | 78 ---- gui/electron.vite.config.ts | 41 -- gui/electron/electron-builder.yml | 84 ++++ gui/electron/electron.vite.config.ts | 30 ++ gui/electron/env.d.ts | 1 + gui/electron/main/index.ts | 4 +- gui/electron/main/utils.ts | 4 +- gui/electron/package.json | 41 ++ gui/electron/preload/index.ts | 4 +- gui/electron/scripts/dev.mjs | 90 ++++ gui/electron/tsconfig.json | 20 + gui/shared/package.json | 18 + gui/shared/src/index.ts | 2 + .../src/interface.ts} | 0 gui/{electron/shared.ts => shared/src/ipc.ts} | 2 +- gui/shared/tsconfig.json | 15 + package.json | 13 +- pnpm-lock.yaml | 415 ++++++++---------- pnpm-workspace.yaml | 4 +- server/android/build.gradle.kts | 2 +- solarxr-protocol | 2 +- 417 files changed, 659 insertions(+), 510 deletions(-) rename gui/{ => app}/.env (100%) rename gui/{ => app}/eslint.config.js (100%) rename gui/{ => app}/index.html (100%) rename gui/{ => app}/openapi-codegen.config.ts (100%) rename gui/{ => app}/package.json (63%) rename gui/{ => app}/postcss.config.cjs (100%) rename gui/{ => app}/public/favicon.ico (100%) rename gui/{ => app}/public/fonts/Lexend[HEXP,wght].woff2 (100%) rename gui/{ => app}/public/fonts/OpenDyslexic-Bold-Italic.woff (100%) rename gui/{ => app}/public/fonts/OpenDyslexic-Bold.woff (100%) rename gui/{ => app}/public/fonts/OpenDyslexic-Italic.woff (100%) rename gui/{ => app}/public/fonts/OpenDyslexic-Regular.woff (100%) rename gui/{ => app}/public/fonts/Ubuntu-R.woff2 (100%) rename gui/{ => app}/public/fonts/noto-sans-v42-latin-regular.woff2 (100%) rename gui/{ => app}/public/i18n/ar/translation.ftl (100%) rename gui/{ => app}/public/i18n/cs/translation.ftl (100%) rename gui/{ => app}/public/i18n/da/translation.ftl (100%) rename gui/{ => app}/public/i18n/de/translation.ftl (100%) rename gui/{ => app}/public/i18n/el/translation.ftl (100%) rename gui/{ => app}/public/i18n/en-x-owo/translation.ftl (100%) rename gui/{ => app}/public/i18n/en/translation.ftl (100%) rename gui/{ => app}/public/i18n/es-419/translation.ftl (100%) rename gui/{ => app}/public/i18n/es-ES/translation.ftl (100%) rename gui/{ => app}/public/i18n/et/translation.ftl (100%) rename gui/{ => app}/public/i18n/fi/translation.ftl (100%) rename gui/{ => app}/public/i18n/fr/translation.ftl (100%) rename gui/{ => app}/public/i18n/he/translation.ftl (100%) rename gui/{ => app}/public/i18n/it/translation.ftl (100%) rename gui/{ => app}/public/i18n/ja/translation.ftl (100%) rename gui/{ => app}/public/i18n/ko/translation.ftl (100%) rename gui/{ => app}/public/i18n/lt/translation.ftl (100%) rename gui/{ => app}/public/i18n/nb-NO/translation.ftl (100%) rename gui/{ => app}/public/i18n/nl/translation.ftl (100%) rename gui/{ => app}/public/i18n/pl/translation.ftl (100%) rename gui/{ => app}/public/i18n/pt-BR/translation.ftl (100%) rename gui/{ => app}/public/i18n/ru/translation.ftl (100%) rename gui/{ => app}/public/i18n/sv-SE/translation.ftl (100%) rename gui/{ => app}/public/i18n/th/translation.ftl (100%) rename gui/{ => app}/public/i18n/tr/translation.ftl (100%) rename gui/{ => app}/public/i18n/uk/translation.ftl (100%) rename gui/{ => app}/public/i18n/vi/translation.ftl (100%) rename gui/{ => app}/public/i18n/zh-Hans/translation.ftl (100%) rename gui/{ => app}/public/i18n/zh-Hant/translation.ftl (100%) rename gui/{ => app}/public/images/R11_board_reset.webp (100%) rename gui/{ => app}/public/images/R12_board_reset.webp (100%) rename gui/{ => app}/public/images/R14_board_reset_sw.webp (100%) rename gui/{ => app}/public/images/assignment-pose.webp (100%) rename gui/{ => app}/public/images/autobone-poster.webp (100%) rename gui/{ => app}/public/images/boxslime.webp (100%) rename gui/{ => app}/public/images/curious-slime.gif (100%) rename gui/{ => app}/public/images/front-standing-pose.webp (100%) rename gui/{ => app}/public/images/happy-slime.gif (100%) rename gui/{ => app}/public/images/jumping-slime.gif (100%) rename gui/{ => app}/public/images/mounting-reset-pose.webp (100%) rename gui/{ => app}/public/images/mounting/MountingFeets.webp (100%) rename gui/{ => app}/public/images/mounting/MountingFeetsSide.webp (100%) rename gui/{ => app}/public/images/quiz/quiz_mocap-pos_face.webp (100%) rename gui/{ => app}/public/images/quiz/quiz_mocap-pos_forehead.webp (100%) rename gui/{ => app}/public/images/reset-pose.webp (100%) rename gui/{ => app}/public/images/reset/FullResetPose.webp (100%) rename gui/{ => app}/public/images/reset/FullResetPoseSide.webp (100%) rename gui/{ => app}/public/images/reset/FullResetPoseWrong.webp (100%) rename gui/{ => app}/public/images/sad-slime.gif (100%) rename gui/{ => app}/public/images/slime-girl.webp (100%) rename gui/{ => app}/public/images/slimes.webp (100%) rename gui/{ => app}/public/images/slimetower.webp (100%) rename gui/{ => app}/public/images/stay-aligned/StayAlignedFloor.webp (100%) rename gui/{ => app}/public/images/stay-aligned/StayAlignedSitting.webp (100%) rename gui/{ => app}/public/images/stay-aligned/StayAlignedStanding.webp (100%) rename gui/{ => app}/public/images/trackers/butterfly_slime.webp (100%) rename gui/{ => app}/public/images/trackers/v1_2_slime.webp (100%) rename gui/{ => app}/public/images/user-height/controller-ok.webp (100%) rename gui/{ => app}/public/images/user-height/controller-wrong-1.webp (100%) rename gui/{ => app}/public/images/user-height/controller-wrong-2.webp (100%) rename gui/{ => app}/public/images/user-height/done.webp (100%) rename gui/{ => app}/public/images/user-height/look-forward-high.webp (100%) rename gui/{ => app}/public/images/user-height/look-forward-low.webp (100%) rename gui/{ => app}/public/images/user-height/look-forward-ok.webp (100%) rename gui/{ => app}/public/images/user-height/stand-still.webp (100%) rename gui/{ => app}/public/images/user-height/timeout.webp (100%) rename gui/{ => app}/public/images/user-height/touch-floor.webp (100%) rename gui/{ => app}/public/images/user-height/wrong-height.webp (100%) rename gui/{ => app}/public/logo.svg (100%) rename gui/{ => app}/public/logo192.png (100%) rename gui/{ => app}/public/logo512.png (100%) rename gui/{ => app}/public/manifest.json (100%) rename gui/{ => app}/public/models/extension.gltf (100%) rename gui/{ => app}/public/models/tracker.gltf (100%) rename gui/{ => app}/public/robots.txt (100%) rename gui/{ => app}/public/sounds/full-reset/end-full-reset-with-tail.ogg (100%) rename gui/{ => app}/public/sounds/full-reset/full-click-1.ogg (100%) rename gui/{ => app}/public/sounds/full-reset/full-click-2.ogg (100%) rename gui/{ => app}/public/sounds/full-reset/full-click-3.ogg (100%) rename gui/{ => app}/public/sounds/full-reset/init-full-reset-with-tail.ogg (100%) rename gui/{ => app}/public/sounds/mew.ogg (100%) rename gui/{ => app}/public/sounds/mounting-reset/end-mounting-reset-with-tail.ogg (100%) rename gui/{ => app}/public/sounds/mounting-reset/init-mounting-reset-with-tail.ogg (100%) rename gui/{ => app}/public/sounds/mounting-reset/mount-click-1.ogg (100%) rename gui/{ => app}/public/sounds/mounting-reset/mount-click-2.ogg (100%) rename gui/{ => app}/public/sounds/mounting-reset/mount-click-3.ogg (100%) rename gui/{ => app}/public/sounds/tracking/pause.ogg (100%) rename gui/{ => app}/public/sounds/tracking/play.ogg (100%) rename gui/{ => app}/public/sounds/yaw-reset/yaw-reset.ogg (100%) rename gui/{ => app}/public/videos/autobone.webm (100%) rename gui/{ => app}/public/videos/turn-on-tracker.webm (100%) rename gui/{ => app}/scripts/check-missing.js (100%) rename gui/{ => app}/scripts/convert-fluent.js (100%) rename gui/{ => app}/scripts/gitversion.mjs (100%) rename gui/{ => app}/scripts/license-list.mjs (100%) rename gui/{ => app}/src/App.tsx (100%) rename gui/{ => app}/src/AppLayout.tsx (100%) rename gui/{ => app}/src/components/BVHButton.tsx (100%) rename gui/{ => app}/src/components/EmptyLayout.scss (100%) rename gui/{ => app}/src/components/EmptyLayout.tsx (100%) rename gui/{ => app}/src/components/MainLayout.scss (100%) rename gui/{ => app}/src/components/MainLayout.tsx (100%) rename gui/{ => app}/src/components/Navbar.tsx (100%) rename gui/{ => app}/src/components/Preload.tsx (100%) rename gui/{ => app}/src/components/SerialDetectionModal.tsx (100%) rename gui/{ => app}/src/components/Sidebar.tsx (100%) rename gui/{ => app}/src/components/Toolbar.tsx (100%) rename gui/{ => app}/src/components/TopBar.tsx (100%) rename gui/{ => app}/src/components/TrackersStillOnModal.tsx (100%) rename gui/{ => app}/src/components/TrackingPauseButton.tsx (100%) rename gui/{ => app}/src/components/TrayOrExitModal.tsx (100%) rename gui/{ => app}/src/components/UnknownDeviceModal.tsx (100%) rename gui/{ => app}/src/components/VersionUpdateModal.tsx (100%) rename gui/{ => app}/src/components/commons/A.tsx (100%) rename gui/{ => app}/src/components/commons/ArrowLink.tsx (100%) rename gui/{ => app}/src/components/commons/BaseModal.tsx (100%) rename gui/{ => app}/src/components/commons/BigButton.tsx (100%) rename gui/{ => app}/src/components/commons/BodyDisplay.tsx (100%) rename gui/{ => app}/src/components/commons/BodyInteractions.tsx (100%) rename gui/{ => app}/src/components/commons/BodyPartIcon.tsx (100%) rename gui/{ => app}/src/components/commons/Button.tsx (100%) rename gui/{ => app}/src/components/commons/ButtonConfirmModal.tsx (100%) rename gui/{ => app}/src/components/commons/Checkbox.tsx (100%) rename gui/{ => app}/src/components/commons/Dropdown.tsx (100%) rename gui/{ => app}/src/components/commons/FileInput.tsx (100%) rename gui/{ => app}/src/components/commons/FirmwareIcon.tsx (100%) rename gui/{ => app}/src/components/commons/Input.tsx (100%) rename gui/{ => app}/src/components/commons/LangSelector.tsx (100%) rename gui/{ => app}/src/components/commons/Modal.tsx (100%) rename gui/{ => app}/src/components/commons/NumberSelector.tsx (100%) rename gui/{ => app}/src/components/commons/PersonFrontIcon.tsx (100%) rename gui/{ => app}/src/components/commons/ProgressBar.tsx (100%) rename gui/{ => app}/src/components/commons/Radio.tsx (100%) rename gui/{ => app}/src/components/commons/Range.tsx (100%) rename gui/{ => app}/src/components/commons/SystemFileInput.tsx (100%) rename gui/{ => app}/src/components/commons/ThemeSelector.tsx (100%) rename gui/{ => app}/src/components/commons/TipBox.tsx (100%) rename gui/{ => app}/src/components/commons/Tooltip.tsx (100%) rename gui/{ => app}/src/components/commons/Typography.tsx (100%) rename gui/{ => app}/src/components/commons/VerticalStepper.tsx (100%) rename gui/{ => app}/src/components/commons/icon/AnkleIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/ArrowIcons.tsx (100%) rename gui/{ => app}/src/components/commons/icon/BatteryIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/BellIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/BugIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/BulbIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/CheckIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/ChecklistIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/ChestIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/CircleIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/ClearIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/CloseIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/ControllerIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/CrossIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/CubeIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/DownloadIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/EscapeIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/EyeIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/FileIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/FingersIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/FolderIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/FootIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/FrontOfChair.tsx (100%) rename gui/{ => app}/src/components/commons/icon/GearIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/HeadIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/HeadsetIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/HipIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/HomeIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/HorizontalAlignIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/HumanIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/ImportIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/LayoutIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/LoaderIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/LowerArmIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/MartialArtsIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/MaximiseIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/MinimiseIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/NeckIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/OutputIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/PauseIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/PawIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/PercentIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/PlayIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/QuestionIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/RecordIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/ResetIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/RouterIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/RulerIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/ShoulderIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/SimevrIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/SittingIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/SkiIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/SlimeUpIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/SlimeVRIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/SparkleIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/SpinIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/SquaresIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/SteamIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/TaybolIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/TrashIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/UploadFileIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/UploadFolderIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/UpperArmIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/UpperChestIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/UpperLegIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/UsbIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/VMCIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/VRCIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/WaistIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/WarningIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/WifiIcon.tsx (100%) rename gui/{ => app}/src/components/commons/icon/WrenchIcon.tsx (100%) rename gui/{ => app}/src/components/firmware-tool/DeviceCard.tsx (100%) rename gui/{ => app}/src/components/firmware-tool/FirmwareTool.tsx (100%) rename gui/{ => app}/src/components/firmware-tool/steps/BoardDefaultsStep.tsx (100%) rename gui/{ => app}/src/components/firmware-tool/steps/BuildStep.tsx (100%) rename gui/{ => app}/src/components/firmware-tool/steps/FlashBtnStep.tsx (100%) rename gui/{ => app}/src/components/firmware-tool/steps/FlashingMethodStep.tsx (100%) rename gui/{ => app}/src/components/firmware-tool/steps/FlashingStep.tsx (100%) rename gui/{ => app}/src/components/firmware-tool/steps/SelectSourceStep.tsx (100%) rename gui/{ => app}/src/components/firmware-update/FirmwareUpdate.tsx (100%) rename gui/{ => app}/src/components/home/Home.tsx (100%) rename gui/{ => app}/src/components/home/HomeSettingsModal.tsx (100%) rename gui/{ => app}/src/components/home/ResetButton.tsx (100%) rename gui/{ => app}/src/components/onboarding/BodyAssignment.tsx (100%) rename gui/{ => app}/src/components/onboarding/NeckWarningModal.tsx (100%) rename gui/{ => app}/src/components/onboarding/OnboardingContextProvider.tsx (100%) rename gui/{ => app}/src/components/onboarding/OnboardingLayout.scss (100%) rename gui/{ => app}/src/components/onboarding/OnboardingLayout.tsx (100%) rename gui/{ => app}/src/components/onboarding/SkipSetupButton.tsx (100%) rename gui/{ => app}/src/components/onboarding/SkipSetupWarningModal.tsx (100%) rename gui/{ => app}/src/components/onboarding/StepperSlider.tsx (100%) rename gui/{ => app}/src/components/onboarding/UdevRulesModal.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/ConnectTracker.scss (100%) rename gui/{ => app}/src/components/onboarding/pages/ConnectTracker.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/ConnectionLost.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/Dongle.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/ErrorCollectingConsent.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/Home.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/WifiCreds.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/assignment-preparation/ExtensionArrow.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/assignment-preparation/StickerSlime.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/assignment-preparation/TrackerArrow.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/body-proportions/AutomaticProportions.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/body-proportions/BodyProportions.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/body-proportions/HeightInput.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/body-proportions/ManualProportions.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/body-proportions/ProportionsResetModal.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/body-proportions/autobone-steps/AutoboneErrorModal.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/body-proportions/autobone-steps/Done.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/body-proportions/autobone-steps/PutTrackersOn.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/body-proportions/autobone-steps/Recording.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/body-proportions/autobone-steps/Requirements.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/body-proportions/autobone-steps/StartRecording.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/body-proportions/autobone-steps/TooSmolModal.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/body-proportions/autobone-steps/VerifyResults.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/mounting/AutomaticMounting.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/mounting/ManualMounting.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/mounting/MountingChoose.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/mounting/MountingSelectionMenu.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/mounting/mounting-steps/Done.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/mounting/mounting-steps/MountingReset.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/mounting/mounting-steps/Preparation.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/mounting/mounting-steps/PutTrackersOn.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/quiz/MocapPreferencesQuestions.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/quiz/MoreSetsConfirm.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/quiz/RuntimeQuestion.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/quiz/SlimeSetQuestion.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/quiz/UsageQuestion.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/stay-aligned/StayAlignedSetup.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/Done.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PutTrackersOnStep.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/RelaxedPoseSteps.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/VerifyMounting.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/trackers-assign/TrackerAssignOptions.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx (100%) rename gui/{ => app}/src/components/onboarding/pages/trackers-assign/TrackerSelectionMenu.tsx (100%) rename gui/{ => app}/src/components/providers/AppContext.tsx (100%) rename gui/{ => app}/src/components/providers/ConfigContext.tsx (100%) rename gui/{ => app}/src/components/settings/SettingsLayout.scss (100%) rename gui/{ => app}/src/components/settings/SettingsLayout.tsx (100%) rename gui/{ => app}/src/components/settings/SettingsPageLayout.tsx (100%) rename gui/{ => app}/src/components/settings/SettingsResetModal.tsx (100%) rename gui/{ => app}/src/components/settings/SettingsSidebar.tsx (100%) rename gui/{ => app}/src/components/settings/pages/AdvancedSettings.tsx (100%) rename gui/{ => app}/src/components/settings/pages/GeneralSettings.tsx (100%) rename gui/{ => app}/src/components/settings/pages/HomeScreenSettings.tsx (100%) rename gui/{ => app}/src/components/settings/pages/InterfaceSettings.tsx (100%) rename gui/{ => app}/src/components/settings/pages/Serial.tsx (100%) rename gui/{ => app}/src/components/settings/pages/VMCSettings.tsx (100%) rename gui/{ => app}/src/components/settings/pages/VRCOSCSettings.tsx (100%) rename gui/{ => app}/src/components/settings/pages/components/HIDSettings.tsx (100%) rename gui/{ => app}/src/components/settings/pages/components/HandsWarningModal.tsx (100%) rename gui/{ => app}/src/components/settings/pages/components/MagnetometerToggleSetting.tsx (100%) rename gui/{ => app}/src/components/settings/pages/components/OutputSettings.tsx (100%) rename gui/{ => app}/src/components/settings/pages/components/ResetsSettings.tsx (100%) rename gui/{ => app}/src/components/settings/pages/components/StayAlignedPoseModal.tsx (100%) rename gui/{ => app}/src/components/settings/pages/components/StayAlignedSettings.tsx (100%) rename gui/{ => app}/src/components/settings/pages/components/TapDetectionSettings.tsx (100%) rename gui/{ => app}/src/components/settings/pages/components/TrackingSettings.tsx (100%) rename gui/{ => app}/src/components/stay-aligned/RelaxedPose.tsx (100%) rename gui/{ => app}/src/components/stay-aligned/StayAlignedInfo.tsx (100%) rename gui/{ => app}/src/components/tracker/SingleTrackerBodyAssignmentMenu.tsx (100%) rename gui/{ => app}/src/components/tracker/TrackerBattery.tsx (100%) rename gui/{ => app}/src/components/tracker/TrackerCard.tsx (100%) rename gui/{ => app}/src/components/tracker/TrackerPartCard.tsx (100%) rename gui/{ => app}/src/components/tracker/TrackerSettings.tsx (100%) rename gui/{ => app}/src/components/tracker/TrackerStatus.tsx (100%) rename gui/{ => app}/src/components/tracker/TrackerWifi.tsx (100%) rename gui/{ => app}/src/components/tracker/TrackersTable.tsx (100%) rename gui/{ => app}/src/components/tracking-checklist/TrackingChecklist.tsx (100%) rename gui/{ => app}/src/components/tracking-checklist/TrackingChecklistModal.tsx (100%) rename gui/{ => app}/src/components/tracking-checklist/TrackingChecklistProvider.tsx (100%) rename gui/{ => app}/src/components/vr-mode/VRModePage.tsx (100%) rename gui/{ => app}/src/components/vrc/VRCWarningsPage.tsx (100%) rename gui/{ => app}/src/components/widgets/IMUVisualizerWidget.tsx (100%) rename gui/{ => app}/src/components/widgets/SkeletonVisualizerWidget.tsx (100%) rename gui/{ => app}/src/firmware-tool-api/firmwareToolComponents.ts (100%) rename gui/{ => app}/src/firmware-tool-api/firmwareToolContext.ts (100%) rename gui/{ => app}/src/firmware-tool-api/firmwareToolFetcher.ts (100%) rename gui/{ => app}/src/firmware-tool-api/firmwareToolSchemas.ts (100%) rename gui/{ => app}/src/firmware-tool-api/firmwareToolUtils.ts (100%) rename gui/{ => app}/src/hooks/app.ts (100%) rename gui/{ => app}/src/hooks/autobone.ts (100%) rename gui/{ => app}/src/hooks/body-parts.ts (100%) rename gui/{ => app}/src/hooks/breakpoint.ts (100%) rename gui/{ => app}/src/hooks/bvh.ts (100%) rename gui/{ => app}/src/hooks/cache.ts (96%) rename gui/{ => app}/src/hooks/choker-warning.ts (100%) rename gui/{ => app}/src/hooks/config.ts (98%) rename gui/{ => app}/src/hooks/countdown.ts (100%) rename gui/{ => app}/src/hooks/crossplatform.ts (100%) rename gui/{ => app}/src/hooks/crypto.ts (100%) rename gui/{ => app}/src/hooks/datafeed-config.ts (100%) rename gui/{ => app}/src/hooks/discord-presence.ts (100%) rename gui/{ => app}/src/hooks/electron.ts (93%) rename gui/{ => app}/src/hooks/firmware-tool.ts (100%) rename gui/{ => app}/src/hooks/firmware-update.ts (100%) rename gui/{ => app}/src/hooks/height.ts (100%) rename gui/{ => app}/src/hooks/imu-logic.ts (100%) rename gui/{ => app}/src/hooks/layout.ts (100%) rename gui/{ => app}/src/hooks/manual-proportions.ts (100%) rename gui/{ => app}/src/hooks/onboarding.ts (100%) rename gui/{ => app}/src/hooks/osc-setting-validator.ts (100%) rename gui/{ => app}/src/hooks/pause-tracking.ts (100%) rename gui/{ => app}/src/hooks/previous.ts (100%) rename gui/{ => app}/src/hooks/reset.ts (100%) rename gui/{ => app}/src/hooks/timeout.ts (100%) rename gui/{ => app}/src/hooks/tracker.ts (100%) rename gui/{ => app}/src/hooks/tracking-checklist.ts (100%) rename gui/{ => app}/src/hooks/vrc-config.ts (100%) rename gui/{ => app}/src/hooks/websocket-api.ts (100%) rename gui/{ => app}/src/hooks/wifi-form.tsx (100%) rename gui/{ => app}/src/i18n/config.tsx (100%) rename gui/{ => app}/src/i18n/names.ts (100%) rename gui/{ => app}/src/index.scss (100%) rename gui/{ => app}/src/index.tsx (100%) rename gui/{ => app}/src/logo.svg (100%) rename gui/{ => app}/src/maths/angle.ts (100%) rename gui/{ => app}/src/maths/quaternion.ts (100%) rename gui/{ => app}/src/maths/vector3.ts (100%) rename gui/{ => app}/src/setupTests.ts (100%) rename gui/{ => app}/src/sounds/sounds.ts (100%) rename gui/{ => app}/src/sounds/xylophone.ts (100%) rename gui/{ => app}/src/store/app-store.ts (100%) rename gui/{ => app}/src/utils/a11y.ts (100%) rename gui/{ => app}/src/utils/formatting.ts (100%) rename gui/{ => app}/src/utils/logging.ts (100%) rename gui/{ => app}/src/utils/sentry.ts (100%) rename gui/{ => app}/src/utils/skeletonHelper.ts (100%) rename gui/{ => app}/src/vite-env.d.ts (100%) rename gui/{ => app}/tailwind.config.ts (100%) rename gui/{ => app}/tsconfig.json (93%) rename gui/{ => app}/tsconfig.node.json (100%) rename gui/{ => app}/vite.config.ts (92%) delete mode 100644 gui/electron-builder.yml delete mode 100644 gui/electron.vite.config.ts create mode 100644 gui/electron/electron-builder.yml create mode 100644 gui/electron/electron.vite.config.ts create mode 100644 gui/electron/env.d.ts create mode 100644 gui/electron/package.json create mode 100644 gui/electron/scripts/dev.mjs create mode 100644 gui/electron/tsconfig.json create mode 100644 gui/shared/package.json create mode 100644 gui/shared/src/index.ts rename gui/{electron/preload/interface.d.ts => shared/src/interface.ts} (100%) rename gui/{electron/shared.ts => shared/src/ipc.ts} (95%) create mode 100644 gui/shared/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e77607b6ee..361bcb2d0c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -82,7 +82,7 @@ jobs: node-version-file: '.node-version' cache: 'pnpm' - name: GUI Lint - run: pnpm i && cd gui && pnpm run lint + run: pnpm i && pnpm run lint server-checks: name: Server Checks @@ -154,9 +154,9 @@ jobs: - name: Build JS env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - run: pnpm i && cd gui && pnpm run build + run: pnpm i && pnpm run build - name: Tar GUI Dist - run: tar -czf slimevr-gui-dist.tar.gz -C gui/out . + run: tar -czf slimevr-gui-dist.tar.gz -C gui/electron/out . - name: Upload GUI Dist uses: actions/upload-artifact@v7 with: @@ -201,21 +201,21 @@ jobs: name: gui-dist - name: Extract GUI for Electron shell: bash - run: mkdir -p gui/out && tar -xzf slimevr-gui-dist.tar.gz -C gui/out + run: mkdir -p gui/electron/out && tar -xzf slimevr-gui-dist.tar.gz -C gui/electron/out - name: Run Electron Builder shell: bash env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} run: | - mkdir -p gui/dist/artifacts/linux/ \ - gui/dist/artifacts/win \ - gui/dist/artifacts/mac + mkdir -p gui/electron/dist/artifacts/linux/ \ + gui/electron/dist/artifacts/win \ + gui/electron/dist/artifacts/mac VERSION=$(git --no-pager tag --sort -taggerdate --points-at HEAD | head -1) [ -z "$VERSION" ] && VERSION="0.0.0" VERSION="${VERSION#v}" echo "Building version: $VERSION" pnpm i - cd gui + cd gui/electron pnpm exec electron-builder --${{ matrix.platform }} \ ${{ matrix.platform == 'macos' && '--universal' || '' }} \ --config.extraMetadata.version="$VERSION" \ @@ -223,7 +223,7 @@ jobs: - name: Collect and Rename Artifacts shell: bash run: | - SRC_DIR="${{ github.workspace }}/gui/dist/artifacts" + SRC_DIR="${{ github.workspace }}/gui/electron/dist/artifacts" DEST_DIR="${{ github.workspace }}/release-out" mkdir -p "$DEST_DIR" @@ -274,7 +274,7 @@ jobs: with: name: gui-dist - name: Extract GUI for Android - run: mkdir -p gui/out && tar -xzf slimevr-gui-dist.tar.gz -C gui/out + run: mkdir -p gui/electron/out && tar -xzf slimevr-gui-dist.tar.gz -C gui/electron/out - name: Build APK run: ./gradlew :server:android:build --build-cache env: diff --git a/flake.nix b/flake.nix index 086bfd68e0..7240011ed6 100644 --- a/flake.nix +++ b/flake.nix @@ -119,56 +119,101 @@ ln -s ${runtime}/lib $out/lib ''; }; - in - { - devShells.default = pkgs.mkShell { - packages = [ - # for running the jar - java - - # for build - pkgs.electron - pkgs.rpm - pkgs.fpm - pkgs.p7zip - pkgs.wineWow64Packages.stable - pkgs.zlib - pkgs.squashfsTools - pkgs.desktop-file-utils - pkgs.fakeroot - pkgs.libarchive - pkgs.icu - pkgs.nodejs_22 - pkgs.pnpm - pkgs.pkg-config - pkgs.python3 - pkgs.gcc - pkgs.gnumake - pkgs.binutils - pkgs.git - pkgs.node-gyp-build - ]; - buildInputs = runtimeLibs; + # Toolchain shared by the dev shell and the packaging FHS env. + commonPackages = [ + # for running the jar + java + + # for build + pkgs.electron + pkgs.rpm + pkgs.fpm + pkgs.p7zip + pkgs.wineWow64Packages.stable + pkgs.zlib + pkgs.squashfsTools + pkgs.desktop-file-utils + pkgs.fakeroot + pkgs.libarchive + pkgs.icu + pkgs.nodejs_22 + pkgs.pnpm + pkgs.pkg-config + pkgs.python3 + pkgs.gcc + pkgs.gnumake + pkgs.binutils + pkgs.git + pkgs.node-gyp-build + ]; + + buildEnv = { JAVA_HOME = "${java}/lib/openjdk"; USE_SYSTEM_FPM = "true"; ELECTRON_BUILDER_7ZIP_PATH = "${pkgs.p7zip}/bin/7za"; APPIMAGE_TOOLS_PATH = "${appImageTools}"; - #ELECTRON_SKIP_BINARY_DOWNLOAD = true; - #ELECTRON_DIST = "${pkgs.electron.dist}"; - #ELECTRON_VERSION = "${pkgs.electron.version}"; - # for electron-vite, so `pnpm gui` works ELECTRON_EXEC_PATH = "${pkgs.electron}/bin/electron"; + }; - shellHook = '' - export LD_LIBRARY_PATH="${ - lib.makeLibraryPath [ - pkgs.systemd - pkgs.hidapi - ] - }:$LD_LIBRARY_PATH" - ''; + # electron-builder produces generic dynamically-linked binaries that + # NixOS can't run (no /lib64 loader). Building and running the packaged + # app inside an FHS sandbox provides that standard loader, so the final + # electron build works. `nix run .#package` runs `pnpm package:build`. + packageBuildScript = pkgs.writeShellScript "slimevr-package-build" '' + set -eu + cd "$(${pkgs.git}/bin/git rev-parse --show-toplevel)" + ${lib.concatStringsSep "\n" ( + lib.mapAttrsToList (name: value: "export ${name}=${lib.escapeShellArg value}") buildEnv + )} + export LD_LIBRARY_PATH="${lib.makeLibraryPath (runtimeLibs ++ [ pkgs.hidapi ])}:''${LD_LIBRARY_PATH:-}" + pnpm install + pnpm --filter @slimevr/gui-electron run package:build + ''; + + packageBuildFhs = pkgs.buildFHSEnv { + name = "slimevr-package-build"; + targetPkgs = _: commonPackages ++ runtimeLibs; + runScript = "${packageBuildScript}"; + }; + in + { + devShells.default = pkgs.mkShell ( + buildEnv + // { + packages = commonPackages; + buildInputs = runtimeLibs; + + shellHook = '' + export LD_LIBRARY_PATH="${ + lib.makeLibraryPath [ + pkgs.systemd + pkgs.hidapi + ] + }:$LD_LIBRARY_PATH" + ''; + } + ); + + # Build the desktop package inside an FHS sandbox: + # nix run .#package + apps.package = { + type = "app"; + program = "${packageBuildFhs}/bin/slimevr-package-build"; + }; + + # Interactive FHS shell for manual packaging / running the built app: + # nix run .#package-shell + apps.package-shell = { + type = "app"; + program = "${ + pkgs.buildFHSEnv { + name = "slimevr-fhs"; + targetPkgs = _: commonPackages ++ runtimeLibs; + runScript = "bash"; + } + }/bin/slimevr-fhs"; }; }; }; diff --git a/gui/.gitignore b/gui/.gitignore index 77383db183..d98181ed14 100644 --- a/gui/.gitignore +++ b/gui/.gitignore @@ -1,16 +1,16 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # JS/TS dependencies -/node_modules -/.pnp +node_modules/ +.pnp .pnp.js # testing -/coverage +coverage/ # Build artifacts -/build -/target +build/ +target/ # misc .DS_Store @@ -26,8 +26,8 @@ yarn-error.log* *.log # vite -/dist -/stats.html +dist/ +stats.html vite.config.ts.timestamp* electron.vite.config.*.mjs diff --git a/gui/.lintstagedrc.mjs b/gui/.lintstagedrc.mjs index 4818e789fc..8d6abe7152 100644 --- a/gui/.lintstagedrc.mjs +++ b/gui/.lintstagedrc.mjs @@ -1,5 +1,5 @@ export default { - '**/*.{ts,tsx}': () => 'tsc -p tsconfig.json --noEmit', - '{electron,src}/**/*.{js,jsx,ts,tsx}': 'eslint --max-warnings=0 --no-warn-ignored --cache --fix', + 'app/**/*.{js,jsx,ts,tsx}': + 'pnpm --filter @slimevr/gui-app exec eslint --max-warnings=0 --no-warn-ignored --cache --fix', '**/*.{js,jsx,ts,tsx,css,scss,md,json}': 'prettier --write', }; diff --git a/gui/.env b/gui/app/.env similarity index 100% rename from gui/.env rename to gui/app/.env diff --git a/gui/eslint.config.js b/gui/app/eslint.config.js similarity index 100% rename from gui/eslint.config.js rename to gui/app/eslint.config.js diff --git a/gui/index.html b/gui/app/index.html similarity index 100% rename from gui/index.html rename to gui/app/index.html diff --git a/gui/openapi-codegen.config.ts b/gui/app/openapi-codegen.config.ts similarity index 100% rename from gui/openapi-codegen.config.ts rename to gui/app/openapi-codegen.config.ts diff --git a/gui/package.json b/gui/app/package.json similarity index 63% rename from gui/package.json rename to gui/app/package.json index 48a9311106..ec542e6cc9 100644 --- a/gui/package.json +++ b/gui/app/package.json @@ -1,55 +1,68 @@ { - "name": "slimevr", + "name": "@slimevr/gui-app", "version": "0.0.0", "author": "SlimeVR Team ", "homepage": "https://slimevr.dev", + "private": true, "type": "module", - "dependencies": { - "@ryuziii/discord-rpc": "1.0.1-rc.1", - "@xhayper/discord-rpc": "^1.3.0", - "commander": "^14.0.3", - "discord-rich-presence": "^0.0.8", - "glob": "^13.0.3", - "open": "^11.0.0", - "pino": "^10.3.1", - "pino-pretty": "^13.1.3", - "pino-roll": "^4.0.0" - }, "scripts": { - "start": "vite --force", - "gui": "electron-vite dev --config electron.vite.config.ts --watch", - "build": "electron-vite build --config electron.vite.config.ts", - "package": "electron-builder", - "package:build": "pnpm build && pnpm package", - "preview": "electron-vite preview --config electron.vite.config.ts", - "skipbundler": "vite build", - "lint": "tsc --noEmit && eslint --max-warnings=0 \"{electron,src}/**/*.{js,jsx,ts,tsx,json}\" && prettier --check \"{electron,src}/**/*.{js,jsx,ts,tsx,css,scss,md,json}\"", - "lint:fix": "tsc --noEmit && eslint --fix --max-warnings=0 \"{electron,src}/**/*.{js,jsx,ts,tsx,json}\" && pnpm run format", - "format": "prettier --write \"{electron,src}/**/*.{js,jsx,ts,tsx,css,scss,md,json}\"", - "javaversion-build": "cd electron/resources/java-version/ && javac JavaVersion.java && jar cvfe JavaVersion.jar JavaVersion JavaVersion.class", - "gen:javaversion": "cd electron/resources/java-version/ && javac JavaVersion.java && jar cvfe JavaVersion.jar JavaVersion JavaVersion.class", + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "tsc --noEmit && eslint --max-warnings=0 \"src/**/*.{js,jsx,ts,tsx,json}\" && prettier --check \"src/**/*.{js,jsx,ts,tsx,css,scss,md,json}\"", + "lint:fix": "tsc --noEmit && eslint --fix --max-warnings=0 \"src/**/*.{js,jsx,ts,tsx,json}\" && pnpm run format", + "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,md,json}\"", "gen:firmware-tool": "openapi-codegen gen firmwareTool" }, - "devDependencies": { - "@dword-design/eslint-plugin-import-alias": "^4.0.9", - "@electron/asar": "^4.0.1", + "dependencies": { "@fluent/bundle": "^0.18.0", "@fluent/react": "^0.15.2", "@fontsource/poppins": "^5.1.0", "@formatjs/intl-localematcher": "^0.2.32", "@hookform/resolvers": "^3.6.0", - "@openapi-codegen/cli": "^3.1.0", - "@openapi-codegen/typescript": "^8.0.2", "@react-hookz/deep-equal": "^3.0.3", "@sentry/react": "10.29.0", + "@slimevr/gui-shared": "workspace:*", + "@tanstack/react-query": "^5.48.0", + "@tweenjs/tween.js": "^25.0.0", + "@twemoji/svg": "^15.0.0", + "ajv": "^8.17.1", + "browser-fs-access": "^0.35.0", + "classnames": "^2.5.1", + "convert": "^5.12.0", + "flatbuffers": "22.10.26", + "intl-pluralrules": "^2.0.1", + "ip-num": "^1.5.1", + "jotai": "^2.12.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-error-boundary": "^4.0.13", + "react-helmet": "^6.1.0", + "react-hook-form": "^7.63.0", + "react-markdown": "^9.0.1", + "react-modal": "^3.16.1", + "react-responsive": "^10.0.0", + "react-router-dom": "^6.26.2", + "remark-gfm": "^4.0.0", + "semver": "^7.6.3", + "solarxr-protocol": "workspace:*", + "tailwind-gradient-mask-image": "^1.2.0", + "three": "^0.163.0", + "ts-pattern": "^5.4.0", + "use-double-tap": "^1.3.6", + "uuid": "^13.0.0", + "yup": "^1.4.0" + }, + "devDependencies": { + "@dword-design/eslint-plugin-import-alias": "^4.0.9", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "^9.39.4", + "@openapi-codegen/cli": "^3.1.0", + "@openapi-codegen/typescript": "^8.0.2", "@sentry/vite-plugin": "^2.22.7", "@stylistic/eslint-plugin": "^5.5.0", "@tailwindcss/forms": "^0.5.9", "@tailwindcss/typography": "^0.5.15", - "@tanstack/react-query": "^5.48.0", - "@tweenjs/tween.js": "^25.0.0", - "@twemoji/svg": "^15.0.0", - "@types/file-saver": "^2.0.7", "@types/node": "^24.3.1", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", @@ -60,56 +73,24 @@ "@typescript-eslint/eslint-plugin": "^8.48.1", "@typescript-eslint/parser": "^8.48.1", "@vitejs/plugin-react": "^4.3.2", - "ajv": "^8.17.1", "autoprefixer": "^10.4.20", - "browser-fs-access": "^0.35.0", - "classnames": "^2.5.1", - "convert": "^5.12.0", - "dmg-license": "^1.0.11", "dotenv": "^16.4.5", - "electron": "^40.3.0", - "electron-builder": "^26.15.0", - "electron-vite": "^5.0.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "^9.39.4", "eslint": "^9.39.1", "eslint-import-resolver-typescript": "^3.10.1", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", - "flatbuffers": "22.10.26", "globals": "^15.10.0", - "intl-pluralrules": "^2.0.1", - "ip-num": "^1.5.1", - "jotai": "^2.12.2", + "postcss": "^8.4.47", "prettier": "^3.3.3", "prompts": "^2.4.2", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-error-boundary": "^4.0.13", - "react-helmet": "^6.1.0", - "react-hook-form": "^7.63.0", - "react-markdown": "^9.0.1", - "react-modal": "^3.16.1", - "react-responsive": "^10.0.0", - "react-router-dom": "^6.26.2", - "remark-gfm": "^4.0.0", "rollup-plugin-visualizer": "^5.12.0", "sass": "^1.79.4", - "semver": "^7.6.3", - "solarxr-protocol": "file:../solarxr-protocol", "spdx-satisfies": "^5.0.1", - "tailwind-gradient-mask-image": "^1.2.0", "tailwindcss": "^3.4.13", - "three": "^0.163.0", - "ts-pattern": "^5.4.0", "typescript": "^5.6.3", "typescript-eslint": "^8.46.2", - "use-double-tap": "^1.3.6", - "uuid": "^13.0.0", - "vite": "^5.4.8", - "yup": "^1.4.0" - }, - "main": "./out/main/index.js" + "vite": "^5.4.8" + } } diff --git a/gui/postcss.config.cjs b/gui/app/postcss.config.cjs similarity index 100% rename from gui/postcss.config.cjs rename to gui/app/postcss.config.cjs diff --git a/gui/public/favicon.ico b/gui/app/public/favicon.ico similarity index 100% rename from gui/public/favicon.ico rename to gui/app/public/favicon.ico diff --git a/gui/public/fonts/Lexend[HEXP,wght].woff2 b/gui/app/public/fonts/Lexend[HEXP,wght].woff2 similarity index 100% rename from gui/public/fonts/Lexend[HEXP,wght].woff2 rename to gui/app/public/fonts/Lexend[HEXP,wght].woff2 diff --git a/gui/public/fonts/OpenDyslexic-Bold-Italic.woff b/gui/app/public/fonts/OpenDyslexic-Bold-Italic.woff similarity index 100% rename from gui/public/fonts/OpenDyslexic-Bold-Italic.woff rename to gui/app/public/fonts/OpenDyslexic-Bold-Italic.woff diff --git a/gui/public/fonts/OpenDyslexic-Bold.woff b/gui/app/public/fonts/OpenDyslexic-Bold.woff similarity index 100% rename from gui/public/fonts/OpenDyslexic-Bold.woff rename to gui/app/public/fonts/OpenDyslexic-Bold.woff diff --git a/gui/public/fonts/OpenDyslexic-Italic.woff b/gui/app/public/fonts/OpenDyslexic-Italic.woff similarity index 100% rename from gui/public/fonts/OpenDyslexic-Italic.woff rename to gui/app/public/fonts/OpenDyslexic-Italic.woff diff --git a/gui/public/fonts/OpenDyslexic-Regular.woff b/gui/app/public/fonts/OpenDyslexic-Regular.woff similarity index 100% rename from gui/public/fonts/OpenDyslexic-Regular.woff rename to gui/app/public/fonts/OpenDyslexic-Regular.woff diff --git a/gui/public/fonts/Ubuntu-R.woff2 b/gui/app/public/fonts/Ubuntu-R.woff2 similarity index 100% rename from gui/public/fonts/Ubuntu-R.woff2 rename to gui/app/public/fonts/Ubuntu-R.woff2 diff --git a/gui/public/fonts/noto-sans-v42-latin-regular.woff2 b/gui/app/public/fonts/noto-sans-v42-latin-regular.woff2 similarity index 100% rename from gui/public/fonts/noto-sans-v42-latin-regular.woff2 rename to gui/app/public/fonts/noto-sans-v42-latin-regular.woff2 diff --git a/gui/public/i18n/ar/translation.ftl b/gui/app/public/i18n/ar/translation.ftl similarity index 100% rename from gui/public/i18n/ar/translation.ftl rename to gui/app/public/i18n/ar/translation.ftl diff --git a/gui/public/i18n/cs/translation.ftl b/gui/app/public/i18n/cs/translation.ftl similarity index 100% rename from gui/public/i18n/cs/translation.ftl rename to gui/app/public/i18n/cs/translation.ftl diff --git a/gui/public/i18n/da/translation.ftl b/gui/app/public/i18n/da/translation.ftl similarity index 100% rename from gui/public/i18n/da/translation.ftl rename to gui/app/public/i18n/da/translation.ftl diff --git a/gui/public/i18n/de/translation.ftl b/gui/app/public/i18n/de/translation.ftl similarity index 100% rename from gui/public/i18n/de/translation.ftl rename to gui/app/public/i18n/de/translation.ftl diff --git a/gui/public/i18n/el/translation.ftl b/gui/app/public/i18n/el/translation.ftl similarity index 100% rename from gui/public/i18n/el/translation.ftl rename to gui/app/public/i18n/el/translation.ftl diff --git a/gui/public/i18n/en-x-owo/translation.ftl b/gui/app/public/i18n/en-x-owo/translation.ftl similarity index 100% rename from gui/public/i18n/en-x-owo/translation.ftl rename to gui/app/public/i18n/en-x-owo/translation.ftl diff --git a/gui/public/i18n/en/translation.ftl b/gui/app/public/i18n/en/translation.ftl similarity index 100% rename from gui/public/i18n/en/translation.ftl rename to gui/app/public/i18n/en/translation.ftl diff --git a/gui/public/i18n/es-419/translation.ftl b/gui/app/public/i18n/es-419/translation.ftl similarity index 100% rename from gui/public/i18n/es-419/translation.ftl rename to gui/app/public/i18n/es-419/translation.ftl diff --git a/gui/public/i18n/es-ES/translation.ftl b/gui/app/public/i18n/es-ES/translation.ftl similarity index 100% rename from gui/public/i18n/es-ES/translation.ftl rename to gui/app/public/i18n/es-ES/translation.ftl diff --git a/gui/public/i18n/et/translation.ftl b/gui/app/public/i18n/et/translation.ftl similarity index 100% rename from gui/public/i18n/et/translation.ftl rename to gui/app/public/i18n/et/translation.ftl diff --git a/gui/public/i18n/fi/translation.ftl b/gui/app/public/i18n/fi/translation.ftl similarity index 100% rename from gui/public/i18n/fi/translation.ftl rename to gui/app/public/i18n/fi/translation.ftl diff --git a/gui/public/i18n/fr/translation.ftl b/gui/app/public/i18n/fr/translation.ftl similarity index 100% rename from gui/public/i18n/fr/translation.ftl rename to gui/app/public/i18n/fr/translation.ftl diff --git a/gui/public/i18n/he/translation.ftl b/gui/app/public/i18n/he/translation.ftl similarity index 100% rename from gui/public/i18n/he/translation.ftl rename to gui/app/public/i18n/he/translation.ftl diff --git a/gui/public/i18n/it/translation.ftl b/gui/app/public/i18n/it/translation.ftl similarity index 100% rename from gui/public/i18n/it/translation.ftl rename to gui/app/public/i18n/it/translation.ftl diff --git a/gui/public/i18n/ja/translation.ftl b/gui/app/public/i18n/ja/translation.ftl similarity index 100% rename from gui/public/i18n/ja/translation.ftl rename to gui/app/public/i18n/ja/translation.ftl diff --git a/gui/public/i18n/ko/translation.ftl b/gui/app/public/i18n/ko/translation.ftl similarity index 100% rename from gui/public/i18n/ko/translation.ftl rename to gui/app/public/i18n/ko/translation.ftl diff --git a/gui/public/i18n/lt/translation.ftl b/gui/app/public/i18n/lt/translation.ftl similarity index 100% rename from gui/public/i18n/lt/translation.ftl rename to gui/app/public/i18n/lt/translation.ftl diff --git a/gui/public/i18n/nb-NO/translation.ftl b/gui/app/public/i18n/nb-NO/translation.ftl similarity index 100% rename from gui/public/i18n/nb-NO/translation.ftl rename to gui/app/public/i18n/nb-NO/translation.ftl diff --git a/gui/public/i18n/nl/translation.ftl b/gui/app/public/i18n/nl/translation.ftl similarity index 100% rename from gui/public/i18n/nl/translation.ftl rename to gui/app/public/i18n/nl/translation.ftl diff --git a/gui/public/i18n/pl/translation.ftl b/gui/app/public/i18n/pl/translation.ftl similarity index 100% rename from gui/public/i18n/pl/translation.ftl rename to gui/app/public/i18n/pl/translation.ftl diff --git a/gui/public/i18n/pt-BR/translation.ftl b/gui/app/public/i18n/pt-BR/translation.ftl similarity index 100% rename from gui/public/i18n/pt-BR/translation.ftl rename to gui/app/public/i18n/pt-BR/translation.ftl diff --git a/gui/public/i18n/ru/translation.ftl b/gui/app/public/i18n/ru/translation.ftl similarity index 100% rename from gui/public/i18n/ru/translation.ftl rename to gui/app/public/i18n/ru/translation.ftl diff --git a/gui/public/i18n/sv-SE/translation.ftl b/gui/app/public/i18n/sv-SE/translation.ftl similarity index 100% rename from gui/public/i18n/sv-SE/translation.ftl rename to gui/app/public/i18n/sv-SE/translation.ftl diff --git a/gui/public/i18n/th/translation.ftl b/gui/app/public/i18n/th/translation.ftl similarity index 100% rename from gui/public/i18n/th/translation.ftl rename to gui/app/public/i18n/th/translation.ftl diff --git a/gui/public/i18n/tr/translation.ftl b/gui/app/public/i18n/tr/translation.ftl similarity index 100% rename from gui/public/i18n/tr/translation.ftl rename to gui/app/public/i18n/tr/translation.ftl diff --git a/gui/public/i18n/uk/translation.ftl b/gui/app/public/i18n/uk/translation.ftl similarity index 100% rename from gui/public/i18n/uk/translation.ftl rename to gui/app/public/i18n/uk/translation.ftl diff --git a/gui/public/i18n/vi/translation.ftl b/gui/app/public/i18n/vi/translation.ftl similarity index 100% rename from gui/public/i18n/vi/translation.ftl rename to gui/app/public/i18n/vi/translation.ftl diff --git a/gui/public/i18n/zh-Hans/translation.ftl b/gui/app/public/i18n/zh-Hans/translation.ftl similarity index 100% rename from gui/public/i18n/zh-Hans/translation.ftl rename to gui/app/public/i18n/zh-Hans/translation.ftl diff --git a/gui/public/i18n/zh-Hant/translation.ftl b/gui/app/public/i18n/zh-Hant/translation.ftl similarity index 100% rename from gui/public/i18n/zh-Hant/translation.ftl rename to gui/app/public/i18n/zh-Hant/translation.ftl diff --git a/gui/public/images/R11_board_reset.webp b/gui/app/public/images/R11_board_reset.webp similarity index 100% rename from gui/public/images/R11_board_reset.webp rename to gui/app/public/images/R11_board_reset.webp diff --git a/gui/public/images/R12_board_reset.webp b/gui/app/public/images/R12_board_reset.webp similarity index 100% rename from gui/public/images/R12_board_reset.webp rename to gui/app/public/images/R12_board_reset.webp diff --git a/gui/public/images/R14_board_reset_sw.webp b/gui/app/public/images/R14_board_reset_sw.webp similarity index 100% rename from gui/public/images/R14_board_reset_sw.webp rename to gui/app/public/images/R14_board_reset_sw.webp diff --git a/gui/public/images/assignment-pose.webp b/gui/app/public/images/assignment-pose.webp similarity index 100% rename from gui/public/images/assignment-pose.webp rename to gui/app/public/images/assignment-pose.webp diff --git a/gui/public/images/autobone-poster.webp b/gui/app/public/images/autobone-poster.webp similarity index 100% rename from gui/public/images/autobone-poster.webp rename to gui/app/public/images/autobone-poster.webp diff --git a/gui/public/images/boxslime.webp b/gui/app/public/images/boxslime.webp similarity index 100% rename from gui/public/images/boxslime.webp rename to gui/app/public/images/boxslime.webp diff --git a/gui/public/images/curious-slime.gif b/gui/app/public/images/curious-slime.gif similarity index 100% rename from gui/public/images/curious-slime.gif rename to gui/app/public/images/curious-slime.gif diff --git a/gui/public/images/front-standing-pose.webp b/gui/app/public/images/front-standing-pose.webp similarity index 100% rename from gui/public/images/front-standing-pose.webp rename to gui/app/public/images/front-standing-pose.webp diff --git a/gui/public/images/happy-slime.gif b/gui/app/public/images/happy-slime.gif similarity index 100% rename from gui/public/images/happy-slime.gif rename to gui/app/public/images/happy-slime.gif diff --git a/gui/public/images/jumping-slime.gif b/gui/app/public/images/jumping-slime.gif similarity index 100% rename from gui/public/images/jumping-slime.gif rename to gui/app/public/images/jumping-slime.gif diff --git a/gui/public/images/mounting-reset-pose.webp b/gui/app/public/images/mounting-reset-pose.webp similarity index 100% rename from gui/public/images/mounting-reset-pose.webp rename to gui/app/public/images/mounting-reset-pose.webp diff --git a/gui/public/images/mounting/MountingFeets.webp b/gui/app/public/images/mounting/MountingFeets.webp similarity index 100% rename from gui/public/images/mounting/MountingFeets.webp rename to gui/app/public/images/mounting/MountingFeets.webp diff --git a/gui/public/images/mounting/MountingFeetsSide.webp b/gui/app/public/images/mounting/MountingFeetsSide.webp similarity index 100% rename from gui/public/images/mounting/MountingFeetsSide.webp rename to gui/app/public/images/mounting/MountingFeetsSide.webp diff --git a/gui/public/images/quiz/quiz_mocap-pos_face.webp b/gui/app/public/images/quiz/quiz_mocap-pos_face.webp similarity index 100% rename from gui/public/images/quiz/quiz_mocap-pos_face.webp rename to gui/app/public/images/quiz/quiz_mocap-pos_face.webp diff --git a/gui/public/images/quiz/quiz_mocap-pos_forehead.webp b/gui/app/public/images/quiz/quiz_mocap-pos_forehead.webp similarity index 100% rename from gui/public/images/quiz/quiz_mocap-pos_forehead.webp rename to gui/app/public/images/quiz/quiz_mocap-pos_forehead.webp diff --git a/gui/public/images/reset-pose.webp b/gui/app/public/images/reset-pose.webp similarity index 100% rename from gui/public/images/reset-pose.webp rename to gui/app/public/images/reset-pose.webp diff --git a/gui/public/images/reset/FullResetPose.webp b/gui/app/public/images/reset/FullResetPose.webp similarity index 100% rename from gui/public/images/reset/FullResetPose.webp rename to gui/app/public/images/reset/FullResetPose.webp diff --git a/gui/public/images/reset/FullResetPoseSide.webp b/gui/app/public/images/reset/FullResetPoseSide.webp similarity index 100% rename from gui/public/images/reset/FullResetPoseSide.webp rename to gui/app/public/images/reset/FullResetPoseSide.webp diff --git a/gui/public/images/reset/FullResetPoseWrong.webp b/gui/app/public/images/reset/FullResetPoseWrong.webp similarity index 100% rename from gui/public/images/reset/FullResetPoseWrong.webp rename to gui/app/public/images/reset/FullResetPoseWrong.webp diff --git a/gui/public/images/sad-slime.gif b/gui/app/public/images/sad-slime.gif similarity index 100% rename from gui/public/images/sad-slime.gif rename to gui/app/public/images/sad-slime.gif diff --git a/gui/public/images/slime-girl.webp b/gui/app/public/images/slime-girl.webp similarity index 100% rename from gui/public/images/slime-girl.webp rename to gui/app/public/images/slime-girl.webp diff --git a/gui/public/images/slimes.webp b/gui/app/public/images/slimes.webp similarity index 100% rename from gui/public/images/slimes.webp rename to gui/app/public/images/slimes.webp diff --git a/gui/public/images/slimetower.webp b/gui/app/public/images/slimetower.webp similarity index 100% rename from gui/public/images/slimetower.webp rename to gui/app/public/images/slimetower.webp diff --git a/gui/public/images/stay-aligned/StayAlignedFloor.webp b/gui/app/public/images/stay-aligned/StayAlignedFloor.webp similarity index 100% rename from gui/public/images/stay-aligned/StayAlignedFloor.webp rename to gui/app/public/images/stay-aligned/StayAlignedFloor.webp diff --git a/gui/public/images/stay-aligned/StayAlignedSitting.webp b/gui/app/public/images/stay-aligned/StayAlignedSitting.webp similarity index 100% rename from gui/public/images/stay-aligned/StayAlignedSitting.webp rename to gui/app/public/images/stay-aligned/StayAlignedSitting.webp diff --git a/gui/public/images/stay-aligned/StayAlignedStanding.webp b/gui/app/public/images/stay-aligned/StayAlignedStanding.webp similarity index 100% rename from gui/public/images/stay-aligned/StayAlignedStanding.webp rename to gui/app/public/images/stay-aligned/StayAlignedStanding.webp diff --git a/gui/public/images/trackers/butterfly_slime.webp b/gui/app/public/images/trackers/butterfly_slime.webp similarity index 100% rename from gui/public/images/trackers/butterfly_slime.webp rename to gui/app/public/images/trackers/butterfly_slime.webp diff --git a/gui/public/images/trackers/v1_2_slime.webp b/gui/app/public/images/trackers/v1_2_slime.webp similarity index 100% rename from gui/public/images/trackers/v1_2_slime.webp rename to gui/app/public/images/trackers/v1_2_slime.webp diff --git a/gui/public/images/user-height/controller-ok.webp b/gui/app/public/images/user-height/controller-ok.webp similarity index 100% rename from gui/public/images/user-height/controller-ok.webp rename to gui/app/public/images/user-height/controller-ok.webp diff --git a/gui/public/images/user-height/controller-wrong-1.webp b/gui/app/public/images/user-height/controller-wrong-1.webp similarity index 100% rename from gui/public/images/user-height/controller-wrong-1.webp rename to gui/app/public/images/user-height/controller-wrong-1.webp diff --git a/gui/public/images/user-height/controller-wrong-2.webp b/gui/app/public/images/user-height/controller-wrong-2.webp similarity index 100% rename from gui/public/images/user-height/controller-wrong-2.webp rename to gui/app/public/images/user-height/controller-wrong-2.webp diff --git a/gui/public/images/user-height/done.webp b/gui/app/public/images/user-height/done.webp similarity index 100% rename from gui/public/images/user-height/done.webp rename to gui/app/public/images/user-height/done.webp diff --git a/gui/public/images/user-height/look-forward-high.webp b/gui/app/public/images/user-height/look-forward-high.webp similarity index 100% rename from gui/public/images/user-height/look-forward-high.webp rename to gui/app/public/images/user-height/look-forward-high.webp diff --git a/gui/public/images/user-height/look-forward-low.webp b/gui/app/public/images/user-height/look-forward-low.webp similarity index 100% rename from gui/public/images/user-height/look-forward-low.webp rename to gui/app/public/images/user-height/look-forward-low.webp diff --git a/gui/public/images/user-height/look-forward-ok.webp b/gui/app/public/images/user-height/look-forward-ok.webp similarity index 100% rename from gui/public/images/user-height/look-forward-ok.webp rename to gui/app/public/images/user-height/look-forward-ok.webp diff --git a/gui/public/images/user-height/stand-still.webp b/gui/app/public/images/user-height/stand-still.webp similarity index 100% rename from gui/public/images/user-height/stand-still.webp rename to gui/app/public/images/user-height/stand-still.webp diff --git a/gui/public/images/user-height/timeout.webp b/gui/app/public/images/user-height/timeout.webp similarity index 100% rename from gui/public/images/user-height/timeout.webp rename to gui/app/public/images/user-height/timeout.webp diff --git a/gui/public/images/user-height/touch-floor.webp b/gui/app/public/images/user-height/touch-floor.webp similarity index 100% rename from gui/public/images/user-height/touch-floor.webp rename to gui/app/public/images/user-height/touch-floor.webp diff --git a/gui/public/images/user-height/wrong-height.webp b/gui/app/public/images/user-height/wrong-height.webp similarity index 100% rename from gui/public/images/user-height/wrong-height.webp rename to gui/app/public/images/user-height/wrong-height.webp diff --git a/gui/public/logo.svg b/gui/app/public/logo.svg similarity index 100% rename from gui/public/logo.svg rename to gui/app/public/logo.svg diff --git a/gui/public/logo192.png b/gui/app/public/logo192.png similarity index 100% rename from gui/public/logo192.png rename to gui/app/public/logo192.png diff --git a/gui/public/logo512.png b/gui/app/public/logo512.png similarity index 100% rename from gui/public/logo512.png rename to gui/app/public/logo512.png diff --git a/gui/public/manifest.json b/gui/app/public/manifest.json similarity index 100% rename from gui/public/manifest.json rename to gui/app/public/manifest.json diff --git a/gui/public/models/extension.gltf b/gui/app/public/models/extension.gltf similarity index 100% rename from gui/public/models/extension.gltf rename to gui/app/public/models/extension.gltf diff --git a/gui/public/models/tracker.gltf b/gui/app/public/models/tracker.gltf similarity index 100% rename from gui/public/models/tracker.gltf rename to gui/app/public/models/tracker.gltf diff --git a/gui/public/robots.txt b/gui/app/public/robots.txt similarity index 100% rename from gui/public/robots.txt rename to gui/app/public/robots.txt diff --git a/gui/public/sounds/full-reset/end-full-reset-with-tail.ogg b/gui/app/public/sounds/full-reset/end-full-reset-with-tail.ogg similarity index 100% rename from gui/public/sounds/full-reset/end-full-reset-with-tail.ogg rename to gui/app/public/sounds/full-reset/end-full-reset-with-tail.ogg diff --git a/gui/public/sounds/full-reset/full-click-1.ogg b/gui/app/public/sounds/full-reset/full-click-1.ogg similarity index 100% rename from gui/public/sounds/full-reset/full-click-1.ogg rename to gui/app/public/sounds/full-reset/full-click-1.ogg diff --git a/gui/public/sounds/full-reset/full-click-2.ogg b/gui/app/public/sounds/full-reset/full-click-2.ogg similarity index 100% rename from gui/public/sounds/full-reset/full-click-2.ogg rename to gui/app/public/sounds/full-reset/full-click-2.ogg diff --git a/gui/public/sounds/full-reset/full-click-3.ogg b/gui/app/public/sounds/full-reset/full-click-3.ogg similarity index 100% rename from gui/public/sounds/full-reset/full-click-3.ogg rename to gui/app/public/sounds/full-reset/full-click-3.ogg diff --git a/gui/public/sounds/full-reset/init-full-reset-with-tail.ogg b/gui/app/public/sounds/full-reset/init-full-reset-with-tail.ogg similarity index 100% rename from gui/public/sounds/full-reset/init-full-reset-with-tail.ogg rename to gui/app/public/sounds/full-reset/init-full-reset-with-tail.ogg diff --git a/gui/public/sounds/mew.ogg b/gui/app/public/sounds/mew.ogg similarity index 100% rename from gui/public/sounds/mew.ogg rename to gui/app/public/sounds/mew.ogg diff --git a/gui/public/sounds/mounting-reset/end-mounting-reset-with-tail.ogg b/gui/app/public/sounds/mounting-reset/end-mounting-reset-with-tail.ogg similarity index 100% rename from gui/public/sounds/mounting-reset/end-mounting-reset-with-tail.ogg rename to gui/app/public/sounds/mounting-reset/end-mounting-reset-with-tail.ogg diff --git a/gui/public/sounds/mounting-reset/init-mounting-reset-with-tail.ogg b/gui/app/public/sounds/mounting-reset/init-mounting-reset-with-tail.ogg similarity index 100% rename from gui/public/sounds/mounting-reset/init-mounting-reset-with-tail.ogg rename to gui/app/public/sounds/mounting-reset/init-mounting-reset-with-tail.ogg diff --git a/gui/public/sounds/mounting-reset/mount-click-1.ogg b/gui/app/public/sounds/mounting-reset/mount-click-1.ogg similarity index 100% rename from gui/public/sounds/mounting-reset/mount-click-1.ogg rename to gui/app/public/sounds/mounting-reset/mount-click-1.ogg diff --git a/gui/public/sounds/mounting-reset/mount-click-2.ogg b/gui/app/public/sounds/mounting-reset/mount-click-2.ogg similarity index 100% rename from gui/public/sounds/mounting-reset/mount-click-2.ogg rename to gui/app/public/sounds/mounting-reset/mount-click-2.ogg diff --git a/gui/public/sounds/mounting-reset/mount-click-3.ogg b/gui/app/public/sounds/mounting-reset/mount-click-3.ogg similarity index 100% rename from gui/public/sounds/mounting-reset/mount-click-3.ogg rename to gui/app/public/sounds/mounting-reset/mount-click-3.ogg diff --git a/gui/public/sounds/tracking/pause.ogg b/gui/app/public/sounds/tracking/pause.ogg similarity index 100% rename from gui/public/sounds/tracking/pause.ogg rename to gui/app/public/sounds/tracking/pause.ogg diff --git a/gui/public/sounds/tracking/play.ogg b/gui/app/public/sounds/tracking/play.ogg similarity index 100% rename from gui/public/sounds/tracking/play.ogg rename to gui/app/public/sounds/tracking/play.ogg diff --git a/gui/public/sounds/yaw-reset/yaw-reset.ogg b/gui/app/public/sounds/yaw-reset/yaw-reset.ogg similarity index 100% rename from gui/public/sounds/yaw-reset/yaw-reset.ogg rename to gui/app/public/sounds/yaw-reset/yaw-reset.ogg diff --git a/gui/public/videos/autobone.webm b/gui/app/public/videos/autobone.webm similarity index 100% rename from gui/public/videos/autobone.webm rename to gui/app/public/videos/autobone.webm diff --git a/gui/public/videos/turn-on-tracker.webm b/gui/app/public/videos/turn-on-tracker.webm similarity index 100% rename from gui/public/videos/turn-on-tracker.webm rename to gui/app/public/videos/turn-on-tracker.webm diff --git a/gui/scripts/check-missing.js b/gui/app/scripts/check-missing.js similarity index 100% rename from gui/scripts/check-missing.js rename to gui/app/scripts/check-missing.js diff --git a/gui/scripts/convert-fluent.js b/gui/app/scripts/convert-fluent.js similarity index 100% rename from gui/scripts/convert-fluent.js rename to gui/app/scripts/convert-fluent.js diff --git a/gui/scripts/gitversion.mjs b/gui/app/scripts/gitversion.mjs similarity index 100% rename from gui/scripts/gitversion.mjs rename to gui/app/scripts/gitversion.mjs diff --git a/gui/scripts/license-list.mjs b/gui/app/scripts/license-list.mjs similarity index 100% rename from gui/scripts/license-list.mjs rename to gui/app/scripts/license-list.mjs diff --git a/gui/src/App.tsx b/gui/app/src/App.tsx similarity index 100% rename from gui/src/App.tsx rename to gui/app/src/App.tsx diff --git a/gui/src/AppLayout.tsx b/gui/app/src/AppLayout.tsx similarity index 100% rename from gui/src/AppLayout.tsx rename to gui/app/src/AppLayout.tsx diff --git a/gui/src/components/BVHButton.tsx b/gui/app/src/components/BVHButton.tsx similarity index 100% rename from gui/src/components/BVHButton.tsx rename to gui/app/src/components/BVHButton.tsx diff --git a/gui/src/components/EmptyLayout.scss b/gui/app/src/components/EmptyLayout.scss similarity index 100% rename from gui/src/components/EmptyLayout.scss rename to gui/app/src/components/EmptyLayout.scss diff --git a/gui/src/components/EmptyLayout.tsx b/gui/app/src/components/EmptyLayout.tsx similarity index 100% rename from gui/src/components/EmptyLayout.tsx rename to gui/app/src/components/EmptyLayout.tsx diff --git a/gui/src/components/MainLayout.scss b/gui/app/src/components/MainLayout.scss similarity index 100% rename from gui/src/components/MainLayout.scss rename to gui/app/src/components/MainLayout.scss diff --git a/gui/src/components/MainLayout.tsx b/gui/app/src/components/MainLayout.tsx similarity index 100% rename from gui/src/components/MainLayout.tsx rename to gui/app/src/components/MainLayout.tsx diff --git a/gui/src/components/Navbar.tsx b/gui/app/src/components/Navbar.tsx similarity index 100% rename from gui/src/components/Navbar.tsx rename to gui/app/src/components/Navbar.tsx diff --git a/gui/src/components/Preload.tsx b/gui/app/src/components/Preload.tsx similarity index 100% rename from gui/src/components/Preload.tsx rename to gui/app/src/components/Preload.tsx diff --git a/gui/src/components/SerialDetectionModal.tsx b/gui/app/src/components/SerialDetectionModal.tsx similarity index 100% rename from gui/src/components/SerialDetectionModal.tsx rename to gui/app/src/components/SerialDetectionModal.tsx diff --git a/gui/src/components/Sidebar.tsx b/gui/app/src/components/Sidebar.tsx similarity index 100% rename from gui/src/components/Sidebar.tsx rename to gui/app/src/components/Sidebar.tsx diff --git a/gui/src/components/Toolbar.tsx b/gui/app/src/components/Toolbar.tsx similarity index 100% rename from gui/src/components/Toolbar.tsx rename to gui/app/src/components/Toolbar.tsx diff --git a/gui/src/components/TopBar.tsx b/gui/app/src/components/TopBar.tsx similarity index 100% rename from gui/src/components/TopBar.tsx rename to gui/app/src/components/TopBar.tsx diff --git a/gui/src/components/TrackersStillOnModal.tsx b/gui/app/src/components/TrackersStillOnModal.tsx similarity index 100% rename from gui/src/components/TrackersStillOnModal.tsx rename to gui/app/src/components/TrackersStillOnModal.tsx diff --git a/gui/src/components/TrackingPauseButton.tsx b/gui/app/src/components/TrackingPauseButton.tsx similarity index 100% rename from gui/src/components/TrackingPauseButton.tsx rename to gui/app/src/components/TrackingPauseButton.tsx diff --git a/gui/src/components/TrayOrExitModal.tsx b/gui/app/src/components/TrayOrExitModal.tsx similarity index 100% rename from gui/src/components/TrayOrExitModal.tsx rename to gui/app/src/components/TrayOrExitModal.tsx diff --git a/gui/src/components/UnknownDeviceModal.tsx b/gui/app/src/components/UnknownDeviceModal.tsx similarity index 100% rename from gui/src/components/UnknownDeviceModal.tsx rename to gui/app/src/components/UnknownDeviceModal.tsx diff --git a/gui/src/components/VersionUpdateModal.tsx b/gui/app/src/components/VersionUpdateModal.tsx similarity index 100% rename from gui/src/components/VersionUpdateModal.tsx rename to gui/app/src/components/VersionUpdateModal.tsx diff --git a/gui/src/components/commons/A.tsx b/gui/app/src/components/commons/A.tsx similarity index 100% rename from gui/src/components/commons/A.tsx rename to gui/app/src/components/commons/A.tsx diff --git a/gui/src/components/commons/ArrowLink.tsx b/gui/app/src/components/commons/ArrowLink.tsx similarity index 100% rename from gui/src/components/commons/ArrowLink.tsx rename to gui/app/src/components/commons/ArrowLink.tsx diff --git a/gui/src/components/commons/BaseModal.tsx b/gui/app/src/components/commons/BaseModal.tsx similarity index 100% rename from gui/src/components/commons/BaseModal.tsx rename to gui/app/src/components/commons/BaseModal.tsx diff --git a/gui/src/components/commons/BigButton.tsx b/gui/app/src/components/commons/BigButton.tsx similarity index 100% rename from gui/src/components/commons/BigButton.tsx rename to gui/app/src/components/commons/BigButton.tsx diff --git a/gui/src/components/commons/BodyDisplay.tsx b/gui/app/src/components/commons/BodyDisplay.tsx similarity index 100% rename from gui/src/components/commons/BodyDisplay.tsx rename to gui/app/src/components/commons/BodyDisplay.tsx diff --git a/gui/src/components/commons/BodyInteractions.tsx b/gui/app/src/components/commons/BodyInteractions.tsx similarity index 100% rename from gui/src/components/commons/BodyInteractions.tsx rename to gui/app/src/components/commons/BodyInteractions.tsx diff --git a/gui/src/components/commons/BodyPartIcon.tsx b/gui/app/src/components/commons/BodyPartIcon.tsx similarity index 100% rename from gui/src/components/commons/BodyPartIcon.tsx rename to gui/app/src/components/commons/BodyPartIcon.tsx diff --git a/gui/src/components/commons/Button.tsx b/gui/app/src/components/commons/Button.tsx similarity index 100% rename from gui/src/components/commons/Button.tsx rename to gui/app/src/components/commons/Button.tsx diff --git a/gui/src/components/commons/ButtonConfirmModal.tsx b/gui/app/src/components/commons/ButtonConfirmModal.tsx similarity index 100% rename from gui/src/components/commons/ButtonConfirmModal.tsx rename to gui/app/src/components/commons/ButtonConfirmModal.tsx diff --git a/gui/src/components/commons/Checkbox.tsx b/gui/app/src/components/commons/Checkbox.tsx similarity index 100% rename from gui/src/components/commons/Checkbox.tsx rename to gui/app/src/components/commons/Checkbox.tsx diff --git a/gui/src/components/commons/Dropdown.tsx b/gui/app/src/components/commons/Dropdown.tsx similarity index 100% rename from gui/src/components/commons/Dropdown.tsx rename to gui/app/src/components/commons/Dropdown.tsx diff --git a/gui/src/components/commons/FileInput.tsx b/gui/app/src/components/commons/FileInput.tsx similarity index 100% rename from gui/src/components/commons/FileInput.tsx rename to gui/app/src/components/commons/FileInput.tsx diff --git a/gui/src/components/commons/FirmwareIcon.tsx b/gui/app/src/components/commons/FirmwareIcon.tsx similarity index 100% rename from gui/src/components/commons/FirmwareIcon.tsx rename to gui/app/src/components/commons/FirmwareIcon.tsx diff --git a/gui/src/components/commons/Input.tsx b/gui/app/src/components/commons/Input.tsx similarity index 100% rename from gui/src/components/commons/Input.tsx rename to gui/app/src/components/commons/Input.tsx diff --git a/gui/src/components/commons/LangSelector.tsx b/gui/app/src/components/commons/LangSelector.tsx similarity index 100% rename from gui/src/components/commons/LangSelector.tsx rename to gui/app/src/components/commons/LangSelector.tsx diff --git a/gui/src/components/commons/Modal.tsx b/gui/app/src/components/commons/Modal.tsx similarity index 100% rename from gui/src/components/commons/Modal.tsx rename to gui/app/src/components/commons/Modal.tsx diff --git a/gui/src/components/commons/NumberSelector.tsx b/gui/app/src/components/commons/NumberSelector.tsx similarity index 100% rename from gui/src/components/commons/NumberSelector.tsx rename to gui/app/src/components/commons/NumberSelector.tsx diff --git a/gui/src/components/commons/PersonFrontIcon.tsx b/gui/app/src/components/commons/PersonFrontIcon.tsx similarity index 100% rename from gui/src/components/commons/PersonFrontIcon.tsx rename to gui/app/src/components/commons/PersonFrontIcon.tsx diff --git a/gui/src/components/commons/ProgressBar.tsx b/gui/app/src/components/commons/ProgressBar.tsx similarity index 100% rename from gui/src/components/commons/ProgressBar.tsx rename to gui/app/src/components/commons/ProgressBar.tsx diff --git a/gui/src/components/commons/Radio.tsx b/gui/app/src/components/commons/Radio.tsx similarity index 100% rename from gui/src/components/commons/Radio.tsx rename to gui/app/src/components/commons/Radio.tsx diff --git a/gui/src/components/commons/Range.tsx b/gui/app/src/components/commons/Range.tsx similarity index 100% rename from gui/src/components/commons/Range.tsx rename to gui/app/src/components/commons/Range.tsx diff --git a/gui/src/components/commons/SystemFileInput.tsx b/gui/app/src/components/commons/SystemFileInput.tsx similarity index 100% rename from gui/src/components/commons/SystemFileInput.tsx rename to gui/app/src/components/commons/SystemFileInput.tsx diff --git a/gui/src/components/commons/ThemeSelector.tsx b/gui/app/src/components/commons/ThemeSelector.tsx similarity index 100% rename from gui/src/components/commons/ThemeSelector.tsx rename to gui/app/src/components/commons/ThemeSelector.tsx diff --git a/gui/src/components/commons/TipBox.tsx b/gui/app/src/components/commons/TipBox.tsx similarity index 100% rename from gui/src/components/commons/TipBox.tsx rename to gui/app/src/components/commons/TipBox.tsx diff --git a/gui/src/components/commons/Tooltip.tsx b/gui/app/src/components/commons/Tooltip.tsx similarity index 100% rename from gui/src/components/commons/Tooltip.tsx rename to gui/app/src/components/commons/Tooltip.tsx diff --git a/gui/src/components/commons/Typography.tsx b/gui/app/src/components/commons/Typography.tsx similarity index 100% rename from gui/src/components/commons/Typography.tsx rename to gui/app/src/components/commons/Typography.tsx diff --git a/gui/src/components/commons/VerticalStepper.tsx b/gui/app/src/components/commons/VerticalStepper.tsx similarity index 100% rename from gui/src/components/commons/VerticalStepper.tsx rename to gui/app/src/components/commons/VerticalStepper.tsx diff --git a/gui/src/components/commons/icon/AnkleIcon.tsx b/gui/app/src/components/commons/icon/AnkleIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/AnkleIcon.tsx rename to gui/app/src/components/commons/icon/AnkleIcon.tsx diff --git a/gui/src/components/commons/icon/ArrowIcons.tsx b/gui/app/src/components/commons/icon/ArrowIcons.tsx similarity index 100% rename from gui/src/components/commons/icon/ArrowIcons.tsx rename to gui/app/src/components/commons/icon/ArrowIcons.tsx diff --git a/gui/src/components/commons/icon/BatteryIcon.tsx b/gui/app/src/components/commons/icon/BatteryIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/BatteryIcon.tsx rename to gui/app/src/components/commons/icon/BatteryIcon.tsx diff --git a/gui/src/components/commons/icon/BellIcon.tsx b/gui/app/src/components/commons/icon/BellIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/BellIcon.tsx rename to gui/app/src/components/commons/icon/BellIcon.tsx diff --git a/gui/src/components/commons/icon/BugIcon.tsx b/gui/app/src/components/commons/icon/BugIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/BugIcon.tsx rename to gui/app/src/components/commons/icon/BugIcon.tsx diff --git a/gui/src/components/commons/icon/BulbIcon.tsx b/gui/app/src/components/commons/icon/BulbIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/BulbIcon.tsx rename to gui/app/src/components/commons/icon/BulbIcon.tsx diff --git a/gui/src/components/commons/icon/CheckIcon.tsx b/gui/app/src/components/commons/icon/CheckIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/CheckIcon.tsx rename to gui/app/src/components/commons/icon/CheckIcon.tsx diff --git a/gui/src/components/commons/icon/ChecklistIcon.tsx b/gui/app/src/components/commons/icon/ChecklistIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/ChecklistIcon.tsx rename to gui/app/src/components/commons/icon/ChecklistIcon.tsx diff --git a/gui/src/components/commons/icon/ChestIcon.tsx b/gui/app/src/components/commons/icon/ChestIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/ChestIcon.tsx rename to gui/app/src/components/commons/icon/ChestIcon.tsx diff --git a/gui/src/components/commons/icon/CircleIcon.tsx b/gui/app/src/components/commons/icon/CircleIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/CircleIcon.tsx rename to gui/app/src/components/commons/icon/CircleIcon.tsx diff --git a/gui/src/components/commons/icon/ClearIcon.tsx b/gui/app/src/components/commons/icon/ClearIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/ClearIcon.tsx rename to gui/app/src/components/commons/icon/ClearIcon.tsx diff --git a/gui/src/components/commons/icon/CloseIcon.tsx b/gui/app/src/components/commons/icon/CloseIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/CloseIcon.tsx rename to gui/app/src/components/commons/icon/CloseIcon.tsx diff --git a/gui/src/components/commons/icon/ControllerIcon.tsx b/gui/app/src/components/commons/icon/ControllerIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/ControllerIcon.tsx rename to gui/app/src/components/commons/icon/ControllerIcon.tsx diff --git a/gui/src/components/commons/icon/CrossIcon.tsx b/gui/app/src/components/commons/icon/CrossIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/CrossIcon.tsx rename to gui/app/src/components/commons/icon/CrossIcon.tsx diff --git a/gui/src/components/commons/icon/CubeIcon.tsx b/gui/app/src/components/commons/icon/CubeIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/CubeIcon.tsx rename to gui/app/src/components/commons/icon/CubeIcon.tsx diff --git a/gui/src/components/commons/icon/DownloadIcon.tsx b/gui/app/src/components/commons/icon/DownloadIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/DownloadIcon.tsx rename to gui/app/src/components/commons/icon/DownloadIcon.tsx diff --git a/gui/src/components/commons/icon/EscapeIcon.tsx b/gui/app/src/components/commons/icon/EscapeIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/EscapeIcon.tsx rename to gui/app/src/components/commons/icon/EscapeIcon.tsx diff --git a/gui/src/components/commons/icon/EyeIcon.tsx b/gui/app/src/components/commons/icon/EyeIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/EyeIcon.tsx rename to gui/app/src/components/commons/icon/EyeIcon.tsx diff --git a/gui/src/components/commons/icon/FileIcon.tsx b/gui/app/src/components/commons/icon/FileIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/FileIcon.tsx rename to gui/app/src/components/commons/icon/FileIcon.tsx diff --git a/gui/src/components/commons/icon/FingersIcon.tsx b/gui/app/src/components/commons/icon/FingersIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/FingersIcon.tsx rename to gui/app/src/components/commons/icon/FingersIcon.tsx diff --git a/gui/src/components/commons/icon/FolderIcon.tsx b/gui/app/src/components/commons/icon/FolderIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/FolderIcon.tsx rename to gui/app/src/components/commons/icon/FolderIcon.tsx diff --git a/gui/src/components/commons/icon/FootIcon.tsx b/gui/app/src/components/commons/icon/FootIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/FootIcon.tsx rename to gui/app/src/components/commons/icon/FootIcon.tsx diff --git a/gui/src/components/commons/icon/FrontOfChair.tsx b/gui/app/src/components/commons/icon/FrontOfChair.tsx similarity index 100% rename from gui/src/components/commons/icon/FrontOfChair.tsx rename to gui/app/src/components/commons/icon/FrontOfChair.tsx diff --git a/gui/src/components/commons/icon/GearIcon.tsx b/gui/app/src/components/commons/icon/GearIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/GearIcon.tsx rename to gui/app/src/components/commons/icon/GearIcon.tsx diff --git a/gui/src/components/commons/icon/HeadIcon.tsx b/gui/app/src/components/commons/icon/HeadIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/HeadIcon.tsx rename to gui/app/src/components/commons/icon/HeadIcon.tsx diff --git a/gui/src/components/commons/icon/HeadsetIcon.tsx b/gui/app/src/components/commons/icon/HeadsetIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/HeadsetIcon.tsx rename to gui/app/src/components/commons/icon/HeadsetIcon.tsx diff --git a/gui/src/components/commons/icon/HipIcon.tsx b/gui/app/src/components/commons/icon/HipIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/HipIcon.tsx rename to gui/app/src/components/commons/icon/HipIcon.tsx diff --git a/gui/src/components/commons/icon/HomeIcon.tsx b/gui/app/src/components/commons/icon/HomeIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/HomeIcon.tsx rename to gui/app/src/components/commons/icon/HomeIcon.tsx diff --git a/gui/src/components/commons/icon/HorizontalAlignIcon.tsx b/gui/app/src/components/commons/icon/HorizontalAlignIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/HorizontalAlignIcon.tsx rename to gui/app/src/components/commons/icon/HorizontalAlignIcon.tsx diff --git a/gui/src/components/commons/icon/HumanIcon.tsx b/gui/app/src/components/commons/icon/HumanIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/HumanIcon.tsx rename to gui/app/src/components/commons/icon/HumanIcon.tsx diff --git a/gui/src/components/commons/icon/ImportIcon.tsx b/gui/app/src/components/commons/icon/ImportIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/ImportIcon.tsx rename to gui/app/src/components/commons/icon/ImportIcon.tsx diff --git a/gui/src/components/commons/icon/LayoutIcon.tsx b/gui/app/src/components/commons/icon/LayoutIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/LayoutIcon.tsx rename to gui/app/src/components/commons/icon/LayoutIcon.tsx diff --git a/gui/src/components/commons/icon/LoaderIcon.tsx b/gui/app/src/components/commons/icon/LoaderIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/LoaderIcon.tsx rename to gui/app/src/components/commons/icon/LoaderIcon.tsx diff --git a/gui/src/components/commons/icon/LowerArmIcon.tsx b/gui/app/src/components/commons/icon/LowerArmIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/LowerArmIcon.tsx rename to gui/app/src/components/commons/icon/LowerArmIcon.tsx diff --git a/gui/src/components/commons/icon/MartialArtsIcon.tsx b/gui/app/src/components/commons/icon/MartialArtsIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/MartialArtsIcon.tsx rename to gui/app/src/components/commons/icon/MartialArtsIcon.tsx diff --git a/gui/src/components/commons/icon/MaximiseIcon.tsx b/gui/app/src/components/commons/icon/MaximiseIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/MaximiseIcon.tsx rename to gui/app/src/components/commons/icon/MaximiseIcon.tsx diff --git a/gui/src/components/commons/icon/MinimiseIcon.tsx b/gui/app/src/components/commons/icon/MinimiseIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/MinimiseIcon.tsx rename to gui/app/src/components/commons/icon/MinimiseIcon.tsx diff --git a/gui/src/components/commons/icon/NeckIcon.tsx b/gui/app/src/components/commons/icon/NeckIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/NeckIcon.tsx rename to gui/app/src/components/commons/icon/NeckIcon.tsx diff --git a/gui/src/components/commons/icon/OutputIcon.tsx b/gui/app/src/components/commons/icon/OutputIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/OutputIcon.tsx rename to gui/app/src/components/commons/icon/OutputIcon.tsx diff --git a/gui/src/components/commons/icon/PauseIcon.tsx b/gui/app/src/components/commons/icon/PauseIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/PauseIcon.tsx rename to gui/app/src/components/commons/icon/PauseIcon.tsx diff --git a/gui/src/components/commons/icon/PawIcon.tsx b/gui/app/src/components/commons/icon/PawIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/PawIcon.tsx rename to gui/app/src/components/commons/icon/PawIcon.tsx diff --git a/gui/src/components/commons/icon/PercentIcon.tsx b/gui/app/src/components/commons/icon/PercentIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/PercentIcon.tsx rename to gui/app/src/components/commons/icon/PercentIcon.tsx diff --git a/gui/src/components/commons/icon/PlayIcon.tsx b/gui/app/src/components/commons/icon/PlayIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/PlayIcon.tsx rename to gui/app/src/components/commons/icon/PlayIcon.tsx diff --git a/gui/src/components/commons/icon/QuestionIcon.tsx b/gui/app/src/components/commons/icon/QuestionIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/QuestionIcon.tsx rename to gui/app/src/components/commons/icon/QuestionIcon.tsx diff --git a/gui/src/components/commons/icon/RecordIcon.tsx b/gui/app/src/components/commons/icon/RecordIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/RecordIcon.tsx rename to gui/app/src/components/commons/icon/RecordIcon.tsx diff --git a/gui/src/components/commons/icon/ResetIcon.tsx b/gui/app/src/components/commons/icon/ResetIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/ResetIcon.tsx rename to gui/app/src/components/commons/icon/ResetIcon.tsx diff --git a/gui/src/components/commons/icon/RouterIcon.tsx b/gui/app/src/components/commons/icon/RouterIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/RouterIcon.tsx rename to gui/app/src/components/commons/icon/RouterIcon.tsx diff --git a/gui/src/components/commons/icon/RulerIcon.tsx b/gui/app/src/components/commons/icon/RulerIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/RulerIcon.tsx rename to gui/app/src/components/commons/icon/RulerIcon.tsx diff --git a/gui/src/components/commons/icon/ShoulderIcon.tsx b/gui/app/src/components/commons/icon/ShoulderIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/ShoulderIcon.tsx rename to gui/app/src/components/commons/icon/ShoulderIcon.tsx diff --git a/gui/src/components/commons/icon/SimevrIcon.tsx b/gui/app/src/components/commons/icon/SimevrIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/SimevrIcon.tsx rename to gui/app/src/components/commons/icon/SimevrIcon.tsx diff --git a/gui/src/components/commons/icon/SittingIcon.tsx b/gui/app/src/components/commons/icon/SittingIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/SittingIcon.tsx rename to gui/app/src/components/commons/icon/SittingIcon.tsx diff --git a/gui/src/components/commons/icon/SkiIcon.tsx b/gui/app/src/components/commons/icon/SkiIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/SkiIcon.tsx rename to gui/app/src/components/commons/icon/SkiIcon.tsx diff --git a/gui/src/components/commons/icon/SlimeUpIcon.tsx b/gui/app/src/components/commons/icon/SlimeUpIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/SlimeUpIcon.tsx rename to gui/app/src/components/commons/icon/SlimeUpIcon.tsx diff --git a/gui/src/components/commons/icon/SlimeVRIcon.tsx b/gui/app/src/components/commons/icon/SlimeVRIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/SlimeVRIcon.tsx rename to gui/app/src/components/commons/icon/SlimeVRIcon.tsx diff --git a/gui/src/components/commons/icon/SparkleIcon.tsx b/gui/app/src/components/commons/icon/SparkleIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/SparkleIcon.tsx rename to gui/app/src/components/commons/icon/SparkleIcon.tsx diff --git a/gui/src/components/commons/icon/SpinIcon.tsx b/gui/app/src/components/commons/icon/SpinIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/SpinIcon.tsx rename to gui/app/src/components/commons/icon/SpinIcon.tsx diff --git a/gui/src/components/commons/icon/SquaresIcon.tsx b/gui/app/src/components/commons/icon/SquaresIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/SquaresIcon.tsx rename to gui/app/src/components/commons/icon/SquaresIcon.tsx diff --git a/gui/src/components/commons/icon/SteamIcon.tsx b/gui/app/src/components/commons/icon/SteamIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/SteamIcon.tsx rename to gui/app/src/components/commons/icon/SteamIcon.tsx diff --git a/gui/src/components/commons/icon/TaybolIcon.tsx b/gui/app/src/components/commons/icon/TaybolIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/TaybolIcon.tsx rename to gui/app/src/components/commons/icon/TaybolIcon.tsx diff --git a/gui/src/components/commons/icon/TrashIcon.tsx b/gui/app/src/components/commons/icon/TrashIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/TrashIcon.tsx rename to gui/app/src/components/commons/icon/TrashIcon.tsx diff --git a/gui/src/components/commons/icon/UploadFileIcon.tsx b/gui/app/src/components/commons/icon/UploadFileIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/UploadFileIcon.tsx rename to gui/app/src/components/commons/icon/UploadFileIcon.tsx diff --git a/gui/src/components/commons/icon/UploadFolderIcon.tsx b/gui/app/src/components/commons/icon/UploadFolderIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/UploadFolderIcon.tsx rename to gui/app/src/components/commons/icon/UploadFolderIcon.tsx diff --git a/gui/src/components/commons/icon/UpperArmIcon.tsx b/gui/app/src/components/commons/icon/UpperArmIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/UpperArmIcon.tsx rename to gui/app/src/components/commons/icon/UpperArmIcon.tsx diff --git a/gui/src/components/commons/icon/UpperChestIcon.tsx b/gui/app/src/components/commons/icon/UpperChestIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/UpperChestIcon.tsx rename to gui/app/src/components/commons/icon/UpperChestIcon.tsx diff --git a/gui/src/components/commons/icon/UpperLegIcon.tsx b/gui/app/src/components/commons/icon/UpperLegIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/UpperLegIcon.tsx rename to gui/app/src/components/commons/icon/UpperLegIcon.tsx diff --git a/gui/src/components/commons/icon/UsbIcon.tsx b/gui/app/src/components/commons/icon/UsbIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/UsbIcon.tsx rename to gui/app/src/components/commons/icon/UsbIcon.tsx diff --git a/gui/src/components/commons/icon/VMCIcon.tsx b/gui/app/src/components/commons/icon/VMCIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/VMCIcon.tsx rename to gui/app/src/components/commons/icon/VMCIcon.tsx diff --git a/gui/src/components/commons/icon/VRCIcon.tsx b/gui/app/src/components/commons/icon/VRCIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/VRCIcon.tsx rename to gui/app/src/components/commons/icon/VRCIcon.tsx diff --git a/gui/src/components/commons/icon/WaistIcon.tsx b/gui/app/src/components/commons/icon/WaistIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/WaistIcon.tsx rename to gui/app/src/components/commons/icon/WaistIcon.tsx diff --git a/gui/src/components/commons/icon/WarningIcon.tsx b/gui/app/src/components/commons/icon/WarningIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/WarningIcon.tsx rename to gui/app/src/components/commons/icon/WarningIcon.tsx diff --git a/gui/src/components/commons/icon/WifiIcon.tsx b/gui/app/src/components/commons/icon/WifiIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/WifiIcon.tsx rename to gui/app/src/components/commons/icon/WifiIcon.tsx diff --git a/gui/src/components/commons/icon/WrenchIcon.tsx b/gui/app/src/components/commons/icon/WrenchIcon.tsx similarity index 100% rename from gui/src/components/commons/icon/WrenchIcon.tsx rename to gui/app/src/components/commons/icon/WrenchIcon.tsx diff --git a/gui/src/components/firmware-tool/DeviceCard.tsx b/gui/app/src/components/firmware-tool/DeviceCard.tsx similarity index 100% rename from gui/src/components/firmware-tool/DeviceCard.tsx rename to gui/app/src/components/firmware-tool/DeviceCard.tsx diff --git a/gui/src/components/firmware-tool/FirmwareTool.tsx b/gui/app/src/components/firmware-tool/FirmwareTool.tsx similarity index 100% rename from gui/src/components/firmware-tool/FirmwareTool.tsx rename to gui/app/src/components/firmware-tool/FirmwareTool.tsx diff --git a/gui/src/components/firmware-tool/steps/BoardDefaultsStep.tsx b/gui/app/src/components/firmware-tool/steps/BoardDefaultsStep.tsx similarity index 100% rename from gui/src/components/firmware-tool/steps/BoardDefaultsStep.tsx rename to gui/app/src/components/firmware-tool/steps/BoardDefaultsStep.tsx diff --git a/gui/src/components/firmware-tool/steps/BuildStep.tsx b/gui/app/src/components/firmware-tool/steps/BuildStep.tsx similarity index 100% rename from gui/src/components/firmware-tool/steps/BuildStep.tsx rename to gui/app/src/components/firmware-tool/steps/BuildStep.tsx diff --git a/gui/src/components/firmware-tool/steps/FlashBtnStep.tsx b/gui/app/src/components/firmware-tool/steps/FlashBtnStep.tsx similarity index 100% rename from gui/src/components/firmware-tool/steps/FlashBtnStep.tsx rename to gui/app/src/components/firmware-tool/steps/FlashBtnStep.tsx diff --git a/gui/src/components/firmware-tool/steps/FlashingMethodStep.tsx b/gui/app/src/components/firmware-tool/steps/FlashingMethodStep.tsx similarity index 100% rename from gui/src/components/firmware-tool/steps/FlashingMethodStep.tsx rename to gui/app/src/components/firmware-tool/steps/FlashingMethodStep.tsx diff --git a/gui/src/components/firmware-tool/steps/FlashingStep.tsx b/gui/app/src/components/firmware-tool/steps/FlashingStep.tsx similarity index 100% rename from gui/src/components/firmware-tool/steps/FlashingStep.tsx rename to gui/app/src/components/firmware-tool/steps/FlashingStep.tsx diff --git a/gui/src/components/firmware-tool/steps/SelectSourceStep.tsx b/gui/app/src/components/firmware-tool/steps/SelectSourceStep.tsx similarity index 100% rename from gui/src/components/firmware-tool/steps/SelectSourceStep.tsx rename to gui/app/src/components/firmware-tool/steps/SelectSourceStep.tsx diff --git a/gui/src/components/firmware-update/FirmwareUpdate.tsx b/gui/app/src/components/firmware-update/FirmwareUpdate.tsx similarity index 100% rename from gui/src/components/firmware-update/FirmwareUpdate.tsx rename to gui/app/src/components/firmware-update/FirmwareUpdate.tsx diff --git a/gui/src/components/home/Home.tsx b/gui/app/src/components/home/Home.tsx similarity index 100% rename from gui/src/components/home/Home.tsx rename to gui/app/src/components/home/Home.tsx diff --git a/gui/src/components/home/HomeSettingsModal.tsx b/gui/app/src/components/home/HomeSettingsModal.tsx similarity index 100% rename from gui/src/components/home/HomeSettingsModal.tsx rename to gui/app/src/components/home/HomeSettingsModal.tsx diff --git a/gui/src/components/home/ResetButton.tsx b/gui/app/src/components/home/ResetButton.tsx similarity index 100% rename from gui/src/components/home/ResetButton.tsx rename to gui/app/src/components/home/ResetButton.tsx diff --git a/gui/src/components/onboarding/BodyAssignment.tsx b/gui/app/src/components/onboarding/BodyAssignment.tsx similarity index 100% rename from gui/src/components/onboarding/BodyAssignment.tsx rename to gui/app/src/components/onboarding/BodyAssignment.tsx diff --git a/gui/src/components/onboarding/NeckWarningModal.tsx b/gui/app/src/components/onboarding/NeckWarningModal.tsx similarity index 100% rename from gui/src/components/onboarding/NeckWarningModal.tsx rename to gui/app/src/components/onboarding/NeckWarningModal.tsx diff --git a/gui/src/components/onboarding/OnboardingContextProvider.tsx b/gui/app/src/components/onboarding/OnboardingContextProvider.tsx similarity index 100% rename from gui/src/components/onboarding/OnboardingContextProvider.tsx rename to gui/app/src/components/onboarding/OnboardingContextProvider.tsx diff --git a/gui/src/components/onboarding/OnboardingLayout.scss b/gui/app/src/components/onboarding/OnboardingLayout.scss similarity index 100% rename from gui/src/components/onboarding/OnboardingLayout.scss rename to gui/app/src/components/onboarding/OnboardingLayout.scss diff --git a/gui/src/components/onboarding/OnboardingLayout.tsx b/gui/app/src/components/onboarding/OnboardingLayout.tsx similarity index 100% rename from gui/src/components/onboarding/OnboardingLayout.tsx rename to gui/app/src/components/onboarding/OnboardingLayout.tsx diff --git a/gui/src/components/onboarding/SkipSetupButton.tsx b/gui/app/src/components/onboarding/SkipSetupButton.tsx similarity index 100% rename from gui/src/components/onboarding/SkipSetupButton.tsx rename to gui/app/src/components/onboarding/SkipSetupButton.tsx diff --git a/gui/src/components/onboarding/SkipSetupWarningModal.tsx b/gui/app/src/components/onboarding/SkipSetupWarningModal.tsx similarity index 100% rename from gui/src/components/onboarding/SkipSetupWarningModal.tsx rename to gui/app/src/components/onboarding/SkipSetupWarningModal.tsx diff --git a/gui/src/components/onboarding/StepperSlider.tsx b/gui/app/src/components/onboarding/StepperSlider.tsx similarity index 100% rename from gui/src/components/onboarding/StepperSlider.tsx rename to gui/app/src/components/onboarding/StepperSlider.tsx diff --git a/gui/src/components/onboarding/UdevRulesModal.tsx b/gui/app/src/components/onboarding/UdevRulesModal.tsx similarity index 100% rename from gui/src/components/onboarding/UdevRulesModal.tsx rename to gui/app/src/components/onboarding/UdevRulesModal.tsx diff --git a/gui/src/components/onboarding/pages/ConnectTracker.scss b/gui/app/src/components/onboarding/pages/ConnectTracker.scss similarity index 100% rename from gui/src/components/onboarding/pages/ConnectTracker.scss rename to gui/app/src/components/onboarding/pages/ConnectTracker.scss diff --git a/gui/src/components/onboarding/pages/ConnectTracker.tsx b/gui/app/src/components/onboarding/pages/ConnectTracker.tsx similarity index 100% rename from gui/src/components/onboarding/pages/ConnectTracker.tsx rename to gui/app/src/components/onboarding/pages/ConnectTracker.tsx diff --git a/gui/src/components/onboarding/pages/ConnectionLost.tsx b/gui/app/src/components/onboarding/pages/ConnectionLost.tsx similarity index 100% rename from gui/src/components/onboarding/pages/ConnectionLost.tsx rename to gui/app/src/components/onboarding/pages/ConnectionLost.tsx diff --git a/gui/src/components/onboarding/pages/Dongle.tsx b/gui/app/src/components/onboarding/pages/Dongle.tsx similarity index 100% rename from gui/src/components/onboarding/pages/Dongle.tsx rename to gui/app/src/components/onboarding/pages/Dongle.tsx diff --git a/gui/src/components/onboarding/pages/ErrorCollectingConsent.tsx b/gui/app/src/components/onboarding/pages/ErrorCollectingConsent.tsx similarity index 100% rename from gui/src/components/onboarding/pages/ErrorCollectingConsent.tsx rename to gui/app/src/components/onboarding/pages/ErrorCollectingConsent.tsx diff --git a/gui/src/components/onboarding/pages/Home.tsx b/gui/app/src/components/onboarding/pages/Home.tsx similarity index 100% rename from gui/src/components/onboarding/pages/Home.tsx rename to gui/app/src/components/onboarding/pages/Home.tsx diff --git a/gui/src/components/onboarding/pages/WifiCreds.tsx b/gui/app/src/components/onboarding/pages/WifiCreds.tsx similarity index 100% rename from gui/src/components/onboarding/pages/WifiCreds.tsx rename to gui/app/src/components/onboarding/pages/WifiCreds.tsx diff --git a/gui/src/components/onboarding/pages/assignment-preparation/ExtensionArrow.tsx b/gui/app/src/components/onboarding/pages/assignment-preparation/ExtensionArrow.tsx similarity index 100% rename from gui/src/components/onboarding/pages/assignment-preparation/ExtensionArrow.tsx rename to gui/app/src/components/onboarding/pages/assignment-preparation/ExtensionArrow.tsx diff --git a/gui/src/components/onboarding/pages/assignment-preparation/StickerSlime.tsx b/gui/app/src/components/onboarding/pages/assignment-preparation/StickerSlime.tsx similarity index 100% rename from gui/src/components/onboarding/pages/assignment-preparation/StickerSlime.tsx rename to gui/app/src/components/onboarding/pages/assignment-preparation/StickerSlime.tsx diff --git a/gui/src/components/onboarding/pages/assignment-preparation/TrackerArrow.tsx b/gui/app/src/components/onboarding/pages/assignment-preparation/TrackerArrow.tsx similarity index 100% rename from gui/src/components/onboarding/pages/assignment-preparation/TrackerArrow.tsx rename to gui/app/src/components/onboarding/pages/assignment-preparation/TrackerArrow.tsx diff --git a/gui/src/components/onboarding/pages/body-proportions/AutomaticProportions.tsx b/gui/app/src/components/onboarding/pages/body-proportions/AutomaticProportions.tsx similarity index 100% rename from gui/src/components/onboarding/pages/body-proportions/AutomaticProportions.tsx rename to gui/app/src/components/onboarding/pages/body-proportions/AutomaticProportions.tsx diff --git a/gui/src/components/onboarding/pages/body-proportions/BodyProportions.tsx b/gui/app/src/components/onboarding/pages/body-proportions/BodyProportions.tsx similarity index 100% rename from gui/src/components/onboarding/pages/body-proportions/BodyProportions.tsx rename to gui/app/src/components/onboarding/pages/body-proportions/BodyProportions.tsx diff --git a/gui/src/components/onboarding/pages/body-proportions/HeightInput.tsx b/gui/app/src/components/onboarding/pages/body-proportions/HeightInput.tsx similarity index 100% rename from gui/src/components/onboarding/pages/body-proportions/HeightInput.tsx rename to gui/app/src/components/onboarding/pages/body-proportions/HeightInput.tsx diff --git a/gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx b/gui/app/src/components/onboarding/pages/body-proportions/ManualProportions.tsx similarity index 100% rename from gui/src/components/onboarding/pages/body-proportions/ManualProportions.tsx rename to gui/app/src/components/onboarding/pages/body-proportions/ManualProportions.tsx diff --git a/gui/src/components/onboarding/pages/body-proportions/ProportionsResetModal.tsx b/gui/app/src/components/onboarding/pages/body-proportions/ProportionsResetModal.tsx similarity index 100% rename from gui/src/components/onboarding/pages/body-proportions/ProportionsResetModal.tsx rename to gui/app/src/components/onboarding/pages/body-proportions/ProportionsResetModal.tsx diff --git a/gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx b/gui/app/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx similarity index 100% rename from gui/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx rename to gui/app/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx diff --git a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/AutoboneErrorModal.tsx b/gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/AutoboneErrorModal.tsx similarity index 100% rename from gui/src/components/onboarding/pages/body-proportions/autobone-steps/AutoboneErrorModal.tsx rename to gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/AutoboneErrorModal.tsx diff --git a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/Done.tsx b/gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/Done.tsx similarity index 100% rename from gui/src/components/onboarding/pages/body-proportions/autobone-steps/Done.tsx rename to gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/Done.tsx diff --git a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx b/gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx similarity index 100% rename from gui/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx rename to gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/Preparation.tsx diff --git a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/PutTrackersOn.tsx b/gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/PutTrackersOn.tsx similarity index 100% rename from gui/src/components/onboarding/pages/body-proportions/autobone-steps/PutTrackersOn.tsx rename to gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/PutTrackersOn.tsx diff --git a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/Recording.tsx b/gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/Recording.tsx similarity index 100% rename from gui/src/components/onboarding/pages/body-proportions/autobone-steps/Recording.tsx rename to gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/Recording.tsx diff --git a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/Requirements.tsx b/gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/Requirements.tsx similarity index 100% rename from gui/src/components/onboarding/pages/body-proportions/autobone-steps/Requirements.tsx rename to gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/Requirements.tsx diff --git a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/StartRecording.tsx b/gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/StartRecording.tsx similarity index 100% rename from gui/src/components/onboarding/pages/body-proportions/autobone-steps/StartRecording.tsx rename to gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/StartRecording.tsx diff --git a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/TooSmolModal.tsx b/gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/TooSmolModal.tsx similarity index 100% rename from gui/src/components/onboarding/pages/body-proportions/autobone-steps/TooSmolModal.tsx rename to gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/TooSmolModal.tsx diff --git a/gui/src/components/onboarding/pages/body-proportions/autobone-steps/VerifyResults.tsx b/gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/VerifyResults.tsx similarity index 100% rename from gui/src/components/onboarding/pages/body-proportions/autobone-steps/VerifyResults.tsx rename to gui/app/src/components/onboarding/pages/body-proportions/autobone-steps/VerifyResults.tsx diff --git a/gui/src/components/onboarding/pages/mounting/AutomaticMounting.tsx b/gui/app/src/components/onboarding/pages/mounting/AutomaticMounting.tsx similarity index 100% rename from gui/src/components/onboarding/pages/mounting/AutomaticMounting.tsx rename to gui/app/src/components/onboarding/pages/mounting/AutomaticMounting.tsx diff --git a/gui/src/components/onboarding/pages/mounting/ManualMounting.tsx b/gui/app/src/components/onboarding/pages/mounting/ManualMounting.tsx similarity index 100% rename from gui/src/components/onboarding/pages/mounting/ManualMounting.tsx rename to gui/app/src/components/onboarding/pages/mounting/ManualMounting.tsx diff --git a/gui/src/components/onboarding/pages/mounting/MountingChoose.tsx b/gui/app/src/components/onboarding/pages/mounting/MountingChoose.tsx similarity index 100% rename from gui/src/components/onboarding/pages/mounting/MountingChoose.tsx rename to gui/app/src/components/onboarding/pages/mounting/MountingChoose.tsx diff --git a/gui/src/components/onboarding/pages/mounting/MountingSelectionMenu.tsx b/gui/app/src/components/onboarding/pages/mounting/MountingSelectionMenu.tsx similarity index 100% rename from gui/src/components/onboarding/pages/mounting/MountingSelectionMenu.tsx rename to gui/app/src/components/onboarding/pages/mounting/MountingSelectionMenu.tsx diff --git a/gui/src/components/onboarding/pages/mounting/mounting-steps/Done.tsx b/gui/app/src/components/onboarding/pages/mounting/mounting-steps/Done.tsx similarity index 100% rename from gui/src/components/onboarding/pages/mounting/mounting-steps/Done.tsx rename to gui/app/src/components/onboarding/pages/mounting/mounting-steps/Done.tsx diff --git a/gui/src/components/onboarding/pages/mounting/mounting-steps/MountingReset.tsx b/gui/app/src/components/onboarding/pages/mounting/mounting-steps/MountingReset.tsx similarity index 100% rename from gui/src/components/onboarding/pages/mounting/mounting-steps/MountingReset.tsx rename to gui/app/src/components/onboarding/pages/mounting/mounting-steps/MountingReset.tsx diff --git a/gui/src/components/onboarding/pages/mounting/mounting-steps/Preparation.tsx b/gui/app/src/components/onboarding/pages/mounting/mounting-steps/Preparation.tsx similarity index 100% rename from gui/src/components/onboarding/pages/mounting/mounting-steps/Preparation.tsx rename to gui/app/src/components/onboarding/pages/mounting/mounting-steps/Preparation.tsx diff --git a/gui/src/components/onboarding/pages/mounting/mounting-steps/PutTrackersOn.tsx b/gui/app/src/components/onboarding/pages/mounting/mounting-steps/PutTrackersOn.tsx similarity index 100% rename from gui/src/components/onboarding/pages/mounting/mounting-steps/PutTrackersOn.tsx rename to gui/app/src/components/onboarding/pages/mounting/mounting-steps/PutTrackersOn.tsx diff --git a/gui/src/components/onboarding/pages/quiz/MocapPreferencesQuestions.tsx b/gui/app/src/components/onboarding/pages/quiz/MocapPreferencesQuestions.tsx similarity index 100% rename from gui/src/components/onboarding/pages/quiz/MocapPreferencesQuestions.tsx rename to gui/app/src/components/onboarding/pages/quiz/MocapPreferencesQuestions.tsx diff --git a/gui/src/components/onboarding/pages/quiz/MoreSetsConfirm.tsx b/gui/app/src/components/onboarding/pages/quiz/MoreSetsConfirm.tsx similarity index 100% rename from gui/src/components/onboarding/pages/quiz/MoreSetsConfirm.tsx rename to gui/app/src/components/onboarding/pages/quiz/MoreSetsConfirm.tsx diff --git a/gui/src/components/onboarding/pages/quiz/RuntimeQuestion.tsx b/gui/app/src/components/onboarding/pages/quiz/RuntimeQuestion.tsx similarity index 100% rename from gui/src/components/onboarding/pages/quiz/RuntimeQuestion.tsx rename to gui/app/src/components/onboarding/pages/quiz/RuntimeQuestion.tsx diff --git a/gui/src/components/onboarding/pages/quiz/SlimeSetQuestion.tsx b/gui/app/src/components/onboarding/pages/quiz/SlimeSetQuestion.tsx similarity index 100% rename from gui/src/components/onboarding/pages/quiz/SlimeSetQuestion.tsx rename to gui/app/src/components/onboarding/pages/quiz/SlimeSetQuestion.tsx diff --git a/gui/src/components/onboarding/pages/quiz/UsageQuestion.tsx b/gui/app/src/components/onboarding/pages/quiz/UsageQuestion.tsx similarity index 100% rename from gui/src/components/onboarding/pages/quiz/UsageQuestion.tsx rename to gui/app/src/components/onboarding/pages/quiz/UsageQuestion.tsx diff --git a/gui/src/components/onboarding/pages/stay-aligned/StayAlignedSetup.tsx b/gui/app/src/components/onboarding/pages/stay-aligned/StayAlignedSetup.tsx similarity index 100% rename from gui/src/components/onboarding/pages/stay-aligned/StayAlignedSetup.tsx rename to gui/app/src/components/onboarding/pages/stay-aligned/StayAlignedSetup.tsx diff --git a/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/Done.tsx b/gui/app/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/Done.tsx similarity index 100% rename from gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/Done.tsx rename to gui/app/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/Done.tsx diff --git a/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx b/gui/app/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx similarity index 100% rename from gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx rename to gui/app/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PreparationStep.tsx diff --git a/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PutTrackersOnStep.tsx b/gui/app/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PutTrackersOnStep.tsx similarity index 100% rename from gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PutTrackersOnStep.tsx rename to gui/app/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/PutTrackersOnStep.tsx diff --git a/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/RelaxedPoseSteps.tsx b/gui/app/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/RelaxedPoseSteps.tsx similarity index 100% rename from gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/RelaxedPoseSteps.tsx rename to gui/app/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/RelaxedPoseSteps.tsx diff --git a/gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/VerifyMounting.tsx b/gui/app/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/VerifyMounting.tsx similarity index 100% rename from gui/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/VerifyMounting.tsx rename to gui/app/src/components/onboarding/pages/stay-aligned/stay-aligned-steps/VerifyMounting.tsx diff --git a/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignOptions.tsx b/gui/app/src/components/onboarding/pages/trackers-assign/TrackerAssignOptions.tsx similarity index 100% rename from gui/src/components/onboarding/pages/trackers-assign/TrackerAssignOptions.tsx rename to gui/app/src/components/onboarding/pages/trackers-assign/TrackerAssignOptions.tsx diff --git a/gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx b/gui/app/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx similarity index 100% rename from gui/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx rename to gui/app/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx diff --git a/gui/src/components/onboarding/pages/trackers-assign/TrackerSelectionMenu.tsx b/gui/app/src/components/onboarding/pages/trackers-assign/TrackerSelectionMenu.tsx similarity index 100% rename from gui/src/components/onboarding/pages/trackers-assign/TrackerSelectionMenu.tsx rename to gui/app/src/components/onboarding/pages/trackers-assign/TrackerSelectionMenu.tsx diff --git a/gui/src/components/providers/AppContext.tsx b/gui/app/src/components/providers/AppContext.tsx similarity index 100% rename from gui/src/components/providers/AppContext.tsx rename to gui/app/src/components/providers/AppContext.tsx diff --git a/gui/src/components/providers/ConfigContext.tsx b/gui/app/src/components/providers/ConfigContext.tsx similarity index 100% rename from gui/src/components/providers/ConfigContext.tsx rename to gui/app/src/components/providers/ConfigContext.tsx diff --git a/gui/src/components/settings/SettingsLayout.scss b/gui/app/src/components/settings/SettingsLayout.scss similarity index 100% rename from gui/src/components/settings/SettingsLayout.scss rename to gui/app/src/components/settings/SettingsLayout.scss diff --git a/gui/src/components/settings/SettingsLayout.tsx b/gui/app/src/components/settings/SettingsLayout.tsx similarity index 100% rename from gui/src/components/settings/SettingsLayout.tsx rename to gui/app/src/components/settings/SettingsLayout.tsx diff --git a/gui/src/components/settings/SettingsPageLayout.tsx b/gui/app/src/components/settings/SettingsPageLayout.tsx similarity index 100% rename from gui/src/components/settings/SettingsPageLayout.tsx rename to gui/app/src/components/settings/SettingsPageLayout.tsx diff --git a/gui/src/components/settings/SettingsResetModal.tsx b/gui/app/src/components/settings/SettingsResetModal.tsx similarity index 100% rename from gui/src/components/settings/SettingsResetModal.tsx rename to gui/app/src/components/settings/SettingsResetModal.tsx diff --git a/gui/src/components/settings/SettingsSidebar.tsx b/gui/app/src/components/settings/SettingsSidebar.tsx similarity index 100% rename from gui/src/components/settings/SettingsSidebar.tsx rename to gui/app/src/components/settings/SettingsSidebar.tsx diff --git a/gui/src/components/settings/pages/AdvancedSettings.tsx b/gui/app/src/components/settings/pages/AdvancedSettings.tsx similarity index 100% rename from gui/src/components/settings/pages/AdvancedSettings.tsx rename to gui/app/src/components/settings/pages/AdvancedSettings.tsx diff --git a/gui/src/components/settings/pages/GeneralSettings.tsx b/gui/app/src/components/settings/pages/GeneralSettings.tsx similarity index 100% rename from gui/src/components/settings/pages/GeneralSettings.tsx rename to gui/app/src/components/settings/pages/GeneralSettings.tsx diff --git a/gui/src/components/settings/pages/HomeScreenSettings.tsx b/gui/app/src/components/settings/pages/HomeScreenSettings.tsx similarity index 100% rename from gui/src/components/settings/pages/HomeScreenSettings.tsx rename to gui/app/src/components/settings/pages/HomeScreenSettings.tsx diff --git a/gui/src/components/settings/pages/InterfaceSettings.tsx b/gui/app/src/components/settings/pages/InterfaceSettings.tsx similarity index 100% rename from gui/src/components/settings/pages/InterfaceSettings.tsx rename to gui/app/src/components/settings/pages/InterfaceSettings.tsx diff --git a/gui/src/components/settings/pages/Serial.tsx b/gui/app/src/components/settings/pages/Serial.tsx similarity index 100% rename from gui/src/components/settings/pages/Serial.tsx rename to gui/app/src/components/settings/pages/Serial.tsx diff --git a/gui/src/components/settings/pages/VMCSettings.tsx b/gui/app/src/components/settings/pages/VMCSettings.tsx similarity index 100% rename from gui/src/components/settings/pages/VMCSettings.tsx rename to gui/app/src/components/settings/pages/VMCSettings.tsx diff --git a/gui/src/components/settings/pages/VRCOSCSettings.tsx b/gui/app/src/components/settings/pages/VRCOSCSettings.tsx similarity index 100% rename from gui/src/components/settings/pages/VRCOSCSettings.tsx rename to gui/app/src/components/settings/pages/VRCOSCSettings.tsx diff --git a/gui/src/components/settings/pages/components/HIDSettings.tsx b/gui/app/src/components/settings/pages/components/HIDSettings.tsx similarity index 100% rename from gui/src/components/settings/pages/components/HIDSettings.tsx rename to gui/app/src/components/settings/pages/components/HIDSettings.tsx diff --git a/gui/src/components/settings/pages/components/HandsWarningModal.tsx b/gui/app/src/components/settings/pages/components/HandsWarningModal.tsx similarity index 100% rename from gui/src/components/settings/pages/components/HandsWarningModal.tsx rename to gui/app/src/components/settings/pages/components/HandsWarningModal.tsx diff --git a/gui/src/components/settings/pages/components/MagnetometerToggleSetting.tsx b/gui/app/src/components/settings/pages/components/MagnetometerToggleSetting.tsx similarity index 100% rename from gui/src/components/settings/pages/components/MagnetometerToggleSetting.tsx rename to gui/app/src/components/settings/pages/components/MagnetometerToggleSetting.tsx diff --git a/gui/src/components/settings/pages/components/OutputSettings.tsx b/gui/app/src/components/settings/pages/components/OutputSettings.tsx similarity index 100% rename from gui/src/components/settings/pages/components/OutputSettings.tsx rename to gui/app/src/components/settings/pages/components/OutputSettings.tsx diff --git a/gui/src/components/settings/pages/components/ResetsSettings.tsx b/gui/app/src/components/settings/pages/components/ResetsSettings.tsx similarity index 100% rename from gui/src/components/settings/pages/components/ResetsSettings.tsx rename to gui/app/src/components/settings/pages/components/ResetsSettings.tsx diff --git a/gui/src/components/settings/pages/components/StayAlignedPoseModal.tsx b/gui/app/src/components/settings/pages/components/StayAlignedPoseModal.tsx similarity index 100% rename from gui/src/components/settings/pages/components/StayAlignedPoseModal.tsx rename to gui/app/src/components/settings/pages/components/StayAlignedPoseModal.tsx diff --git a/gui/src/components/settings/pages/components/StayAlignedSettings.tsx b/gui/app/src/components/settings/pages/components/StayAlignedSettings.tsx similarity index 100% rename from gui/src/components/settings/pages/components/StayAlignedSettings.tsx rename to gui/app/src/components/settings/pages/components/StayAlignedSettings.tsx diff --git a/gui/src/components/settings/pages/components/TapDetectionSettings.tsx b/gui/app/src/components/settings/pages/components/TapDetectionSettings.tsx similarity index 100% rename from gui/src/components/settings/pages/components/TapDetectionSettings.tsx rename to gui/app/src/components/settings/pages/components/TapDetectionSettings.tsx diff --git a/gui/src/components/settings/pages/components/TrackingSettings.tsx b/gui/app/src/components/settings/pages/components/TrackingSettings.tsx similarity index 100% rename from gui/src/components/settings/pages/components/TrackingSettings.tsx rename to gui/app/src/components/settings/pages/components/TrackingSettings.tsx diff --git a/gui/src/components/stay-aligned/RelaxedPose.tsx b/gui/app/src/components/stay-aligned/RelaxedPose.tsx similarity index 100% rename from gui/src/components/stay-aligned/RelaxedPose.tsx rename to gui/app/src/components/stay-aligned/RelaxedPose.tsx diff --git a/gui/src/components/stay-aligned/StayAlignedInfo.tsx b/gui/app/src/components/stay-aligned/StayAlignedInfo.tsx similarity index 100% rename from gui/src/components/stay-aligned/StayAlignedInfo.tsx rename to gui/app/src/components/stay-aligned/StayAlignedInfo.tsx diff --git a/gui/src/components/tracker/SingleTrackerBodyAssignmentMenu.tsx b/gui/app/src/components/tracker/SingleTrackerBodyAssignmentMenu.tsx similarity index 100% rename from gui/src/components/tracker/SingleTrackerBodyAssignmentMenu.tsx rename to gui/app/src/components/tracker/SingleTrackerBodyAssignmentMenu.tsx diff --git a/gui/src/components/tracker/TrackerBattery.tsx b/gui/app/src/components/tracker/TrackerBattery.tsx similarity index 100% rename from gui/src/components/tracker/TrackerBattery.tsx rename to gui/app/src/components/tracker/TrackerBattery.tsx diff --git a/gui/src/components/tracker/TrackerCard.tsx b/gui/app/src/components/tracker/TrackerCard.tsx similarity index 100% rename from gui/src/components/tracker/TrackerCard.tsx rename to gui/app/src/components/tracker/TrackerCard.tsx diff --git a/gui/src/components/tracker/TrackerPartCard.tsx b/gui/app/src/components/tracker/TrackerPartCard.tsx similarity index 100% rename from gui/src/components/tracker/TrackerPartCard.tsx rename to gui/app/src/components/tracker/TrackerPartCard.tsx diff --git a/gui/src/components/tracker/TrackerSettings.tsx b/gui/app/src/components/tracker/TrackerSettings.tsx similarity index 100% rename from gui/src/components/tracker/TrackerSettings.tsx rename to gui/app/src/components/tracker/TrackerSettings.tsx diff --git a/gui/src/components/tracker/TrackerStatus.tsx b/gui/app/src/components/tracker/TrackerStatus.tsx similarity index 100% rename from gui/src/components/tracker/TrackerStatus.tsx rename to gui/app/src/components/tracker/TrackerStatus.tsx diff --git a/gui/src/components/tracker/TrackerWifi.tsx b/gui/app/src/components/tracker/TrackerWifi.tsx similarity index 100% rename from gui/src/components/tracker/TrackerWifi.tsx rename to gui/app/src/components/tracker/TrackerWifi.tsx diff --git a/gui/src/components/tracker/TrackersTable.tsx b/gui/app/src/components/tracker/TrackersTable.tsx similarity index 100% rename from gui/src/components/tracker/TrackersTable.tsx rename to gui/app/src/components/tracker/TrackersTable.tsx diff --git a/gui/src/components/tracking-checklist/TrackingChecklist.tsx b/gui/app/src/components/tracking-checklist/TrackingChecklist.tsx similarity index 100% rename from gui/src/components/tracking-checklist/TrackingChecklist.tsx rename to gui/app/src/components/tracking-checklist/TrackingChecklist.tsx diff --git a/gui/src/components/tracking-checklist/TrackingChecklistModal.tsx b/gui/app/src/components/tracking-checklist/TrackingChecklistModal.tsx similarity index 100% rename from gui/src/components/tracking-checklist/TrackingChecklistModal.tsx rename to gui/app/src/components/tracking-checklist/TrackingChecklistModal.tsx diff --git a/gui/src/components/tracking-checklist/TrackingChecklistProvider.tsx b/gui/app/src/components/tracking-checklist/TrackingChecklistProvider.tsx similarity index 100% rename from gui/src/components/tracking-checklist/TrackingChecklistProvider.tsx rename to gui/app/src/components/tracking-checklist/TrackingChecklistProvider.tsx diff --git a/gui/src/components/vr-mode/VRModePage.tsx b/gui/app/src/components/vr-mode/VRModePage.tsx similarity index 100% rename from gui/src/components/vr-mode/VRModePage.tsx rename to gui/app/src/components/vr-mode/VRModePage.tsx diff --git a/gui/src/components/vrc/VRCWarningsPage.tsx b/gui/app/src/components/vrc/VRCWarningsPage.tsx similarity index 100% rename from gui/src/components/vrc/VRCWarningsPage.tsx rename to gui/app/src/components/vrc/VRCWarningsPage.tsx diff --git a/gui/src/components/widgets/IMUVisualizerWidget.tsx b/gui/app/src/components/widgets/IMUVisualizerWidget.tsx similarity index 100% rename from gui/src/components/widgets/IMUVisualizerWidget.tsx rename to gui/app/src/components/widgets/IMUVisualizerWidget.tsx diff --git a/gui/src/components/widgets/SkeletonVisualizerWidget.tsx b/gui/app/src/components/widgets/SkeletonVisualizerWidget.tsx similarity index 100% rename from gui/src/components/widgets/SkeletonVisualizerWidget.tsx rename to gui/app/src/components/widgets/SkeletonVisualizerWidget.tsx diff --git a/gui/src/firmware-tool-api/firmwareToolComponents.ts b/gui/app/src/firmware-tool-api/firmwareToolComponents.ts similarity index 100% rename from gui/src/firmware-tool-api/firmwareToolComponents.ts rename to gui/app/src/firmware-tool-api/firmwareToolComponents.ts diff --git a/gui/src/firmware-tool-api/firmwareToolContext.ts b/gui/app/src/firmware-tool-api/firmwareToolContext.ts similarity index 100% rename from gui/src/firmware-tool-api/firmwareToolContext.ts rename to gui/app/src/firmware-tool-api/firmwareToolContext.ts diff --git a/gui/src/firmware-tool-api/firmwareToolFetcher.ts b/gui/app/src/firmware-tool-api/firmwareToolFetcher.ts similarity index 100% rename from gui/src/firmware-tool-api/firmwareToolFetcher.ts rename to gui/app/src/firmware-tool-api/firmwareToolFetcher.ts diff --git a/gui/src/firmware-tool-api/firmwareToolSchemas.ts b/gui/app/src/firmware-tool-api/firmwareToolSchemas.ts similarity index 100% rename from gui/src/firmware-tool-api/firmwareToolSchemas.ts rename to gui/app/src/firmware-tool-api/firmwareToolSchemas.ts diff --git a/gui/src/firmware-tool-api/firmwareToolUtils.ts b/gui/app/src/firmware-tool-api/firmwareToolUtils.ts similarity index 100% rename from gui/src/firmware-tool-api/firmwareToolUtils.ts rename to gui/app/src/firmware-tool-api/firmwareToolUtils.ts diff --git a/gui/src/hooks/app.ts b/gui/app/src/hooks/app.ts similarity index 100% rename from gui/src/hooks/app.ts rename to gui/app/src/hooks/app.ts diff --git a/gui/src/hooks/autobone.ts b/gui/app/src/hooks/autobone.ts similarity index 100% rename from gui/src/hooks/autobone.ts rename to gui/app/src/hooks/autobone.ts diff --git a/gui/src/hooks/body-parts.ts b/gui/app/src/hooks/body-parts.ts similarity index 100% rename from gui/src/hooks/body-parts.ts rename to gui/app/src/hooks/body-parts.ts diff --git a/gui/src/hooks/breakpoint.ts b/gui/app/src/hooks/breakpoint.ts similarity index 100% rename from gui/src/hooks/breakpoint.ts rename to gui/app/src/hooks/breakpoint.ts diff --git a/gui/src/hooks/bvh.ts b/gui/app/src/hooks/bvh.ts similarity index 100% rename from gui/src/hooks/bvh.ts rename to gui/app/src/hooks/bvh.ts diff --git a/gui/src/hooks/cache.ts b/gui/app/src/hooks/cache.ts similarity index 96% rename from gui/src/hooks/cache.ts rename to gui/app/src/hooks/cache.ts index 2299a310e8..312a789745 100644 --- a/gui/src/hooks/cache.ts +++ b/gui/app/src/hooks/cache.ts @@ -1,4 +1,4 @@ -import { CrossStorage } from 'electron/preload/interface'; +import { CrossStorage } from '@slimevr/gui-shared'; const localStore: CrossStorage = { get: async (key: string) => diff --git a/gui/src/hooks/choker-warning.ts b/gui/app/src/hooks/choker-warning.ts similarity index 100% rename from gui/src/hooks/choker-warning.ts rename to gui/app/src/hooks/choker-warning.ts diff --git a/gui/src/hooks/config.ts b/gui/app/src/hooks/config.ts similarity index 98% rename from gui/src/hooks/config.ts rename to gui/app/src/hooks/config.ts index 51a4679a5f..9af8331bee 100644 --- a/gui/src/hooks/config.ts +++ b/gui/app/src/hooks/config.ts @@ -4,7 +4,7 @@ import { useDebouncedEffect } from './timeout'; import { waitUntil } from '@/utils/a11y'; import { v4 as uuidv4 } from 'uuid'; import { useElectron } from './electron'; -import { CrossStorage } from 'electron/preload/interface'; +import { CrossStorage } from '@slimevr/gui-shared'; export interface WindowConfig { width: number; diff --git a/gui/src/hooks/countdown.ts b/gui/app/src/hooks/countdown.ts similarity index 100% rename from gui/src/hooks/countdown.ts rename to gui/app/src/hooks/countdown.ts diff --git a/gui/src/hooks/crossplatform.ts b/gui/app/src/hooks/crossplatform.ts similarity index 100% rename from gui/src/hooks/crossplatform.ts rename to gui/app/src/hooks/crossplatform.ts diff --git a/gui/src/hooks/crypto.ts b/gui/app/src/hooks/crypto.ts similarity index 100% rename from gui/src/hooks/crypto.ts rename to gui/app/src/hooks/crypto.ts diff --git a/gui/src/hooks/datafeed-config.ts b/gui/app/src/hooks/datafeed-config.ts similarity index 100% rename from gui/src/hooks/datafeed-config.ts rename to gui/app/src/hooks/datafeed-config.ts diff --git a/gui/src/hooks/discord-presence.ts b/gui/app/src/hooks/discord-presence.ts similarity index 100% rename from gui/src/hooks/discord-presence.ts rename to gui/app/src/hooks/discord-presence.ts diff --git a/gui/src/hooks/electron.ts b/gui/app/src/hooks/electron.ts similarity index 93% rename from gui/src/hooks/electron.ts rename to gui/app/src/hooks/electron.ts index d1d62304f3..322afa76a4 100644 --- a/gui/src/hooks/electron.ts +++ b/gui/app/src/hooks/electron.ts @@ -1,4 +1,4 @@ -import { IElectronAPI, OSStats } from 'electron/preload/interface'; +import { IElectronAPI, OSStats } from '@slimevr/gui-shared'; import { createContext, useContext, useLayoutEffect, useState } from 'react'; type CachedData = { diff --git a/gui/src/hooks/firmware-tool.ts b/gui/app/src/hooks/firmware-tool.ts similarity index 100% rename from gui/src/hooks/firmware-tool.ts rename to gui/app/src/hooks/firmware-tool.ts diff --git a/gui/src/hooks/firmware-update.ts b/gui/app/src/hooks/firmware-update.ts similarity index 100% rename from gui/src/hooks/firmware-update.ts rename to gui/app/src/hooks/firmware-update.ts diff --git a/gui/src/hooks/height.ts b/gui/app/src/hooks/height.ts similarity index 100% rename from gui/src/hooks/height.ts rename to gui/app/src/hooks/height.ts diff --git a/gui/src/hooks/imu-logic.ts b/gui/app/src/hooks/imu-logic.ts similarity index 100% rename from gui/src/hooks/imu-logic.ts rename to gui/app/src/hooks/imu-logic.ts diff --git a/gui/src/hooks/layout.ts b/gui/app/src/hooks/layout.ts similarity index 100% rename from gui/src/hooks/layout.ts rename to gui/app/src/hooks/layout.ts diff --git a/gui/src/hooks/manual-proportions.ts b/gui/app/src/hooks/manual-proportions.ts similarity index 100% rename from gui/src/hooks/manual-proportions.ts rename to gui/app/src/hooks/manual-proportions.ts diff --git a/gui/src/hooks/onboarding.ts b/gui/app/src/hooks/onboarding.ts similarity index 100% rename from gui/src/hooks/onboarding.ts rename to gui/app/src/hooks/onboarding.ts diff --git a/gui/src/hooks/osc-setting-validator.ts b/gui/app/src/hooks/osc-setting-validator.ts similarity index 100% rename from gui/src/hooks/osc-setting-validator.ts rename to gui/app/src/hooks/osc-setting-validator.ts diff --git a/gui/src/hooks/pause-tracking.ts b/gui/app/src/hooks/pause-tracking.ts similarity index 100% rename from gui/src/hooks/pause-tracking.ts rename to gui/app/src/hooks/pause-tracking.ts diff --git a/gui/src/hooks/previous.ts b/gui/app/src/hooks/previous.ts similarity index 100% rename from gui/src/hooks/previous.ts rename to gui/app/src/hooks/previous.ts diff --git a/gui/src/hooks/reset.ts b/gui/app/src/hooks/reset.ts similarity index 100% rename from gui/src/hooks/reset.ts rename to gui/app/src/hooks/reset.ts diff --git a/gui/src/hooks/timeout.ts b/gui/app/src/hooks/timeout.ts similarity index 100% rename from gui/src/hooks/timeout.ts rename to gui/app/src/hooks/timeout.ts diff --git a/gui/src/hooks/tracker.ts b/gui/app/src/hooks/tracker.ts similarity index 100% rename from gui/src/hooks/tracker.ts rename to gui/app/src/hooks/tracker.ts diff --git a/gui/src/hooks/tracking-checklist.ts b/gui/app/src/hooks/tracking-checklist.ts similarity index 100% rename from gui/src/hooks/tracking-checklist.ts rename to gui/app/src/hooks/tracking-checklist.ts diff --git a/gui/src/hooks/vrc-config.ts b/gui/app/src/hooks/vrc-config.ts similarity index 100% rename from gui/src/hooks/vrc-config.ts rename to gui/app/src/hooks/vrc-config.ts diff --git a/gui/src/hooks/websocket-api.ts b/gui/app/src/hooks/websocket-api.ts similarity index 100% rename from gui/src/hooks/websocket-api.ts rename to gui/app/src/hooks/websocket-api.ts diff --git a/gui/src/hooks/wifi-form.tsx b/gui/app/src/hooks/wifi-form.tsx similarity index 100% rename from gui/src/hooks/wifi-form.tsx rename to gui/app/src/hooks/wifi-form.tsx diff --git a/gui/src/i18n/config.tsx b/gui/app/src/i18n/config.tsx similarity index 100% rename from gui/src/i18n/config.tsx rename to gui/app/src/i18n/config.tsx diff --git a/gui/src/i18n/names.ts b/gui/app/src/i18n/names.ts similarity index 100% rename from gui/src/i18n/names.ts rename to gui/app/src/i18n/names.ts diff --git a/gui/src/index.scss b/gui/app/src/index.scss similarity index 100% rename from gui/src/index.scss rename to gui/app/src/index.scss diff --git a/gui/src/index.tsx b/gui/app/src/index.tsx similarity index 100% rename from gui/src/index.tsx rename to gui/app/src/index.tsx diff --git a/gui/src/logo.svg b/gui/app/src/logo.svg similarity index 100% rename from gui/src/logo.svg rename to gui/app/src/logo.svg diff --git a/gui/src/maths/angle.ts b/gui/app/src/maths/angle.ts similarity index 100% rename from gui/src/maths/angle.ts rename to gui/app/src/maths/angle.ts diff --git a/gui/src/maths/quaternion.ts b/gui/app/src/maths/quaternion.ts similarity index 100% rename from gui/src/maths/quaternion.ts rename to gui/app/src/maths/quaternion.ts diff --git a/gui/src/maths/vector3.ts b/gui/app/src/maths/vector3.ts similarity index 100% rename from gui/src/maths/vector3.ts rename to gui/app/src/maths/vector3.ts diff --git a/gui/src/setupTests.ts b/gui/app/src/setupTests.ts similarity index 100% rename from gui/src/setupTests.ts rename to gui/app/src/setupTests.ts diff --git a/gui/src/sounds/sounds.ts b/gui/app/src/sounds/sounds.ts similarity index 100% rename from gui/src/sounds/sounds.ts rename to gui/app/src/sounds/sounds.ts diff --git a/gui/src/sounds/xylophone.ts b/gui/app/src/sounds/xylophone.ts similarity index 100% rename from gui/src/sounds/xylophone.ts rename to gui/app/src/sounds/xylophone.ts diff --git a/gui/src/store/app-store.ts b/gui/app/src/store/app-store.ts similarity index 100% rename from gui/src/store/app-store.ts rename to gui/app/src/store/app-store.ts diff --git a/gui/src/utils/a11y.ts b/gui/app/src/utils/a11y.ts similarity index 100% rename from gui/src/utils/a11y.ts rename to gui/app/src/utils/a11y.ts diff --git a/gui/src/utils/formatting.ts b/gui/app/src/utils/formatting.ts similarity index 100% rename from gui/src/utils/formatting.ts rename to gui/app/src/utils/formatting.ts diff --git a/gui/src/utils/logging.ts b/gui/app/src/utils/logging.ts similarity index 100% rename from gui/src/utils/logging.ts rename to gui/app/src/utils/logging.ts diff --git a/gui/src/utils/sentry.ts b/gui/app/src/utils/sentry.ts similarity index 100% rename from gui/src/utils/sentry.ts rename to gui/app/src/utils/sentry.ts diff --git a/gui/src/utils/skeletonHelper.ts b/gui/app/src/utils/skeletonHelper.ts similarity index 100% rename from gui/src/utils/skeletonHelper.ts rename to gui/app/src/utils/skeletonHelper.ts diff --git a/gui/src/vite-env.d.ts b/gui/app/src/vite-env.d.ts similarity index 100% rename from gui/src/vite-env.d.ts rename to gui/app/src/vite-env.d.ts diff --git a/gui/tailwind.config.ts b/gui/app/tailwind.config.ts similarity index 100% rename from gui/tailwind.config.ts rename to gui/app/tailwind.config.ts diff --git a/gui/tsconfig.json b/gui/app/tsconfig.json similarity index 93% rename from gui/tsconfig.json rename to gui/app/tsconfig.json index 3c2bdef4c4..a89d9f33b5 100644 --- a/gui/tsconfig.json +++ b/gui/app/tsconfig.json @@ -20,6 +20,6 @@ "@/*": ["./src/*"] } }, - "include": ["src", "electron/**/*"], + "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } diff --git a/gui/tsconfig.node.json b/gui/app/tsconfig.node.json similarity index 100% rename from gui/tsconfig.node.json rename to gui/app/tsconfig.node.json diff --git a/gui/vite.config.ts b/gui/app/vite.config.ts similarity index 92% rename from gui/vite.config.ts rename to gui/app/vite.config.ts index b2f86c21ea..9ce51a4ab0 100644 --- a/gui/vite.config.ts +++ b/gui/app/vite.config.ts @@ -53,19 +53,12 @@ export default defineConfig({ build: { target: 'es2022', emptyOutDir: true, - - commonjsOptions: { - include: [/solarxr-protocol/, /node_modules/], - }, - sourcemap: true, }, optimizeDeps: { esbuildOptions: { target: 'es2022', }, - needsInterop: ['solarxr-protocol'], - include: ['solarxr-protocol'], }, resolve: { alias: { diff --git a/gui/electron-builder.yml b/gui/electron-builder.yml deleted file mode 100644 index 04cfc4598b..0000000000 --- a/gui/electron-builder.yml +++ /dev/null @@ -1,78 +0,0 @@ -appId: dev.slimevr.SlimeVR -productName: SlimeVR -# Global naming pattern -artifactName: "${productName}-${version}-${os}-${arch}.${ext}" - -directories: - output: dist/artifacts/${os} - -asar: true -asarUnpack: - - out/main/chunks/*.jar - -electronLanguages: - - en-US - -files: - - out/**/* - - index.html - - package.json - - node_modules/** - - "!node_modules/*/{README,readme,README.md,readme.md,CHANGELOG,CHANGELOG.md,changelog.md}" - - "!node_modules/*/{test,tests,__tests__,docs,doc,example,examples}" - - "!node_modules/*/.{git,github,vscode,editorconfig,eslintrc,prettierrc}" - - "!node_modules/**/*.{map,ts,tsx,d.ts}" - - "!**/.DS_Store" - - -linux: - category: Game - artifactName: "${productName}-${arch}.${ext}" - target: - - target: AppImage - - target: deb - - target: rpm - extraFiles: - - from: "../server/desktop/build/libs/slimevr.jar" - to: "." - - from: "../bindings-provider/build/linux-${arch}/slimevr-bindings-provider" - to: "." - - from: "../bindings-provider/build/linux-${arch}/libopenvr_api.so" - to: "." - - from: "./electron/resources/69-slimevr-devices.rules" - to: "." - icon: "./electron/resources/icons" - -deb: - depends: [openjdk-17-jre-headless, udev] - afterInstall: "./electron/resources/scripts/postinstall.sh" - afterRemove: "./electron/resources/scripts/postremove.sh" - -rpm: - depends: [java-latest-openjdk, udev] - afterInstall: "./electron/resources/scripts/postinstall.sh" - afterRemove: "./electron/resources/scripts/postremove.sh" - -win: - artifactName: "${productName}-${os}-${arch}.${ext}" - target: zip - icon: "./electron/resources/icons/icon.ico" - extraFiles: - - from: "../server/desktop/build/libs/slimevr.jar" - to: "." - - from: "../server/core/resources" - to: "." - filter: ["**/*"] - - from: "../bindings-provider/build/${os}-${arch}/Release/SlimeVR-Bindings-Provider.exe" - to: "." - - from: "../bindings-provider/build/${os}-${arch}/openvr_api.dll" - to: "." - -mac: - target: dmg - artifactName: "SlimeVR-mac.${ext}" - x64ArchFiles: "**/register-protocol-handler.node" - icon: "./electron/resources/icons/icon.icns" - extraFiles: - - from: "../server/desktop/build/libs/slimevr.jar" - to: "Resources/slimevr.jar" diff --git a/gui/electron.vite.config.ts b/gui/electron.vite.config.ts deleted file mode 100644 index 7735ecdd10..0000000000 --- a/gui/electron.vite.config.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { defineConfig } from 'electron-vite'; -import { resolve } from 'path'; -import rendererConfig from './vite.config'; // Import your existing React config - -export default defineConfig({ - main: { - build: { - rollupOptions: { - input: resolve(__dirname, 'electron/main/index.ts'), - external: ['pino', 'pino-pretty', 'pino-roll', 'commander', 'open'], - }, - }, - }, - preload: { - build: { - rollupOptions: { - input: resolve(__dirname, 'electron/preload/index.ts'), - output: { - format: 'cjs', // Force CJS for the preload - entryFileNames: 'index.js', // Change back to .js - }, - }, - }, - }, - renderer: { - ...rendererConfig, - root: '.', - build: { - commonjsOptions: { - // Force Rollup to treat the protocol directory as CommonJS - // even though it's not in node_modules - include: [/solarxr-protocol/, /node_modules/], - // Required for Flatbuffers/Generated code interop - transformMixedEsModules: true, - }, - rollupOptions: { - input: resolve(__dirname, 'index.html'), - }, - }, - }, -}); diff --git a/gui/electron/electron-builder.yml b/gui/electron/electron-builder.yml new file mode 100644 index 0000000000..7f60c8757e --- /dev/null +++ b/gui/electron/electron-builder.yml @@ -0,0 +1,84 @@ +appId: dev.slimevr.SlimeVR +productName: SlimeVR +# The package is named @slimevr/gui-electron; pin the executable name so +# electron-builder doesn't derive an invalid one from the scoped package name. +executableName: slimevr +# Global naming pattern +artifactName: "${productName}-${version}-${os}-${arch}.${ext}" + +directories: + output: dist/artifacts/${os} + +asar: true +asarUnpack: + # JavaVersion.jar is spawned as an external `java -jar` process, so it must + # live outside the asar. electron-vite references ?asset files under the + # main-process public dir (resources/) at runtime as ../../resources/... + - resources/java-version/*.jar + +electronLanguages: + - en-US + +files: + - out/**/* + - resources/**/* + - package.json + - node_modules/** + - "!node_modules/*/{README,readme,README.md,readme.md,CHANGELOG,CHANGELOG.md,changelog.md}" + - "!node_modules/*/{test,tests,__tests__,docs,doc,example,examples}" + - "!node_modules/*/.{git,github,vscode,editorconfig,eslintrc,prettierrc}" + - "!node_modules/**/*.{map,ts,tsx,d.ts}" + - "!**/.DS_Store" + + +linux: + category: Game + artifactName: "${productName}-${arch}.${ext}" + target: + - target: AppImage + - target: deb + - target: rpm + extraFiles: + - from: "../../server/desktop/build/libs/slimevr.jar" + to: "." + - from: "../../bindings-provider/build/linux-${arch}/slimevr-bindings-provider" + to: "." + - from: "../../bindings-provider/build/linux-${arch}/libopenvr_api.so" + to: "." + - from: "./resources/69-slimevr-devices.rules" + to: "." + icon: "./resources/icons" + +deb: + depends: [openjdk-17-jre-headless, udev] + afterInstall: "./resources/scripts/postinstall.sh" + afterRemove: "./resources/scripts/postremove.sh" + +rpm: + depends: [java-latest-openjdk, udev] + afterInstall: "./resources/scripts/postinstall.sh" + afterRemove: "./resources/scripts/postremove.sh" + +win: + artifactName: "${productName}-${os}-${arch}.${ext}" + target: zip + icon: "./resources/icons/icon.ico" + extraFiles: + - from: "../../server/desktop/build/libs/slimevr.jar" + to: "." + - from: "../../server/core/resources" + to: "." + filter: ["**/*"] + - from: "../../bindings-provider/build/${os}-${arch}/Release/SlimeVR-Bindings-Provider.exe" + to: "." + - from: "../../bindings-provider/build/${os}-${arch}/openvr_api.dll" + to: "." + +mac: + target: dmg + artifactName: "SlimeVR-mac.${ext}" + x64ArchFiles: "**/register-protocol-handler.node" + icon: "./resources/icons/icon.icns" + extraFiles: + - from: "../../server/desktop/build/libs/slimevr.jar" + to: "Resources/slimevr.jar" diff --git a/gui/electron/electron.vite.config.ts b/gui/electron/electron.vite.config.ts new file mode 100644 index 0000000000..b4b2a0745f --- /dev/null +++ b/gui/electron/electron.vite.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'electron-vite'; +import { resolve } from 'path'; + +export default defineConfig({ + main: { + build: { + // @slimevr/gui-shared is a workspace source package (TS). electron-vite + // externalizes all package.json deps by default; exclude this one so it + // gets bundled in (its runtime IPC_CHANNELS compiled into the output + // instead of imported as raw .ts at runtime). + externalizeDeps: { exclude: ['@slimevr/gui-shared'] }, + rollupOptions: { + input: resolve(__dirname, 'main/index.ts'), + external: ['pino', 'pino-pretty', 'pino-roll', 'commander', 'open'], + }, + }, + }, + preload: { + build: { + externalizeDeps: { exclude: ['@slimevr/gui-shared'] }, + rollupOptions: { + input: resolve(__dirname, 'preload/index.ts'), + output: { + format: 'cjs', // Force CJS for the preload + entryFileNames: 'index.js', // Change back to .js + }, + }, + }, + }, +}); diff --git a/gui/electron/env.d.ts b/gui/electron/env.d.ts new file mode 100644 index 0000000000..d5822de05e --- /dev/null +++ b/gui/electron/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/gui/electron/main/index.ts b/gui/electron/main/index.ts index 52bd95fd5a..ad26652465 100644 --- a/gui/electron/main/index.ts +++ b/gui/electron/main/index.ts @@ -10,7 +10,7 @@ import { shell, Tray, } from 'electron'; -import { IPC_CHANNELS } from '../shared'; +import { IPC_CHANNELS } from '@slimevr/gui-shared'; import path, { dirname, join } from 'path'; import open from 'open'; import trayIcon from '../resources/icons/icon.png?asset'; @@ -33,7 +33,7 @@ import { closeLogger, logger } from './logger'; import { spawn } from 'node:child_process'; import { discordPresence } from './presence'; import { options } from './cli'; -import { ServerStatusEvent } from 'electron/preload/interface'; +import { ServerStatusEvent } from '@slimevr/gui-shared'; import { mkdir, writeFile } from 'node:fs/promises'; import { MenuItem } from 'electron/main'; diff --git a/gui/electron/main/utils.ts b/gui/electron/main/utils.ts index 29cbf53c17..5f6445c4f0 100644 --- a/gui/electron/main/utils.ts +++ b/gui/electron/main/utils.ts @@ -1,7 +1,7 @@ import os from 'os'; -import { OSStats } from '../preload/interface'; +import { OSStats } from '@slimevr/gui-shared'; import { ipcMain, IpcMainInvokeEvent } from 'electron'; -import { IpcInvokeMap } from '../shared'; +import { IpcInvokeMap } from '@slimevr/gui-shared'; import net from 'net'; export const getPlatform = (): OSStats['type'] => { diff --git a/gui/electron/package.json b/gui/electron/package.json new file mode 100644 index 0000000000..5f9efce4dc --- /dev/null +++ b/gui/electron/package.json @@ -0,0 +1,41 @@ +{ + "name": "@slimevr/gui-electron", + "version": "0.0.0", + "author": "SlimeVR Team ", + "homepage": "https://slimevr.dev", + "private": true, + "type": "module", + "main": "./out/main/index.js", + "scripts": { + "dev": "node scripts/dev.mjs", + "build": "electron-vite build --config electron.vite.config.ts && pnpm --filter @slimevr/gui-app exec vite build --outDir ../electron/out/renderer --emptyOutDir", + "package": "electron-builder", + "package:build": "pnpm build && pnpm package", + "preview": "electron ./out/main/index.js", + "lint": "tsc --noEmit && prettier --check \"{main,preload,scripts}/**/*.{js,mjs,ts}\"", + "format": "prettier --write \"{main,preload,scripts}/**/*.{js,mjs,ts}\"", + "gen:javaversion": "cd resources/java-version/ && javac JavaVersion.java && jar cvfe JavaVersion.jar JavaVersion JavaVersion.class" + }, + "dependencies": { + "@slimevr/gui-app": "workspace:*", + "@slimevr/gui-shared": "workspace:*", + "@xhayper/discord-rpc": "^1.3.0", + "commander": "^14.0.3", + "glob": "^13.0.3", + "open": "^11.0.0", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", + "pino-roll": "^4.0.0" + }, + "devDependencies": { + "@electron/asar": "^4.0.1", + "@types/node": "^24.3.1", + "dmg-license": "^1.0.11", + "dotenv": "^16.4.5", + "electron": "^40.3.0", + "electron-builder": "^26.15.0", + "electron-vite": "^5.0.0", + "prettier": "^3.3.3", + "typescript": "^5.6.3" + } +} diff --git a/gui/electron/preload/index.ts b/gui/electron/preload/index.ts index ac50ad2513..6dc650da9e 100644 --- a/gui/electron/preload/index.ts +++ b/gui/electron/preload/index.ts @@ -1,6 +1,6 @@ import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'; -import { IElectronAPI, ServerStatusEvent } from './interface'; -import { IPC_CHANNELS } from '../shared'; +import { IElectronAPI, ServerStatusEvent } from '@slimevr/gui-shared'; +import { IPC_CHANNELS } from '@slimevr/gui-shared'; contextBridge.exposeInMainWorld('electronAPI', { onServerStatus: (callback) => { diff --git a/gui/electron/scripts/dev.mjs b/gui/electron/scripts/dev.mjs new file mode 100644 index 0000000000..0417aef8f9 --- /dev/null +++ b/gui/electron/scripts/dev.mjs @@ -0,0 +1,90 @@ +// Dev orchestrator: start the renderer (gui/app) Vite dev server, then run +// electron-vite in watch mode pointed at that dev URL. Electron loads the live +// renderer (HMR) via ELECTRON_RENDERER_URL, while main/preload rebuild and the +// app auto-restarts on changes. +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { join, delimiter } from 'node:path'; + +const HOST = '127.0.0.1'; +const PORT = 5173; +const RENDERER_URL = `http://${HOST}:${PORT}`; + +// electron-vite launches electron via process.env.ELECTRON_EXEC_PATH, else it +// reads the npm electron binary's path.txt (absent here - the nix devShell +// provides electron on PATH). Resolve it from PATH, skipping node_modules/.bin +// (pnpm prepends that, where the uninstalled npm `electron` shim lives). +function resolveElectronExecPath() { + if (process.env.ELECTRON_EXEC_PATH) return process.env.ELECTRON_EXEC_PATH; + const exe = process.platform === 'win32' ? 'electron.exe' : 'electron'; + for (const dir of (process.env.PATH || '').split(delimiter)) { + if (!dir || dir.includes('node_modules')) continue; + const candidate = join(dir, exe); + if (existsSync(candidate)) return candidate; + } + return ''; +} +const ELECTRON_EXEC_PATH = resolveElectronExecPath(); + +const children = []; +let shuttingDown = false; + +function shutdown(code = 0) { + if (shuttingDown) return; + shuttingDown = true; + for (const child of children) { + child.removeAllListeners('exit'); + child.kill(); + } + process.exit(code); +} + +process.on('SIGINT', () => shutdown(0)); +process.on('SIGTERM', () => shutdown(0)); + +function run(command, args, extraEnv = {}) { + const child = spawn(command, args, { + stdio: 'inherit', + shell: process.platform === 'win32', + env: { ...process.env, ...extraEnv }, + }); + child.on('exit', (code) => { + if (!shuttingDown) shutdown(code ?? 0); + }); + children.push(child); + return child; +} + +async function waitForServer(url, timeoutMs = 30000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + await fetch(url); + return; + } catch { + await new Promise((r) => setTimeout(r, 250)); + } + } + throw new Error(`Renderer dev server did not start at ${url} within ${timeoutMs}ms`); +} + +// 1. Start the renderer dev server (fixed port so the URL is deterministic). +run('pnpm', [ + '--filter', + '@slimevr/gui-app', + 'exec', + 'vite', + '--host', + HOST, + '--port', + String(PORT), + '--strictPort', +]); + +// 2. Wait for it, then launch electron-vite (main + preload watch) at the dev URL. +await waitForServer(RENDERER_URL); +run( + 'pnpm', + ['exec', 'electron-vite', 'dev', '--watch', '--config', 'electron.vite.config.ts'], + { ELECTRON_RENDERER_URL: RENDERER_URL, ELECTRON_EXEC_PATH } +); diff --git a/gui/electron/tsconfig.json b/gui/electron/tsconfig.json new file mode 100644 index 0000000000..97f513b475 --- /dev/null +++ b/gui/electron/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["es2022"], + "module": "esnext", + "moduleResolution": "bundler", + "allowJs": true, + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noEmit": true, + "types": ["node", "electron-vite/node"] + }, + "include": ["main", "preload", "env.d.ts"] +} diff --git a/gui/shared/package.json b/gui/shared/package.json new file mode 100644 index 0000000000..cb5db4e53a --- /dev/null +++ b/gui/shared/package.json @@ -0,0 +1,18 @@ +{ + "name": "@slimevr/gui-shared", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "lint": "tsc --noEmit" + }, + "devDependencies": { + "electron": "^40.3.0", + "typescript": "^5.6.3" + } +} diff --git a/gui/shared/src/index.ts b/gui/shared/src/index.ts new file mode 100644 index 0000000000..144ff25234 --- /dev/null +++ b/gui/shared/src/index.ts @@ -0,0 +1,2 @@ +export * from './interface'; +export * from './ipc'; diff --git a/gui/electron/preload/interface.d.ts b/gui/shared/src/interface.ts similarity index 100% rename from gui/electron/preload/interface.d.ts rename to gui/shared/src/interface.ts diff --git a/gui/electron/shared.ts b/gui/shared/src/ipc.ts similarity index 95% rename from gui/electron/shared.ts rename to gui/shared/src/ipc.ts index ac32c9234e..dc981d4ae6 100644 --- a/gui/electron/shared.ts +++ b/gui/shared/src/ipc.ts @@ -4,7 +4,7 @@ import { SaveDialogOptions, SaveDialogReturnValue, } from 'electron'; -import { DiscordPresence, GHGet, GHReturn, OSStats } from './preload/interface'; +import { DiscordPresence, GHGet, GHReturn, OSStats } from './interface'; export const IPC_CHANNELS = { SERVER_STATUS: 'server-status', diff --git a/gui/shared/tsconfig.json b/gui/shared/tsconfig.json new file mode 100644 index 0000000000..17d8d59319 --- /dev/null +++ b/gui/shared/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["dom", "es2022"], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/package.json b/package.json index 4b500ef7c1..0ff193497f 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,16 @@ "packageManager": "pnpm@10.33.0", "workspaces": [ "solarxr-protocol", - "gui" + "gui/app", + "gui/electron", + "gui/shared" ], "scripts": { - "gui": "pnpm run update-solarxr && cd gui && pnpm run gui", - "lint:fix": "cd gui && pnpm lint:fix", - "skipbundler": "cd gui && pnpm run skipbundler", - "build": "cd gui && pnpm build", + "gui": "pnpm run update-solarxr && pnpm --filter @slimevr/gui-electron dev", + "lint": "pnpm --filter @slimevr/gui-shared lint && pnpm --filter @slimevr/gui-app lint && pnpm --filter @slimevr/gui-electron lint", + "lint:fix": "pnpm --filter @slimevr/gui-app lint:fix && pnpm --filter @slimevr/gui-electron format", + "skipbundler": "pnpm --filter @slimevr/gui-app build", + "build": "pnpm run update-solarxr && pnpm --filter @slimevr/gui-electron build", "update-solarxr": "cd solarxr-protocol && pnpm run build", "prepare": "husky && pnpm run update-solarxr", "preinstall": "npx only-allow pnpm" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1eb02f7b7..59d0b2a52b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,48 +15,8 @@ importers: specifier: ^15.2.10 version: 15.5.2 - gui: + gui/app: dependencies: - '@ryuziii/discord-rpc': - specifier: 1.0.1-rc.1 - version: 1.0.1-rc.1 - '@xhayper/discord-rpc': - specifier: ^1.3.0 - version: 1.3.3 - commander: - specifier: ^14.0.3 - version: 14.0.3 - discord-rich-presence: - specifier: ^0.0.8 - version: 0.0.8(encoding@0.1.13) - glob: - specifier: ^13.0.3 - version: 13.0.6 - open: - specifier: ^11.0.0 - version: 11.0.0 - pino: - specifier: ^10.3.1 - version: 10.3.1 - pino-pretty: - specifier: ^13.1.3 - version: 13.1.3 - pino-roll: - specifier: ^4.0.0 - version: 4.0.0 - devDependencies: - '@dword-design/eslint-plugin-import-alias': - specifier: ^4.0.9 - version: 4.0.9 - '@electron/asar': - specifier: ^4.0.1 - version: 4.2.0 - '@eslint/eslintrc': - specifier: ^3.3.5 - version: 3.3.5 - '@eslint/js': - specifier: ^9.39.4 - version: 9.39.4 '@fluent/bundle': specifier: ^0.18.0 version: 0.18.0 @@ -72,18 +32,118 @@ importers: '@hookform/resolvers': specifier: ^3.6.0 version: 3.10.0(react-hook-form@7.72.1(react@18.3.1)) - '@openapi-codegen/cli': - specifier: ^3.1.0 - version: 3.1.0(encoding@0.1.13) - '@openapi-codegen/typescript': - specifier: ^8.0.2 - version: 8.1.1 '@react-hookz/deep-equal': specifier: ^3.0.3 version: 3.0.4 '@sentry/react': specifier: 10.29.0 version: 10.29.0(react@18.3.1) + '@slimevr/gui-shared': + specifier: workspace:* + version: link:../shared + '@tanstack/react-query': + specifier: ^5.48.0 + version: 5.99.0(react@18.3.1) + '@tweenjs/tween.js': + specifier: ^25.0.0 + version: 25.0.0 + '@twemoji/svg': + specifier: ^15.0.0 + version: 15.0.0 + ajv: + specifier: ^8.17.1 + version: 8.18.0 + browser-fs-access: + specifier: ^0.35.0 + version: 0.35.0 + classnames: + specifier: ^2.5.1 + version: 2.5.1 + convert: + specifier: ^5.12.0 + version: 5.14.1 + flatbuffers: + specifier: 22.10.26 + version: 22.10.26 + intl-pluralrules: + specifier: ^2.0.1 + version: 2.0.1 + ip-num: + specifier: ^1.5.1 + version: 1.5.2 + jotai: + specifier: ^2.12.2 + version: 2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1) + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-error-boundary: + specifier: ^4.0.13 + version: 4.1.2(react@18.3.1) + react-helmet: + specifier: ^6.1.0 + version: 6.1.0(react@18.3.1) + react-hook-form: + specifier: ^7.63.0 + version: 7.72.1(react@18.3.1) + react-markdown: + specifier: ^9.0.1 + version: 9.1.0(@types/react@18.3.28)(react@18.3.1) + react-modal: + specifier: ^3.16.1 + version: 3.16.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-responsive: + specifier: ^10.0.0 + version: 10.0.1(react@18.3.1) + react-router-dom: + specifier: ^6.26.2 + version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + remark-gfm: + specifier: ^4.0.0 + version: 4.0.1 + semver: + specifier: ^7.6.3 + version: 7.7.4 + solarxr-protocol: + specifier: workspace:* + version: link:../../solarxr-protocol + tailwind-gradient-mask-image: + specifier: ^1.2.0 + version: 1.2.0 + three: + specifier: ^0.163.0 + version: 0.163.0 + ts-pattern: + specifier: ^5.4.0 + version: 5.9.0 + use-double-tap: + specifier: ^1.3.6 + version: 1.3.7(react@18.3.1) + uuid: + specifier: ^13.0.0 + version: 13.0.0 + yup: + specifier: ^1.4.0 + version: 1.7.1 + devDependencies: + '@dword-design/eslint-plugin-import-alias': + specifier: ^4.0.9 + version: 4.0.9 + '@eslint/eslintrc': + specifier: ^3.3.5 + version: 3.3.5 + '@eslint/js': + specifier: ^9.39.4 + version: 9.39.4 + '@openapi-codegen/cli': + specifier: ^3.1.0 + version: 3.1.0(encoding@0.1.13) + '@openapi-codegen/typescript': + specifier: ^8.0.2 + version: 8.1.1 '@sentry/vite-plugin': specifier: ^2.22.7 version: 2.23.1(encoding@0.1.13) @@ -96,18 +156,6 @@ importers: '@tailwindcss/typography': specifier: ^0.5.15 version: 0.5.19(tailwindcss@3.4.19(yaml@2.8.3)) - '@tanstack/react-query': - specifier: ^5.48.0 - version: 5.99.0(react@18.3.1) - '@tweenjs/tween.js': - specifier: ^25.0.0 - version: 25.0.0 - '@twemoji/svg': - specifier: ^15.0.0 - version: 15.0.0 - '@types/file-saver': - specifier: ^2.0.7 - version: 2.0.7 '@types/node': specifier: ^24.3.1 version: 24.12.2 @@ -138,36 +186,12 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.2 version: 4.7.0(vite@5.4.21(@types/node@24.12.2)(sass@1.99.0)) - ajv: - specifier: ^8.17.1 - version: 8.18.0 autoprefixer: specifier: ^10.4.20 version: 10.5.0(postcss@8.5.10) - browser-fs-access: - specifier: ^0.35.0 - version: 0.35.0 - classnames: - specifier: ^2.5.1 - version: 2.5.1 - convert: - specifier: ^5.12.0 - version: 5.14.1 - dmg-license: - specifier: ^1.0.11 - version: 1.0.11 dotenv: specifier: ^16.4.5 version: 16.6.1 - electron: - specifier: ^40.3.0 - version: 40.9.1 - electron-builder: - specifier: ^26.15.0 - version: 26.15.3(electron-builder-squirrel-windows@26.15.3) - electron-vite: - specifier: ^5.0.0 - version: 5.0.0(@swc/core@1.15.26)(vite@5.4.21(@types/node@24.12.2)(sass@1.99.0)) eslint: specifier: ^9.39.1 version: 9.39.4(jiti@1.21.7) @@ -186,102 +210,106 @@ importers: eslint-plugin-react-hooks: specifier: ^7.0.1 version: 7.0.1(eslint@9.39.4(jiti@1.21.7)) - flatbuffers: - specifier: 22.10.26 - version: 22.10.26 globals: specifier: ^15.10.0 version: 15.15.0 - intl-pluralrules: - specifier: ^2.0.1 - version: 2.0.1 - ip-num: - specifier: ^1.5.1 - version: 1.5.2 - jotai: - specifier: ^2.12.2 - version: 2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@18.3.28)(react@18.3.1) + postcss: + specifier: ^8.4.47 + version: 8.5.10 prettier: specifier: ^3.3.3 version: 3.8.3 prompts: specifier: ^2.4.2 version: 2.4.2 - react: - specifier: ^18.3.1 - version: 18.3.1 - react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) - react-error-boundary: - specifier: ^4.0.13 - version: 4.1.2(react@18.3.1) - react-helmet: - specifier: ^6.1.0 - version: 6.1.0(react@18.3.1) - react-hook-form: - specifier: ^7.63.0 - version: 7.72.1(react@18.3.1) - react-markdown: - specifier: ^9.0.1 - version: 9.1.0(@types/react@18.3.28)(react@18.3.1) - react-modal: - specifier: ^3.16.1 - version: 3.16.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-responsive: - specifier: ^10.0.0 - version: 10.0.1(react@18.3.1) - react-router-dom: - specifier: ^6.26.2 - version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - remark-gfm: - specifier: ^4.0.0 - version: 4.0.1 rollup-plugin-visualizer: specifier: ^5.12.0 version: 5.14.0(rollup@4.60.1) sass: specifier: ^1.79.4 version: 1.99.0 - semver: - specifier: ^7.6.3 - version: 7.7.4 - solarxr-protocol: - specifier: file:../solarxr-protocol - version: link:../solarxr-protocol spdx-satisfies: specifier: ^5.0.1 version: 5.0.1 - tailwind-gradient-mask-image: - specifier: ^1.2.0 - version: 1.2.0 tailwindcss: specifier: ^3.4.13 version: 3.4.19(yaml@2.8.3) - three: - specifier: ^0.163.0 - version: 0.163.0 - ts-pattern: - specifier: ^5.4.0 - version: 5.9.0 typescript: specifier: ^5.6.3 version: 5.9.3 typescript-eslint: specifier: ^8.46.2 version: 8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3) - use-double-tap: - specifier: ^1.3.6 - version: 1.3.7(react@18.3.1) - uuid: - specifier: ^13.0.0 - version: 13.0.0 vite: specifier: ^5.4.8 version: 5.4.21(@types/node@24.12.2)(sass@1.99.0) - yup: - specifier: ^1.4.0 - version: 1.7.1 + + gui/electron: + dependencies: + '@slimevr/gui-app': + specifier: workspace:* + version: link:../app + '@slimevr/gui-shared': + specifier: workspace:* + version: link:../shared + '@xhayper/discord-rpc': + specifier: ^1.3.0 + version: 1.3.3 + commander: + specifier: ^14.0.3 + version: 14.0.3 + glob: + specifier: ^13.0.3 + version: 13.0.6 + open: + specifier: ^11.0.0 + version: 11.0.0 + pino: + specifier: ^10.3.1 + version: 10.3.1 + pino-pretty: + specifier: ^13.1.3 + version: 13.1.3 + pino-roll: + specifier: ^4.0.0 + version: 4.0.0 + devDependencies: + '@electron/asar': + specifier: ^4.0.1 + version: 4.2.0 + '@types/node': + specifier: ^24.3.1 + version: 24.12.2 + dmg-license: + specifier: ^1.0.11 + version: 1.0.11 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + electron: + specifier: ^40.3.0 + version: 40.9.1 + electron-builder: + specifier: ^26.15.0 + version: 26.15.3(electron-builder-squirrel-windows@26.15.3) + electron-vite: + specifier: ^5.0.0 + version: 5.0.0(@swc/core@1.15.26)(vite@5.4.21(@types/node@24.12.2)(sass@1.99.0)) + prettier: + specifier: ^3.3.3 + version: 3.8.3 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + + gui/shared: + devDependencies: + electron: + specifier: ^40.3.0 + version: 40.9.1 + typescript: + specifier: ^5.6.3 + version: 5.9.3 solarxr-protocol: dependencies: @@ -1203,9 +1231,6 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@ryuziii/discord-rpc@1.0.1-rc.1': - resolution: {integrity: sha512-q9YgU8Rj9To1LWzo4u8cXOHUorEkB5KZ5cdW80KoYtAUx+nQy7wYCEFiNh8kcmqFqQ8m3Fsx1IXx3UpVymkaSw==} - '@sapphire/async-queue@1.5.5': resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} @@ -1471,9 +1496,6 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/file-saver@2.0.7': - resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} - '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} @@ -1545,9 +1567,6 @@ packages: '@types/webxr@0.5.24': resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} - '@types/ws@8.18.1': - resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -1612,6 +1631,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -1733,6 +1753,7 @@ packages: '@xmldom/xmldom@0.8.12': resolution: {integrity: sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version abbrev@4.0.0: resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} @@ -1932,9 +1953,6 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - bindings@1.5.0: - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} @@ -2320,13 +2338,6 @@ packages: discord-api-types@0.38.46: resolution: {integrity: sha512-Ae7NcagMG+FPxwuQxGCPEHmLCKMm8YBMPWEuF5J3L+KWrlH4XGR3UoVo4Ne8bwhhHXbpf+DxDqOeW2jBFupXCQ==} - discord-rich-presence@0.0.8: - resolution: {integrity: sha512-IpVMPjv15C9UvppxvrrGdv6bzQHOW1P1vLoMH15HvdJwGJ3dBd2bnrJ63Uy36YRUfrAMxGLiwUDHncvC8AuPaQ==} - - discord-rpc@https://codeload.github.com/discordjs/rpc/tar.gz/9e7de2a6d917591f10a66389e62e1dc053c04fec: - resolution: {tarball: https://codeload.github.com/discordjs/rpc/tar.gz/9e7de2a6d917591f10a66389e62e1dc053c04fec} - version: 4.0.1 - dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -2686,9 +2697,6 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - file-uri-to-path@1.0.0: - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} - filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} @@ -4210,10 +4218,6 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} - register-scheme@https://codeload.github.com/devsnek/node-register-scheme/tar.gz/e7cc9a63a1f512565da44cb57316d9fb10750e17: - resolution: {tarball: https://codeload.github.com/devsnek/node-register-scheme/tar.gz/e7cc9a63a1f512565da44cb57316d9fb10750e17} - version: 0.0.2 - remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -4968,18 +4972,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@7.5.10: - resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} - engines: {node: '>=8.3.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.20.0: resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} @@ -5885,14 +5877,6 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@ryuziii/discord-rpc@1.0.1-rc.1': - dependencies: - '@types/ws': 8.18.1 - ws: 8.20.0 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - '@sapphire/async-queue@1.5.5': {} '@sapphire/snowflake@3.5.5': {} @@ -6144,8 +6128,6 @@ snapshots: '@types/estree@1.0.8': {} - '@types/file-saver@2.0.7': {} - '@types/fs-extra@9.0.13': dependencies: '@types/node': 24.12.2 @@ -6222,10 +6204,6 @@ snapshots: '@types/webxr@0.5.24': {} - '@types/ws@8.18.1': - dependencies: - '@types/node': 24.12.2 - '@types/yauzl@2.10.3': dependencies: '@types/node': 24.12.2 @@ -6650,11 +6628,6 @@ snapshots: binary-extensions@2.3.0: {} - bindings@1.5.0: - dependencies: - file-uri-to-path: 1.0.0 - optional: true - bluebird@3.7.2: {} boolean@3.2.0: @@ -7042,25 +7015,6 @@ snapshots: discord-api-types@0.38.46: {} - discord-rich-presence@0.0.8(encoding@0.1.13): - dependencies: - discord-rpc: https://codeload.github.com/discordjs/rpc/tar.gz/9e7de2a6d917591f10a66389e62e1dc053c04fec(encoding@0.1.13) - transitivePeerDependencies: - - bufferutil - - encoding - - utf-8-validate - - discord-rpc@https://codeload.github.com/discordjs/rpc/tar.gz/9e7de2a6d917591f10a66389e62e1dc053c04fec(encoding@0.1.13): - dependencies: - node-fetch: 2.7.0(encoding@0.1.13) - ws: 7.5.10 - optionalDependencies: - register-scheme: https://codeload.github.com/devsnek/node-register-scheme/tar.gz/e7cc9a63a1f512565da44cb57316d9fb10750e17 - transitivePeerDependencies: - - bufferutil - - encoding - - utf-8-validate - dlv@1.1.3: {} dmg-builder@26.15.3(electron-builder-squirrel-windows@26.15.3): @@ -7660,9 +7614,6 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-uri-to-path@1.0.0: - optional: true - filelist@1.0.6: dependencies: minimatch: 5.1.9 @@ -9426,12 +9377,6 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 - register-scheme@https://codeload.github.com/devsnek/node-register-scheme/tar.gz/e7cc9a63a1f512565da44cb57316d9fb10750e17: - dependencies: - bindings: 1.5.0 - node-addon-api: 1.7.2 - optional: true - remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -10365,8 +10310,6 @@ snapshots: wrappy@1.0.2: {} - ws@7.5.10: {} - ws@8.20.0: {} wsl-utils@0.3.1: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 174b2672e4..0f98102ef7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,8 @@ packages: - "solarxr-protocol" - - "gui" + - "gui/app" + - "gui/electron" + - "gui/shared" - "!solarxr-protocol/lib/flatbuffers/**" onlyBuiltDependencies: diff --git a/server/android/build.gradle.kts b/server/android/build.gradle.kts index c78a7a8762..808aa46e17 100644 --- a/server/android/build.gradle.kts +++ b/server/android/build.gradle.kts @@ -32,7 +32,7 @@ java { val copyGuiAssets = tasks.register("copyGuiAssets") { val target = layout.projectDirectory.dir("src/main/assets/web-gui") delete(target) - from(rootProject.layout.projectDirectory.dir("gui/out/renderer")) + from(rootProject.layout.projectDirectory.dir("gui/electron/out/renderer")) into(target) if (inputs.sourceFiles.isEmpty) { throw GradleException("You need to run \"pnpm run build\" on the gui folder first!") diff --git a/solarxr-protocol b/solarxr-protocol index 9948881a63..dd513496d8 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 9948881a630c42f336694c7b494a27e11fe350d3 +Subproject commit dd513496d81a5896fbf23c88eb320a011598ab1d From b7f87ab1f19e95e1392a80b0c8aa7be4527c677b Mon Sep 17 00:00:00 2001 From: loucass003 Date: Fri, 3 Jul 2026 02:26:38 +0200 Subject: [PATCH 217/350] Remove useless comments --- gui/electron/scripts/dev.mjs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/gui/electron/scripts/dev.mjs b/gui/electron/scripts/dev.mjs index 0417aef8f9..ebcfa5d496 100644 --- a/gui/electron/scripts/dev.mjs +++ b/gui/electron/scripts/dev.mjs @@ -1,7 +1,3 @@ -// Dev orchestrator: start the renderer (gui/app) Vite dev server, then run -// electron-vite in watch mode pointed at that dev URL. Electron loads the live -// renderer (HMR) via ELECTRON_RENDERER_URL, while main/preload rebuild and the -// app auto-restarts on changes. import { spawn } from 'node:child_process'; import { existsSync } from 'node:fs'; import { join, delimiter } from 'node:path'; @@ -10,10 +6,6 @@ const HOST = '127.0.0.1'; const PORT = 5173; const RENDERER_URL = `http://${HOST}:${PORT}`; -// electron-vite launches electron via process.env.ELECTRON_EXEC_PATH, else it -// reads the npm electron binary's path.txt (absent here - the nix devShell -// provides electron on PATH). Resolve it from PATH, skipping node_modules/.bin -// (pnpm prepends that, where the uninstalled npm `electron` shim lives). function resolveElectronExecPath() { if (process.env.ELECTRON_EXEC_PATH) return process.env.ELECTRON_EXEC_PATH; const exe = process.platform === 'win32' ? 'electron.exe' : 'electron'; From 0c1ef06e3ff36e33406fcf0894d0331096da9886 Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 2 Jul 2026 21:57:46 -0400 Subject: [PATCH 218/350] Move configs out of states --- .../dev/slimevr/android/ForegroundService.kt | 7 +- .../src/main/java/dev/slimevr/app-context.kt | 4 + .../java/dev/slimevr/resets/behaviours.kt | 5 +- .../main/java/dev/slimevr/resets/module.kt | 17 ++-- .../main/java/dev/slimevr/solarxr/module.kt | 8 +- .../main/java/dev/slimevr/solarxr/resets.kt | 49 ++++++------ .../java/dev/slimevr/solarxr/tapdetection.kt | 52 +++++++------ .../src/main/java/dev/slimevr/solarxr/vmc.kt | 77 +++++++++++++------ .../main/java/dev/slimevr/solarxr/vrcosc.kt | 61 ++++++++------- .../dev/slimevr/tapdetection/behaviours.kt | 9 +++ .../java/dev/slimevr/tapdetection/module.kt | 37 +++++++++ .../java/dev/slimevr/tracker/behaviours.kt | 7 +- .../main/java/dev/slimevr/vmc/behaviours.kt | 42 ++++------ .../src/main/java/dev/slimevr/vmc/module.kt | 10 +-- .../src/main/java/dev/slimevr/vrcosc/input.kt | 4 +- .../main/java/dev/slimevr/vrcosc/module.kt | 10 +-- .../main/java/dev/slimevr/vrcosc/oscquery.kt | 4 +- .../main/java/dev/slimevr/vrcosc/output.kt | 7 +- .../main/java/dev/slimevr/vrcosc/settings.kt | 26 ------- .../src/main/java/dev/slimevr/desktop/Main.kt | 7 +- 20 files changed, 247 insertions(+), 196 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/tapdetection/module.kt delete mode 100644 server/core/src/main/java/dev/slimevr/vrcosc/settings.kt diff --git a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt index 7e4bef5660..32e4e90d26 100644 --- a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt +++ b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt @@ -32,6 +32,7 @@ import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resets.ResetsManager import dev.slimevr.skeleton.Skeleton +import dev.slimevr.tapdetection.TapDetectionManager import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer import dev.slimevr.util.safeLaunch @@ -145,13 +146,14 @@ class ForegroundService : Service() { val trackingChecklist = TrackingChecklist.create(scope = scope) val udpServer = UdpServer.create(scope = scope, addressResolver = ::resolveAndroidUdpAddress) val bvhManager = BVHManager.create(skeleton = skeleton, storage = storage, scope = scope) - val vmcManager = VMCManager.create(skeleton = skeleton, ctx = phase1, scope = scope) + val vmcManager = VMCManager.create(ctx = phase1, skeleton = skeleton, scope = this) val vrcOscManager = VRCOSCManager.create( ctx = phase1, scope = scope, oscQueryAddress = resolveAndroidOscQueryAddress(), ) - val resetsManager = ResetsManager.create(server = server, scope = scope) + val resetsManager = ResetsManager.create(server = server, scope = scope, settings = config.settings) + val tapDetectionManager = TapDetectionManager.create(server = server, scope = this, settings = config.settings) val appContext = AppContext( server = server, @@ -170,6 +172,7 @@ class ForegroundService : Service() { vmcManager = vmcManager, vrcOscManager = vrcOscManager, resetsManager = resetsManager, + tapDetectionManager = tapDetectionManager, ) acquireLocks() diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index b930404ae7..16cea2d0a8 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -9,6 +9,7 @@ import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resets.ResetsManager import dev.slimevr.serial.SerialServer import dev.slimevr.skeleton.Skeleton +import dev.slimevr.tapdetection.TapDetectionManager import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer import dev.slimevr.vmc.VMCManager @@ -41,6 +42,7 @@ interface AppContextProvider : Phase1ContextProvider { val vmcManager: VMCManager val vrcOscManager: VRCOSCManager val resetsManager: ResetsManager + val tapDetectionManager: TapDetectionManager fun startObserving() suspend fun dispose() } @@ -62,6 +64,7 @@ class AppContext( override val vmcManager: VMCManager, override val vrcOscManager: VRCOSCManager, override val resetsManager: ResetsManager, + override val tapDetectionManager: TapDetectionManager, ) : AppContextProvider { override fun startObserving() { skeleton.startObserving() @@ -75,6 +78,7 @@ class AppContext( vmcManager.startObserving() vrcOscManager.startObserving(this) resetsManager.startObserving() + tapDetectionManager.startObserving() } override suspend fun dispose() { diff --git a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt index b364f354f2..3f2420ae71 100644 --- a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt @@ -12,8 +12,6 @@ const val MOUNTING_RESET_TIMEOUT = 120 * 1000L // 120 seconds class ResetsBasicBehaviour : ResetsBehaviour { override fun reduce(state: ResetsState, action: ResetsActions) = when (action) { - is ResetsActions.UpdateConfig -> state.copy(config = action.config) - // Clear the states of the `canDoXReset`s to false is ResetsActions.ClearResets -> { state.copy( @@ -26,13 +24,12 @@ class ResetsBasicBehaviour : ResetsBehaviour { is ResetsActions.EndReset -> { if (action.resetType == ResetType.FULL) { state.copy(canDoYawReset = true, canDoMountingReset = true, lastFullResetTime = System.nanoTime()) - } else if (action.resetType == ResetType.MOUNTING) { - state.copy(config = state.config.copy(lastMountingMethod = MountingMethods.AUTOMATIC)) } else { state.copy() } } } + } class ResetsMountingTimeoutBehaviour : ResetsBehaviour { diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index 917eea7fbc..7366beb63a 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -2,7 +2,12 @@ package dev.slimevr.resets import dev.slimevr.AppLogger import dev.slimevr.VRServer +import dev.slimevr.config.MountingMethods import dev.slimevr.config.ResetsConfig +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions +import dev.slimevr.config.SettingsConfigState +import dev.slimevr.config.SettingsContext import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.tracker.TrackerActions @@ -20,14 +25,12 @@ import solarxr_protocol.rpc.ResetType import kotlin.collections.listOf data class ResetsState( - val config: ResetsConfig = ResetsConfig(), val canDoYawReset: Boolean, val canDoMountingReset: Boolean, val lastFullResetTime: Long, ) sealed interface ResetsActions { - data class UpdateConfig(val config: ResetsConfig) : ResetsActions data class ClearResets(val resetType: List) : ResetsActions data class EndReset(val resetType: ResetType) : ResetsActions } @@ -35,7 +38,7 @@ sealed interface ResetsActions { typealias ResetsContext = Context typealias ResetsBehaviour = Behaviour -class ResetsManager(val context: ResetsContext, val server: VRServer) { +class ResetsManager(val context: ResetsContext, val server: VRServer, val settings: Settings) { fun startObserving() = context.observeAll(this) private var resetJob: Job = Job() @@ -70,9 +73,9 @@ class ResetsManager(val context: ResetsContext, val server: VRServer) { } delay((remainder).toLong()) - executeTrackerResets(resetType, bodyParts, context.state.value.config) + executeTrackerResets(resetType, bodyParts, settings.context.state.value.data.resetsConfig) - context.dispatch(ResetsActions.EndReset(resetType)) + settings.context.dispatch(SettingsActions.Update {copy(resetsConfig = resetsConfig.copy(lastMountingMethod = MountingMethods.MANUAL))}) AppLogger.resets.info("${resetType.name} Reset from $resetSourceName") @@ -118,7 +121,7 @@ class ResetsManager(val context: ResetsContext, val server: VRServer) { } companion object { - fun create(server: VRServer, scope: CoroutineScope): ResetsManager { + fun create(server: VRServer, scope: CoroutineScope, settings: Settings): ResetsManager { val context = Context.create( initialState = ResetsState( canDoYawReset = false, @@ -129,7 +132,7 @@ class ResetsManager(val context: ResetsContext, val server: VRServer) { behaviours = listOf(ResetsBasicBehaviour(), ResetsMountingTimeoutBehaviour()), name = "ResetsManager", ) - return ResetsManager(context, server) + return ResetsManager(context, server, settings) } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 36a1224c28..2455c32e52 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -71,10 +71,10 @@ class SolarXRBridge( appContext.vrcConfigManager?.let { vrc -> add(VrcBehaviour(vrc, appContext.server, userHeight = { appContext.skeleton.context.state.value.skeletonHeight })) } - add(ResetsBehaviour(appContext.resetsManager)) - add(TapDetectionBehaviour(appContext.config.settings)) - add(VrcOscBehaviour(appContext.vrcOscManager)) - add(VmcBehaviour(appContext.vmcManager)) + add(ResetsBehaviour(appContext.config.settings)) + add(TapDetectionBehaviour(appContext.config.settings, appContext.tapDetectionManager)) + add(VrcOscBehaviour(appContext.config.settings, appContext.vrcOscManager)) + add(VmcBehaviour(appContext.config.settings)) add(HeightCalibrationBehaviour(appContext.heightCalibrationManager)) add(ProvisioningBehaviour(appContext.server, appContext.provisioningManager)) add(SkeletonBehaviour(appContext.config.userConfig, appContext.skeleton)) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/resets.kt b/server/core/src/main/java/dev/slimevr/solarxr/resets.kt index 876c693442..44d9ad7c69 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/resets.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/resets.kt @@ -1,41 +1,44 @@ package dev.slimevr.solarxr import dev.slimevr.config.ResetsConfig -import dev.slimevr.resets.ResetsActions -import dev.slimevr.resets.ResetsManager +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions import solarxr_protocol.rpc.ChangeResetsSettingsRequest import solarxr_protocol.rpc.ResetsSettingsRequest import solarxr_protocol.rpc.ResetsSettingsResponse class ResetsBehaviour( - private val resetsManager: ResetsManager, + private val settings: Settings, ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { + // Send config receiver.rpcDispatcher.on { - receiver.sendRpc(buildResetsSettings(resetsManager.context.state.value.config)) + val config = settings.context.state.value.data.resetsConfig + receiver.sendRpc(ResetsSettingsResponse( + resetMountingFeet = config.resetMountingFeet, + armsResetMode = config.armsResetMode, + yawResetSmoothTime = config.yawResetSmoothTime, + saveMountingReset = config.saveMountingReset, + resetHmdPitch = config.resetHmdPitch, + )) } - val oldConfig = resetsManager.context.state.value.config + // Receive config receiver.rpcDispatcher.on { req -> - resetsManager.context.dispatch( - ResetsActions.UpdateConfig( - ResetsConfig( - resetMountingFeet = req.resetMountingFeet == true, - armsResetMode = req.armsResetMode ?: oldConfig.armsResetMode, - yawResetSmoothTime = req.yawResetSmoothTime ?: oldConfig.yawResetSmoothTime, - saveMountingReset = req.saveMountingReset == true, - resetHmdPitch = req.resetHmdPitch == true, - ), - ), + val oldConfig = settings.context.state.value.data.resetsConfig + settings.context.dispatch( + SettingsActions.Update { + copy( + resetsConfig = ResetsConfig ( + resetMountingFeet = req.resetMountingFeet == true, + armsResetMode = req.armsResetMode ?: oldConfig.armsResetMode, + yawResetSmoothTime = req.yawResetSmoothTime ?: oldConfig.yawResetSmoothTime, + saveMountingReset = req.saveMountingReset == true, + resetHmdPitch = req.resetHmdPitch == true, + ) + ) + } ) } } - - private fun buildResetsSettings(config: ResetsConfig): ResetsSettingsResponse = ResetsSettingsResponse( - resetMountingFeet = config.resetMountingFeet, - armsResetMode = config.armsResetMode, - yawResetSmoothTime = config.yawResetSmoothTime, - saveMountingReset = config.saveMountingReset, - resetHmdPitch = config.resetHmdPitch, - ) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt index d1f115c8e2..0a1c6bb3cf 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt @@ -3,30 +3,45 @@ package dev.slimevr.solarxr import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions import dev.slimevr.config.TapDetectionConfig +import dev.slimevr.tapdetection.TapDetectionActions +import dev.slimevr.tapdetection.TapDetectionManager import solarxr_protocol.rpc.ChangeTapDetectionSettingsRequest import solarxr_protocol.rpc.TapDetectionSettingsRequest import solarxr_protocol.rpc.TapDetectionSettingsResponse import solarxr_protocol.rpc.TapDetectionSetupModeRequest -import solarxr_protocol.rpc.VMCOSCSettingsRequest class TapDetectionBehaviour( private val settings: Settings, + private val tapDetectionManager: TapDetectionManager, ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { + // Send config receiver.rpcDispatcher.on { - receiver.sendRpc(buildTapDetectionSettings(settings.context.state.value.data.tapDetectionConfig)) + val config = settings.context.state.value.data.tapDetectionConfig + receiver.sendRpc(TapDetectionSettingsResponse( + yawResetDelay = config.yawResetDelay, + fullResetDelay = config.fullResetDelay, + mountingResetDelay = config.mountingResetDelay, + yawResetEnabled = config.yawResetEnabled, + fullResetEnabled = config.fullResetEnabled, + mountingResetEnabled = config.mountingResetEnabled, + yawResetTaps = config.yawResetTaps.toUByte(), + fullResetTaps = config.fullResetTaps.toUByte(), + mountingResetTaps = config.mountingResetTaps.toUByte(), + yawResetTracker = config.yawResetBodyPart, + fullResetTracker = config.fullResetBodyPart, + mountingResetTracker = config.mountingResetBodyPart, + numberTrackersOverThreshold = config.numberTrackersOverThreshold.toUByte(), + )) } - receiver.rpcDispatcher.on { req -> - println("req.setupMode " + req.setupMode) // TODO - } - - val oldConfig = settings.context.state.value.data.tapDetectionConfig + // Receive config receiver.rpcDispatcher.on { req -> + val oldConfig = settings.context.state.value.data.tapDetectionConfig settings.context.dispatch( SettingsActions.Update { copy( - tapDetectionConfig = TapDetectionConfig( + tapDetectionConfig = TapDetectionConfig ( yawResetDelay = req.yawResetDelay ?: oldConfig.yawResetDelay, fullResetDelay = req.fullResetDelay ?: oldConfig.fullResetDelay, mountingResetDelay = req.mountingResetDelay ?: oldConfig.mountingResetDelay, @@ -45,21 +60,10 @@ class TapDetectionBehaviour( }, ) } - } - private fun buildTapDetectionSettings(config: TapDetectionConfig): TapDetectionSettingsResponse = TapDetectionSettingsResponse( - yawResetDelay = config.yawResetDelay, - fullResetDelay = config.fullResetDelay, - mountingResetDelay = config.mountingResetDelay, - yawResetEnabled = config.yawResetEnabled, - fullResetEnabled = config.fullResetEnabled, - mountingResetEnabled = config.mountingResetEnabled, - yawResetTaps = config.yawResetTaps.toUByte(), - fullResetTaps = config.fullResetTaps.toUByte(), - mountingResetTaps = config.mountingResetTaps.toUByte(), - yawResetTracker = config.yawResetBodyPart, - fullResetTracker = config.fullResetBodyPart, - mountingResetTracker = config.mountingResetBodyPart, - numberTrackersOverThreshold = config.numberTrackersOverThreshold.toUByte(), - ) + // Setup Mode (tap to assign) + receiver.rpcDispatcher.on { req -> + tapDetectionManager.context.dispatch(TapDetectionActions.SetSetupMode(req.setupMode == true)) + } + } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt index 03bba277de..8b2022a06f 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt @@ -1,43 +1,70 @@ package dev.slimevr.solarxr +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions import dev.slimevr.config.VMCConfig -import dev.slimevr.vmc.VMCActions -import dev.slimevr.vmc.VMCManager import solarxr_protocol.rpc.ChangeVMCOSCSettingsRequest +import solarxr_protocol.rpc.ChangeVRMSettingsRequest import solarxr_protocol.rpc.VMCOSCSettingsRequest import solarxr_protocol.rpc.VMCOSCSettingsResponse +import solarxr_protocol.rpc.VRMSettingsRequest +import solarxr_protocol.rpc.VRMSettingsResponse class VmcBehaviour( - private val vmcManager: VMCManager, + private val settings: Settings, ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { + // Send VMC config receiver.rpcDispatcher.on { - receiver.sendRpc(buildVmcOscSettings(vmcManager.context.state.value.config)) + val config = settings.context.state.value.data.vmcConfig + receiver.sendRpc(VMCOSCSettingsResponse( + enabled = config.enabled, + portIn = config.portIn.toUShort(), + portOut = config.portOut.toUShort(), + address = config.address, + anchorHip = config.anchorAtHips, + mirrorTracking = config.mirrorTracking, + )) } - val oldConfig = vmcManager.context.state.value.config + // Send VRM json + receiver.rpcDispatcher.on { + val config = settings.context.state.value.data.vmcConfig + receiver.sendRpc(VRMSettingsResponse( + vrmJson = config.vrmJson, + )) + } + + // Receive VMC config receiver.rpcDispatcher.on { req -> - vmcManager.context.dispatch( - VMCActions.UpdateConfig( - VMCConfig( - enabled = req.enabled == true, - portIn = req.portIn?.toInt() ?: oldConfig.portIn, - portOut = req.portOut?.toInt() ?: oldConfig.portOut, - address = req.address ?: oldConfig.address, - mirrorTracking = req.mirrorTracking == true, - anchorAtHips = req.anchorHip == true, - ), - ), + val oldConfig = settings.context.state.value.data.vmcConfig + settings.context.dispatch( + SettingsActions.Update { + copy( + vmcConfig = VMCConfig ( + enabled = req.enabled == true, + portIn = req.portIn?.toInt() ?: oldConfig.portIn, + portOut = req.portOut?.toInt() ?: oldConfig.portOut, + address = req.address ?: oldConfig.address, + mirrorTracking = req.mirrorTracking == true, + anchorAtHips = req.anchorHip == true, + ) + ) + } ) } - } - private fun buildVmcOscSettings(config: VMCConfig): VMCOSCSettingsResponse = VMCOSCSettingsResponse( - enabled = config.enabled, - portIn = config.portIn.toUShort(), - portOut = config.portOut.toUShort(), - address = config.address, - anchorHip = config.anchorAtHips, - mirrorTracking = config.mirrorTracking, - ) + // Receive VRM json + receiver.rpcDispatcher.on { req -> + settings.context.dispatch( + SettingsActions.Update { + copy( + vmcConfig = VMCConfig ( + vrmJson = req.vrmJson, + ) + ) + } + ) + } + } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt index 5d5b9b6f22..6a700d5597 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt @@ -1,5 +1,8 @@ package dev.slimevr.solarxr +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions +import dev.slimevr.config.TapDetectionConfig import dev.slimevr.config.VRCOSCConfig import dev.slimevr.config.VRCOSCManualNetworkConfig import dev.slimevr.vrcosc.VRCOSCActions @@ -22,6 +25,7 @@ import solarxr_protocol.rpc.VRCOSCDiscoveredTarget as RpcVRCOSCDiscoveredTarget private const val STATUS_SAMPLE_MS = 300L internal class VrcOscBehaviour( + private val settings: Settings, private val vrcOscManager: VRCOSCManager, ) : SolarXRBridgeBehaviour { @OptIn(FlowPreview::class) @@ -34,32 +38,46 @@ internal class VrcOscBehaviour( .map { state -> state.status } .drop(1) .sample(STATUS_SAMPLE_MS) - .onEach { status -> receiver.sendRpc(buildStatusResponse(status, vrcOscManager.context.state.value.config.enabled)) } + .onEach { status -> receiver.sendRpc(buildStatusResponse(status, settings.context.state.value.data.vrcOscConfig.enabled)) } .launchIn(receiver.context.scope) receiver.rpcDispatcher.on { - receiver.sendRpc(buildVrcOscSettings(vrcOscManager.context.state.value.config)) + val config = settings.context.state.value.data.vrcOscConfig + receiver.sendRpc(VRCOSCSettingsResponse( + enabled = config.enabled, + manualNetwork = config.manualNetwork?.let { manual -> + VRCOSCNetworkSettings( + portIn = manual.portIn.toUShort(), + portOut = manual.portOut.toUShort(), + address = manual.address, + ) + }, + )) } receiver.rpcDispatcher.on { val state = vrcOscManager.context.state.value - receiver.sendRpc(buildStatusResponse(state.status, state.config.enabled)) + val config = settings.context.state.value.data.vrcOscConfig + receiver.sendRpc(buildStatusResponse(state.status, config.enabled)) } receiver.rpcDispatcher.on { req -> - vrcOscManager.context.dispatch( - VRCOSCActions.UpdateConfig( - VRCOSCConfig( - enabled = req.enabled == true, - manualNetwork = req.manualNetwork?.takeIf { it.portIn !== null && it.portOut !== null && it.address !== null }?.let { network -> - VRCOSCManualNetworkConfig( - portIn = network.portIn?.toInt() ?: error("portIn should be set"), - portOut = network.portOut?.toInt() ?: error("portOut should be set"), - address = network.address ?: error("address should be set"), - ) - }, - ), - ), + settings.context.dispatch( + SettingsActions.Update { + copy( + vrcOscConfig = VRCOSCConfig( + enabled = req.enabled == true, + manualNetwork = req.manualNetwork?.takeIf { it.portIn !== null && it.portOut !== null && it.address !== null } + ?.let { network -> + VRCOSCManualNetworkConfig( + portIn = network.portIn?.toInt() ?: error("portIn should be set"), + portOut = network.portOut?.toInt() ?: error("portOut should be set"), + address = network.address ?: error("address should be set"), + ) + }, + ), + ) + } ) } } @@ -83,15 +101,4 @@ internal class VrcOscBehaviour( RpcVRCOSCDiscoveredTarget(name = target.name, address = target.address, portOut = target.portOut.toUShort()) }, ) - - private fun buildVrcOscSettings(config: VRCOSCConfig): VRCOSCSettingsResponse = VRCOSCSettingsResponse( - enabled = config.enabled, - manualNetwork = config.manualNetwork?.let { manual -> - VRCOSCNetworkSettings( - portIn = manual.portIn.toUShort(), - portOut = manual.portOut.toUShort(), - address = manual.address, - ) - }, - ) } diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt new file mode 100644 index 0000000000..2c8f0d008c --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt @@ -0,0 +1,9 @@ +package dev.slimevr.tapdetection + +class TapDetectionBasicBehaviour : TapDetectionBehaviour { + override fun reduce(state: TapDetectionState, action: TapDetectionActions) = when (action) { + is TapDetectionActions.SetSetupMode -> { + state.copy(setupMode = action.setupMode) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/module.kt b/server/core/src/main/java/dev/slimevr/tapdetection/module.kt new file mode 100644 index 0000000000..6c56c1a84b --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/tapdetection/module.kt @@ -0,0 +1,37 @@ +package dev.slimevr.tapdetection + +import dev.slimevr.VRServer +import dev.slimevr.config.Settings +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context +import kotlinx.coroutines.CoroutineScope +import kotlin.collections.listOf + +data class TapDetectionState( + val setupMode: Boolean, +) + +sealed interface TapDetectionActions { + data class SetSetupMode(val setupMode: Boolean) : TapDetectionActions +} + +typealias TapDetectionContext = Context +typealias TapDetectionBehaviour = Behaviour + +class TapDetectionManager(val context: TapDetectionContext, val server: VRServer) { + fun startObserving() = context.observeAll(this) + + companion object { + fun create(server: VRServer, scope: CoroutineScope, settings: Settings): TapDetectionManager { + val context = Context.create( + initialState = TapDetectionState( + setupMode = false, + ), + scope = scope, + behaviours = listOf(TapDetectionBasicBehaviour()), + name = "TapDetectionManager", + ) + return TapDetectionManager(context, server) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index f5bb7b29c6..697ceb8750 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -36,7 +36,6 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { private val tapTimestamps = ArrayDeque() private var waitForLowAccel = false private var resetType: ResetType? = null - private var setupModeAssign = false private var tapsNeeded = 0 private var actionDelay = 0f private var numberTrackersOverThreshold = 0 @@ -66,7 +65,7 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { // Inner flow (process) is refreshed everytime this tracker's acceleration is updated .flatMapLatest { receiver.context.state - .filter { resetType != null || setupModeAssign } + .filter { resetType != null || false } // setupModeAssign .distinctUntilChangedBy { it.rawAcceleration } } .onEach { currentTracker -> @@ -87,7 +86,7 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { // If setupMode is true, double tap to assign // TODO setupMode if (false) { - setupModeAssign = true + //setupModeAssign = true resetType = null tapsNeeded = 2 actionDelay = 0f @@ -175,7 +174,7 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { // Taps completed! receiver.context.scope.safeLaunch { // If it's in setup mode, tap to assign - if (setupModeAssign) { + if (false) { //setupModeAssign receiver.appContext.server.sendSolarxrRpc( TapDetectionSetupNotification(currentTracker.id.toUShort()), ) diff --git a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt index 09bdc7bea7..e81bbfa7a9 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt @@ -2,7 +2,6 @@ package dev.slimevr.vmc import dev.slimevr.AppLogger import dev.slimevr.config.Settings -import dev.slimevr.config.SettingsActions import dev.slimevr.config.VMCConfig import dev.slimevr.context.Behaviour import dev.slimevr.osc.OscArg @@ -28,26 +27,15 @@ class VMCOutputBehaviour( ) : VMCBehaviourType { private val initTime = System.currentTimeMillis() - override fun reduce(state: VMCState, action: VMCActions) = when (action) { - is VMCActions.UpdateConfig -> state.copy(config = action.config) - } - override fun observe(receiver: VMCManager) { var sender: OscSender? = null var vrmGeometry: VrmGeometry? = null - receiver.context.state - .map { it.config } - .distinctUntilChanged() - .onEach { config -> - settings.context.dispatch(SettingsActions.Update { copy(vmcConfig = config) }) - }.launchIn(receiver.context.scope) - - receiver.context.state - .map { it.config.vrmJson } + settings.context.state + .map { it.data.vmcConfig.vrmJson } .distinctUntilChanged() - .onEach { json -> - vrmGeometry = json?.takeIf { value -> value.isNotEmpty() }?.let { value -> + .onEach { vrmJson -> + vrmGeometry = vrmJson?.takeIf { value -> value.isNotEmpty() }?.let { value -> try { buildVrmGeometry(VrmReader(value)) } catch (e: Exception) { @@ -57,8 +45,8 @@ class VMCOutputBehaviour( } }.launchIn(receiver.context.scope) - receiver.context.state - .map { Triple(it.config.enabled, it.config.portOut, it.config.address) } + settings.context.state + .map { Triple(it.data.vmcConfig.enabled, it.data.vmcConfig.portOut, it.data.vmcConfig.address) } .distinctUntilChanged() .onEach { (enabled, port, addr) -> sender?.close() @@ -79,7 +67,7 @@ class VMCOutputBehaviour( skeleton.computed .onEach { bones -> val s = sender ?: return@onEach - val config = receiver.context.state.value.config + val config = settings.context.state.value.data.vmcConfig val currentTime = System.currentTimeMillis() val vrm = vrmGeometry receiver.context.scope.safeLaunch { @@ -92,7 +80,9 @@ class VMCOutputBehaviour( }.launchIn(receiver.context.scope) } - private fun buildBundle(bones: Map, config: VMCConfig, currentTime: Long, vrm: VrmGeometry?): OscBundle = OscBundle(1L, buildMessages(bones, config, currentTime, vrm).map { msg -> OscContent.Message(msg) }.toList()) + private fun buildBundle(bones: Map, config: VMCConfig, currentTime: Long, vrm: VrmGeometry?): OscBundle { + return OscBundle(1L, buildMessages(bones, config, currentTime, vrm).map { msg -> OscContent.Message(msg) }.toList()) + } private fun transformMessage(address: String, name: String, pos: Vector3, rot: Quaternion): OscMessage = OscMessage( address, @@ -152,21 +142,17 @@ class VMCOutputBehaviour( } } -class VMCInputBehaviour : Behaviour { - override fun reduce(state: VMCState, action: VMCActions) = when (action) { - is VMCActions.UpdateConfig -> state.copy(config = action.config) - } - +class VMCInputBehaviour(private val settings: Settings) : Behaviour { override fun observe(receiver: VMCManager) { var oscReceiver: OscReceiver? = null - receiver.context.state - .map { it.config.portIn } + settings.context.state + .map { it.data.vmcConfig.portIn } .distinctUntilChanged() .onEach { portIn -> oscReceiver?.close() oscReceiver = null - if (receiver.context.state.value.config.enabled) { + if (settings.context.state.value.data.vmcConfig.enabled) { oscReceiver = OscReceiver(portIn) AppLogger.vmc.info("VMC input listening on port $portIn") receiver.context.scope.safeLaunch { diff --git a/server/core/src/main/java/dev/slimevr/vmc/module.kt b/server/core/src/main/java/dev/slimevr/vmc/module.kt index c583f42381..7350609091 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/module.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/module.kt @@ -1,16 +1,14 @@ package dev.slimevr.vmc import dev.slimevr.Phase1ContextProvider -import dev.slimevr.config.VMCConfig import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.skeleton.Skeleton import kotlinx.coroutines.CoroutineScope -data class VMCState(val config: VMCConfig = VMCConfig()) +object VMCState sealed interface VMCActions { - data class UpdateConfig(val config: VMCConfig) : VMCActions } typealias VMCContext = Context @@ -20,14 +18,14 @@ class VMCManager(val context: VMCContext) { fun startObserving() = context.observeAll(this) companion object { - fun create(skeleton: Skeleton, ctx: Phase1ContextProvider, scope: CoroutineScope): VMCManager { + fun create(ctx: Phase1ContextProvider, skeleton: Skeleton, scope: CoroutineScope): VMCManager { val settings = ctx.config.settings val context = Context.create( - initialState = VMCState(config = settings.context.state.value.data.vmcConfig), + initialState = VMCState, scope = scope, behaviours = listOf( VMCOutputBehaviour(skeleton, settings), - VMCInputBehaviour(), + VMCInputBehaviour(settings), ), name = "VMC", ) diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt index 15c11a398e..e150a5aa4e 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt @@ -132,8 +132,8 @@ class VRCOSCInputBehaviour( val registry = VRSystemTrackerRegistry(appContext, receiver) var oscReceiver: OscReceiver? = null - receiver.context.state - .map { state -> Pair(state.config.enabled, vrcOscPortIn(state.config)) } + receiver.settings.context.state + .map { state -> Pair(state.data.vrcOscConfig.enabled, vrcOscPortIn(state.data.vrcOscConfig)) } .distinctUntilChanged() .onEach { (enabled, portIn) -> oscReceiver?.close() diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt index d687ec8905..f302324749 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt @@ -3,6 +3,7 @@ package dev.slimevr.vrcosc import dev.slimevr.AppContextProvider import dev.slimevr.EventDispatcher import dev.slimevr.Phase1ContextProvider +import dev.slimevr.config.Settings import dev.slimevr.config.VRCOSCConfig import dev.slimevr.context.Behaviour import dev.slimevr.context.Context @@ -50,7 +51,6 @@ data class VRCOSCStatus( ) data class VRCOSCState( - val config: VRCOSCConfig = VRCOSCConfig(), val status: VRCOSCStatus = VRCOSCStatus(), ) @@ -61,8 +61,6 @@ enum class VRSystemTracker { } sealed interface VRCOSCActions { - data class UpdateConfig(val config: VRCOSCConfig) : VRCOSCActions - data class SetInput( val state: VRCOSCInputState, val port: Int? = null, @@ -97,12 +95,12 @@ typealias VRCOSCBehaviour = Behaviour class VRCOSCManager( val context: VRCOSCContext, val oscQueryAddress: String, + val settings: Settings, ) { val events: EventDispatcher = EventDispatcher() fun startObserving(appContext: AppContextProvider) { val behaviours = listOf( - VRCOSCSettingsBehaviour(appContext.config.settings), VRCOSCOutputBehaviour(appContext.skeleton), VRCOSCInputBehaviour(appContext), VRCOSCOscQueryBehaviour(localIp = oscQueryAddress), @@ -125,17 +123,15 @@ class VRCOSCManager( oscQueryAddress: String, ): VRCOSCManager { val settings = ctx.config.settings - val initialConfig = settings.context.state.value.data.vrcOscConfig val context = Context.create( initialState = VRCOSCState( - config = initialConfig, status = VRCOSCStatus(), ), behaviours = emptyList(), scope = scope, name = "VRCOSC", ) - return VRCOSCManager(context, oscQueryAddress) + return VRCOSCManager(context, oscQueryAddress, settings) } } } diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt b/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt index 2bd272b2ce..9cc1bfb528 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt @@ -47,8 +47,8 @@ class VRCOSCOscQueryBehaviour( override fun observe(receiver: VRCOSCManager) { val runtime = OscQueryRuntime(localIp) - receiver.context.state - .map { state -> Triple(state.config.enabled, state.config.manualNetwork == null, vrcOscPortIn(state.config)) } + receiver.settings.context.state + .map { state -> Triple(state.data.vrcOscConfig.enabled, state.data.vrcOscConfig.manualNetwork == null, vrcOscPortIn(state.data.vrcOscConfig)) } .distinctUntilChanged() .onEach { (enabled, automatic, portIn) -> if (!enabled || !automatic) { diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt index 8a4f4e1d16..86a7c27b09 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt @@ -53,7 +53,7 @@ class VRCOSCOutputBehaviour( } private fun observeTargetChanges(receiver: VRCOSCManager, runtime: OutputRuntime) { - val configFlow = receiver.context.state.map { state -> state.config }.distinctUntilChanged() + val configFlow = receiver.settings.context.state.map { it.data.vrcOscConfig }.distinctUntilChanged() val discoveredFlow = receiver.context.state.map { state -> state.status.discoveredTargets }.distinctUntilChanged() configFlow.combine(discoveredFlow) { config, discoveredTargets -> @@ -142,12 +142,13 @@ class VRCOSCOutputBehaviour( ) { val sender = runtime.sender ?: return val state = receiver.context.state.value - if (!state.config.enabled) return + val vrcOscConfig = receiver.settings.context.state.value.data.vrcOscConfig + if (!vrcOscConfig.enabled) return val now = System.currentTimeMillis() if (runtime.frameSendFailureActive && now < runtime.nextFrameRetryAt) return - val bundle = buildOutgoingBundle(bones, state.config) ?: return + val bundle = buildOutgoingBundle(bones, vrcOscConfig) ?: return try { sender.send(bundle) diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/settings.kt b/server/core/src/main/java/dev/slimevr/vrcosc/settings.kt deleted file mode 100644 index 26d9f24836..0000000000 --- a/server/core/src/main/java/dev/slimevr/vrcosc/settings.kt +++ /dev/null @@ -1,26 +0,0 @@ -package dev.slimevr.vrcosc - -import dev.slimevr.config.Settings -import dev.slimevr.config.SettingsActions -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach - -class VRCOSCSettingsBehaviour( - private val settings: Settings, -) : VRCOSCBehaviour { - override fun reduce(state: VRCOSCState, action: VRCOSCActions) = when (action) { - is VRCOSCActions.UpdateConfig -> state.copy(config = action.config) - else -> state - } - - override fun observe(receiver: VRCOSCManager) { - receiver.context.state - .map { state -> state.config } - .distinctUntilChanged() - .onEach { config -> - settings.context.dispatch(SettingsActions.Update { copy(vrcOscConfig = config) }) - }.launchIn(receiver.context.scope) - } -} 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 8f89f3e6b5..e29b9d8589 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -30,6 +30,7 @@ import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resets.ResetsManager import dev.slimevr.resolveConfigDirectory import dev.slimevr.skeleton.Skeleton +import dev.slimevr.tapdetection.TapDetectionManager import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer import dev.slimevr.util.safeLaunch @@ -86,13 +87,14 @@ fun main(args: Array) = runBlocking { val trackingChecklist = TrackingChecklist.create(scope = this) val udpServer = UdpServer.create(scope = this, addressResolver = ::resolveDesktopUdpAddress) val bvhManager = BVHManager.create(skeleton = skeleton, storage = storage, scope = this) - val vmcManager = VMCManager.create(skeleton = skeleton, ctx = phase1, scope = this) + val vmcManager = VMCManager.create(ctx = phase1, skeleton = skeleton, scope = this) val vrcOscManager = VRCOSCManager.create( ctx = phase1, scope = this, oscQueryAddress = resolveDesktopOscQueryAddress(), ) - val resetsManager = ResetsManager.create(server = server, scope = this) + val resetsManager = ResetsManager.create(server = server, scope = this, settings = config.settings) + val tapDetectionManager = TapDetectionManager.create(server = server, scope = this, settings = config.settings) val appContext = AppContext( server = server, @@ -111,6 +113,7 @@ fun main(args: Array) = runBlocking { vmcManager = vmcManager, vrcOscManager = vrcOscManager, resetsManager = resetsManager, + tapDetectionManager = tapDetectionManager, ) try { From a894b499218e09d7ee70d0e3fdc1c4ddd5465cca Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 2 Jul 2026 21:58:53 -0400 Subject: [PATCH 219/350] spotless --- .../dev/slimevr/android/ForegroundService.kt | 2 +- .../java/dev/slimevr/resets/behaviours.kt | 1 - .../main/java/dev/slimevr/resets/module.kt | 2 +- .../main/java/dev/slimevr/solarxr/resets.kt | 22 ++++++----- .../java/dev/slimevr/solarxr/tapdetection.kt | 34 +++++++++-------- .../src/main/java/dev/slimevr/solarxr/vmc.kt | 38 ++++++++++--------- .../main/java/dev/slimevr/solarxr/vrcosc.kt | 24 ++++++------ .../dev/slimevr/tapdetection/behaviours.kt | 10 ++--- .../java/dev/slimevr/tapdetection/module.kt | 32 ++++++++-------- .../java/dev/slimevr/tracker/behaviours.kt | 4 +- .../main/java/dev/slimevr/vmc/behaviours.kt | 4 +- .../src/main/java/dev/slimevr/vmc/module.kt | 3 +- 12 files changed, 91 insertions(+), 85 deletions(-) diff --git a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt index 32e4e90d26..ed3e70057e 100644 --- a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt +++ b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt @@ -152,7 +152,7 @@ class ForegroundService : Service() { scope = scope, oscQueryAddress = resolveAndroidOscQueryAddress(), ) - val resetsManager = ResetsManager.create(server = server, scope = scope, settings = config.settings) + val resetsManager = ResetsManager.create(server = server, scope = scope, settings = config.settings) val tapDetectionManager = TapDetectionManager.create(server = server, scope = this, settings = config.settings) val appContext = AppContext( diff --git a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt index 3f2420ae71..6ea245a39c 100644 --- a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt @@ -29,7 +29,6 @@ class ResetsBasicBehaviour : ResetsBehaviour { } } } - } class ResetsMountingTimeoutBehaviour : ResetsBehaviour { diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index 7366beb63a..2018c76b1b 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -75,7 +75,7 @@ class ResetsManager(val context: ResetsContext, val server: VRServer, val settin executeTrackerResets(resetType, bodyParts, settings.context.state.value.data.resetsConfig) - settings.context.dispatch(SettingsActions.Update {copy(resetsConfig = resetsConfig.copy(lastMountingMethod = MountingMethods.MANUAL))}) + settings.context.dispatch(SettingsActions.Update { copy(resetsConfig = resetsConfig.copy(lastMountingMethod = MountingMethods.MANUAL)) }) AppLogger.resets.info("${resetType.name} Reset from $resetSourceName") diff --git a/server/core/src/main/java/dev/slimevr/solarxr/resets.kt b/server/core/src/main/java/dev/slimevr/solarxr/resets.kt index 44d9ad7c69..6b63ef5de2 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/resets.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/resets.kt @@ -14,13 +14,15 @@ class ResetsBehaviour( // Send config receiver.rpcDispatcher.on { val config = settings.context.state.value.data.resetsConfig - receiver.sendRpc(ResetsSettingsResponse( - resetMountingFeet = config.resetMountingFeet, - armsResetMode = config.armsResetMode, - yawResetSmoothTime = config.yawResetSmoothTime, - saveMountingReset = config.saveMountingReset, - resetHmdPitch = config.resetHmdPitch, - )) + receiver.sendRpc( + ResetsSettingsResponse( + resetMountingFeet = config.resetMountingFeet, + armsResetMode = config.armsResetMode, + yawResetSmoothTime = config.yawResetSmoothTime, + saveMountingReset = config.saveMountingReset, + resetHmdPitch = config.resetHmdPitch, + ), + ) } // Receive config @@ -29,15 +31,15 @@ class ResetsBehaviour( settings.context.dispatch( SettingsActions.Update { copy( - resetsConfig = ResetsConfig ( + resetsConfig = ResetsConfig( resetMountingFeet = req.resetMountingFeet == true, armsResetMode = req.armsResetMode ?: oldConfig.armsResetMode, yawResetSmoothTime = req.yawResetSmoothTime ?: oldConfig.yawResetSmoothTime, saveMountingReset = req.saveMountingReset == true, resetHmdPitch = req.resetHmdPitch == true, - ) + ), ) - } + }, ) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt index 0a1c6bb3cf..faf9799a2f 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt @@ -18,21 +18,23 @@ class TapDetectionBehaviour( // Send config receiver.rpcDispatcher.on { val config = settings.context.state.value.data.tapDetectionConfig - receiver.sendRpc(TapDetectionSettingsResponse( - yawResetDelay = config.yawResetDelay, - fullResetDelay = config.fullResetDelay, - mountingResetDelay = config.mountingResetDelay, - yawResetEnabled = config.yawResetEnabled, - fullResetEnabled = config.fullResetEnabled, - mountingResetEnabled = config.mountingResetEnabled, - yawResetTaps = config.yawResetTaps.toUByte(), - fullResetTaps = config.fullResetTaps.toUByte(), - mountingResetTaps = config.mountingResetTaps.toUByte(), - yawResetTracker = config.yawResetBodyPart, - fullResetTracker = config.fullResetBodyPart, - mountingResetTracker = config.mountingResetBodyPart, - numberTrackersOverThreshold = config.numberTrackersOverThreshold.toUByte(), - )) + receiver.sendRpc( + TapDetectionSettingsResponse( + yawResetDelay = config.yawResetDelay, + fullResetDelay = config.fullResetDelay, + mountingResetDelay = config.mountingResetDelay, + yawResetEnabled = config.yawResetEnabled, + fullResetEnabled = config.fullResetEnabled, + mountingResetEnabled = config.mountingResetEnabled, + yawResetTaps = config.yawResetTaps.toUByte(), + fullResetTaps = config.fullResetTaps.toUByte(), + mountingResetTaps = config.mountingResetTaps.toUByte(), + yawResetTracker = config.yawResetBodyPart, + fullResetTracker = config.fullResetBodyPart, + mountingResetTracker = config.mountingResetBodyPart, + numberTrackersOverThreshold = config.numberTrackersOverThreshold.toUByte(), + ), + ) } // Receive config @@ -41,7 +43,7 @@ class TapDetectionBehaviour( settings.context.dispatch( SettingsActions.Update { copy( - tapDetectionConfig = TapDetectionConfig ( + tapDetectionConfig = TapDetectionConfig( yawResetDelay = req.yawResetDelay ?: oldConfig.yawResetDelay, fullResetDelay = req.fullResetDelay ?: oldConfig.fullResetDelay, mountingResetDelay = req.mountingResetDelay ?: oldConfig.mountingResetDelay, diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt index 8b2022a06f..e899d06dc9 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt @@ -17,22 +17,26 @@ class VmcBehaviour( // Send VMC config receiver.rpcDispatcher.on { val config = settings.context.state.value.data.vmcConfig - receiver.sendRpc(VMCOSCSettingsResponse( - enabled = config.enabled, - portIn = config.portIn.toUShort(), - portOut = config.portOut.toUShort(), - address = config.address, - anchorHip = config.anchorAtHips, - mirrorTracking = config.mirrorTracking, - )) + receiver.sendRpc( + VMCOSCSettingsResponse( + enabled = config.enabled, + portIn = config.portIn.toUShort(), + portOut = config.portOut.toUShort(), + address = config.address, + anchorHip = config.anchorAtHips, + mirrorTracking = config.mirrorTracking, + ), + ) } // Send VRM json receiver.rpcDispatcher.on { val config = settings.context.state.value.data.vmcConfig - receiver.sendRpc(VRMSettingsResponse( - vrmJson = config.vrmJson, - )) + receiver.sendRpc( + VRMSettingsResponse( + vrmJson = config.vrmJson, + ), + ) } // Receive VMC config @@ -41,16 +45,16 @@ class VmcBehaviour( settings.context.dispatch( SettingsActions.Update { copy( - vmcConfig = VMCConfig ( + vmcConfig = VMCConfig( enabled = req.enabled == true, portIn = req.portIn?.toInt() ?: oldConfig.portIn, portOut = req.portOut?.toInt() ?: oldConfig.portOut, address = req.address ?: oldConfig.address, mirrorTracking = req.mirrorTracking == true, anchorAtHips = req.anchorHip == true, - ) + ), ) - } + }, ) } @@ -59,11 +63,11 @@ class VmcBehaviour( settings.context.dispatch( SettingsActions.Update { copy( - vmcConfig = VMCConfig ( + vmcConfig = VMCConfig( vrmJson = req.vrmJson, - ) + ), ) - } + }, ) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt index 6a700d5597..360999afb0 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt @@ -43,16 +43,18 @@ internal class VrcOscBehaviour( receiver.rpcDispatcher.on { val config = settings.context.state.value.data.vrcOscConfig - receiver.sendRpc(VRCOSCSettingsResponse( - enabled = config.enabled, - manualNetwork = config.manualNetwork?.let { manual -> - VRCOSCNetworkSettings( - portIn = manual.portIn.toUShort(), - portOut = manual.portOut.toUShort(), - address = manual.address, - ) - }, - )) + receiver.sendRpc( + VRCOSCSettingsResponse( + enabled = config.enabled, + manualNetwork = config.manualNetwork?.let { manual -> + VRCOSCNetworkSettings( + portIn = manual.portIn.toUShort(), + portOut = manual.portOut.toUShort(), + address = manual.address, + ) + }, + ), + ) } receiver.rpcDispatcher.on { @@ -77,7 +79,7 @@ internal class VrcOscBehaviour( }, ), ) - } + }, ) } } diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt index 2c8f0d008c..5b72589048 100644 --- a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt @@ -1,9 +1,9 @@ package dev.slimevr.tapdetection class TapDetectionBasicBehaviour : TapDetectionBehaviour { - override fun reduce(state: TapDetectionState, action: TapDetectionActions) = when (action) { - is TapDetectionActions.SetSetupMode -> { - state.copy(setupMode = action.setupMode) - } - } + override fun reduce(state: TapDetectionState, action: TapDetectionActions) = when (action) { + is TapDetectionActions.SetSetupMode -> { + state.copy(setupMode = action.setupMode) + } + } } diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/module.kt b/server/core/src/main/java/dev/slimevr/tapdetection/module.kt index 6c56c1a84b..f4fc2c3433 100644 --- a/server/core/src/main/java/dev/slimevr/tapdetection/module.kt +++ b/server/core/src/main/java/dev/slimevr/tapdetection/module.kt @@ -8,30 +8,30 @@ import kotlinx.coroutines.CoroutineScope import kotlin.collections.listOf data class TapDetectionState( - val setupMode: Boolean, + val setupMode: Boolean, ) sealed interface TapDetectionActions { - data class SetSetupMode(val setupMode: Boolean) : TapDetectionActions + data class SetSetupMode(val setupMode: Boolean) : TapDetectionActions } typealias TapDetectionContext = Context typealias TapDetectionBehaviour = Behaviour class TapDetectionManager(val context: TapDetectionContext, val server: VRServer) { - fun startObserving() = context.observeAll(this) + fun startObserving() = context.observeAll(this) - companion object { - fun create(server: VRServer, scope: CoroutineScope, settings: Settings): TapDetectionManager { - val context = Context.create( - initialState = TapDetectionState( - setupMode = false, - ), - scope = scope, - behaviours = listOf(TapDetectionBasicBehaviour()), - name = "TapDetectionManager", - ) - return TapDetectionManager(context, server) - } - } + companion object { + fun create(server: VRServer, scope: CoroutineScope, settings: Settings): TapDetectionManager { + val context = Context.create( + initialState = TapDetectionState( + setupMode = false, + ), + scope = scope, + behaviours = listOf(TapDetectionBasicBehaviour()), + name = "TapDetectionManager", + ) + return TapDetectionManager(context, server) + } + } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 697ceb8750..5005d50ac0 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -86,7 +86,7 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { // If setupMode is true, double tap to assign // TODO setupMode if (false) { - //setupModeAssign = true + // setupModeAssign = true resetType = null tapsNeeded = 2 actionDelay = 0f @@ -174,7 +174,7 @@ class TrackerTapDetectionBehaviour : TrackerBehaviour { // Taps completed! receiver.context.scope.safeLaunch { // If it's in setup mode, tap to assign - if (false) { //setupModeAssign + if (false) { // setupModeAssign receiver.appContext.server.sendSolarxrRpc( TapDetectionSetupNotification(currentTracker.id.toUShort()), ) diff --git a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt index e81bbfa7a9..9ec4aeb9dc 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt @@ -80,9 +80,7 @@ class VMCOutputBehaviour( }.launchIn(receiver.context.scope) } - private fun buildBundle(bones: Map, config: VMCConfig, currentTime: Long, vrm: VrmGeometry?): OscBundle { - return OscBundle(1L, buildMessages(bones, config, currentTime, vrm).map { msg -> OscContent.Message(msg) }.toList()) - } + private fun buildBundle(bones: Map, config: VMCConfig, currentTime: Long, vrm: VrmGeometry?): OscBundle = OscBundle(1L, buildMessages(bones, config, currentTime, vrm).map { msg -> OscContent.Message(msg) }.toList()) private fun transformMessage(address: String, name: String, pos: Vector3, rot: Quaternion): OscMessage = OscMessage( address, diff --git a/server/core/src/main/java/dev/slimevr/vmc/module.kt b/server/core/src/main/java/dev/slimevr/vmc/module.kt index 7350609091..a9f2fcaf46 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/module.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/module.kt @@ -8,8 +8,7 @@ import kotlinx.coroutines.CoroutineScope object VMCState -sealed interface VMCActions { -} +sealed interface VMCActions typealias VMCContext = Context typealias VMCBehaviourType = Behaviour From 3afd7375c2a44099954dfc14526525cc11b17a4d Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 3 Jul 2026 21:14:06 -0400 Subject: [PATCH 220/350] Rewrite TapDetection into its own module --- .../dev/slimevr/android/ForegroundService.kt | 4 +- .../main/java/dev/slimevr/resets/module.kt | 5 +- .../dev/slimevr/tapdetection/behaviours.kt | 191 ++++++++++++++++++ .../java/dev/slimevr/tapdetection/module.kt | 8 +- .../java/dev/slimevr/tracker/behaviours.kt | 182 ----------------- .../main/java/dev/slimevr/tracker/module.kt | 1 - .../src/main/java/dev/slimevr/desktop/Main.kt | 4 +- 7 files changed, 203 insertions(+), 192 deletions(-) diff --git a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt index ed3e70057e..8f98dd5aa6 100644 --- a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt +++ b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt @@ -152,8 +152,8 @@ class ForegroundService : Service() { scope = scope, oscQueryAddress = resolveAndroidOscQueryAddress(), ) - val resetsManager = ResetsManager.create(server = server, scope = scope, settings = config.settings) - val tapDetectionManager = TapDetectionManager.create(server = server, scope = this, settings = config.settings) + val resetsManager = ResetsManager.create(ctx = phase1, scope = this) + val tapDetectionManager = TapDetectionManager.create(ctx = phase1, resetsManager = resetsManager, scope = this) val appContext = AppContext( server = server, diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index 2018c76b1b..bcd79aa855 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -1,6 +1,7 @@ package dev.slimevr.resets import dev.slimevr.AppLogger +import dev.slimevr.Phase1ContextProvider import dev.slimevr.VRServer import dev.slimevr.config.MountingMethods import dev.slimevr.config.ResetsConfig @@ -121,7 +122,7 @@ class ResetsManager(val context: ResetsContext, val server: VRServer, val settin } companion object { - fun create(server: VRServer, scope: CoroutineScope, settings: Settings): ResetsManager { + fun create(ctx: Phase1ContextProvider, scope: CoroutineScope): ResetsManager { val context = Context.create( initialState = ResetsState( canDoYawReset = false, @@ -132,7 +133,7 @@ class ResetsManager(val context: ResetsContext, val server: VRServer, val settin behaviours = listOf(ResetsBasicBehaviour(), ResetsMountingTimeoutBehaviour()), name = "ResetsManager", ) - return ResetsManager(context, server, settings) + return ResetsManager(context, ctx.server, ctx.config.settings) } } } diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt index 5b72589048..0bb4f20d06 100644 --- a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt @@ -1,6 +1,197 @@ package dev.slimevr.tapdetection +import dev.slimevr.config.TapDetectionConfig +import dev.slimevr.tracker.Tracker +import dev.slimevr.util.safeLaunch +import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.onEach +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.rpc.ResetType +import solarxr_protocol.rpc.TapDetectionSetupNotification + +private const val NS_CONVERTER = 1.0e9f +private const val CLUMP_TIME_NS = 0.06f * NS_CONVERTER +private const val NEEDED_ACCEL_DELTA = 6.0f +private const val ALLOWED_BODY_ACCEL = 2.5f +private const val ALLOWED_BODY_ACCEL_SQUARED = ALLOWED_BODY_ACCEL * ALLOWED_BODY_ACCEL +private const val TAP_WINDOW_PER_TAP_NS = 0.3f * NS_CONVERTER + class TapDetectionBasicBehaviour : TapDetectionBehaviour { + data class TrackerTapDetectionState( + var trackerId: Int, + var numberTrackersOverThreshold: Int = 0, + var resetType: ResetType? = null, + var tapsNeeded: Int = 2, + var actionDelay: Float = 0f, + val accelList: ArrayDeque> = ArrayDeque(), + val tapTimestamps: ArrayDeque = ArrayDeque(), + var waitForLowAccel: Boolean = false, + ) + + @OptIn(ExperimentalCoroutinesApi::class) + override fun observe(receiver: TapDetectionManager) { + val tapConfigFlow = receiver.settings.context.state + .map { it.data.tapDetectionConfig } + .distinctUntilChanged() + val setupModeFlow = receiver.context.state + .map { it.setupMode } + .distinctUntilChanged() + val okTrackersFlow = receiver.server.context.state + .map { it.trackers.values } + .flatMapLatest { trackers -> + combine(trackers.map { it.context.state }) { states -> + states.map { it.bodyPart to it.status } + } + .distinctUntilChanged() + .map { trackers.filter { tracker -> tracker.context.state.value.status == TrackerStatus.OK } } + } + + // Outer flow is refreshed whenever TapDetection config, setupMode, or a tracker's bodyPart or status changes + combine( + tapConfigFlow, + setupModeFlow, + okTrackersFlow, + ::Triple, + ) + .flatMapLatest { (tapDetectionConfig, setupMode, trackers) -> + // Computed once per outer-flow refresh for all trackers + val trackersBodyParts = trackers.map { it.context.state.value.bodyPart }.toSet() + val yawResetBodyPart = listOf(tapDetectionConfig.yawResetBodyPart, BodyPart.UPPER_CHEST, BodyPart.CHEST, BodyPart.HIP, BodyPart.WAIST) + .firstOrNull { it in trackersBodyParts } ?: BodyPart.UPPER_CHEST + val fullResetBodyPart = listOf(tapDetectionConfig.fullResetBodyPart, BodyPart.LEFT_UPPER_LEG, BodyPart.LEFT_LOWER_LEG) + .firstOrNull { it in trackersBodyParts } ?: BodyPart.LEFT_UPPER_LEG + val mountingResetBodyPart = listOf(tapDetectionConfig.mountingResetBodyPart, BodyPart.RIGHT_UPPER_LEG, BodyPart.RIGHT_LOWER_LEG) + .firstOrNull { it in trackersBodyParts } ?: BodyPart.RIGHT_UPPER_LEG + + trackers.map { tracker -> + val trackerTapDetectionState = createTrackerTapDetectionState(tapDetectionConfig, setupMode, tracker, yawResetBodyPart, fullResetBodyPart, mountingResetBodyPart) + + // Inner flow emits whenever a tracker's rawAcceleration is updated + tracker.context.state + .filter { trackerTapDetectionState.resetType != null || setupMode } + .map { it.rawAcceleration } + .distinctUntilChanged() + .onEach { processTapDetection(receiver, setupMode, trackerTapDetectionState, it) } + }.merge() + } + .launchIn(receiver.context.scope) + } + + // Loads TapDetection config + private fun createTrackerTapDetectionState( + tapDetectionConfig: TapDetectionConfig, + setupMode: Boolean, + tracker: Tracker, + yawResetBodyPart: BodyPart, + fullResetBodyPart: BodyPart, + mountingResetBodyPart: BodyPart, + ): TrackerTapDetectionState { + // This holds a tracker's config and state for tap detection + val trackerTapDetectionState = TrackerTapDetectionState(tracker.context.state.value.id) + trackerTapDetectionState.numberTrackersOverThreshold = tapDetectionConfig.numberTrackersOverThreshold + + // setupMode uses defaults + if (!setupMode) { + when (tracker.context.state.value.bodyPart) { + yawResetBodyPart if tapDetectionConfig.yawResetEnabled -> { + trackerTapDetectionState.resetType = ResetType.YAW + trackerTapDetectionState.tapsNeeded = tapDetectionConfig.yawResetTaps + trackerTapDetectionState.actionDelay = tapDetectionConfig.yawResetDelay + } + + fullResetBodyPart if tapDetectionConfig.fullResetEnabled -> { + trackerTapDetectionState.resetType = ResetType.FULL + trackerTapDetectionState.tapsNeeded = tapDetectionConfig.fullResetTaps + trackerTapDetectionState.actionDelay = tapDetectionConfig.fullResetDelay + } + + mountingResetBodyPart if tapDetectionConfig.mountingResetEnabled -> { + trackerTapDetectionState.resetType = ResetType.MOUNTING + trackerTapDetectionState.tapsNeeded = tapDetectionConfig.mountingResetTaps + trackerTapDetectionState.actionDelay = tapDetectionConfig.mountingResetDelay + } + + else -> {} + } + } + + return trackerTapDetectionState + } + + // Logic loop for tap detection + private fun processTapDetection( + receiver: TapDetectionManager, + setupMode: Boolean, + state: TrackerTapDetectionState, + trackerAcceleration: Vector3, + ) { + val now = System.nanoTime() + + // Get the acceleration of the tracker and store it + state.accelList.add(trackerAcceleration.len() to now) + + // Remove old stored accelerations (if they are too old) + while (state.accelList.isNotEmpty() && now - state.accelList.first().second > CLUMP_TIME_NS) { + state.accelList.removeFirst() + } + + val max = state.accelList.maxOfOrNull { it.first } ?: 0f + val min = state.accelList.minOfOrNull { it.first } ?: 0f + val accelDelta = max - min + + // Check for a single tap + if (accelDelta > NEEDED_ACCEL_DELTA && !state.waitForLowAccel) { + val othersOverThreshold = receiver.server.context.state.value.trackers.values + .count { it.context.state.value.id != state.trackerId && it.context.state.value.rawAcceleration.lenSq() > ALLOWED_BODY_ACCEL_SQUARED } + if (othersOverThreshold < state.numberTrackersOverThreshold) { + state.tapTimestamps.add(now) + // After a tap, a lower acceleration is needed before another one + state.waitForLowAccel = true + } + } + + // Achieved low accel? + if (max < ALLOWED_BODY_ACCEL) state.waitForLowAccel = false + + if (state.tapTimestamps.isNotEmpty()) { + // Remove old stored taps (if they are too old) + val totalWindowNs = (TAP_WINDOW_PER_TAP_NS * state.tapTimestamps.size).toLong() + while (state.tapTimestamps.isNotEmpty() && now - state.tapTimestamps.first() > totalWindowNs) { + state.tapTimestamps.removeFirst() + } + + if (state.tapTimestamps.size >= state.tapsNeeded) { + // Taps completed! + receiver.context.scope.safeLaunch { + // If it's in setup mode, tap to assign + if (setupMode) { + receiver.server.sendSolarxrRpc( + TapDetectionSetupNotification(state.trackerId.toUShort()), + ) + } + + // If it has a reset to execute + state.resetType?.let { reset -> + receiver.resetsManager.scheduleReset("TapDetection", reset, state.actionDelay) + } + } + + state.accelList.clear() + state.tapTimestamps.clear() + state.waitForLowAccel = false + } + } + } + override fun reduce(state: TapDetectionState, action: TapDetectionActions) = when (action) { is TapDetectionActions.SetSetupMode -> { state.copy(setupMode = action.setupMode) diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/module.kt b/server/core/src/main/java/dev/slimevr/tapdetection/module.kt index f4fc2c3433..fde7adcb54 100644 --- a/server/core/src/main/java/dev/slimevr/tapdetection/module.kt +++ b/server/core/src/main/java/dev/slimevr/tapdetection/module.kt @@ -1,9 +1,11 @@ package dev.slimevr.tapdetection +import dev.slimevr.Phase1ContextProvider import dev.slimevr.VRServer import dev.slimevr.config.Settings import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.resets.ResetsManager import kotlinx.coroutines.CoroutineScope import kotlin.collections.listOf @@ -18,11 +20,11 @@ sealed interface TapDetectionActions { typealias TapDetectionContext = Context typealias TapDetectionBehaviour = Behaviour -class TapDetectionManager(val context: TapDetectionContext, val server: VRServer) { +class TapDetectionManager(val context: TapDetectionContext, val server: VRServer, val resetsManager: ResetsManager, val settings: Settings) { fun startObserving() = context.observeAll(this) companion object { - fun create(server: VRServer, scope: CoroutineScope, settings: Settings): TapDetectionManager { + fun create(ctx: Phase1ContextProvider, resetsManager: ResetsManager, scope: CoroutineScope): TapDetectionManager { val context = Context.create( initialState = TapDetectionState( setupMode = false, @@ -31,7 +33,7 @@ class TapDetectionManager(val context: TapDetectionContext, val server: VRServer behaviours = listOf(TapDetectionBasicBehaviour()), name = "TapDetectionManager", ) - return TapDetectionManager(context, server) + return TapDetectionManager(context, ctx.server, resetsManager, ctx.config.settings) } } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 5005d50ac0..22380f293a 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -1,203 +1,21 @@ package dev.slimevr.tracker -import dev.slimevr.VRServerState -import dev.slimevr.config.SettingsState import dev.slimevr.skeleton.SkeletonActions import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive -import solarxr_protocol.datatypes.BodyPart -import solarxr_protocol.rpc.ResetType -import solarxr_protocol.rpc.TapDetectionSetupNotification import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch import kotlin.time.TimeSource -private const val NS_CONVERTER = 1.0e9f -private const val CLUMP_TIME_NS = 0.06f * NS_CONVERTER -private const val NEEDED_ACCEL_DELTA = 6.0f -private const val ALLOWED_BODY_ACCEL = 2.25f -private const val ALLOWED_BODY_ACCEL_SQUARED = ALLOWED_BODY_ACCEL * ALLOWED_BODY_ACCEL -private const val TAP_WINDOW_PER_TAP_NS = 0.3f * NS_CONVERTER - -class TrackerTapDetectionBehaviour : TrackerBehaviour { - private val accelList = ArrayDeque>() - private val tapTimestamps = ArrayDeque() - private var waitForLowAccel = false - private var resetType: ResetType? = null - private var tapsNeeded = 0 - private var actionDelay = 0f - private var numberTrackersOverThreshold = 0 - - @OptIn(ExperimentalCoroutinesApi::class) - override fun observe(receiver: Tracker) { - // Outer flow (loading) is refreshed when TapDetection config or a tracker's bodyPart or status changes - val tapConfigChangingFlow = receiver.appContext.config.settings.context.state - .distinctUntilChangedBy { configState -> - configState.data.tapDetectionConfig - } - val trackerChangingFlow = receiver.appContext.server.context.state - .flatMapLatest { serverState -> - combine(serverState.trackers.values.map { tracker -> tracker.context.state }) - { trackerStates -> - trackerStates.map { it.bodyPart to it.status } - } - } - .distinctUntilChanged() - - combine( - tapConfigChangingFlow, - trackerChangingFlow, - ) { configState, _ -> - loadTapDetection(receiver, configState, receiver.appContext.server.context.state.value) - } - // Inner flow (process) is refreshed everytime this tracker's acceleration is updated - .flatMapLatest { - receiver.context.state - .filter { resetType != null || false } // setupModeAssign - .distinctUntilChangedBy { it.rawAcceleration } - } - .onEach { currentTracker -> - processTapDetection(receiver, currentTracker) - }.launchIn(receiver.context.scope) - } - - // Loads TapDetection config - private fun loadTapDetection( - receiver: Tracker, - configState: SettingsState, - serverState: VRServerState, - ) { - val tapDetectionConfig = configState.data.tapDetectionConfig - numberTrackersOverThreshold = tapDetectionConfig.numberTrackersOverThreshold - resetTapDetection() - - // If setupMode is true, double tap to assign - // TODO setupMode - if (false) { - // setupModeAssign = true - resetType = null - tapsNeeded = 2 - actionDelay = 0f - return - } - - val trackersBodyParts = serverState.trackers.values - .map { it.context.state.value.bodyPart } - .toSet() - val yawResetBodyPart = listOf(tapDetectionConfig.yawResetBodyPart, BodyPart.UPPER_CHEST, BodyPart.CHEST, BodyPart.HIP, BodyPart.WAIST) - .firstOrNull { it in trackersBodyParts } - val fullResetBodyPart = listOf(tapDetectionConfig.fullResetBodyPart, BodyPart.LEFT_UPPER_LEG, BodyPart.LEFT_LOWER_LEG) - .firstOrNull { it in trackersBodyParts } - val mountingResetBodyPart = listOf(tapDetectionConfig.mountingResetBodyPart, BodyPart.RIGHT_UPPER_LEG, BodyPart.RIGHT_LOWER_LEG) - .firstOrNull { it in trackersBodyParts } - - // Switch case for each possible action - when (receiver.context.state.value.bodyPart) { - null -> resetType = null - - // BodyParts above could be null - yawResetBodyPart if tapDetectionConfig.yawResetEnabled -> { - resetType = ResetType.YAW - tapsNeeded = tapDetectionConfig.yawResetTaps - actionDelay = tapDetectionConfig.yawResetDelay - } - - fullResetBodyPart if tapDetectionConfig.fullResetEnabled -> { - resetType = ResetType.FULL - tapsNeeded = tapDetectionConfig.fullResetTaps - actionDelay = tapDetectionConfig.fullResetDelay - } - - mountingResetBodyPart if tapDetectionConfig.mountingResetEnabled -> { - resetType = ResetType.MOUNTING - tapsNeeded = tapDetectionConfig.mountingResetTaps - actionDelay = tapDetectionConfig.mountingResetDelay - } - - else -> resetType = null - } - } - - // Logic loop for tap detection - private fun processTapDetection( - receiver: Tracker, - currentTracker: TrackerState, - ) { - val now = System.nanoTime() - - // Get the acceleration of the tracker and store it - accelList.add(currentTracker.rawAcceleration.len() to now) - - // Remove old stored accelerations (if they are too old) - while (accelList.isNotEmpty() && now - accelList.first().second > CLUMP_TIME_NS) { - accelList.removeFirst() - } - - val max = accelList.maxOfOrNull { it.first } ?: 0f - val min = accelList.minOfOrNull { it.first } ?: 0f - val accelDelta = max - min - - // Check for a single tap - if (accelDelta > NEEDED_ACCEL_DELTA && !waitForLowAccel) { - val othersOverThreshold = receiver.appContext.server.context.state.value.trackers.values - .count { it.context.state.value.id != currentTracker.id && it.context.state.value.rawAcceleration.lenSq() > ALLOWED_BODY_ACCEL_SQUARED } - if (othersOverThreshold < numberTrackersOverThreshold) { - tapTimestamps.add(now) - // After a tap, a lower acceleration is needed before another one - waitForLowAccel = true - } - } - - // Achieved low accel? - if (max < ALLOWED_BODY_ACCEL) waitForLowAccel = false - - if (tapTimestamps.isNotEmpty()) { - // Remove old stored taps (if they are too old) - val totalWindowNs = (TAP_WINDOW_PER_TAP_NS * tapTimestamps.size).toLong() - while (tapTimestamps.isNotEmpty() && now - tapTimestamps.first() > totalWindowNs) { - tapTimestamps.removeFirst() - } - - if (tapTimestamps.size >= tapsNeeded) { - // Taps completed! - receiver.context.scope.safeLaunch { - // If it's in setup mode, tap to assign - if (false) { // setupModeAssign - receiver.appContext.server.sendSolarxrRpc( - TapDetectionSetupNotification(currentTracker.id.toUShort()), - ) - } - - // If it has a reset to execute - resetType?.let { reset -> - receiver.appContext.resetsManager.scheduleReset("TapDetection", reset, actionDelay) - } - } - - resetTapDetection() - } - } - } - - private fun resetTapDetection() { - accelList.clear() - tapTimestamps.clear() - waitForLowAccel = false - } -} - class TrackerBasicBehaviour : TrackerBehaviour { override fun reduce(state: TrackerState, action: TrackerActions) = when (action) { is TrackerActions.Update -> action.transform(state) diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index d890ec0f67..d32a1556f3 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -106,7 +106,6 @@ class Tracker( TrackerBasicBehaviour(), TrackerConfigBehaviour(settings, hardwareId), TrackerTPSBehaviour(), - TrackerTapDetectionBehaviour(), TrackerToSkeletonBehaviour(), ) val context = Context.create( 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 e29b9d8589..7d68bdac2a 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -93,8 +93,8 @@ fun main(args: Array) = runBlocking { scope = this, oscQueryAddress = resolveDesktopOscQueryAddress(), ) - val resetsManager = ResetsManager.create(server = server, scope = this, settings = config.settings) - val tapDetectionManager = TapDetectionManager.create(server = server, scope = this, settings = config.settings) + val resetsManager = ResetsManager.create(ctx = phase1, scope = this) + val tapDetectionManager = TapDetectionManager.create(ctx = phase1, resetsManager = resetsManager, scope = this) val appContext = AppContext( server = server, From 3da6efbdc09c0bf05ef5b5a932d621a7f1e8f290 Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 3 Jul 2026 21:33:28 -0400 Subject: [PATCH 221/350] Tap detection over threshold shared --- .../dev/slimevr/tapdetection/behaviours.kt | 64 +++++++++++-------- 1 file changed, 37 insertions(+), 27 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt index 0bb4f20d06..f3c37952c2 100644 --- a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt @@ -63,6 +63,9 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { ::Triple, ) .flatMapLatest { (tapDetectionConfig, setupMode, trackers) -> + // To keep track of which trackers are over threshold + val trackersOverThreshold = mutableSetOf() + // Computed once per outer-flow refresh for all trackers val trackersBodyParts = trackers.map { it.context.state.value.bodyPart }.toSet() val yawResetBodyPart = listOf(tapDetectionConfig.yawResetBodyPart, BodyPart.UPPER_CHEST, BodyPart.CHEST, BodyPart.HIP, BodyPart.WAIST) @@ -80,13 +83,13 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { .filter { trackerTapDetectionState.resetType != null || setupMode } .map { it.rawAcceleration } .distinctUntilChanged() - .onEach { processTapDetection(receiver, setupMode, trackerTapDetectionState, it) } + .onEach { processTapDetection(receiver, setupMode, trackersOverThreshold, trackerTapDetectionState, it) } }.merge() } .launchIn(receiver.context.scope) } - // Loads TapDetection config + // Loads TapDetection config and initializes a tracker's state private fun createTrackerTapDetectionState( tapDetectionConfig: TapDetectionConfig, setupMode: Boolean, @@ -131,63 +134,70 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { private fun processTapDetection( receiver: TapDetectionManager, setupMode: Boolean, - state: TrackerTapDetectionState, + trackersOverThreshold: MutableSet, + trackerTapDetectionState: TrackerTapDetectionState, trackerAcceleration: Vector3, ) { val now = System.nanoTime() // Get the acceleration of the tracker and store it - state.accelList.add(trackerAcceleration.len() to now) + trackerTapDetectionState.accelList.add(trackerAcceleration.len() to now) // Remove old stored accelerations (if they are too old) - while (state.accelList.isNotEmpty() && now - state.accelList.first().second > CLUMP_TIME_NS) { - state.accelList.removeFirst() + while (trackerTapDetectionState.accelList.isNotEmpty() && now - trackerTapDetectionState.accelList.first().second > CLUMP_TIME_NS) { + trackerTapDetectionState.accelList.removeFirst() } - val max = state.accelList.maxOfOrNull { it.first } ?: 0f - val min = state.accelList.minOfOrNull { it.first } ?: 0f + val max = trackerTapDetectionState.accelList.maxOfOrNull { it.first } ?: 0f + val min = trackerTapDetectionState.accelList.minOfOrNull { it.first } ?: 0f val accelDelta = max - min + // Is this tracker over threshold for false positive prevention? + val isOverThreshold = trackerAcceleration.lenSq() > ALLOWED_BODY_ACCEL_SQUARED + if (isOverThreshold) { + trackersOverThreshold.add(trackerTapDetectionState.trackerId) + } else { + trackersOverThreshold.remove(trackerTapDetectionState.trackerId) + } + // Check for a single tap - if (accelDelta > NEEDED_ACCEL_DELTA && !state.waitForLowAccel) { - val othersOverThreshold = receiver.server.context.state.value.trackers.values - .count { it.context.state.value.id != state.trackerId && it.context.state.value.rawAcceleration.lenSq() > ALLOWED_BODY_ACCEL_SQUARED } - if (othersOverThreshold < state.numberTrackersOverThreshold) { - state.tapTimestamps.add(now) - // After a tap, a lower acceleration is needed before another one - state.waitForLowAccel = true + if (accelDelta > NEEDED_ACCEL_DELTA && !trackerTapDetectionState.waitForLowAccel) { + val othersOverThreshold = trackersOverThreshold.size - if (isOverThreshold) 1 else 0 + if (othersOverThreshold < trackerTapDetectionState.numberTrackersOverThreshold) { + trackerTapDetectionState.tapTimestamps.add(now) + trackerTapDetectionState.waitForLowAccel = true } } // Achieved low accel? - if (max < ALLOWED_BODY_ACCEL) state.waitForLowAccel = false + if (max < ALLOWED_BODY_ACCEL) trackerTapDetectionState.waitForLowAccel = false - if (state.tapTimestamps.isNotEmpty()) { + if (trackerTapDetectionState.tapTimestamps.isNotEmpty()) { // Remove old stored taps (if they are too old) - val totalWindowNs = (TAP_WINDOW_PER_TAP_NS * state.tapTimestamps.size).toLong() - while (state.tapTimestamps.isNotEmpty() && now - state.tapTimestamps.first() > totalWindowNs) { - state.tapTimestamps.removeFirst() + val totalWindowNs = (TAP_WINDOW_PER_TAP_NS * trackerTapDetectionState.tapTimestamps.size).toLong() + while (trackerTapDetectionState.tapTimestamps.isNotEmpty() && now - trackerTapDetectionState.tapTimestamps.first() > totalWindowNs) { + trackerTapDetectionState.tapTimestamps.removeFirst() } - if (state.tapTimestamps.size >= state.tapsNeeded) { + if (trackerTapDetectionState.tapTimestamps.size >= trackerTapDetectionState.tapsNeeded) { // Taps completed! receiver.context.scope.safeLaunch { // If it's in setup mode, tap to assign if (setupMode) { receiver.server.sendSolarxrRpc( - TapDetectionSetupNotification(state.trackerId.toUShort()), + TapDetectionSetupNotification(trackerTapDetectionState.trackerId.toUShort()), ) } // If it has a reset to execute - state.resetType?.let { reset -> - receiver.resetsManager.scheduleReset("TapDetection", reset, state.actionDelay) + trackerTapDetectionState.resetType?.let { reset -> + receiver.resetsManager.scheduleReset("TapDetection", reset, trackerTapDetectionState.actionDelay) } } - state.accelList.clear() - state.tapTimestamps.clear() - state.waitForLowAccel = false + trackerTapDetectionState.accelList.clear() + trackerTapDetectionState.tapTimestamps.clear() + trackerTapDetectionState.waitForLowAccel = false } } } From 2fc891d9f9f3390ea15585ace0074dfcaf49b0bc Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 3 Jul 2026 23:06:09 -0400 Subject: [PATCH 222/350] VRCOSC use_manual_network bool --- CONTRIBUTING.md | 3 +- .../firmware-tool/steps/FlashingStep.tsx | 2 +- .../trackers-assign/TrackerAssignment.tsx | 2 +- .../settings/pages/VRCOSCSettings.tsx | 44 ++++++++----------- .../pages/components/TapDetectionSettings.tsx | 41 +++++++---------- gui/electron/scripts/dev.mjs | 13 +++++- .../main/java/dev/slimevr/config/settings.kt | 12 ++++- .../main/java/dev/slimevr/config/vrcosc.kt | 32 -------------- .../main/java/dev/slimevr/solarxr/vrcosc.kt | 28 ++++-------- .../main/java/dev/slimevr/vrcosc/oscquery.kt | 6 +-- .../java/dev/slimevr/vrcosc/output-encoder.kt | 11 ++--- .../main/java/dev/slimevr/vrcosc/output.kt | 6 +-- .../src/main/java/dev/slimevr/vrcosc/utils.kt | 3 +- solarxr-protocol | 2 +- 14 files changed, 82 insertions(+), 123 deletions(-) delete mode 100644 server/core/src/main/java/dev/slimevr/config/vrcosc.kt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6c81cd7300..b8d0298919 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,7 @@ be at `server/desktop/build/libs/slimevr.jar` (you can ignore `server.jar`). - Activate corepack (included with Node.js) via `corepack enable` (might require launching Git Bash as administrator on Windows) +- Make sure you're at the root of the repository. - Run `pnpm i` in your IDE's terminal to download and install dependencies. - To launch the GUI in dev mode, run `pnpm gui`. - Finally, to compile for production, run `pnpm package:build`. @@ -88,7 +89,7 @@ Eclipse will only do a subset of the checks in `spotless`, so you may still want ### Electron (gui) We use ESLint and Prettier to format GUI code. -- First, go into the GUI's directory with your terminal by running `cd gui`. +- Make sure you're at the root of the repository. - To check code formatting, run `pnpm run lint`. - To fix code formatting, run `pnpm run lint:fix` and `pnpm run format` diff --git a/gui/app/src/components/firmware-tool/steps/FlashingStep.tsx b/gui/app/src/components/firmware-tool/steps/FlashingStep.tsx index 0d2443488c..f40218e4cb 100644 --- a/gui/app/src/components/firmware-tool/steps/FlashingStep.tsx +++ b/gui/app/src/components/firmware-tool/steps/FlashingStep.tsx @@ -124,7 +124,7 @@ export function FlashingStep({ const device = status[id]; return { type: device.type, - deviceId: id, + deviceIdPort: id, deviceNames: device.deviceNames, }; }); diff --git a/gui/app/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx b/gui/app/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx index 69a17c881b..37e7a4ab64 100644 --- a/gui/app/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx +++ b/gui/app/src/components/onboarding/pages/trackers-assign/TrackerAssignment.tsx @@ -81,7 +81,7 @@ export function TrackersAssignPage() { new TapDetectionSetupModeRequestT(false) ); }; - }, [] ); + }, []); const trackerPartGrouped = useMemo( () => diff --git a/gui/app/src/components/settings/pages/VRCOSCSettings.tsx b/gui/app/src/components/settings/pages/VRCOSCSettings.tsx index 53e06a7c26..5f33fb0c47 100644 --- a/gui/app/src/components/settings/pages/VRCOSCSettings.tsx +++ b/gui/app/src/components/settings/pages/VRCOSCSettings.tsx @@ -8,7 +8,6 @@ import { ChangeVRCOSCSettingsRequestT, RpcMessage, VRCOSCInputState, - VRCOSCNetworkSettingsT, VRCOSCOscQueryState, VRCOSCOutputState, VRCOSCSettingsRequestT, @@ -36,13 +35,13 @@ import { interface VRCOSCSettingsForm { enabled: boolean; useManualNetwork: boolean; - manualNetwork: OSCPortsAddress; + portsAddress: OSCPortsAddress; } const defaultVRCOSCSettings: VRCOSCSettingsForm = { enabled: false, useManualNetwork: false, - manualNetwork: { + portsAddress: { portIn: 9001, portOut: 9000, address: '127.0.0.1', @@ -363,7 +362,7 @@ export function VRCOSCSettings() { object({ enabled: boolean().required(), useManualNetwork: boolean().required(), - manualNetwork: oscValidator, + portsAddress: oscValidator, }) ), }); @@ -380,9 +379,10 @@ export function VRCOSCSettings() { const req = new ChangeVRCOSCSettingsRequestT(); req.enabled = values.enabled; - req.manualNetwork = values.useManualNetwork - ? Object.assign(new VRCOSCNetworkSettingsT(), values.manualNetwork) - : null; + req.useManualNetwork = values.useManualNetwork; + req.portIn = values.portsAddress.portIn; + req.portOut = values.portsAddress.portOut; + req.address = values.portsAddress.address; sendRPCPacket(RpcMessage.ChangeVRCOSCSettingsRequest, req); }; @@ -406,16 +406,10 @@ export function VRCOSCSettings() { const formData = defaultVRCOSCSettings; if (response) { formData.enabled = response.enabled; - if (response.manualNetwork) { - formData.useManualNetwork = true; - formData.manualNetwork.portIn = response.manualNetwork.portIn; - formData.manualNetwork.portOut = response.manualNetwork.portOut; - if (response.manualNetwork.address) { - formData.manualNetwork.address = asString( - response.manualNetwork.address - ); - } - } + formData.useManualNetwork = response.useManualNetwork; + formData.portsAddress.portIn = response.portIn; + formData.portsAddress.portOut = response.portOut; + formData.portsAddress.address = asString(response.address); reset(formData); } @@ -478,12 +472,10 @@ export function VRCOSCSettings() { setValue('useManualNetwork', true, { shouldDirty: true, }); - setValue( - 'manualNetwork.address', - asString(target.address), - { shouldDirty: true } - ); - setValue('manualNetwork.portOut', target.portOut, { + setValue('portsAddress.address', asString(target.address), { + shouldDirty: true, + }); + setValue('portsAddress.portOut', target.portOut, { shouldDirty: true, }); }} @@ -533,7 +525,7 @@ export function VRCOSCSettings() { @@ -545,7 +537,7 @@ export function VRCOSCSettings() { @@ -565,7 +557,7 @@ export function VRCOSCSettings() { { const formData: DefaultValues = { yawResetEnabled: - settings.yawResetEnabled || defaultValues.yawResetEnabled, + settings.yawResetEnabled || defaultValues.yawResetEnabled, fullResetEnabled: - settings.fullResetEnabled || - defaultValues.fullResetEnabled, + settings.fullResetEnabled || defaultValues.fullResetEnabled, mountingResetEnabled: - settings.mountingResetEnabled || - defaultValues.mountingResetEnabled, - yawResetDelay: - settings.yawResetDelay || defaultValues.yawResetDelay, - fullResetDelay: - settings.fullResetDelay || defaultValues.fullResetDelay, + settings.mountingResetEnabled || defaultValues.mountingResetEnabled, + yawResetDelay: settings.yawResetDelay || defaultValues.yawResetDelay, + fullResetDelay: settings.fullResetDelay || defaultValues.fullResetDelay, mountingResetDelay: - settings.mountingResetDelay || - defaultValues.mountingResetDelay, - yawResetTaps: - settings.yawResetTaps || defaultValues.yawResetTaps, - fullResetTaps: - settings.fullResetTaps || defaultValues.fullResetTaps, + settings.mountingResetDelay || defaultValues.mountingResetDelay, + yawResetTaps: settings.yawResetTaps || defaultValues.yawResetTaps, + fullResetTaps: settings.fullResetTaps || defaultValues.fullResetTaps, mountingResetTaps: - settings.mountingResetTaps || - defaultValues.mountingResetTaps, + settings.mountingResetTaps || defaultValues.mountingResetTaps, yawResetTracker: String( - settings.yawResetTracker || defaultValues.yawResetTracker + settings.yawResetTracker || defaultValues.yawResetTracker ), fullResetTracker: String( - settings.fullResetTracker || defaultValues.fullResetTracker + settings.fullResetTracker || defaultValues.fullResetTracker ), mountingResetTracker: String( - settings.mountingResetTracker || - defaultValues.mountingResetTracker + settings.mountingResetTracker || defaultValues.mountingResetTracker ), numberTrackersOverThreshold: - settings.numberTrackersOverThreshold || - defaultValues.numberTrackersOverThreshold, + settings.numberTrackersOverThreshold || + defaultValues.numberTrackersOverThreshold, }; reset({ ...getValues(), ...formData }); }, [settings]); diff --git a/gui/electron/scripts/dev.mjs b/gui/electron/scripts/dev.mjs index ebcfa5d496..121982da49 100644 --- a/gui/electron/scripts/dev.mjs +++ b/gui/electron/scripts/dev.mjs @@ -57,7 +57,9 @@ async function waitForServer(url, timeoutMs = 30000) { await new Promise((r) => setTimeout(r, 250)); } } - throw new Error(`Renderer dev server did not start at ${url} within ${timeoutMs}ms`); + throw new Error( + `Renderer dev server did not start at ${url} within ${timeoutMs}ms` + ); } // 1. Start the renderer dev server (fixed port so the URL is deterministic). @@ -77,6 +79,13 @@ run('pnpm', [ await waitForServer(RENDERER_URL); run( 'pnpm', - ['exec', 'electron-vite', 'dev', '--watch', '--config', 'electron.vite.config.ts'], + [ + 'exec', + 'electron-vite', + 'dev', + '--watch', + '--config', + 'electron.vite.config.ts', + ], { ELECTRON_RENDERER_URL: RENDERER_URL, ELECTRON_EXEC_PATH } ); diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index eccac3ac83..1a7d4f34f2 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -49,8 +49,8 @@ data class TapDetectionConfig( @Serializable data class VMCConfig( val enabled: Boolean = true, - val portOut: Int = 39539, val portIn: Int = 39540, + val portOut: Int = 39539, val address: String = "127.0.0.1", /** Mirror the tracking before sending it (turn left <=> turn right, left leg <=> right leg) */ val mirrorTracking: Boolean = false, @@ -60,6 +60,16 @@ data class VMCConfig( val vrmJson: String? = null, ) +const val DEFAULT_VRC_OSC_PORT_OUT: Int = 9000 +@Serializable +data class VRCOSCConfig( + val enabled: Boolean = false, + val useManualNetwork: Boolean = false, + val portIn: Int = 9001, + val portOut: Int = DEFAULT_VRC_OSC_PORT_OUT, + val address: String = "127.0.0.1", +) + @Serializable data class SettingsConfigState( val version: Int = SETTINGS_CONFIG_VERSION, diff --git a/server/core/src/main/java/dev/slimevr/config/vrcosc.kt b/server/core/src/main/java/dev/slimevr/config/vrcosc.kt deleted file mode 100644 index 613a36fe62..0000000000 --- a/server/core/src/main/java/dev/slimevr/config/vrcosc.kt +++ /dev/null @@ -1,32 +0,0 @@ -package dev.slimevr.config - -import kotlinx.serialization.Serializable - -internal const val DEFAULT_VRC_OSC_PORT_IN: Int = 9001 -internal const val DEFAULT_VRC_OSC_PORT_OUT: Int = 9000 -internal const val DEFAULT_VRC_OSC_ADDRESS: String = "127.0.0.1" - -@Serializable -data class VRCOSCTrackers( - val head: Boolean = false, - val chest: Boolean = false, - val waist: Boolean = true, - val knees: Boolean = false, - val feet: Boolean = true, - val elbows: Boolean = false, - val hands: Boolean = false, -) - -@Serializable -data class VRCOSCManualNetworkConfig( - val portIn: Int, - val portOut: Int, - val address: String, -) - -@Serializable -data class VRCOSCConfig( - val enabled: Boolean = false, - val manualNetwork: VRCOSCManualNetworkConfig? = null, - val trackers: VRCOSCTrackers = VRCOSCTrackers(), -) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt index 360999afb0..49fa43946a 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt @@ -2,10 +2,7 @@ package dev.slimevr.solarxr import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions -import dev.slimevr.config.TapDetectionConfig import dev.slimevr.config.VRCOSCConfig -import dev.slimevr.config.VRCOSCManualNetworkConfig -import dev.slimevr.vrcosc.VRCOSCActions import dev.slimevr.vrcosc.VRCOSCManager import dev.slimevr.vrcosc.VRCOSCStatus import kotlinx.coroutines.FlowPreview @@ -15,7 +12,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.sample import solarxr_protocol.rpc.ChangeVRCOSCSettingsRequest -import solarxr_protocol.rpc.VRCOSCNetworkSettings import solarxr_protocol.rpc.VRCOSCSettingsRequest import solarxr_protocol.rpc.VRCOSCSettingsResponse import solarxr_protocol.rpc.VRCOSCStatusChangeResponse @@ -46,13 +42,10 @@ internal class VrcOscBehaviour( receiver.sendRpc( VRCOSCSettingsResponse( enabled = config.enabled, - manualNetwork = config.manualNetwork?.let { manual -> - VRCOSCNetworkSettings( - portIn = manual.portIn.toUShort(), - portOut = manual.portOut.toUShort(), - address = manual.address, - ) - }, + useManualNetwork = config.useManualNetwork, + portIn = config.portIn.toUShort(), + portOut = config.portOut.toUShort(), + address = config.address, ), ) } @@ -64,19 +57,16 @@ internal class VrcOscBehaviour( } receiver.rpcDispatcher.on { req -> + val oldConfig = settings.context.state.value.data.vrcOscConfig settings.context.dispatch( SettingsActions.Update { copy( vrcOscConfig = VRCOSCConfig( enabled = req.enabled == true, - manualNetwork = req.manualNetwork?.takeIf { it.portIn !== null && it.portOut !== null && it.address !== null } - ?.let { network -> - VRCOSCManualNetworkConfig( - portIn = network.portIn?.toInt() ?: error("portIn should be set"), - portOut = network.portOut?.toInt() ?: error("portOut should be set"), - address = network.address ?: error("address should be set"), - ) - }, + useManualNetwork = req.useManualNetwork == true, + portIn = req.portIn?.toInt() ?: oldConfig.portIn, + portOut = req.portOut?.toInt() ?: oldConfig.portOut, + address = req.address ?: oldConfig.address, ), ) }, diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt b/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt index 9cc1bfb528..b741e0eb0e 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt @@ -48,10 +48,10 @@ class VRCOSCOscQueryBehaviour( val runtime = OscQueryRuntime(localIp) receiver.settings.context.state - .map { state -> Triple(state.data.vrcOscConfig.enabled, state.data.vrcOscConfig.manualNetwork == null, vrcOscPortIn(state.data.vrcOscConfig)) } + .map { state -> Triple(state.data.vrcOscConfig.enabled, state.data.vrcOscConfig.useManualNetwork, vrcOscPortIn(state.data.vrcOscConfig)) } .distinctUntilChanged() - .onEach { (enabled, automatic, portIn) -> - if (!enabled || !automatic) { + .onEach { (enabled, useManualNetwork, portIn) -> + if (!enabled || useManualNetwork) { stopOscQuery(receiver, runtime) return@onEach } diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt index 18b350dd62..ff8033e9ee 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt @@ -68,12 +68,13 @@ internal fun buildYawAlignMessage(headRotation: Quaternion): OscMessage { ) } +// TODO read from Output Settings private fun shouldSendTracker(bodyPart: BodyPart, config: VRCOSCConfig): Boolean = when (bodyPart) { - BodyPart.HIP -> config.trackers.waist - BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> config.trackers.feet - BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG -> config.trackers.knees - BodyPart.UPPER_CHEST -> config.trackers.chest - BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM -> config.trackers.elbows + BodyPart.HIP -> true + BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> true + BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG -> true + BodyPart.UPPER_CHEST -> true // TODO why UPPER_CHEST instead of CHEST? + BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM -> false else -> false } diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt index 86a7c27b09..ddf053792b 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt @@ -228,8 +228,8 @@ class VRCOSCOutputBehaviour( config: VRCOSCConfig, discoveredTargets: List, ): OscSenderTarget? { - config.manualNetwork?.let { manual -> - return OscSenderTarget(address = manual.address, port = manual.portOut) + if (config.useManualNetwork) { + return OscSenderTarget(address = config.address, port = config.portOut) } val discoveredTarget = discoveredTargets.firstOrNull() ?: return null @@ -243,7 +243,7 @@ class VRCOSCOutputBehaviour( config: VRCOSCConfig, discoveredTargets: List, ): VRCOSCTargetSource = when { - config.manualNetwork != null -> VRCOSCTargetSource.MANUAL + config.useManualNetwork -> VRCOSCTargetSource.MANUAL discoveredTargets.isNotEmpty() -> VRCOSCTargetSource.DISCOVERED else -> VRCOSCTargetSource.NONE } diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/utils.kt b/server/core/src/main/java/dev/slimevr/vrcosc/utils.kt index 14b1eebcfb..0e769d83e6 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/utils.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/utils.kt @@ -1,6 +1,5 @@ package dev.slimevr.vrcosc -import dev.slimevr.config.DEFAULT_VRC_OSC_PORT_IN import dev.slimevr.config.VRCOSCConfig import dev.slimevr.osc.OscArg import io.github.axisangles.ktmath.EulerAngles @@ -11,7 +10,7 @@ import kotlin.math.PI internal const val TRACKING_VRSYSTEM_PATH: String = "/tracking/vrsystem" -internal fun vrcOscPortIn(config: VRCOSCConfig): Int = config.manualNetwork?.portIn ?: DEFAULT_VRC_OSC_PORT_IN +internal fun vrcOscPortIn(config: VRCOSCConfig): Int = config.portIn internal fun parsePosition(args: List, startIndex: Int = 0): Vector3? { val x = args.getOrNull(startIndex)?.asFloatOrNull() ?: return null diff --git a/solarxr-protocol b/solarxr-protocol index dd513496d8..3c1d033426 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit dd513496d81a5896fbf23c88eb320a011598ab1d +Subproject commit 3c1d0334268e6ee68db9dda5ebf1cd11a02fe041 From 460761627082d78f6a9cfd1c4f96854e6c95c53c Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 6 Jul 2026 03:52:41 -0500 Subject: [PATCH 223/350] Port #1825 See: --- .../main/java/dev/slimevr/feature-flags.kt | 1 - .../core/src/main/java/dev/slimevr/logger.kt | 1 + .../main/java/dev/slimevr/solarxr/module.kt | 3 +- .../slimevr/trackingchecklist/behaviours.kt | 18 -- .../dev/slimevr/trackingchecklist/module.kt | 11 +- server/desktop/build.gradle.kts | 4 + .../src/main/java/dev/slimevr/desktop/Main.kt | 7 +- .../java/dev/slimevr/desktop/ipc/solarxr.kt | 23 +++ .../main/java/dev/slimevr/desktop/steamvr.kt | 102 +++++++++++ .../desktop/trackingchecklist/behaviours.kt | 159 ++++++++++++++++++ .../src/main/java/dev/slimevr/platform.kt | 38 +++++ 11 files changed, 340 insertions(+), 27 deletions(-) create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/steamvr.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt diff --git a/server/core/src/main/java/dev/slimevr/feature-flags.kt b/server/core/src/main/java/dev/slimevr/feature-flags.kt index 08e485a25a..bfbd943966 100644 --- a/server/core/src/main/java/dev/slimevr/feature-flags.kt +++ b/server/core/src/main/java/dev/slimevr/feature-flags.kt @@ -4,5 +4,4 @@ data class FeatureFlags( var steam: Boolean = false, var skipCheckUdev: Boolean = false, var udevRulesInstalled: Boolean? = null, - val supportsSteamVR: Boolean = true, ) diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index 9496692a53..070a74b4d5 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -13,6 +13,7 @@ object AppLogger { val udp = logger("UDPConnection") val ipc = logger("IPC") val solarxr = logger("SolarXR") + val checklist = logger("Checklist") val steamvr = logger("SteamVR") val hid = logger("HID") val serial = logger("Serial") diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 2455c32e52..3432ecd643 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -91,11 +91,12 @@ class SolarXRBridge( id: Int, appContext: AppContextProvider, scope: CoroutineScope, + extraBehaviours: (AppContextProvider) -> List = { emptyList() }, ): SolarXRBridge { val managedContext = ManagedContext.create( initialState = SolarXRBridgeState(dataFeedConfigs = listOf()), scope = scope, - behaviours = buildBehaviours(appContext), + behaviours = buildBehaviours(appContext) + extraBehaviours(appContext), name = "SolarXR[$id]", ) diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index ac57d1a6d6..7c05f4635c 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -48,24 +48,6 @@ private fun trackerStatesFlow(server: VRServer): Flow> = allC private fun deviceStatesFlow(server: VRServer): Flow> = allContextStates(server, { state -> state.devices.values }) { device -> device.context.state } -class SteamVRCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { - override fun observe(receiver: TrackingChecklist) { - server.context.state - .map { state -> - val connected = state.drivers.isNotEmpty() - TrackingChecklistStep( - valid = connected, - enabled = true, - ignorable = true, - extraData = if (!connected) TrackingChecklistSteamVRDisconnected() else null, - ) - } - .distinctUntilChanged() - .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.STEAMVR_DISCONNECTED, step)) } - .launchIn(receiver.context.scope) - } -} - class HMDCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { private fun computeStep(trackers: List): TrackingChecklistStep { // FIXME: Most likely incomplete diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt index 2da509c8bb..2e712d565b 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt @@ -26,25 +26,24 @@ class ChecklistBaseBehaviour : TrackingChecklistBehaviourType { class TrackingChecklist( val context: TrackingChecklistContext, + val extraBehaviours: (AppContextProvider) -> List, ) { fun startObserving(appContext: AppContextProvider) { val stepBehaviours: List = buildList { - if (appContext.featureFlags.supportsSteamVR) { - add(SteamVRCheckBehaviour(appContext.server)) - } add(HMDCheckBehaviour(appContext.server)) add(TrackerRestCheckBehaviour(appContext.server)) add(TrackerErrorCheckBehaviour(appContext.server)) appContext.vrcConfigManager?.let { add(VRChatSettingsCheckBehaviour(appContext.server, appContext.skeleton, it)) } appContext.networkProfileManager?.let { add(NetworkProfileCheckBehaviour(it)) } - } + } + + extraBehaviours(appContext) context.behaviours.addAll(stepBehaviours) context.observeAll(this) } companion object { - fun create(scope: CoroutineScope): TrackingChecklist { + fun create(scope: CoroutineScope, extraBehaviours: (AppContextProvider) -> List = { emptyList() }): TrackingChecklist { val initialBehaviours = listOf(ChecklistBaseBehaviour()) val context = Context.create( initialState = TrackingChecklistState(), @@ -52,7 +51,7 @@ class TrackingChecklist( behaviours = initialBehaviours, name = "TrackingChecklist", ) - val checklist = TrackingChecklist(context) + val checklist = TrackingChecklist(context, extraBehaviours) return checklist } } diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index fe72ae6f4a..45cddc94f7 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -10,6 +10,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { kotlin("jvm") + kotlin("plugin.serialization") application id("com.gradleup.shadow") id("com.github.gmazzo.buildconfig") @@ -80,6 +81,7 @@ dependencies { implementation("net.java.dev.jna:jna:5.+") implementation("net.java.dev.jna:jna-platform:5.+") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0") implementation("com.fazecast:jSerialComm:2.11.3") { exclude(group = "com.fazecast", module = "android") } @@ -87,6 +89,8 @@ dependencies { implementation("io.klogging:klogging:0.11.7") val ktorVersion = "3.4.1" + implementation("io.ktor:ktor-client-core:$ktorVersion") + implementation("io.ktor:ktor-client-cio:$ktorVersion") implementation("io.ktor:ktor-server-core-jvm:$ktorVersion") implementation("io.ktor:ktor-server-netty-jvm:$ktorVersion") implementation("io.ktor:ktor-server-websockets-jvm:$ktorVersion") 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 7d68bdac2a..353f516d24 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -20,6 +20,7 @@ import dev.slimevr.desktop.ipc.createSolarXRWebsocketServer import dev.slimevr.desktop.networkprofile.setupDesktopNetworkProfileChecker import dev.slimevr.desktop.serial.DesktopFirmwareFlasher import dev.slimevr.desktop.serial.createDesktopSerialServer +import dev.slimevr.desktop.trackingchecklist.SteamVRCheckBehaviour import dev.slimevr.desktop.udp.resolveDesktopUdpAddress import dev.slimevr.desktop.vrchat.createDesktopVRCConfigManager import dev.slimevr.desktop.vrchat.resolveDesktopOscQueryAddress @@ -84,7 +85,11 @@ fun main(args: Array) = runBlocking { val skeleton = Skeleton.create(scope = this, ctx = phase1) val provisioningManager = ProvisioningManager.create(ctx = phase1, scope = this) val heightCalibrationManager = HeightCalibrationManager.create(ctx = phase1, scope = this) - val trackingChecklist = TrackingChecklist.create(scope = this) + val trackingChecklist = TrackingChecklist.create(scope = this, extraBehaviours = { appContext -> + buildList { + add(SteamVRCheckBehaviour(appContext.server)) + } + }) val udpServer = UdpServer.create(scope = this, addressResolver = ::resolveDesktopUdpAddress) val bvhManager = BVHManager.create(skeleton = skeleton, storage = storage, scope = this) val vmcManager = VMCManager.create(ctx = phase1, skeleton = skeleton, scope = this) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt index 3814de9fd7..b7cde9d9f9 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt @@ -3,16 +3,34 @@ package dev.slimevr.desktop.ipc import com.google.flatbuffers.FlatBufferBuilder import dev.slimevr.AppContextProvider import dev.slimevr.VRServerActions +import dev.slimevr.desktop.unblockSteamVRDriver import dev.slimevr.fbscodegen.runtime.JvmFlatBufferReader import dev.slimevr.fbscodegen.runtime.JvmFlatBufferWriter import dev.slimevr.solarxr.SolarXRBridge +import dev.slimevr.solarxr.SolarXRBridgeBehaviour import dev.slimevr.solarxr.onSolarXRMessage +import dev.slimevr.util.safeLaunch +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO import io.ktor.util.moveToByteArray import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import solarxr_protocol.MessageBundle +import solarxr_protocol.rpc.EnableSteamVRDriverRequest import java.nio.ByteBuffer +class EnableSteamVRDriverBehaviour : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { + receiver.rpcDispatcher.on { + receiver.context.scope.safeLaunch { + val client = HttpClient(CIO) + unblockSteamVRDriver(client, "slimevr") + client.close() + } + } + } +} + suspend fun handleSolarXRBridge( appContext: AppContextProvider, messages: Flow, @@ -22,6 +40,11 @@ suspend fun handleSolarXRBridge( id = appContext.server.nextHandle(), appContext = appContext, scope = this, + extraBehaviours = { _ -> + buildList { + add(EnableSteamVRDriverBehaviour()) + } + }, ) appContext.server.context.dispatch(VRServerActions.SolarXRConnected(bridge)) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/steamvr.kt b/server/desktop/src/main/java/dev/slimevr/desktop/steamvr.kt new file mode 100644 index 0000000000..ecf634b7e8 --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/steamvr.kt @@ -0,0 +1,102 @@ +package dev.slimevr.desktop + +import dev.slimevr.AppLogger +import dev.slimevr.tryOpenUri +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.http.HttpMethod +import io.ktor.http.isSuccess +import kotlinx.coroutines.delay +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +@Serializable +data class DriverManifest( + val directory: String, + @SerialName("hmd_presence") + val hmdPresence: List?, + val name: String, +) + +@Serializable +data class Driver( + @SerialName("always_activate") + val alwaysActivate: Boolean, + @SerialName("blocked_by_safe_mode") + val blockedBySafeMode: Boolean, + val enabled: Boolean, + @SerialName("enabled_by_default") + val enabledByDefault: Boolean, + val id: Int, + @SerialName("load_priority") + val loadPriority: Int, + val manifest: DriverManifest, + @SerialName("on_safemode_whitelist") + val onSafeModeWhitelist: Boolean, + @SerialName("show_enable_in_settings") + val showEnableInSettings: Boolean, +) + +@Serializable +private data class DriverListResponse( + val drivers: List, + @SerialName("jsonid") + val jsonId: String, +) + +private const val SERVER_URL = "http://127.0.0.1:27062" +private const val REFERER = "$SERVER_URL/dashboard/index.html" +private val jsonIgnoreUnknownKeys = Json { ignoreUnknownKeys = true } + +suspend fun getSteamVRDriversList(client: HttpClient): List { + val resp = client.request("$SERVER_URL/drivers/list.json") { + header("Referer", REFERER) + } + + if (!resp.status.isSuccess()) { + throw RuntimeException("Failed to connect to SteamVR web server (status ${resp.status})") + } + + val body: String = resp.body() + + val driverList: DriverListResponse = try { + jsonIgnoreUnknownKeys.decodeFromString(body) + } catch (e: Exception) { + throw RuntimeException("Failed to decode SteamVR drivers list", e) + } + + if (driverList.jsonId != "vr_driver_list") { + throw RuntimeException("Failed to decode SteamVR drivers list", RuntimeException("SteamVR driver list response had wrong jsonId (${driverList.jsonId})")) + } + + return driverList.drivers +} + +suspend fun unblockSteamVRDriver(client: HttpClient, driver: String) { + val unblockReq = client.get("$SERVER_URL/drivers/unblock") { + method = HttpMethod.Post + header("Referer", REFERER) + setBody("""{"driver":"$driver"}""") + } + if (!unblockReq.status.isSuccess()) { + throw RuntimeException("Failed to unblock SteamVR driver \"$driver\": got HTTP status code ${unblockReq.status}") + } + + val enableReq = client.get("$SERVER_URL/drivers/setenable") { + method = HttpMethod.Post + header("Referer", REFERER) + setBody("""{"driver":"$driver","enable":true}""") + } + if (!enableReq.status.isSuccess()) { + throw RuntimeException("Failed to enable SteamVR driver \"$driver\": got HTTP status code ${enableReq.status}") + } + + delay(500) + + tryOpenUri("vrmonitor://restartsystem") +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt b/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt new file mode 100644 index 0000000000..24f551d007 --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt @@ -0,0 +1,159 @@ +package dev.slimevr.desktop.trackingchecklist + +import com.sun.jna.Native +import com.sun.jna.platform.win32.Kernel32 +import com.sun.jna.platform.win32.Kernel32Util +import com.sun.jna.platform.win32.Tlhelp32 +import com.sun.jna.platform.win32.WinBase +import com.sun.jna.platform.win32.WinDef +import dev.slimevr.AppLogger +import dev.slimevr.CURRENT_PLATFORM +import dev.slimevr.Platform +import dev.slimevr.VRServer +import dev.slimevr.desktop.getSteamVRDriversList +import dev.slimevr.trackingchecklist.TrackingChecklist +import dev.slimevr.trackingchecklist.TrackingChecklistActions +import dev.slimevr.trackingchecklist.TrackingChecklistBehaviourType +import dev.slimevr.util.safeLaunch +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import solarxr_protocol.rpc.TrackingChecklistSteamVRDisconnected +import solarxr_protocol.rpc.TrackingChecklistStep +import solarxr_protocol.rpc.TrackingChecklistStepId +import java.io.IOException + +data class Process(val pid: ULong, val name: String) + +private suspend fun getRunningProcesses(): List = when (CURRENT_PLATFORM) { + Platform.LINUX -> buildList { + val psProc = try { + ProcessBuilder("ps", "-eo", "pid,comm").redirectErrorStream(true).start() + } catch (_: IOException) { + return@buildList + } + + val lines = withContext(Dispatchers.IO) { psProc.inputStream.bufferedReader().readLines() } + // skip the header + for (line in lines.slice(1 until lines.size)) { + val data = line.trimStart().split(' ') + add(Process(data[0].toULong(), data[1])) + } + } + + Platform.WINDOWS -> buildList { + val k32 = Kernel32.INSTANCE + val snapshot = k32.CreateToolhelp32Snapshot( + Tlhelp32.TH32CS_SNAPPROCESS, + WinDef.DWORD(0), + ) + if (WinBase.INVALID_HANDLE_VALUE.equals(snapshot)) { + return@buildList + } + + try { + val entry = Tlhelp32.PROCESSENTRY32() + if (!k32.Process32First(snapshot, entry)) { + val err = k32.GetLastError() + AppLogger.checklist.warn("Failed to retrieve process information: ${Kernel32Util.formatMessage(err)} (code $err)") + return@buildList + } + + do { + add(Process(entry.th32ProcessID.toLong().toULong(), Native.toString(entry.szExeFile))) + } while (k32.Process32Next(snapshot, entry)) + } finally { + k32.CloseHandle(snapshot) + } + } + + else -> emptyList() +} + +data class SteamVRDriverState(val connected: Boolean = false, val installed: Boolean = true, val blocked: Boolean = false, val enabled: Boolean = true) + +class SteamVRCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { + private val steamVRProcName = when (CURRENT_PLATFORM) { + Platform.WINDOWS -> "vrserver.exe" + else -> "vrserver" + } + private val driverState = MutableStateFlow(SteamVRDriverState()) + private val standableState = MutableStateFlow(false) + + override fun observe(receiver: TrackingChecklist) { + receiver.context.scope.safeLaunch { + val client = HttpClient(CIO) + while (isActive) { + val connected = server.context.state.value.drivers.isNotEmpty() + val running = connected || + getRunningProcesses().any { proc -> + proc.name == steamVRProcName + } + + val drivers = if (running) { + try { + getSteamVRDriversList(client) + } catch (e: Exception) { + AppLogger.checklist.warn(e, "Failed to get SteamVR drivers list") + null + } + } else { + null + } + + val driver = drivers?.firstOrNull { + it.manifest.name == "slimevr" + } + val standableDriver = drivers?.firstOrNull { + it.manifest.name == "standable" + } + + driverState.update { + SteamVRDriverState(connected = connected, installed = !running || driver != null, driver?.blockedBySafeMode ?: false, enabled = driver?.enabled ?: true) + } + standableState.update { + standableDriver != null + } + delay(3000) + } + client.close() + } + + driverState + .map { state -> + TrackingChecklistStep( + valid = state.connected, + enabled = true, + ignorable = true, + extraData = if (!state.connected) { + TrackingChecklistSteamVRDisconnected( + driverInstalled = state.installed, + driverBlockedBySafeMode = state.blocked, + driverEnabled = state.enabled, + ) + } else { + null + }, + ) + } + .distinctUntilChanged() + .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.STEAMVR_DISCONNECTED, step)) } + .launchIn(receiver.context.scope) + standableState + .map { installed -> + TrackingChecklistStep(valid = !installed, enabled = true) + } + .distinctUntilChanged() + .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.STANDABLE_INSTALLED, step)) } + .launchIn(receiver.context.scope) + } +} diff --git a/server/desktop/src/main/java/dev/slimevr/platform.kt b/server/desktop/src/main/java/dev/slimevr/platform.kt index e8527c9a25..94e25aee57 100644 --- a/server/desktop/src/main/java/dev/slimevr/platform.kt +++ b/server/desktop/src/main/java/dev/slimevr/platform.kt @@ -1,5 +1,9 @@ package dev.slimevr +import com.sun.jna.platform.win32.Kernel32 +import com.sun.jna.platform.win32.Kernel32Util +import com.sun.jna.platform.win32.Shell32 +import com.sun.jna.platform.win32.WinUser import java.io.File import java.nio.file.Path import java.util.* @@ -37,6 +41,40 @@ fun getJavaExecutable(forceConsole: Boolean): String { return bin + "java" } +fun tryOpenUri(uri: String) { + when (CURRENT_PLATFORM) { + Platform.LINUX -> { + ProcessBuilder("xdg-open", uri).start() + } + + Platform.WINDOWS -> { + val ret = Shell32.INSTANCE.ShellExecute( + null, + null, + uri, + null, + null, + WinUser.SW_SHOWNORMAL, + ).toLong() + // According to Win32 documentation: + // If the function succeeds, it returns a value greater than 32. + // If the function fails, it returns an error value that indicates the cause of the failure. + if (ret <= 32) { + val err = Kernel32.INSTANCE.GetLastError() + throw RuntimeException( + "Failed to open URI $uri: ${ + Kernel32Util.formatMessage( + err, + ) + } (ret $ret)", + ) + } + } + + else -> Unit + } +} + fun getSocketDirectory(): String { val envDir = System.getenv("SLIMEVR_SOCKET_DIR") if (envDir != null) return envDir From f0667dc74cd41596f541424e6798224d4f8541d2 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 6 Jul 2026 03:53:43 -0500 Subject: [PATCH 224/350] Spotless --- server/core/src/main/java/dev/slimevr/config/settings.kt | 1 + .../src/main/java/dev/slimevr/vrcosc/output-encoder.kt | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 1a7d4f34f2..daa95801cd 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -61,6 +61,7 @@ data class VMCConfig( ) const val DEFAULT_VRC_OSC_PORT_OUT: Int = 9000 + @Serializable data class VRCOSCConfig( val enabled: Boolean = false, diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt index ff8033e9ee..458592f235 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt @@ -71,10 +71,16 @@ internal fun buildYawAlignMessage(headRotation: Quaternion): OscMessage { // TODO read from Output Settings private fun shouldSendTracker(bodyPart: BodyPart, config: VRCOSCConfig): Boolean = when (bodyPart) { BodyPart.HIP -> true + BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> true + BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG -> true - BodyPart.UPPER_CHEST -> true // TODO why UPPER_CHEST instead of CHEST? + + // TODO why UPPER_CHEST instead of CHEST? + BodyPart.UPPER_CHEST -> true + BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM -> false + else -> false } From 8b7ce00d926cc332464e621f29d35eebc32d9704 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 6 Jul 2026 03:53:54 -0500 Subject: [PATCH 225/350] Log found path of bindings provider --- server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 496fa1e4d3..060e679e56 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -59,6 +59,7 @@ suspend fun startBindingProvider() = withContext(Dispatchers.IO) { return@withContext } + AppLogger.steamvr.info("Found bindings provider at $path") val proc = try { ProcessBuilder(path.toString()).start() } catch (e: Exception) { From 1ec08024e7019654f1e21e198e6448c2c7cdc254 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 6 Jul 2026 03:54:06 -0500 Subject: [PATCH 226/350] Log where we're creating IPC sockets on Linux --- server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index 69ca5e4987..888fb6e2c8 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -81,6 +81,7 @@ private suspend fun acceptUnixClients( handle: suspend (SocketChannel) -> Unit, ) = withContext(Dispatchers.IO) { val path = Path(getSocketDirectory(), name) + AppLogger.ipc.info("Creating $name socket at $path") val file = path.toFile() if (file.exists()) { check(!isSocketInUse(path.toString())) { From 960647c6919504b3db68056390385f6f204d65fa Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 6 Jul 2026 03:58:11 -0500 Subject: [PATCH 227/350] Fix bindings provider build after solarxr rework --- bindings-provider/src/main.cpp | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/bindings-provider/src/main.cpp b/bindings-provider/src/main.cpp index f699b9307f..52e21ce9c4 100644 --- a/bindings-provider/src/main.cpp +++ b/bindings-provider/src/main.cpp @@ -30,36 +30,36 @@ static void shutdown_vr(vr::IVRSystem *_sys) { vr::VR_Shutdown(); } static void onYawReset(SolarXRConnection &conn) { flatbuffers::FlatBufferBuilder fbb; - auto resetReq = rpc::CreateResetRequest(fbb, rpc::ResetType::Yaw, 0, 0.f); + auto resetReq = rpc::CreateResetRequest(fbb, rpc::ResetType::YAW, 0, 0.f); auto msgHeader = rpc::CreateRpcMessageHeader( fbb, nullptr, rpc::RpcMessage::ResetRequest, resetReq.Union()); auto rpcMsgs = fbb.CreateVector({ msgHeader }); - auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs, 0); + auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs); fbb.Finish(bundle); conn.sendMsg(fbb); } static void onFullReset(SolarXRConnection &conn) { flatbuffers::FlatBufferBuilder fbb; - auto resetReq = rpc::CreateResetRequest(fbb, rpc::ResetType::Full, 0, 0.f); + auto resetReq = rpc::CreateResetRequest(fbb, rpc::ResetType::FULL, 0, 0.f); auto msgHeader = rpc::CreateRpcMessageHeader( fbb, nullptr, rpc::RpcMessage::ResetRequest, resetReq.Union()); auto rpcMsgs = fbb.CreateVector({ msgHeader }); - auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs, 0); + auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs); fbb.Finish(bundle); conn.sendMsg(fbb); } static void onMountingCalibration(SolarXRConnection &conn) { flatbuffers::FlatBufferBuilder fbb; - auto resetReq = rpc::CreateResetRequest(fbb, rpc::ResetType::Mounting, 0, 0.f); + auto resetReq = rpc::CreateResetRequest(fbb, rpc::ResetType::MOUNTING, 0, 0.f); auto msgHeader = rpc::CreateRpcMessageHeader( fbb, nullptr, rpc::RpcMessage::ResetRequest, resetReq.Union()); auto rpcMsgs = fbb.CreateVector({ msgHeader }); - auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs, 0); + auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs); fbb.Finish(bundle); conn.sendMsg(fbb); } @@ -68,12 +68,12 @@ static void onFeetMountingCalibration(SolarXRConnection &conn) { auto bodyParts = fbb.CreateVector( { datatypes::BodyPart::LEFT_FOOT, datatypes::BodyPart::RIGHT_FOOT }); - auto resetReq = rpc::CreateResetRequest(fbb, rpc::ResetType::Mounting, bodyParts, 0.f); + auto resetReq = rpc::CreateResetRequest(fbb, rpc::ResetType::MOUNTING, bodyParts, 0.f); auto msgHeader = rpc::CreateRpcMessageHeader( fbb, nullptr, rpc::RpcMessage::ResetRequest, resetReq.Union()); auto rpcMsgs = fbb.CreateVector({ msgHeader }); - auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs, 0); + auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs); fbb.Finish(bundle); conn.sendMsg(fbb); } @@ -88,7 +88,7 @@ static void onToggleTracking(SolarXRConnection &conn) { toggleReq.Union()); auto rpcMsgs = fbb.CreateVector({ msgHeader }); - auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs, 0); + auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs); fbb.Finish(bundle); conn.sendMsg(fbb); } From 0ff1bc9264cefdc08e310265801b705549b72565 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 6 Jul 2026 04:37:22 -0500 Subject: [PATCH 228/350] Fix Android --- server/android/build.gradle.kts | 4 ++-- .../main/java/dev/slimevr/android/ForegroundService.kt | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/android/build.gradle.kts b/server/android/build.gradle.kts index 808aa46e17..8be4c1cf25 100644 --- a/server/android/build.gradle.kts +++ b/server/android/build.gradle.kts @@ -32,10 +32,10 @@ java { val copyGuiAssets = tasks.register("copyGuiAssets") { val target = layout.projectDirectory.dir("src/main/assets/web-gui") delete(target) - from(rootProject.layout.projectDirectory.dir("gui/electron/out/renderer")) + from(rootProject.layout.projectDirectory.dir("gui/app/dist")) into(target) if (inputs.sourceFiles.isEmpty) { - throw GradleException("You need to run \"pnpm run build\" on the gui folder first!") + throw GradleException("You need to run \"pnpm run build\" in the gui/app folder first!") } } tasks.preBuild { diff --git a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt index 8f98dd5aa6..bd0f749e84 100644 --- a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt +++ b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt @@ -146,20 +146,20 @@ class ForegroundService : Service() { val trackingChecklist = TrackingChecklist.create(scope = scope) val udpServer = UdpServer.create(scope = scope, addressResolver = ::resolveAndroidUdpAddress) val bvhManager = BVHManager.create(skeleton = skeleton, storage = storage, scope = scope) - val vmcManager = VMCManager.create(ctx = phase1, skeleton = skeleton, scope = this) + val vmcManager = VMCManager.create(ctx = phase1, skeleton = skeleton, scope = scope) val vrcOscManager = VRCOSCManager.create( ctx = phase1, scope = scope, oscQueryAddress = resolveAndroidOscQueryAddress(), ) - val resetsManager = ResetsManager.create(ctx = phase1, scope = this) - val tapDetectionManager = TapDetectionManager.create(ctx = phase1, resetsManager = resetsManager, scope = this) + val resetsManager = ResetsManager.create(ctx = phase1, scope = scope) + val tapDetectionManager = TapDetectionManager.create(ctx = phase1, resetsManager = resetsManager, scope = scope) val appContext = AppContext( server = server, config = config, serialServer = serialServer, - featureFlags = FeatureFlags(supportsSteamVR = false), + featureFlags = FeatureFlags(), skeleton = skeleton, firmwareManager = firmwareManager, vrcConfigManager = null, From 006d8bb1e23d4fc93ff52edcf7d82539517fb854 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 6 Jul 2026 20:27:40 -0500 Subject: [PATCH 229/350] actually fix Android lol --- server/android/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/android/build.gradle.kts b/server/android/build.gradle.kts index 8be4c1cf25..45a2385467 100644 --- a/server/android/build.gradle.kts +++ b/server/android/build.gradle.kts @@ -32,10 +32,10 @@ java { val copyGuiAssets = tasks.register("copyGuiAssets") { val target = layout.projectDirectory.dir("src/main/assets/web-gui") delete(target) - from(rootProject.layout.projectDirectory.dir("gui/app/dist")) + from(rootProject.layout.projectDirectory.dir("gui/electron/out/renderer")) into(target) if (inputs.sourceFiles.isEmpty) { - throw GradleException("You need to run \"pnpm run build\" in the gui/app folder first!") + throw GradleException("You need to run \"pnpm run build\" first!") } } tasks.preBuild { From fdfbc35c531d062606d69b1415ffe918efe30347 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Tue, 7 Jul 2026 03:29:03 -0400 Subject: [PATCH 230/350] Fix unit test compiler errors --- server/core/src/test/java/dev/slimevr/TestServer.kt | 6 ++++-- .../src/test/java/dev/slimevr/firmware/OtaUploadTest.kt | 3 +-- .../core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt | 4 +++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 61e673417d..134c362962 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -27,6 +27,7 @@ import dev.slimevr.skeleton.DEFAULT_SKELETON_STATE import dev.slimevr.skeleton.ProportionsBehaviour import dev.slimevr.skeleton.Skeleton import dev.slimevr.skeleton.buildBones +import dev.slimevr.tapdetection.TapDetectionManager import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer import dev.slimevr.vmc.VMCManager @@ -86,7 +87,7 @@ fun buildTestSkeleton(scope: CoroutineScope): Skeleton { return skeleton } -fun buildTestResetsManager(server: VRServer, scope: CoroutineScope): ResetsManager { +fun buildTestResetsManager(server: VRServer, settings: Settings, scope: CoroutineScope): ResetsManager { val context = Context.create( initialState = ResetsState( canDoYawReset = true, @@ -97,7 +98,7 @@ fun buildTestResetsManager(server: VRServer, scope: CoroutineScope): ResetsManag behaviours = listOf(ResetsBasicBehaviour(), ResetsMountingTimeoutBehaviour()), name = "TestResetsManager", ) - val resetsManager = ResetsManager(context, server) + val resetsManager = ResetsManager(context, server, settings) resetsManager.startObserving() return resetsManager } @@ -142,6 +143,7 @@ abstract class TestAppContext : AppContextProvider { override val vmcManager: VMCManager get() = error("not used in test") override val vrcOscManager: VRCOSCManager get() = error("not used in test") override val resetsManager: ResetsManager get() = error("not used in test") + override val tapDetectionManager: TapDetectionManager get() = error("not used in test") override fun startObserving() {} override suspend fun dispose() = Unit } diff --git a/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt b/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt index c19454c148..b4219f0948 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt @@ -15,7 +15,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.io.readByteArray -import solarxr_protocol.datatypes.DeviceId import solarxr_protocol.datatypes.TrackerStatus import kotlin.test.Test import kotlin.test.assertContentEquals @@ -105,7 +104,7 @@ class OtaUploadTest { val job = backgroundScope.safeLaunch { result = waitForReconnected( vrServer, - DeviceId(device.context.state.value.id.toUByte()), + device.context.state.value.id.toUShort(), timeoutMs = 1_000, ) } diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt index da1eeadc5b..246c140e00 100644 --- a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -3,6 +3,7 @@ package dev.slimevr.solarxr import dev.slimevr.EventDispatcher import dev.slimevr.TestAppContext import dev.slimevr.buildTestResetsManager +import dev.slimevr.buildTestSettings import dev.slimevr.buildTestSkeleton import dev.slimevr.buildTestVrServer import dev.slimevr.context.Context @@ -19,7 +20,8 @@ import kotlin.test.assertEquals private fun testConn(backgroundScope: kotlinx.coroutines.CoroutineScope, onSend: suspend (ByteArray) -> Unit): SolarXRBridge { val server = buildTestVrServer(backgroundScope) val skeleton = buildTestSkeleton(backgroundScope) - val resetsManager = buildTestResetsManager(server, backgroundScope) + val settings = buildTestSettings(backgroundScope) + val resetsManager = buildTestResetsManager(server, settings, backgroundScope) val appContext = object : TestAppContext() { override val server = server override val skeleton = skeleton From 5f909831a7df1033898513450633080ae5cb21a8 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Tue, 7 Jul 2026 05:21:25 -0400 Subject: [PATCH 231/350] Add acceleration calibration unit test --- .../slimevr/tracker/SessionCalibrationTest.kt | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt index 4d42624258..ed03f5393e 100644 --- a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt @@ -8,9 +8,11 @@ import dev.slimevr.degreeToRadian import dev.slimevr.quaternionApproxEqual import dev.slimevr.quaternionAssertEquals import dev.slimevr.quaternionAssertNotEquals +import dev.slimevr.vectorAssertEquals import io.github.axisangles.ktmath.EulerAngles import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 import org.junit.jupiter.api.DynamicTest import org.junit.jupiter.api.TestFactory import kotlin.math.abs @@ -325,6 +327,52 @@ class SessionCalibrationTest { ) } + @TestFactory + fun makeRawAccelerationTests(): List = heading.flatMap { hC -> + attitude.flatMap { aA -> + heading.map { hA -> + DynamicTest.dynamicTest( + "( hC: $hC, aA: $aA, hA: $hA )", + ) { + testMakeRawAcceleration(Vector3.POS_X, Quaternion.IDENTITY, hC, aA, hA) + } + } + } + } + + /** + * Prove that we can transform acceleration to world space from tracker space, and + * back to tracker space from world space. + */ + fun testMakeRawAcceleration( + acceleration: Vector3, + boneOrientation: Quaternion, + headingCorrect: Quaternion, + attitudeAlign: Quaternion, + headingAlign: Quaternion, + ) { + val rawOrientation = undoCalibration( + boneOrientation, + headingCorrect, + attitudeAlign, + headingAlign, + ) + val rawAcceleration = undoCalibration( + acceleration, + rawOrientation, + headingCorrect, + headingAlign, + ) + val newAcceleration = applyCalibration( + rawAcceleration, + rawOrientation, + headingCorrect, + headingAlign, + ) + // Now that we re-applied the calibrations, let's see if it matches! + vectorAssertEquals(acceleration, newAcceleration) + } + companion object { // 5 steps val step = (-180..180 step 72).map { it.toFloat() } From e6e15a8ff2ef9fe369a77042b7cbb33d525e0d62 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Wed, 8 Jul 2026 14:32:41 -0400 Subject: [PATCH 232/350] Calculate center of mass & clean up velocity state --- .../dev/slimevr/skeleton/skatingcorrection.kt | 81 ++++++++++++++----- 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt b/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt index 67506a2c0c..ef0fd4b7a1 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt @@ -19,6 +19,13 @@ data class VelocityState( val acceleration: Vector3, ) +data class COMState( + val time: ComparableTimeMark, + val position: Vector3, + val velocity: Vector3, + val acceleration: Vector3, +) + val VELOCITY_BODY_PARTS = arrayOf(BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT) val FOOT_VELOCITY_SENSITIVITY = 1f @@ -50,6 +57,7 @@ val BODY_PART_MASSES = mapOf( BodyPart.RIGHT_LOWER_LEG to 0.0620f, ) +// TODO Implement feet accel/velocity sensitivity calculation fun shouldLock( velocity: VelocityState, thresholdMultiplier: Float, @@ -59,8 +67,11 @@ fun shouldLock( (velocity.position.y - FLOOR_LEVEL <= FLOOR_DISTANCE_THRESHOLD * thresholdMultiplier) && (velocity.acceleration.len() <= SKATING_ACCELERATION_THRESHOLD * thresholdMultiplier) -fun centerOfMass(bones: Map) { - // TODO Compute center of mass using BODY_PART_MASSES +// TODO Use this to calculate feet pressure +fun centerOfMass(bones: Map): Vector3 = BODY_PART_MASSES.entries.fold(Vector3.NULL) { acc: Vector3, massEntry -> + val bone = bones[massEntry.key] ?: return@fold acc + val boneCenter = (bone.headPosition + bone.tailPosition) / 2f + return@fold acc + (boneCenter * massEntry.value) } // Probably not a SkeletonProcessor, maybe computed processor or smth @@ -75,12 +86,55 @@ class SkatingCorrectionProcessor : SkeletonProcessor { val velocity: MutableMap = mutableMapOf() val lockStates: MutableMap = mutableMapOf() + var comState: COMState? = null + override fun process(state: SkeletonState): SkeletonState { + val curTime = TimeSource.Monotonic.markNow() + + val lastComState = comState + val com = centerOfMass(curPositions) + val newComState = if (lastComState != null) { + val deltaT = (curTime - lastComState.time).toDouble(DurationUnit.SECONDS).toFloat() + .coerceAtLeast(0.001f) + val comVelocity = (com - lastComState.position) / deltaT + val comAcceleration = (comVelocity - lastComState.velocity) / deltaT + COMState( + curTime, + com, + comVelocity, + comAcceleration, + ) + } else { + COMState( + curTime, + com, + Vector3.NULL, + Vector3.NULL, + ) + } + comState = newComState + for (bodyPart in VELOCITY_BODY_PARTS) { - // Pull data val curBone = curPositions[bodyPart] ?: continue - val curTime = TimeSource.Monotonic.markNow() - val lastVel = velocity.getOrElse(bodyPart) { + val lastVel = velocity[bodyPart] + + // Calculate velocity state + val newVel = if (lastVel != null) { + val deltaP = curBone.tailPosition - lastVel.position + val deltaT = + (curTime - lastVel.time).toDouble(DurationUnit.SECONDS).toFloat() + .coerceAtLeast(0.001f) + VelocityState( + curTime, + curBone.tailPosition, + curBone.rotation, + deltaP.len(), + deltaP / deltaT, + // May need to be `angleToR` while polarity tracking is not implemented + lastVel.rotation.angleToQ(curBone.rotation) / deltaT, + Vector3.NULL, + ) + } else { VelocityState( curTime, curBone.tailPosition, @@ -91,24 +145,9 @@ class SkatingCorrectionProcessor : SkeletonProcessor { Vector3.NULL, ) } - - // Calculate velocity - val deltaP = curBone.tailPosition - lastVel.position - val deltaT = - (curTime - lastVel.time).toDouble(DurationUnit.SECONDS).toFloat() - val newVel = VelocityState( - curTime, - curBone.tailPosition, - curBone.rotation, - deltaP.len(), - deltaP / deltaT, - // May need to be `angleToR` while polarity tracking is not implemented - lastVel.rotation.angleToQ(curBone.rotation) / deltaT, - Vector3.NULL, - ) velocity[bodyPart] = newVel - // Consider locking + // Consider locking BodyPart val wasLocked = lockStates.getOrDefault(bodyPart, false) val isLocked = shouldLock( newVel, From 588c8d9cc3b9d3136a06d93f03e21e6cd78f9881 Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 8 Jul 2026 19:15:48 -0400 Subject: [PATCH 233/350] Update CONTRIBUTING.md --- CONTRIBUTING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b8d0298919..db548506ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,7 +26,7 @@ Before contributing to the server, read [server/README.md](server/README.md) for The Java code is built with `gradle`, a CLI tool that manages java projects and their dependencies. -- You can run the server by running `./gradlew run` in your IDE's terminal. +- You can run the server by running `./gradlew :server:desktop:run` in your IDE's terminal. - To compile the code, run `./gradlew shadowJar`. The result will be at `server/desktop/build/libs/slimevr.jar` (you can ignore `server.jar`). @@ -39,7 +39,7 @@ be at `server/desktop/build/libs/slimevr.jar` (you can ignore `server.jar`). - Make sure you're at the root of the repository. - Run `pnpm i` in your IDE's terminal to download and install dependencies. - To launch the GUI in dev mode, run `pnpm gui`. -- Finally, to compile for production, run `pnpm package:build`. +- Finally, to compile for production, run `pnpm package:build` under `gui/electron`. The result will be in `gui/dist/artifacts/`. Content will change depending on the platform. ## Code style From d99a0a77a375d2eb858b8cb2f5aa271eaef3cee8 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Thu, 9 Jul 2026 03:32:31 +0200 Subject: [PATCH 234/350] Just placeholder of the brainstorming for IK --- .../main/java/dev/slimevr/skeleton/behaviours.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 6147a2d60d..58d591c6c6 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -124,9 +124,21 @@ class ComputedSkeletonBehaviour( val targetState = receiver.context.state.value val processed = processors .filter { processor -> processor.enabled } - .fold(targetState) { state, processor -> processor.process(state) } + .fold(targetState) { state, processor -> processor.process(state) } // TODO: Add a constrain processor (maybe not needed) + val rootHead = Vector3(0f, targetState.skeletonHeight, 0f) // FIXME WRONG - receiver.computed.value = buildBones(processed, rootHead = rootHead) + val fk = buildBones(processed, rootHead = rootHead) + +// val targetProcessors = [FloorClip, FloorSkating, ToePlant, FootPlant] +// +// val targets = targetProcessors +// .filter { targetProcessors -> targetProcessors.enabled } +// .fold(emptyList()) { targets, processor -> processor.process(fk, targets) } +// +// val ikOutput = solver.solve(fk, targets) + +// receiver.computed.value = ikOutput + receiver.computed.value = fk } catch (e: CancellationException) { throw e } catch (e: Exception) { From 0278968e941c7bdbaf0cb0d3d5d6ae5e09674955 Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 10 Jul 2026 19:04:25 -0400 Subject: [PATCH 235/350] Fix VMC/VRM solarxr communication --- .../components/settings/pages/VMCSettings.tsx | 48 +++++++++++-------- .../src/main/java/dev/slimevr/solarxr/vmc.kt | 7 ++- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/gui/app/src/components/settings/pages/VMCSettings.tsx b/gui/app/src/components/settings/pages/VMCSettings.tsx index 65e0782e9a..f60a781228 100644 --- a/gui/app/src/components/settings/pages/VMCSettings.tsx +++ b/gui/app/src/components/settings/pages/VMCSettings.tsx @@ -52,7 +52,7 @@ export function VRMFileUpload() { const { l10n } = useLocalization(); const [modelName, setModelName] = useState(null); - const { control, watch } = useForm<{ + const { control, watch, getValues } = useForm<{ vrmJson?: FileList; }>({ defaultValues: { vrmJson: undefined }, @@ -60,9 +60,8 @@ export function VRMFileUpload() { mode: 'onChange', }); - const vrmJson = watch('vrmJson'); - const updateVRMJson = async () => { + const vrmJson = getValues('vrmJson'); const req = new ChangeVRMSettingsRequestT(); if (vrmJson !== undefined) { if (vrmJson.length > 0) { @@ -80,8 +79,13 @@ export function VRMFileUpload() { }; useEffect(() => { - updateVRMJson(); - }, [vrmJson]); + const subscription = watch((_value, { name, type }) => { + if (type === 'change') { + updateVRMJson(); + } + }); + return () => subscription.unsubscribe(); + }, []); useEffect(() => { sendRPCPacket( @@ -160,26 +164,32 @@ export function VMCSettings() { }; useEffect(() => { - const subscription = watch(() => handleSubmit(onSubmit)()); + const subscription = watch((_value, { type }) => { + if (type === 'change') { + handleSubmit(onSubmit)(); + } + }); return () => subscription.unsubscribe(); }, []); useRPCPacket( RpcMessage.VMCOSCSettingsResponse, (settings: VMCOSCSettingsResponseT) => { - const formData: VMCSettingsForm = defaultVMCSettings; - if (settings) { - formData.enabled = settings.enabled; - if (settings.portIn) formData.portsAddress.portIn = settings.portIn; - if (settings.portOut) formData.portsAddress.portOut = settings.portOut; - if (settings.address) - formData.portsAddress.address = settings.address.toString(); - - formData.anchorHip = settings.anchorHip; - formData.mirrorTracking = settings.mirrorTracking; - - reset(formData); - } + const formData: VMCSettingsForm = { + ...defaultVMCSettings, + portsAddress: { ...defaultVMCSettings.portsAddress }, + }; + + formData.enabled = settings.enabled; + if (settings.portIn) formData.portsAddress.portIn = settings.portIn; + if (settings.portOut) formData.portsAddress.portOut = settings.portOut; + if (settings.address) + formData.portsAddress.address = settings.address.toString(); + + formData.anchorHip = settings.anchorHip; + formData.mirrorTracking = settings.mirrorTracking; + + reset(formData); } ); diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt index e899d06dc9..87b7b250e3 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt @@ -45,7 +45,7 @@ class VmcBehaviour( settings.context.dispatch( SettingsActions.Update { copy( - vmcConfig = VMCConfig( + vmcConfig = oldConfig.copy( enabled = req.enabled == true, portIn = req.portIn?.toInt() ?: oldConfig.portIn, portOut = req.portOut?.toInt() ?: oldConfig.portOut, @@ -60,12 +60,11 @@ class VmcBehaviour( // Receive VRM json receiver.rpcDispatcher.on { req -> + val oldConfig = settings.context.state.value.data.vmcConfig settings.context.dispatch( SettingsActions.Update { copy( - vmcConfig = VMCConfig( - vrmJson = req.vrmJson, - ), + vmcConfig = oldConfig.copy(vrmJson = req.vrmJson), ) }, ) From b379ebc34e03b7cacb5b855dd0bab8b951a8a696 Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 10 Jul 2026 19:18:02 -0400 Subject: [PATCH 236/350] Also fix VRCOSC --- .../settings/pages/VRCOSCSettings.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/gui/app/src/components/settings/pages/VRCOSCSettings.tsx b/gui/app/src/components/settings/pages/VRCOSCSettings.tsx index 5f33fb0c47..5131757d31 100644 --- a/gui/app/src/components/settings/pages/VRCOSCSettings.tsx +++ b/gui/app/src/components/settings/pages/VRCOSCSettings.tsx @@ -388,7 +388,11 @@ export function VRCOSCSettings() { }; useEffect(() => { - const subscription = watch(() => handleSubmit(onSubmit)()); + const subscription = watch((_value, { type }) => { + if (type === 'change') { + handleSubmit(onSubmit)(); + } + }); return () => subscription.unsubscribe(); }, []); @@ -403,16 +407,18 @@ export function VRCOSCSettings() { useRPCPacket( RpcMessage.VRCOSCSettingsResponse, (response: VRCOSCSettingsResponseT) => { - const formData = defaultVRCOSCSettings; - if (response) { - formData.enabled = response.enabled; - formData.useManualNetwork = response.useManualNetwork; - formData.portsAddress.portIn = response.portIn; - formData.portsAddress.portOut = response.portOut; - formData.portsAddress.address = asString(response.address); - - reset(formData); - } + const formData: VRCOSCSettingsForm = { + ...defaultVRCOSCSettings, + portsAddress: { ...defaultVRCOSCSettings.portsAddress }, + }; + + formData.enabled = response.enabled; + formData.useManualNetwork = response.useManualNetwork; + formData.portsAddress.portIn = response.portIn; + formData.portsAddress.portOut = response.portOut; + formData.portsAddress.address = asString(response.address); + + reset(formData); } ); From deb0f1a1b2dae45f6d90ce37aa62521af4a3076d Mon Sep 17 00:00:00 2001 From: Erimel Date: Mon, 13 Jul 2026 16:25:29 -0400 Subject: [PATCH 237/350] Fix h2 in h1 --- .../pages/components/OutputSettings.tsx | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/gui/app/src/components/settings/pages/components/OutputSettings.tsx b/gui/app/src/components/settings/pages/components/OutputSettings.tsx index acddb28cea..f86be4c7ce 100644 --- a/gui/app/src/components/settings/pages/components/OutputSettings.tsx +++ b/gui/app/src/components/settings/pages/components/OutputSettings.tsx @@ -220,33 +220,33 @@ export function OutputTrackersSettings() { <> {l10n.getString('settings-general-output')} -
+ +
- - {l10n.getString( - 'settings-general-steamvr-trackers-tracker_toggling' - )} - -
- {l10n - .getString( - 'settings-general-output-trackers-tracker_toggling-description' - ) - .split('\n') - .map((line, i) => ( - {line} - ))} -
- + + {l10n.getString( + 'settings-general-steamvr-trackers-tracker_toggling' + )} +
+ {l10n + .getString( + 'settings-general-output-trackers-tracker_toggling-description' + ) + .split('\n') + .map((line, i) => ( + {line} + ))} +
+
From 4add1655ff8c0fde9625daad01b149309633de1d Mon Sep 17 00:00:00 2001 From: Erimel Date: Mon, 13 Jul 2026 16:27:05 -0400 Subject: [PATCH 238/350] Remove extra name argument in VMCSettings --- gui/app/src/components/settings/pages/VMCSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/app/src/components/settings/pages/VMCSettings.tsx b/gui/app/src/components/settings/pages/VMCSettings.tsx index f60a781228..00e43b7633 100644 --- a/gui/app/src/components/settings/pages/VMCSettings.tsx +++ b/gui/app/src/components/settings/pages/VMCSettings.tsx @@ -79,7 +79,7 @@ export function VRMFileUpload() { }; useEffect(() => { - const subscription = watch((_value, { name, type }) => { + const subscription = watch((_value, { type }) => { if (type === 'change') { updateVRMJson(); } From 6d6492c124cf5a5ed28440b97525b3f62f85b169 Mon Sep 17 00:00:00 2001 From: Erimel Date: Mon, 13 Jul 2026 16:30:26 -0400 Subject: [PATCH 239/350] Add missing config and update solarxr --- .../pages/components/TrackingSettings.tsx | 43 +++---- .../main/java/dev/slimevr/config/resets.kt | 36 ------ .../java/dev/slimevr/config/serializers.kt | 3 + .../main/java/dev/slimevr/config/settings.kt | 113 +++++++++++++++--- .../java/dev/slimevr/skeleton/behaviours.kt | 12 +- .../src/main/java/dev/slimevr/solarxr/mag.kt | 7 +- .../main/java/dev/slimevr/udp/behaviours.kt | 2 +- .../src/main/java/dev/slimevr/udp/server.kt | 2 +- solarxr-protocol | 2 +- 9 files changed, 138 insertions(+), 82 deletions(-) delete mode 100644 server/core/src/main/java/dev/slimevr/config/resets.kt diff --git a/gui/app/src/components/settings/pages/components/TrackingSettings.tsx b/gui/app/src/components/settings/pages/components/TrackingSettings.tsx index 3e4f657520..5478f24ec5 100644 --- a/gui/app/src/components/settings/pages/components/TrackingSettings.tsx +++ b/gui/app/src/components/settings/pages/components/TrackingSettings.tsx @@ -31,16 +31,16 @@ type SkeletonForm = { toeSnap: boolean; footPlant: boolean; mocapMode: boolean; - usePosition: boolean; + useTrackerPositions: boolean; enforceConstraints: boolean; correctConstraints: boolean; }; ratios: { - imputeSpineFromUpperLower: number; + imputeSpineFromUpperToLower: number; imputeSpineCurvature: number; - interpHipLegs: number; - interpKneeTrackerAnkle: number; - interpKneeAnkle: number; + interpolateHipWithKnees: number; + interpolateComputedKneesWithAnkles: number; + interpolateKneesWithAnkles: number; skatingCorrectionStrength: number; }; filtering: { @@ -66,16 +66,16 @@ const defaultValues: SkeletonForm = { toeSnap: false, footPlant: true, mocapMode: false, - usePosition: true, + useTrackerPositions: true, enforceConstraints: true, correctConstraints: true, }, ratios: { - imputeSpineFromUpperLower: 0.5, + imputeSpineFromUpperToLower: 0.5, imputeSpineCurvature: 0.5, - interpHipLegs: 0.25, - interpKneeTrackerAnkle: 0.85, - interpKneeAnkle: 0.2, + interpolateHipWithKnees: 0.25, + interpolateComputedKneesWithAnkles: 0.85, + interpolateKneesWithAnkles: 0.2, skatingCorrectionStrength: 0.3, }, filtering: { amount: 0.1, type: FilteringType.NONE }, @@ -110,17 +110,20 @@ export function TrackingSettings() { toggles.toeSnap = values.toggles.toeSnap; toggles.footPlant = values.toggles.footPlant; toggles.mocapMode = values.toggles.mocapMode; - toggles.usePosition = values.toggles.usePosition; + toggles.useTrackerPositions = values.toggles.useTrackerPositions; toggles.enforceConstraints = values.toggles.enforceConstraints; toggles.correctConstraints = values.toggles.correctConstraints; settingsReq.toggles = toggles; const ratios = new SkeletonRatiosT(); - ratios.imputeSpineFromUpperLower = values.ratios.imputeSpineFromUpperLower; + ratios.imputeSpineFromUpperToLower = + values.ratios.imputeSpineFromUpperToLower; ratios.imputeSpineCurvature = values.ratios.imputeSpineCurvature; - ratios.interpHipLegs = values.ratios.interpHipLegs; - ratios.interpKneeTrackerAnkle = values.ratios.interpKneeTrackerAnkle; - ratios.interpKneeAnkle = values.ratios.interpKneeAnkle; + ratios.interpolateHipWithKnees = values.ratios.interpolateHipWithKnees; + ratios.interpolateComputedKneesWithAnkles = + values.ratios.interpolateComputedKneesWithAnkles; + ratios.interpolateKneesWithAnkles = + values.ratios.interpolateKneesWithAnkles; ratios.skatingCorrectionStrength = values.ratios.skatingCorrectionStrength; settingsReq.ratios = ratios; @@ -415,7 +418,7 @@ export function TrackingSettings() { variant="toggle" outlined control={control} - name="toggles.usePosition" + name="toggles.useTrackerPositions" label={l10n.getString( 'settings-general-fk_settings-ik-use_position' )} @@ -461,7 +464,7 @@ export function TrackingSettings() {
{ } } +object BodyPartListSerializer : KSerializer> by ListSerializer(BodyPartSerializer) + @Serializable private data class QuaternionSurrogate(val w: Float, val x: Float, val y: Float, val z: Float) diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index daa95801cd..9b52f87c58 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -3,6 +3,7 @@ package dev.slimevr.config import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import io.github.axisangles.ktmath.Quaternion +import io.ktor.utils.io.core.Output import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -13,6 +14,8 @@ import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.ArmsResetMode +import solarxr_protocol.rpc.FilteringType private const val SETTINGS_CONFIG_VERSION = 2 @@ -26,6 +29,25 @@ data class TrackerConfig( val magEnabled: Boolean? = null, ) +@Serializable +data class TrackersConfig( + val trackerPort: Int = 6969, // Not in SolarXR + val globalMagEnabled: Boolean = false, +) + +@Serializable +data class HidConfig( + val trackersOverHid: Boolean = false, +) + +@Serializable +data class OutputTrackersConfig( + val automaticTrackerToggle: Boolean = true, + @Serializable(with = BodyPartListSerializer::class) + val trackers: List = listOf(), + val sendDerivedVelocity: Boolean = false, +) + @Serializable data class TapDetectionConfig( var yawResetDelay: Float = 0.2f, @@ -46,18 +68,63 @@ data class TapDetectionConfig( var numberTrackersOverThreshold: Int = 1, ) +enum class MountingMethods(val id: Int) { + MANUAL(0), + AUTOMATIC(1), +} + @Serializable -data class VMCConfig( - val enabled: Boolean = true, - val portIn: Int = 39540, - val portOut: Int = 39539, - val address: String = "127.0.0.1", - /** Mirror the tracking before sending it (turn left <=> turn right, left leg <=> right leg) */ - val mirrorTracking: Boolean = false, - /** Anchor the tracking at the hip (sitting down)? */ - val anchorAtHips: Boolean = false, - /** JSON part of the VRM to be used */ - val vrmJson: String? = null, +data class ResetsConfig( + /** Always reset mounting for feet */ + val resetMountingFeet: Boolean = false, + /** Reset mode used for the arms */ + val armsResetMode: ArmsResetMode = ArmsResetMode.BACK, + /** Yaw reset smoothing time in seconds */ + val yawResetSmoothTime: Float = 0.0f, + /** Save automatic mounting reset calibration */ + val saveMountingReset: Boolean = false, + /** Reset the HMD's pitch upon full reset */ + val resetHmdPitch: Boolean = false, + val lastMountingMethod: MountingMethods = MountingMethods.AUTOMATIC, +) + +// Used in SkeletonConfig +@Serializable +data class SkeletonTogglesConfig( + val forceArmsFromHmd: Boolean = true, + val floorClip: Boolean = true, + val skatingCorrection: Boolean = true, + val toeSnap: Boolean = false, + val footPlant: Boolean = true, + val mocapMode: Boolean = false, + val useTrackerPositions: Boolean = true, + val enforceConstraints: Boolean = true, + val correctConstraints: Boolean = true, +) + +// Used in SkeletonConfig +@Serializable +data class SkeletonRatiosConfig( + val imputeSpineFromUpperToLower: Float = 0.5f, + val imputeSpineCurvature: Float = 0.5f, + val interpolateHipWithKnees: Float = 0.25f, + val interpolateComputedKneesWithAnkles: Float = 0.85f, + val interpolateKneesWithAnkles: Float = 0.00f, + val skatingCorrectionStrength: Float = 0.3f, +) + +// Used in SkeletonConfig +@Serializable +data class SkeletonFilteringConfig( + val type: FilteringType = FilteringType.PREDICTION, + val amount: Float = 0.2f, +) + +@Serializable +data class SkeletonConfig( + val toggles: SkeletonTogglesConfig = SkeletonTogglesConfig(), + val ratios: SkeletonRatiosConfig = SkeletonRatiosConfig(), + val filtering: SkeletonFilteringConfig = SkeletonFilteringConfig(), ) const val DEFAULT_VRC_OSC_PORT_OUT: Int = 9000 @@ -71,19 +138,35 @@ data class VRCOSCConfig( val address: String = "127.0.0.1", ) +@Serializable +data class VMCConfig( + val enabled: Boolean = true, + val portIn: Int = 39540, + val portOut: Int = 39539, + val address: String = "127.0.0.1", + /** Mirror the tracking before sending it (turn left <=> turn right, left leg <=> right leg) */ + val mirrorTracking: Boolean = false, + /** Anchor the tracking at the hip (sitting down)? */ + val anchorAtHips: Boolean = false, + /** JSON part of the VRM to be used */ + val vrmJson: String? = null, +) + @Serializable data class SettingsConfigState( val version: Int = SETTINGS_CONFIG_VERSION, - val trackerPort: Int = 6969, - val globalMagEnabled: Boolean = true, val mutedVRCWarnings: List = listOf(), val mutedChecklistSteps: Set = emptySet(), val allowedUdpDevices: Set = emptySet(), val trackers: Map = emptyMap(), - val vrcOscConfig: VRCOSCConfig = VRCOSCConfig(), - val vmcConfig: VMCConfig = VMCConfig(), + val trackersConfig: TrackersConfig = TrackersConfig(), + val hidConfig: HidConfig = HidConfig(), + val outputTrackersConfig: OutputTrackersConfig = OutputTrackersConfig(), val tapDetectionConfig: TapDetectionConfig = TapDetectionConfig(), val resetsConfig: ResetsConfig = ResetsConfig(), + val skeletonConfig: SkeletonConfig = SkeletonConfig(), + val vrcOscConfig: VRCOSCConfig = VRCOSCConfig(), + val vmcConfig: VMCConfig = VMCConfig(), ) private fun migrateSettingsConfig(json: JsonObject): JsonObject { diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 58d591c6c6..e29100b88f 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -129,15 +129,15 @@ class ComputedSkeletonBehaviour( val rootHead = Vector3(0f, targetState.skeletonHeight, 0f) // FIXME WRONG val fk = buildBones(processed, rootHead = rootHead) -// val targetProcessors = [FloorClip, FloorSkating, ToePlant, FootPlant] +// val targetProcessors = [FloorClip, FloorSkating, ToePlant, FootPlant] // -// val targets = targetProcessors -// .filter { targetProcessors -> targetProcessors.enabled } -// .fold(emptyList()) { targets, processor -> processor.process(fk, targets) } +// val targets = targetProcessors +// .filter { targetProcessors -> targetProcessors.enabled } +// .fold(emptyList()) { targets, processor -> processor.process(fk, targets) } // -// val ikOutput = solver.solve(fk, targets) +// val ikOutput = solver.solve(fk, targets) -// receiver.computed.value = ikOutput +// receiver.computed.value = ikOutput receiver.computed.value = fk } catch (e: CancellationException) { throw e diff --git a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt index adf7709c06..0bb0751836 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt @@ -36,7 +36,10 @@ class MagBehaviour( val enable = req.enable == true if (trackerId == null) { - receiver.appContext.config.settings.context.dispatch(SettingsActions.Update { copy(globalMagEnabled = enable) }) + val settings = receiver.appContext.config.settings + val oldTrackersConfig = settings.context.state.value.data.trackersConfig + settings.context.dispatch(SettingsActions.Update { copy(trackersConfig = oldTrackersConfig.copy(globalMagEnabled = enable)) }) + appContext.server.context.state.value.trackers.values.forEach { tracker -> val state = tracker.context.state.value if (state.magStatus == MagnetometerStatus.NOT_SUPPORTED) return@forEach @@ -86,7 +89,7 @@ class MagBehaviour( receiver.sendRpc( MagToggleResponse( trackerId = null, - enable = receiver.appContext.config.settings.context.state.value.data.globalMagEnabled, + enable = receiver.appContext.config.settings.context.state.value.data.trackersConfig.globalMagEnabled, ), ) return@on diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index badc276bea..b733bc57c5 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -370,7 +370,7 @@ class SensorInfoBehaviour : UDPConnectionBehaviour { } ?: MagnetometerStatus.NOT_SUPPORTED var desiredMagStatus = tracker.context.state.value.magStatus - val globalMagEnabled = receiver.appContext.config.settings.context.state.value.data.globalMagEnabled + val globalMagEnabled = receiver.appContext.config.settings.context.state.value.data.trackersConfig.globalMagEnabled if (remoteMagStatus != desiredMagStatus) { if (globalMagEnabled && remoteMagStatus != MagnetometerStatus.ENABLED && desiredMagStatus != MagnetometerStatus.NOT_SUPPORTED) { desiredMagStatus = MagnetometerStatus.ENABLED diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index 01daffb661..7516df2c6d 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -49,7 +49,7 @@ class UdpServer(val context: UdpServerContext, private val addressResolver: (Ine fun startReceiving(appContext: AppContextProvider, scope: CoroutineScope) { if (receiveJob != null) return receiveJob = scope.safeLaunch { - val port = appContext.config.settings.context.state.value.data.trackerPort + val port = appContext.config.settings.context.state.value.data.trackersConfig.trackerPort val selectorManager = SelectorManager(Dispatchers.IO) val socket = aSocket(selectorManager).udp().bind(port = port) this@UdpServer.selectorManager = selectorManager diff --git a/solarxr-protocol b/solarxr-protocol index 3c1d033426..288d65fc4a 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 3c1d0334268e6ee68db9dda5ebf1cd11a02fe041 +Subproject commit 288d65fc4a7ba063dcec931cdc0d67c7a4b53cdd From 90706ac9cbc3dda19f2e0a6f6de8e5c0246462ff Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 13 Jul 2026 23:05:12 +0200 Subject: [PATCH 240/350] Fix eslint --- gui/.lintstagedrc.mjs | 2 +- gui/app/eslint.config.js | 24 +- gui/app/package.json | 9 +- .../steps/FlashingMethodStep.tsx | 2 +- gui/electron/main/index.ts | 41 ++-- pnpm-lock.yaml | 206 ++++++------------ 6 files changed, 108 insertions(+), 176 deletions(-) diff --git a/gui/.lintstagedrc.mjs b/gui/.lintstagedrc.mjs index 8d6abe7152..75232776e1 100644 --- a/gui/.lintstagedrc.mjs +++ b/gui/.lintstagedrc.mjs @@ -1,5 +1,5 @@ export default { 'app/**/*.{js,jsx,ts,tsx}': - 'pnpm --filter @slimevr/gui-app exec eslint --max-warnings=0 --no-warn-ignored --cache --fix', + 'pnpm --filter @slimevr/gui-app exec eslint --no-warn-ignored --cache --fix', '**/*.{js,jsx,ts,tsx,css,scss,md,json}': 'prettier --write', }; diff --git a/gui/app/eslint.config.js b/gui/app/eslint.config.js index 89705e16dd..23d791bde9 100644 --- a/gui/app/eslint.config.js +++ b/gui/app/eslint.config.js @@ -2,7 +2,8 @@ import { FlatCompat } from '@eslint/eslintrc'; import eslint from '@eslint/js'; import globals from 'globals'; import tseslint from 'typescript-eslint'; -import stylistic from '@stylistic/eslint-plugin'; +import react from 'eslint-plugin-react'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; const compat = new FlatCompat(); @@ -10,7 +11,6 @@ export const gui = [ eslint.configs.recommended, ...tseslint.configs.recommended, ...compat.extends('plugin:@dword-design/import-alias/recommended'), - ...compat.plugins('eslint-plugin-react-hooks'), // Add import-alias rule inside compat because plugin doesn't like flat configs ...compat.config({ rules: { @@ -39,20 +39,28 @@ export const gui = [ ...globals.jest, }, }, - files: ['{electron,src}/**/*.{js,jsx,ts,tsx,json}'], + files: ['{electron,src}/**/*.{js,jsx,ts,tsx}'], plugins: { '@typescript-eslint': tseslint.plugin, - '@stylistic': stylistic, + react, }, rules: { 'react/react-in-jsx-scope': 'off', 'react/prop-types': 'off', + 'react/no-unescaped-entities': 'off', + // effect on every render (a common infinite-loop / perf footgun). + 'no-restricted-syntax': [ + 'error', + { + selector: + 'CallExpression[callee.name=/^use(Effect|LayoutEffect|InsertionEffect)$/][arguments.length<2]', + message: + 'Pass a dependency array to this hook (use [] to run it once). Omitting it re-runs on every render.', + }, + ], 'spaced-comment': 'error', - quotes: ['error', 'single'], 'no-duplicate-imports': 'error', - 'no-inline-styles': 'off', '@typescript-eslint/no-explicit-any': 'off', - 'react/no-unescaped-entities': 'off', camelcase: 'error', '@typescript-eslint/no-unused-vars': [ 'warn', @@ -62,7 +70,6 @@ export const gui = [ ignoreRestSiblings: true, }, ], - '@stylistic/jsx-self-closing-comp': 'error', }, settings: { 'import/resolver': { @@ -73,6 +80,7 @@ export const gui = [ }, }, }, + eslintPluginPrettierRecommended, // Global ignore { ignores: ['**/firmware-tool-api/'], diff --git a/gui/app/package.json b/gui/app/package.json index ec542e6cc9..2c9a02d2d2 100644 --- a/gui/app/package.json +++ b/gui/app/package.json @@ -9,8 +9,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "lint": "tsc --noEmit && eslint --max-warnings=0 \"src/**/*.{js,jsx,ts,tsx,json}\" && prettier --check \"src/**/*.{js,jsx,ts,tsx,css,scss,md,json}\"", - "lint:fix": "tsc --noEmit && eslint --fix --max-warnings=0 \"src/**/*.{js,jsx,ts,tsx,json}\" && pnpm run format", + "lint": "tsc --noEmit && eslint \"src/**/*.{js,jsx,ts,tsx}\" && prettier --check \"src/**/*.{css,scss,md,json}\"", + "lint:fix": "tsc --noEmit && eslint --fix \"src/**/*.{js,jsx,ts,tsx}\" && prettier --write \"src/**/*.{css,scss,md,json}\"", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx,css,scss,md,json}\"", "gen:firmware-tool": "openapi-codegen gen firmwareTool" }, @@ -60,7 +60,6 @@ "@openapi-codegen/cli": "^3.1.0", "@openapi-codegen/typescript": "^8.0.2", "@sentry/vite-plugin": "^2.22.7", - "@stylistic/eslint-plugin": "^5.5.0", "@tailwindcss/forms": "^0.5.9", "@tailwindcss/typography": "^0.5.15", "@types/node": "^24.3.1", @@ -76,11 +75,11 @@ "autoprefixer": "^10.4.20", "dotenv": "^16.4.5", "eslint": "^9.39.1", + "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^3.10.1", "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.0.1", "globals": "^15.10.0", "postcss": "^8.4.47", "prettier": "^3.3.3", diff --git a/gui/app/src/components/firmware-tool/steps/FlashingMethodStep.tsx b/gui/app/src/components/firmware-tool/steps/FlashingMethodStep.tsx index 49661d8695..03da9427ac 100644 --- a/gui/app/src/components/firmware-tool/steps/FlashingMethodStep.tsx +++ b/gui/app/src/components/firmware-tool/steps/FlashingMethodStep.tsx @@ -144,7 +144,7 @@ function SerialDevicesList({ clearInterval(id); }; } - }); + }, [isActive]); return (
diff --git a/gui/electron/main/index.ts b/gui/electron/main/index.ts index ad26652465..acff710b7b 100644 --- a/gui/electron/main/index.ts +++ b/gui/electron/main/index.ts @@ -10,7 +10,7 @@ import { shell, Tray, } from 'electron'; -import { IPC_CHANNELS } from '@slimevr/gui-shared'; +import { GHGet, GHReturn, IPC_CHANNELS } from '@slimevr/gui-shared'; import path, { dirname, join } from 'path'; import open from 'open'; import trayIcon from '../resources/icons/icon.png?asset'; @@ -65,22 +65,31 @@ protocol.registerSchemesAsPrivileged([ let mainWindow: BrowserWindow | null = null; -handleIpc(IPC_CHANNELS.GH_FETCH, async (e, options) => { - if (options.type === 'fw-releases') { - return fetch( - 'https://api.github.com/repos/SlimeVR/SlimeVR-Tracker-ESP/releases' - ).then((res) => res.json()); - } - if (options.type === 'asset') { - if ( - !options.url.startsWith( - 'https://github.com/SlimeVR/SlimeVR-Tracker-ESP/releases/download' - ) - ) - return null; - return fetch(options.url).then((res) => res.json()); +handleIpc( + IPC_CHANNELS.GH_FETCH, + async (_e: unknown, options: T): Promise => { + switch (options.type) { + case 'fw-releases': { + return fetch( + 'https://api.github.com/repos/SlimeVR/SlimeVR-Tracker-ESP/releases' + ).then((res) => res.json()) as Promise; + } + case 'asset': { + if ( + !options.url.startsWith( + 'https://github.com/SlimeVR/SlimeVR-Tracker-ESP/releases/download' + ) + ) + return null; + return fetch(options.url).then((res) => res.json()) as Promise< + GHReturn[T['type']] + >; + } + default: + throw 'unhandled type'; + } } -}); +); handleIpc(IPC_CHANNELS.OS_STATS, async () => { return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59d0b2a52b..b0d98dc5cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,9 +147,6 @@ importers: '@sentry/vite-plugin': specifier: ^2.22.7 version: 2.23.1(encoding@0.1.13) - '@stylistic/eslint-plugin': - specifier: ^5.5.0 - version: 5.10.0(eslint@9.39.4(jiti@1.21.7)) '@tailwindcss/forms': specifier: ^0.5.9 version: 0.5.11(tailwindcss@3.4.19(yaml@2.8.3)) @@ -195,21 +192,21 @@ importers: eslint: specifier: ^9.39.1 version: 9.39.4(jiti@1.21.7) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.39.4(jiti@1.21.7)) eslint-import-resolver-typescript: specifier: ^3.10.1 version: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-import: specifier: ^2.32.0 version: 2.32.0(@typescript-eslint/parser@8.58.2(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7)) - eslint-plugin-jsx-a11y: - specifier: ^6.10.2 - version: 6.10.2(eslint@9.39.4(jiti@1.21.7)) + eslint-plugin-prettier: + specifier: ^5.5.6 + version: 5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7))(prettier@3.8.3) eslint-plugin-react: specifier: ^7.37.5 version: 7.37.5(eslint@9.39.4(jiti@1.21.7)) - eslint-plugin-react-hooks: - specifier: ^7.0.1 - version: 7.0.1(eslint@9.39.4(jiti@1.21.7)) globals: specifier: ^15.10.0 version: 15.15.0 @@ -1078,6 +1075,10 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@pkgr/core@0.3.6': + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} + '@react-hookz/deep-equal@3.0.4': resolution: {integrity: sha512-QpcSUTP1I0lI8/GvSEvclkoEGRWjbYxuUWaecxr8eJRceivGF18wHoJwaej2NYxMn7SqhKaZ9JYQvmdATiSG8A==} engines: {node: '>=18.0.0'} @@ -1335,12 +1336,6 @@ packages: resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==} engines: {node: '>=14.16'} - '@stylistic/eslint-plugin@5.10.0': - resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^9.0.0 || ^10.0.0 - '@swc/core-darwin-arm64@1.15.26': resolution: {integrity: sha512-OmcP96CFsNOwa65tamQayRcfqhNlcQ3YCWOq+0Wb+CAM4uB7kOMrXY41Gj4atthxrGhLQ9pg7Vk26iApb88idA==} engines: {node: '>=10'} @@ -1826,10 +1821,6 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -1874,9 +1865,6 @@ packages: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} - ast-types-flow@0.0.8: - resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -1917,14 +1905,6 @@ packages: aws4@1.13.2: resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} - axe-core@4.11.3: - resolution: {integrity: sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==} - engines: {node: '>=4'} - - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} - babel-plugin-add-module-exports@1.0.4: resolution: {integrity: sha512-g+8yxHUZ60RcyaUpfNzy56OtWW+x9cyEe9j+CranqLiqbju2yf/Cy6ZtYK40EZxtrdHllzlVZgLmcOUCTlJ7Jg==} @@ -2219,9 +2199,6 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - damerau-levenshtein@1.0.8: - resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2421,9 +2398,6 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - encoding@0.1.13: resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} @@ -2504,6 +2478,12 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + eslint-import-resolver-node@0.3.10: resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} @@ -2551,17 +2531,19 @@ packages: '@typescript-eslint/parser': optional: true - eslint-plugin-jsx-a11y@6.10.2: - resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} - engines: {node: '>=4.0'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - - eslint-plugin-react-hooks@7.0.1: - resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} - engines: {node: '>=18'} + eslint-plugin-prettier@5.5.6: + resolution: {integrity: sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==} + engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true eslint-plugin-react@7.37.5: resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} @@ -2656,6 +2638,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -2919,12 +2904,6 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - hermes-estree@0.25.1: - resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} - - hermes-parser@0.25.1: - resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -3322,13 +3301,6 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} - language-subtag-registry@0.3.23: - resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} - - language-tags@1.0.9: - resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} - engines: {node: '>=0.10'} - lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -4045,6 +4017,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} + engines: {node: '>=6.0.0'} + prettier@3.8.3: resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} engines: {node: '>=14'} @@ -4541,10 +4517,6 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} - string.prototype.includes@2.0.1: - resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} - engines: {node: '>= 0.4'} - string.prototype.matchall@4.0.12: resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} engines: {node: '>= 0.4'} @@ -4625,6 +4597,10 @@ packages: resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==} hasBin: true + synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} + engines: {node: ^14.18.0 || >=16.0.0} + tailwind-gradient-mask-image@1.2.0: resolution: {integrity: sha512-tUJaGhvqbJFiVKJu6EU5n//KvGdVvY3L3VOFNqjztk13+ifAk00pcSNHBTgHfUiBGOEzDn0gFRbSmsftUV1lXA==} @@ -5045,15 +5021,6 @@ packages: yup@1.7.1: resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==} - zod-validation-error@4.0.2: - resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -5794,6 +5761,8 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@pkgr/core@0.3.6': {} + '@react-hookz/deep-equal@3.0.4': {} '@remix-run/router@1.23.2': {} @@ -5984,16 +5953,6 @@ snapshots: '@sindresorhus/is@5.6.0': {} - '@stylistic/eslint-plugin@5.10.0(eslint@9.39.4(jiti@1.21.7))': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@1.21.7)) - '@typescript-eslint/types': 8.58.2 - eslint: 9.39.4(jiti@1.21.7) - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - estraverse: 5.3.0 - picomatch: 4.0.4 - '@swc/core-darwin-arm64@1.15.26': optional: true @@ -6492,8 +6451,6 @@ snapshots: argparse@2.0.1: {} - aria-query@5.3.2: {} - array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -6571,8 +6528,6 @@ snapshots: assert-plus@1.0.0: {} - ast-types-flow@0.0.8: {} - astral-regex@2.0.0: {} async-exit-hook@2.0.1: {} @@ -6602,10 +6557,6 @@ snapshots: aws4@1.13.2: {} - axe-core@4.11.3: {} - - axobject-query@4.1.0: {} - babel-plugin-add-module-exports@1.0.4: {} babel-plugin-module-resolver@5.0.3: @@ -6898,8 +6849,6 @@ snapshots: csstype@3.2.3: {} - damerau-levenshtein@1.0.8: {} - data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -7145,8 +7094,6 @@ snapshots: emoji-regex@8.0.0: {} - emoji-regex@9.2.2: {} - encoding@0.1.13: dependencies: iconv-lite: 0.6.3 @@ -7341,6 +7288,10 @@ snapshots: escape-string-regexp@5.0.0: {} + eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@1.21.7)): + dependencies: + eslint: 9.39.4(jiti@1.21.7) + eslint-import-resolver-node@0.3.10: dependencies: debug: 3.2.7 @@ -7404,35 +7355,14 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@1.21.7)): + eslint-plugin-prettier@5.5.6(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7))(prettier@3.8.3): dependencies: - aria-query: 5.3.2 - array-includes: 3.1.9 - array.prototype.flatmap: 1.3.3 - ast-types-flow: 0.0.8 - axe-core: 4.11.3 - axobject-query: 4.1.0 - damerau-levenshtein: 1.0.8 - emoji-regex: 9.2.2 - eslint: 9.39.4(jiti@1.21.7) - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - language-tags: 1.0.9 - minimatch: 3.1.5 - object.fromentries: 2.0.8 - safe-regex-test: 1.1.0 - string.prototype.includes: 2.0.1 - - eslint-plugin-react-hooks@7.0.1(eslint@9.39.4(jiti@1.21.7)): - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 eslint: 9.39.4(jiti@1.21.7) - hermes-parser: 0.25.1 - zod: 4.3.6 - zod-validation-error: 4.0.2(zod@4.3.6) - transitivePeerDependencies: - - supports-color + prettier: 3.8.3 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.13 + optionalDependencies: + eslint-config-prettier: 10.1.8(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@1.21.7)): dependencies: @@ -7578,6 +7508,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-diff@1.3.0: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7888,12 +7820,6 @@ snapshots: help-me@5.0.0: {} - hermes-estree@0.25.1: {} - - hermes-parser@0.25.1: - dependencies: - hermes-estree: 0.25.1 - highlight.js@10.7.3: {} hoist-non-react-statics@3.3.2: @@ -8247,12 +8173,6 @@ snapshots: kleur@3.0.3: {} - language-subtag-registry@0.3.23: {} - - language-tags@1.0.9: - dependencies: - language-subtag-registry: 0.3.23 - lazy-val@1.0.5: {} levn@0.4.1: @@ -9184,6 +9104,10 @@ snapshots: prelude-ls@1.2.1: {} + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + prettier@3.8.3: {} proc-log@6.1.0: {} @@ -9764,12 +9688,6 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 - string.prototype.includes@2.0.1: - dependencies: - call-bind: 1.0.9 - define-properties: 1.2.1 - es-abstract: 1.24.2 - string.prototype.matchall@4.0.12: dependencies: call-bind: 1.0.9 @@ -9887,6 +9805,10 @@ snapshots: transitivePeerDependencies: - encoding + synckit@0.11.13: + dependencies: + '@pkgr/core': 0.3.6 + tailwind-gradient-mask-image@1.2.0: {} tailwindcss@3.4.19(yaml@2.8.3): @@ -10371,10 +10293,4 @@ snapshots: toposort: 2.0.2 type-fest: 2.19.0 - zod-validation-error@4.0.2(zod@4.3.6): - dependencies: - zod: 4.3.6 - - zod@4.3.6: {} - zwitch@2.0.4: {} From 8c49a7dcc28f4ba7806be1092432018ae9cde680 Mon Sep 17 00:00:00 2001 From: Erimel Date: Mon, 13 Jul 2026 17:08:14 -0400 Subject: [PATCH 241/350] Skeleton Settings communication --- gui/app/public/i18n/en/translation.ftl | 8 +- .../pages/components/TrackingSettings.tsx | 10 +- .../main/java/dev/slimevr/solarxr/module.kt | 3 +- ...eletonconfig.kt => skeletonproportions.kt} | 2 +- .../dev/slimevr/solarxr/skeletonsettings.kt | 94 +++++++++++++++++++ 5 files changed, 106 insertions(+), 11 deletions(-) rename server/core/src/main/java/dev/slimevr/solarxr/{skeletonconfig.kt => skeletonproportions.kt} (98%) create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt diff --git a/gui/app/public/i18n/en/translation.ftl b/gui/app/public/i18n/en/translation.ftl index 1a603299ea..52930eb54a 100644 --- a/gui/app/public/i18n/en/translation.ftl +++ b/gui/app/public/i18n/en/translation.ftl @@ -629,11 +629,11 @@ settings-general-fk_settings-arm_fk-forward = Forward settings-general-fk_settings-arm_fk-forward-description = Expects your arms to be raised forward at 90 degrees. Useful for VTubing. settings-general-fk_settings-skeleton_settings-ratios = Skeleton ratios settings-general-fk_settings-skeleton_settings-ratios-description = Change the values of skeleton settings. You may need to adjust your proportions after changing these. -settings-general-fk_settings-skeleton_settings-impute_spine_from_upper_lower = Impute missing spine trackers from upper to lower +settings-general-fk_settings-skeleton_settings-impute_spine_from_upper_to_lower = Impute missing spine trackers from upper to lower settings-general-fk_settings-skeleton_settings-impute_spine_curvature = Spine curvature when missing adjacent spine trackers -settings-general-fk_settings-skeleton_settings-interp_hip_legs = Average the hip's yaw and roll with the legs' -settings-general-fk_settings-skeleton_settings-interp_knee_tracker_ankle = Average the knee trackers' yaw and roll with the ankles' -settings-general-fk_settings-skeleton_settings-interp_knee_ankle = Average the knees' yaw and roll with the ankles' +settings-general-fk_settings-skeleton_settings-interpolate_hip_with_knees = Interpolate the hip with the knees +settings-general-fk_settings-skeleton_settings-interpolate_computed_knees_with_ankles = Interpolate the computed knees with the ankles +settings-general-fk_settings-skeleton_settings-interpolate_knees_with_ankles = Interpolate the knees with the ankles settings-general-fk_settings-self_localization-title = Mocap mode settings-general-fk_settings-self_localization-description = Mocap Mode allows the skeleton to roughly track its own position without a headset or other trackers. Note that this requires feet and head trackers to work and is still experimental. diff --git a/gui/app/src/components/settings/pages/components/TrackingSettings.tsx b/gui/app/src/components/settings/pages/components/TrackingSettings.tsx index 5478f24ec5..aa5a7358f8 100644 --- a/gui/app/src/components/settings/pages/components/TrackingSettings.tsx +++ b/gui/app/src/components/settings/pages/components/TrackingSettings.tsx @@ -75,7 +75,7 @@ const defaultValues: SkeletonForm = { imputeSpineCurvature: 0.5, interpolateHipWithKnees: 0.25, interpolateComputedKneesWithAnkles: 0.85, - interpolateKneesWithAnkles: 0.2, + interpolateKneesWithAnkles: 0.0, skatingCorrectionStrength: 0.3, }, filtering: { amount: 0.1, type: FilteringType.NONE }, @@ -466,7 +466,7 @@ export function TrackingSettings() { control={control} name="ratios.imputeSpineFromUpperToLower" label={l10n.getString( - 'settings-general-fk_settings-skeleton_settings-impute_spine_from_upper_lower' + 'settings-general-fk_settings-skeleton_settings-impute_spine_from_upper_to_lower' )} valueLabelFormat={(value) => percentageFormat.format(value)} min={0.0} @@ -488,7 +488,7 @@ export function TrackingSettings() { control={control} name="ratios.interpolateHipWithKnees" label={l10n.getString( - 'settings-general-fk_settings-skeleton_settings-interp_hip_legs' + 'settings-general-fk_settings-skeleton_settings-interpolate_hip_with_knees' )} valueLabelFormat={(value) => percentageFormat.format(value)} min={0.0} @@ -499,7 +499,7 @@ export function TrackingSettings() { control={control} name="ratios.interpolateComputedKneesWithAnkles" label={l10n.getString( - 'settings-general-fk_settings-skeleton_settings-interp_knee_tracker_ankle' + 'settings-general-fk_settings-skeleton_settings-interpolate_computed_knees_with_ankles' )} valueLabelFormat={(value) => percentageFormat.format(value)} min={0.0} @@ -510,7 +510,7 @@ export function TrackingSettings() { control={control} name="ratios.interpolateKneesWithAnkles" label={l10n.getString( - 'settings-general-fk_settings-skeleton_settings-interp_knee_ankle' + 'settings-general-fk_settings-skeleton_settings-interpolate_knees_with_ankles' )} valueLabelFormat={(value) => percentageFormat.format(value)} min={0.0} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 3432ecd643..eefe4d8fc6 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -77,7 +77,8 @@ class SolarXRBridge( add(VmcBehaviour(appContext.config.settings)) add(HeightCalibrationBehaviour(appContext.heightCalibrationManager)) add(ProvisioningBehaviour(appContext.server, appContext.provisioningManager)) - add(SkeletonBehaviour(appContext.config.userConfig, appContext.skeleton)) + add(SkeletonSettingsBehaviour(appContext.config.settings)) + add(SkeletonProportionsBehaviour(appContext.config.userConfig, appContext.skeleton)) add(TrackingChecklistBehaviour(appContext.trackingChecklist, appContext.config.settings)) add(AssignTrackerBehaviour(appContext.server)) add(MagBehaviour(appContext)) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeletonproportions.kt similarity index 98% rename from server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt rename to server/core/src/main/java/dev/slimevr/solarxr/skeletonproportions.kt index 838eeea6cc..a3a7843cbe 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeletonconfig.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeletonproportions.kt @@ -17,7 +17,7 @@ import solarxr_protocol.rpc.SkeletonProportionsResponse private const val MIN_HEIGHT = 1.0f -class SkeletonBehaviour( +class SkeletonProportionsBehaviour( private val userConfig: UserConfig, private val skeleton: Skeleton, ) : SolarXRBridgeBehaviour { diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt new file mode 100644 index 0000000000..f84f377590 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt @@ -0,0 +1,94 @@ +package dev.slimevr.solarxr + +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions +import dev.slimevr.config.SkeletonConfig +import dev.slimevr.config.SkeletonFilteringConfig +import dev.slimevr.config.SkeletonRatiosConfig +import dev.slimevr.config.SkeletonTogglesConfig +import solarxr_protocol.rpc.ChangeSkeletonSettingsRequest +import solarxr_protocol.rpc.SkeletonFiltering +import solarxr_protocol.rpc.SkeletonRatios +import solarxr_protocol.rpc.SkeletonSettingsRequest +import solarxr_protocol.rpc.SkeletonSettingsResponse +import solarxr_protocol.rpc.SkeletonToggles + +class SkeletonSettingsBehaviour( + private val settings: Settings, +) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { + // Send config + receiver.rpcDispatcher.on { + val config = settings.context.state.value.data.skeletonConfig + receiver.sendRpc( + SkeletonSettingsResponse( + toggles = SkeletonToggles( + forceArmsFromHmd = config.toggles.forceArmsFromHmd, + floorClip = config.toggles.floorClip, + skatingCorrection = config.toggles.skatingCorrection, + toeSnap = config.toggles.toeSnap, + footPlant = config.toggles.footPlant, + mocapMode = config.toggles.mocapMode, + useTrackerPositions = config.toggles.useTrackerPositions, + enforceConstraints = config.toggles.enforceConstraints, + correctConstraints = config.toggles.correctConstraints, + ), + ratios = SkeletonRatios( + imputeSpineFromUpperToLower = config.ratios.imputeSpineFromUpperToLower, + imputeSpineCurvature = config.ratios.imputeSpineCurvature, + interpolateHipWithKnees = config.ratios.interpolateHipWithKnees, + interpolateComputedKneesWithAnkles = config.ratios.interpolateComputedKneesWithAnkles, + interpolateKneesWithAnkles = config.ratios.interpolateKneesWithAnkles, + skatingCorrectionStrength = config.ratios.skatingCorrectionStrength, + ), + filtering = SkeletonFiltering( + type = config.filtering.type, + amount = config.filtering.amount, + ), + ), + ) + } + + // Receive config + receiver.rpcDispatcher.on { req -> + val oldConfig = settings.context.state.value.data.skeletonConfig + settings.context.dispatch( + SettingsActions.Update { + copy( + skeletonConfig = SkeletonConfig( + toggles = req.toggles?.let { + SkeletonTogglesConfig( + forceArmsFromHmd = it.forceArmsFromHmd == true, + floorClip = it.floorClip == true, + skatingCorrection = it.skatingCorrection == true, + toeSnap = it.toeSnap == true, + footPlant = it.footPlant == true, + mocapMode = it.mocapMode == true, + useTrackerPositions = it.useTrackerPositions == true, + enforceConstraints = it.enforceConstraints == true, + correctConstraints = it.correctConstraints == true, + ) + } ?: oldConfig.toggles, + ratios = req.ratios?.let { + SkeletonRatiosConfig( + imputeSpineFromUpperToLower = it.imputeSpineFromUpperToLower ?: oldConfig.ratios.imputeSpineFromUpperToLower, + imputeSpineCurvature = it.imputeSpineCurvature ?: oldConfig.ratios.imputeSpineCurvature, + interpolateHipWithKnees = it.interpolateHipWithKnees ?: oldConfig.ratios.interpolateHipWithKnees, + interpolateComputedKneesWithAnkles = it.interpolateComputedKneesWithAnkles ?: oldConfig.ratios.interpolateComputedKneesWithAnkles, + interpolateKneesWithAnkles = it.interpolateKneesWithAnkles ?: oldConfig.ratios.interpolateKneesWithAnkles, + skatingCorrectionStrength = it.skatingCorrectionStrength ?: oldConfig.ratios.skatingCorrectionStrength, + ) + } ?: oldConfig.ratios, + filtering = req.filtering?.let { + SkeletonFilteringConfig( + type = it.type ?: oldConfig.filtering.type, + amount = it.amount ?: oldConfig.filtering.amount, + ) + } ?: oldConfig.filtering, + ), + ) + }, + ) + } + } +} From 6378eb30cdcdcc40410d30ddffdbd379f5c367b3 Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 14 Jul 2026 15:53:31 -0400 Subject: [PATCH 242/350] OutputTrackerToggle --- .../pages/components/OutputSettings.tsx | 18 ++-- .../dev/slimevr/android/ForegroundService.kt | 3 + .../src/main/java/dev/slimevr/app-context.kt | 4 + .../main/java/dev/slimevr/config/settings.kt | 3 + .../slimevr/outputtrackertoggle/behaviours.kt | 86 +++++++++++++++++++ .../dev/slimevr/outputtrackertoggle/module.kt | 44 ++++++++++ .../main/java/dev/slimevr/solarxr/module.kt | 1 + .../dev/slimevr/solarxr/outputtrackers.kt | 44 ++++++++++ .../src/main/java/dev/slimevr/desktop/Main.kt | 3 + 9 files changed, 197 insertions(+), 9 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt create mode 100644 server/core/src/main/java/dev/slimevr/outputtrackertoggle/module.kt create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/outputtrackers.kt diff --git a/gui/app/src/components/settings/pages/components/OutputSettings.tsx b/gui/app/src/components/settings/pages/components/OutputSettings.tsx index f86be4c7ce..3eb6be8bdf 100644 --- a/gui/app/src/components/settings/pages/components/OutputSettings.tsx +++ b/gui/app/src/components/settings/pages/components/OutputSettings.tsx @@ -54,10 +54,10 @@ const defaultValues: OutputTrackersForm = { // The tracker is at the tail of the bone. // Example, the waist tracker is at the tail of the hip bone so we use the hip BodyPart -function trackersToBodyPartList( +function togglesToBodyParts( trackers: OutputTrackersForm['trackers'] ): BodyPart[] { - const enabledBodyParts: [boolean, BodyPart][] = [ + const toggleToBodyPart: [boolean, BodyPart][] = [ [trackers.waist, BodyPart.HIP], [trackers.chest, BodyPart.CHEST], [trackers.leftFoot, BodyPart.LEFT_FOOT], @@ -70,12 +70,12 @@ function trackersToBodyPartList( [trackers.rightHand, BodyPart.RIGHT_HAND], ]; - return enabledBodyParts + return toggleToBodyPart .filter(([enabled]) => enabled) .map(([, part]) => part); } -function bodyPartListToTrackers( +function bodyPartsToTrackers( bodyParts: BodyPart[] ): OutputTrackersForm['trackers'] { const set = new Set(bodyParts); @@ -146,7 +146,7 @@ export function OutputTrackersSettings() { blockHandsWarning.current = false; } - settingsReq.trackers = trackersToBodyPartList({ + settingsReq.trackers = togglesToBodyParts({ ...values.trackers, leftHand, rightHand, @@ -176,14 +176,14 @@ export function OutputTrackersSettings() { if (settings.trackers) { formData = { - ...bodyPartListToTrackers(settings.trackers), + trackers: bodyPartsToTrackers(settings.trackers), automaticTrackerToggle: settings.automaticTrackerToggle, sendDerivedVelocity: settings.sendDerivedVelocity, }; if ( - !blockHandsWarning.current && - (settings.trackers.includes(BodyPart.LEFT_HAND) || - settings.trackers.includes(BodyPart.RIGHT_HAND)) + !blockHandsWarning.current && + (settings.trackers.includes(BodyPart.LEFT_HAND) || + settings.trackers.includes(BodyPart.RIGHT_HAND)) ) { blockHandsWarning.current = true; } diff --git a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt index bd0f749e84..e9269b381a 100644 --- a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt +++ b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt @@ -29,6 +29,7 @@ import dev.slimevr.config.AppConfig import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.networkprofile.NetworkProfileManager +import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resets.ResetsManager import dev.slimevr.skeleton.Skeleton @@ -145,6 +146,7 @@ class ForegroundService : Service() { val heightCalibrationManager = HeightCalibrationManager.create(ctx = phase1, scope = scope) val trackingChecklist = TrackingChecklist.create(scope = scope) val udpServer = UdpServer.create(scope = scope, addressResolver = ::resolveAndroidUdpAddress) + val outputTrackerToggle = OutputTrackerToggleManager.create(ctx = phase1, scope = scope) val bvhManager = BVHManager.create(skeleton = skeleton, storage = storage, scope = scope) val vmcManager = VMCManager.create(ctx = phase1, skeleton = skeleton, scope = scope) val vrcOscManager = VRCOSCManager.create( @@ -168,6 +170,7 @@ class ForegroundService : Service() { heightCalibrationManager = heightCalibrationManager, trackingChecklist = trackingChecklist, udpServer = udpServer, + outputTrackerToggle = outputTrackerToggle, bvhManager = bvhManager, vmcManager = vmcManager, vrcOscManager = vrcOscManager, diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index 16cea2d0a8..9fd9cec10f 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -1,5 +1,6 @@ package dev.slimevr +import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.bvh.BVHManager import dev.slimevr.config.AppConfig import dev.slimevr.firmware.FirmwareManager @@ -38,6 +39,7 @@ interface AppContextProvider : Phase1ContextProvider { val heightCalibrationManager: HeightCalibrationManager val trackingChecklist: TrackingChecklist val udpServer: UdpServer + val outputTrackerToggle: OutputTrackerToggleManager val bvhManager: BVHManager val vmcManager: VMCManager val vrcOscManager: VRCOSCManager @@ -60,6 +62,7 @@ class AppContext( override val heightCalibrationManager: HeightCalibrationManager, override val trackingChecklist: TrackingChecklist, override val udpServer: UdpServer, + override val outputTrackerToggle: OutputTrackerToggleManager, override val bvhManager: BVHManager, override val vmcManager: VMCManager, override val vrcOscManager: VRCOSCManager, @@ -75,6 +78,7 @@ class AppContext( networkProfileManager?.startObserving() trackingChecklist.startObserving(this) udpServer.startReceiving(this, server.context.scope) + outputTrackerToggle.startObserving() vmcManager.startObserving() vrcOscManager.startObserving(this) resetsManager.startObserving() diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 9b52f87c58..a2b4918e39 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -43,6 +43,9 @@ data class HidConfig( @Serializable data class OutputTrackersConfig( val automaticTrackerToggle: Boolean = true, + /** + * Do not read from directly, instead use + */ @Serializable(with = BodyPartListSerializer::class) val trackers: List = listOf(), val sendDerivedVelocity: Boolean = false, diff --git a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt new file mode 100644 index 0000000000..2508248183 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt @@ -0,0 +1,86 @@ +package dev.slimevr.outputtrackertoggle + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.rpc.OutputTrackersSettingsResponse +import kotlin.collections.filter +import kotlin.collections.map + +private val automaticToggleToTracker = mapOf( + BodyPart.CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST), + BodyPart.LEFT_UPPER_ARM to setOf(BodyPart.LEFT_UPPER_ARM, BodyPart.LEFT_LOWER_ARM), + BodyPart.RIGHT_UPPER_ARM to setOf(BodyPart.RIGHT_UPPER_ARM, BodyPart.RIGHT_LOWER_ARM), + BodyPart.HIP to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), + BodyPart.LEFT_UPPER_LEG to setOf(BodyPart.LEFT_UPPER_LEG), + BodyPart.RIGHT_UPPER_LEG to setOf(BodyPart.RIGHT_UPPER_LEG), + BodyPart.LEFT_FOOT to setOf(BodyPart.LEFT_LOWER_LEG, BodyPart.LEFT_FOOT), + BodyPart.RIGHT_FOOT to setOf(BodyPart.RIGHT_LOWER_LEG, BodyPart.RIGHT_FOOT), +) + +class OutputTrackerToggleBasicBehaviour : OutputTrackerToggleBehaviour { + @OptIn(ExperimentalCoroutinesApi::class) + override fun observe(receiver: OutputTrackerToggleManager) { + receiver.settings.context.state + .map { it.data.outputTrackersConfig } + .distinctUntilChanged() + .onEach { config -> + if (!config.automaticTrackerToggle) { + // Manual + receiver.context.dispatch(OutputTrackerToggleActions.SetOutputTrackers(config.trackers)) + } + } + .filter { it.automaticTrackerToggle } // Automatic + .flatMapLatest { config -> + receiver.server.context.state + .map { it.trackers.values } + .flatMapLatest { trackers -> + combine(trackers.map { it.context.state }) { states -> + states.map { it.bodyPart to it.status } + } + .distinctUntilChanged() + .map { trackerStates -> + trackerStates + .filter { it.second == TrackerStatus.OK } + .map { it.first } + .toSet() + } + } + .map { okBodyParts -> config to okBodyParts } + } + .onEach { (config, okBodyParts) -> + val automaticTrackers = automaticToggleToTracker + .filterValues { it.any { bp -> bp in okBodyParts } } + .keys + // Hands aren't toggled automatically + val handTrackers = config.trackers.filter { + it == BodyPart.LEFT_HAND || it == BodyPart.RIGHT_HAND + } + val outputTrackers = (automaticTrackers + handTrackers).toList() + + // Update state + receiver.context.dispatch(OutputTrackerToggleActions.SetOutputTrackers(outputTrackers)) + + // Update SolarXR + receiver.server.sendSolarxrRpc(OutputTrackersSettingsResponse( + automaticTrackerToggle = true, + trackers = outputTrackers, + sendDerivedVelocity = config.sendDerivedVelocity, + ),) + } + .launchIn(receiver.context.scope) + } + + override fun reduce(state: OutputTrackerToggleState, action: OutputTrackerToggleActions) = when (action) { + is OutputTrackerToggleActions.SetOutputTrackers -> { + state.copy(trackers = action.trackers) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/module.kt b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/module.kt new file mode 100644 index 0000000000..384cdc1e15 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/module.kt @@ -0,0 +1,44 @@ +package dev.slimevr.outputtrackertoggle + +import dev.slimevr.Phase1ContextProvider +import dev.slimevr.VRServer +import dev.slimevr.config.Settings +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context +import kotlinx.coroutines.CoroutineScope +import solarxr_protocol.datatypes.BodyPart +import kotlin.collections.listOf + +data class OutputTrackerToggleState( + /** + * The output trackers should be on the tail of the present BodyParts + * Example: for the SteamVR waist tracker, since it uses the hip's rotation and tail position, + * we check for BodyPart.HIP. + */ + val trackers: List, +) + +sealed interface OutputTrackerToggleActions { + data class SetOutputTrackers(val trackers: List) : OutputTrackerToggleActions +} + +typealias OutputTrackerToggleContext = Context +typealias OutputTrackerToggleBehaviour = Behaviour + +class OutputTrackerToggleManager(val context: OutputTrackerToggleContext, val server: VRServer, val settings: Settings) { + fun startObserving() = context.observeAll(this) + + companion object { + fun create(ctx: Phase1ContextProvider, scope: CoroutineScope): OutputTrackerToggleManager { + val context = Context.create( + initialState = OutputTrackerToggleState( + trackers = listOf(), + ), + scope = scope, + behaviours = listOf(OutputTrackerToggleBasicBehaviour()), + name = "OutputTrackerToggleManager", + ) + return OutputTrackerToggleManager(context, ctx.server, ctx.config.settings) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index eefe4d8fc6..ad5a8f012e 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -77,6 +77,7 @@ class SolarXRBridge( add(VmcBehaviour(appContext.config.settings)) add(HeightCalibrationBehaviour(appContext.heightCalibrationManager)) add(ProvisioningBehaviour(appContext.server, appContext.provisioningManager)) + add(OutputTrackersBehaviour(appContext.config.settings, appContext.outputTrackerToggle)) add(SkeletonSettingsBehaviour(appContext.config.settings)) add(SkeletonProportionsBehaviour(appContext.config.userConfig, appContext.skeleton)) add(TrackingChecklistBehaviour(appContext.trackingChecklist, appContext.config.settings)) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/outputtrackers.kt b/server/core/src/main/java/dev/slimevr/solarxr/outputtrackers.kt new file mode 100644 index 0000000000..0e1f4a7721 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/outputtrackers.kt @@ -0,0 +1,44 @@ +package dev.slimevr.solarxr + +import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager +import dev.slimevr.config.OutputTrackersConfig +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions +import solarxr_protocol.rpc.ChangeOutputTrackersSettingsRequest +import solarxr_protocol.rpc.OutputTrackersSettingsRequest +import solarxr_protocol.rpc.OutputTrackersSettingsResponse + +class OutputTrackersBehaviour( + private val settings: Settings, + private val outputTrackerToggleManager: OutputTrackerToggleManager, +) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { + // Send config + receiver.rpcDispatcher.on { + val config = settings.context.state.value.data.outputTrackersConfig + receiver.sendRpc( + OutputTrackersSettingsResponse( + automaticTrackerToggle = config.automaticTrackerToggle, + trackers = outputTrackerToggleManager.context.state.value.trackers, + sendDerivedVelocity = config.sendDerivedVelocity, + ), + ) + } + + // Receive config + receiver.rpcDispatcher.on { req -> + val oldConfig = settings.context.state.value.data.outputTrackersConfig + settings.context.dispatch( + SettingsActions.Update { + copy( + outputTrackersConfig = OutputTrackersConfig( + automaticTrackerToggle = req.automaticTrackerToggle == true, + trackers = req.trackers ?: oldConfig.trackers, + sendDerivedVelocity = req.sendDerivedVelocity == true, + ), + ) + }, + ) + } + } +} 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 353f516d24..f674935ec2 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -27,6 +27,7 @@ import dev.slimevr.desktop.vrchat.resolveDesktopOscQueryAddress import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.networkprofile.NetworkProfileManager +import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resets.ResetsManager import dev.slimevr.resolveConfigDirectory @@ -91,6 +92,7 @@ fun main(args: Array) = runBlocking { } }) val udpServer = UdpServer.create(scope = this, addressResolver = ::resolveDesktopUdpAddress) + val outputTrackerToggle = OutputTrackerToggleManager.create(ctx = phase1, scope = this) val bvhManager = BVHManager.create(skeleton = skeleton, storage = storage, scope = this) val vmcManager = VMCManager.create(ctx = phase1, skeleton = skeleton, scope = this) val vrcOscManager = VRCOSCManager.create( @@ -114,6 +116,7 @@ fun main(args: Array) = runBlocking { heightCalibrationManager = heightCalibrationManager, trackingChecklist = trackingChecklist, udpServer = udpServer, + outputTrackerToggle = outputTrackerToggle, bvhManager = bvhManager, vmcManager = vmcManager, vrcOscManager = vrcOscManager, From 20be49e4b03eab3a1ca53017e2bec1eb172416b5 Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 14 Jul 2026 16:16:45 -0400 Subject: [PATCH 243/350] Use OutputTrackerToggle for VRCOSC trackers --- .../dev/slimevr/android/ForegroundService.kt | 1 + .../main/java/dev/slimevr/vrcosc/module.kt | 5 +++- .../java/dev/slimevr/vrcosc/output-encoder.kt | 23 ++++--------------- .../main/java/dev/slimevr/vrcosc/output.kt | 3 ++- .../src/main/java/dev/slimevr/desktop/Main.kt | 1 + 5 files changed, 12 insertions(+), 21 deletions(-) diff --git a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt index e9269b381a..181c67c75d 100644 --- a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt +++ b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt @@ -153,6 +153,7 @@ class ForegroundService : Service() { ctx = phase1, scope = scope, oscQueryAddress = resolveAndroidOscQueryAddress(), + outputTrackerToggle = outputTrackerToggle, ) val resetsManager = ResetsManager.create(ctx = phase1, scope = scope) val tapDetectionManager = TapDetectionManager.create(ctx = phase1, resetsManager = resetsManager, scope = scope) diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt index f302324749..f1c812f2c7 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt @@ -7,6 +7,7 @@ import dev.slimevr.config.Settings import dev.slimevr.config.VRCOSCConfig import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.CoroutineScope @@ -96,6 +97,7 @@ class VRCOSCManager( val context: VRCOSCContext, val oscQueryAddress: String, val settings: Settings, + val outputTrackerToggle: OutputTrackerToggleManager, ) { val events: EventDispatcher = EventDispatcher() @@ -121,6 +123,7 @@ class VRCOSCManager( ctx: Phase1ContextProvider, scope: CoroutineScope, oscQueryAddress: String, + outputTrackerToggle: OutputTrackerToggleManager, ): VRCOSCManager { val settings = ctx.config.settings val context = Context.create( @@ -131,7 +134,7 @@ class VRCOSCManager( scope = scope, name = "VRCOSC", ) - return VRCOSCManager(context, oscQueryAddress, settings) + return VRCOSCManager(context, oscQueryAddress, settings, outputTrackerToggle) } } } diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt index 458592f235..059260adfb 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt @@ -5,6 +5,7 @@ import dev.slimevr.osc.OscArg import dev.slimevr.osc.OscBundle import dev.slimevr.osc.OscContent import dev.slimevr.osc.OscMessage +import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.skeleton.BoneState import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion @@ -18,18 +19,18 @@ private val trackerIdsByBodyPart = mapOf( BodyPart.RIGHT_FOOT to 3, BodyPart.LEFT_UPPER_LEG to 4, BodyPart.RIGHT_UPPER_LEG to 5, - BodyPart.UPPER_CHEST to 6, + BodyPart.CHEST to 6, BodyPart.LEFT_UPPER_ARM to 7, BodyPart.RIGHT_UPPER_ARM to 8, ) internal fun buildOutgoingBundle( bones: Map, - config: VRCOSCConfig, + outputTrackerToggle: OutputTrackerToggleManager, ): OscBundle? { val messages = buildList { for ((bodyPart, trackerId) in trackerIdsByBodyPart) { - if (!shouldSendTracker(bodyPart, config)) continue + if (bodyPart !in outputTrackerToggle.context.state.value.trackers) continue val bone = bones[bodyPart] ?: continue add( @@ -68,22 +69,6 @@ internal fun buildYawAlignMessage(headRotation: Quaternion): OscMessage { ) } -// TODO read from Output Settings -private fun shouldSendTracker(bodyPart: BodyPart, config: VRCOSCConfig): Boolean = when (bodyPart) { - BodyPart.HIP -> true - - BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> true - - BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG -> true - - // TODO why UPPER_CHEST instead of CHEST? - BodyPart.UPPER_CHEST -> true - - BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_UPPER_ARM -> false - - else -> false -} - private fun positionMessage(address: String, position: Vector3) = OscMessage( address, listOf( diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt index ddf053792b..730387236b 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt @@ -3,6 +3,7 @@ package dev.slimevr.vrcosc import dev.slimevr.AppLogger import dev.slimevr.config.VRCOSCConfig import dev.slimevr.osc.OscSender +import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.Skeleton import io.github.axisangles.ktmath.Quaternion @@ -148,7 +149,7 @@ class VRCOSCOutputBehaviour( val now = System.currentTimeMillis() if (runtime.frameSendFailureActive && now < runtime.nextFrameRetryAt) return - val bundle = buildOutgoingBundle(bones, vrcOscConfig) ?: return + val bundle = buildOutgoingBundle(bones, receiver.outputTrackerToggle) ?: return try { sender.send(bundle) 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 f674935ec2..8a0f024299 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -99,6 +99,7 @@ fun main(args: Array) = runBlocking { ctx = phase1, scope = this, oscQueryAddress = resolveDesktopOscQueryAddress(), + outputTrackerToggle = outputTrackerToggle, ) val resetsManager = ResetsManager.create(ctx = phase1, scope = this) val tapDetectionManager = TapDetectionManager.create(ctx = phase1, resetsManager = resetsManager, scope = this) From abec5ea4b90abe5cb8f1dc42f4746fd41040460d Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 14 Jul 2026 16:47:53 -0400 Subject: [PATCH 244/350] update solarxr --- .../components/settings/pages/components/OutputSettings.tsx | 6 +++--- gui/app/src/components/tracker/TrackerSettings.tsx | 4 ++-- gui/app/src/components/tracker/TrackersTable.tsx | 6 +++--- server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt | 6 ++---- solarxr-protocol | 2 +- 5 files changed, 11 insertions(+), 13 deletions(-) diff --git a/gui/app/src/components/settings/pages/components/OutputSettings.tsx b/gui/app/src/components/settings/pages/components/OutputSettings.tsx index 3eb6be8bdf..0a0d64a9aa 100644 --- a/gui/app/src/components/settings/pages/components/OutputSettings.tsx +++ b/gui/app/src/components/settings/pages/components/OutputSettings.tsx @@ -181,9 +181,9 @@ export function OutputTrackersSettings() { sendDerivedVelocity: settings.sendDerivedVelocity, }; if ( - !blockHandsWarning.current && - (settings.trackers.includes(BodyPart.LEFT_HAND) || - settings.trackers.includes(BodyPart.RIGHT_HAND)) + !blockHandsWarning.current && + (settings.trackers.includes(BodyPart.LEFT_HAND) || + settings.trackers.includes(BodyPart.RIGHT_HAND)) ) { blockHandsWarning.current = true; } diff --git a/gui/app/src/components/tracker/TrackerSettings.tsx b/gui/app/src/components/tracker/TrackerSettings.tsx index b2b2e7f339..75c017d710 100644 --- a/gui/app/src/components/tracker/TrackerSettings.tsx +++ b/gui/app/src/components/tracker/TrackerSettings.tsx @@ -318,9 +318,9 @@ export function TrackerSettingsPage() {
{l10n.getString('tracker-infos-url')} - {tracker?.device?.hardwareInfo?.ipAddress?.addr + {tracker?.device?.hardwareInfo?.ipAddress ? `udp://${IPv4.fromNumber( - tracker?.device?.hardwareInfo?.ipAddress?.addr || 0 + tracker?.device?.hardwareInfo?.ipAddress || 0 ).toString()}` : '--'} diff --git a/gui/app/src/components/tracker/TrackersTable.tsx b/gui/app/src/components/tracker/TrackersTable.tsx index bbd109de74..fa33db39b0 100644 --- a/gui/app/src/components/tracker/TrackersTable.tsx +++ b/gui/app/src/components/tracker/TrackersTable.tsx @@ -263,9 +263,9 @@ function Row({ /> - {tracker?.temp && tracker?.temp?.temp != 0 && ( + {tracker?.temp && tracker?.temp != 0 && ( - {tracker.temp.temp.toFixed(2)} + {tracker.temp.toFixed(2)} )} @@ -290,7 +290,7 @@ function Row({ udp:// {IPv4.fromNumber( - device?.hardwareInfo?.ipAddress?.addr || 0 + device?.hardwareInfo?.ipAddress || 0 ).toString()} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 31304cbb62..c0eac61d39 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -22,9 +22,7 @@ import solarxr_protocol.data_feed.server.ServerGuards import solarxr_protocol.data_feed.tracker_data.TrackerData import solarxr_protocol.data_feed.tracker_data.TrackerDataMask import solarxr_protocol.data_feed.tracker_data.TrackerInfo -import solarxr_protocol.datatypes.Ipv4Address import solarxr_protocol.datatypes.MagnetometerStatus -import solarxr_protocol.datatypes.Temperature import solarxr_protocol.datatypes.hardware_info.HardwareInfo import solarxr_protocol.datatypes.hardware_info.HardwareStatus import solarxr_protocol.datatypes.math.Quat @@ -59,7 +57,7 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas null }, tps = if (trackerMask.tps == true) tracker.tps else null, - temp = if (trackerMask.temp == true && tracker.imuTemp != null) Temperature(temp = tracker.imuTemp) else null, + temp = if (trackerMask.temp == true && tracker.imuTemp != null) tracker.imuTemp else null, rawAcceleration = if (trackerMask.rawAcceleration == true) tracker.rawAcceleration.let { Vec3f(it.x, it.y, it.z) } else null, linearAcceleration = if (trackerMask.linearAcceleration == true) tracker.acceleration.let { Vec3f(it.x, it.y, it.z) } else null, rotationReferenceAdjusted = if (trackerMask.rotationReferenceAdjusted == true) tracker.rotation.let { Quat(it.x, it.y, it.z, it.w) } else null, @@ -93,7 +91,7 @@ private fun createDevice( officialBoardType = device.boardType, model = device.mcuType.toString(), firmwareVersion = device.firmware, - ipAddress = Ipv4Address(ipv4AddressFromString(device.address)), + ipAddress = ipv4AddressFromString(device.address), ), trackers = if (trackerMask != null) { trackers.filter { it.deviceId == device.id } diff --git a/solarxr-protocol b/solarxr-protocol index 288d65fc4a..f6e845ab72 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 288d65fc4a7ba063dcec931cdc0d67c7a4b53cdd +Subproject commit f6e845ab720f134340bb67537a4d89bd376cae99 From 68545e52f7ce251b05c3944ee15a9cd26a877bc7 Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 14 Jul 2026 16:57:04 -0400 Subject: [PATCH 245/350] Fix reset guards --- server/core/src/main/java/dev/slimevr/resets/module.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index bcd79aa855..ce69b26f9e 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -76,6 +76,8 @@ class ResetsManager(val context: ResetsContext, val server: VRServer, val settin executeTrackerResets(resetType, bodyParts, settings.context.state.value.data.resetsConfig) + // Update state and config + context.dispatch(ResetsActions.EndReset(resetType)) settings.context.dispatch(SettingsActions.Update { copy(resetsConfig = resetsConfig.copy(lastMountingMethod = MountingMethods.MANUAL)) }) AppLogger.resets.info("${resetType.name} Reset from $resetSourceName") From fc7316cb4bcf50b9c762ff0e4d011ede3348c89c Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 14 Jul 2026 19:03:24 -0400 Subject: [PATCH 246/350] Get rid of unused offsets and fix setting height --- gui/app/public/i18n/en/translation.ftl | 17 ----------------- .../body-proportions/ScaledProportions.tsx | 5 +---- gui/app/src/hooks/manual-proportions.ts | 6 ++---- .../java/dev/slimevr/skeleton/proportions.kt | 10 +--------- solarxr-protocol | 2 +- 5 files changed, 5 insertions(+), 35 deletions(-) diff --git a/gui/app/public/i18n/en/translation.ftl b/gui/app/public/i18n/en/translation.ftl index 52930eb54a..b49a1e03c8 100644 --- a/gui/app/public/i18n/en/translation.ftl +++ b/gui/app/public/i18n/en/translation.ftl @@ -137,10 +137,6 @@ skeleton_bone-UPPER_CHEST-desc = This is the distance from the base of your neck to the middle of your chest. To adjust it, adjust your Torso Length properly and modify it in various positions (sitting down, bending over, lying down, etc.) until your virtual spine matches with your real one. -skeleton_bone-CHEST_OFFSET = Chest Offset -skeleton_bone-CHEST_OFFSET-desc = - This can be adjusted to move your virtual chest tracker up or down in order to aid - with calibration in certain games or applications that may expect it to be higher or lower. skeleton_bone-CHEST = Chest Length skeleton_bone-CHEST-desc = This is the distance from the middle of your chest to the middle of your spine. @@ -156,10 +152,6 @@ skeleton_bone-HIP-desc = This is the distance from your belly button to your hips. To adjust it, set your Torso Length properly and modify it in various positions (sitting down, bending over, lying down, etc.) until your virtual spine matches your real one. -skeleton_bone-HIP_OFFSET = Hip Offset -skeleton_bone-HIP_OFFSET-desc = - This can be adjusted to move your virtual hip tracker up or down in order to aid - with calibration in certain games or applications that may expect it to be on your waist. skeleton_bone-HIPS_WIDTH = Hips Width skeleton_bone-HIPS_WIDTH-desc = This is the distance between the start of your legs. @@ -190,11 +182,6 @@ skeleton_bone-FOOT_SHIFT-desc = It accounts for your lower legs going backwards when standing up straight. To adjust it, set Foot Length to 0, perform a full reset and modify it until your virtual feet line up with the middle of your ankles. -skeleton_bone-SKELETON_OFFSET = Skeleton Offset -skeleton_bone-SKELETON_OFFSET-desc = - This can be adjusted to offset all your trackers forward or backward. - It can be used to help with calibration in certain games or applications - that may expect your trackers to be more forward. skeleton_bone-SHOULDERS_DISTANCE = Shoulders Distance skeleton_bone-SHOULDERS_DISTANCE-desc = This is the vertical distance from the base of your neck to your shoulders. @@ -233,10 +220,6 @@ skeleton_bone-HAND_Z-desc = To adjust it for motion capture, set it to 0. To adjust it for elbow tracking from your controllers, set Arm Length to 0 and modify it until your elbow trackers line up horizontally with your wrists. -skeleton_bone-ELBOW_OFFSET = Elbow Offset -skeleton_bone-ELBOW_OFFSET-desc = - This can be adjusted to move your virtual elbow trackers up or down in order to aid - with VRChat accidentally binding an elbow tracker to the chest. ## Tracker reset buttons reset-reset_all = Reset all proportions diff --git a/gui/app/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx b/gui/app/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx index a389010daa..ca8d5eedd0 100644 --- a/gui/app/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx +++ b/gui/app/src/components/onboarding/pages/body-proportions/ScaledProportions.tsx @@ -287,10 +287,7 @@ export function ScaledProportionsPage() { const skeletonSettingsRequest = new ChangeUserHeightRequestT(); skeletonSettingsRequest.hmdHeight = newHeight; skeletonSettingsRequest.floorHeight = 0; - sendRPCPacket( - RpcMessage.ChangeSkeletonSettingsRequest, - skeletonSettingsRequest - ); + sendRPCPacket(RpcMessage.ChangeUserHeightRequest, skeletonSettingsRequest); sendRPCPacket( RpcMessage.SkeletonProportionsResetAllRequest, new SkeletonProportionsResetAllRequestT() diff --git a/gui/app/src/hooks/manual-proportions.ts b/gui/app/src/hooks/manual-proportions.ts index c2c50749a7..e83be0d63e 100644 --- a/gui/app/src/hooks/manual-proportions.ts +++ b/gui/app/src/hooks/manual-proportions.ts @@ -145,6 +145,7 @@ export function useManualProportions({ type }: { type: 'linear' | 'ratio' }): { const oldGroupTotal = skeleton.skeletonParts .filter(({ bone }) => group.includes(bone)) .reduce((acc, cur) => cur.value + acc, 0); + for (const part of group) { const currentValue = skeleton.skeletonParts.find(({ bone }) => bone === part); if (!currentValue) throw 'invalid state - the bone should exists'; @@ -164,10 +165,7 @@ export function useManualProportions({ type }: { type: 'linear' | 'ratio' }): { const oldGroupTotal = skeleton.skeletonParts .filter(({ bone }) => group.includes(bone)) .reduce((acc, cur) => cur.value + acc, 0); - let newValue = part.value + oldGroupTotal * params.newValue; // the new ratio is computed from the group size and not the bone - if (newValue <= 0) - // Prevent ratios from getting below zero - newValue = 0; + const newValue = Math.max(part.value + oldGroupTotal * params.newValue, 0); // the new ratio is computed from the group size and not the bone sendRPCPacket( RpcMessage.ChangeSkeletonProportionsRequest, diff --git a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt index 22373eb84a..adb7608092 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt @@ -7,29 +7,25 @@ import solarxr_protocol.rpc.SkeletonBone import kotlin.collections.map import kotlin.collections.plus -// Placeholder, move this to config defaults somehow +// TODO : Placeholder, move this to config defaults somehow val DEFAULT_PROPORTIONS = mapOf( SkeletonBone.HEAD to 0.1f, SkeletonBone.NECK to 0.1f, SkeletonBone.UPPER_CHEST to 0.16f, SkeletonBone.CHEST to 0.16f, - SkeletonBone.CHEST_OFFSET to 0f, SkeletonBone.WAIST to 0.2f, SkeletonBone.HIP to 0.04f, - SkeletonBone.HIP_OFFSET to 0f, SkeletonBone.HIPS_WIDTH to 0.26f, SkeletonBone.UPPER_LEG to 0.42f, SkeletonBone.LOWER_LEG to 0.5f, SkeletonBone.FOOT_LENGTH to 0.05f, SkeletonBone.FOOT_SHIFT to -0.05f, - SkeletonBone.SKELETON_OFFSET to 0f, SkeletonBone.SHOULDERS_DISTANCE to 0.08f, SkeletonBone.SHOULDERS_WIDTH to 0.35f, SkeletonBone.UPPER_ARM to 0.26f, SkeletonBone.LOWER_ARM to 0.26f, SkeletonBone.HAND_Y to 0.035f, SkeletonBone.HAND_Z to 0.13f, - SkeletonBone.ELBOW_OFFSET to 0f, ) // Set of SkeletonBones whose lengths sum to standing height (spine + legs). @@ -50,23 +46,19 @@ private val BONE_VALUE_TO_OFFSETS: Map> = m SkeletonBone.NECK to mapOf(BodyPart.NECK to Vector3.NEG_Y), SkeletonBone.UPPER_CHEST to mapOf(BodyPart.UPPER_CHEST to Vector3.NEG_Y), SkeletonBone.CHEST to mapOf(BodyPart.CHEST to Vector3.NEG_Y), - SkeletonBone.CHEST_OFFSET to mapOf(), SkeletonBone.WAIST to mapOf(BodyPart.WAIST to Vector3.NEG_Y), SkeletonBone.HIP to mapOf(BodyPart.HIP to Vector3.NEG_Y), - SkeletonBone.HIP_OFFSET to mapOf(), SkeletonBone.HIPS_WIDTH to mapOf(BodyPart.LEFT_HIP to Vector3.NEG_X / 2f, BodyPart.RIGHT_HIP to Vector3.POS_X / 2f), SkeletonBone.UPPER_LEG to mapOf(BodyPart.LEFT_UPPER_LEG to Vector3.NEG_Y, BodyPart.RIGHT_UPPER_LEG to Vector3.NEG_Y), SkeletonBone.LOWER_LEG to mapOf(BodyPart.LEFT_LOWER_LEG to Vector3.NEG_Y, BodyPart.RIGHT_LOWER_LEG to Vector3.NEG_Y), SkeletonBone.FOOT_LENGTH to mapOf(BodyPart.LEFT_FOOT to Vector3.NEG_Z, BodyPart.RIGHT_FOOT to Vector3.NEG_Z), SkeletonBone.FOOT_SHIFT to mapOf(BodyPart.LEFT_LOWER_LEG to Vector3.NEG_Z, BodyPart.RIGHT_LOWER_LEG to Vector3.NEG_Z), - SkeletonBone.SKELETON_OFFSET to mapOf(), SkeletonBone.SHOULDERS_DISTANCE to mapOf(BodyPart.LEFT_SHOULDER to Vector3.NEG_Y, BodyPart.RIGHT_SHOULDER to Vector3.NEG_Y), SkeletonBone.SHOULDERS_WIDTH to mapOf(BodyPart.LEFT_SHOULDER to Vector3.NEG_X / 2f, BodyPart.RIGHT_SHOULDER to Vector3.POS_X / 2f), SkeletonBone.UPPER_ARM to mapOf(BodyPart.LEFT_UPPER_ARM to Vector3.NEG_Y, BodyPart.RIGHT_UPPER_ARM to Vector3.NEG_Y), SkeletonBone.LOWER_ARM to mapOf(BodyPart.LEFT_LOWER_ARM to Vector3.NEG_Y, BodyPart.RIGHT_LOWER_ARM to Vector3.NEG_Y), SkeletonBone.HAND_Y to mapOf(BodyPart.LEFT_HAND to Vector3.NEG_Y, BodyPart.RIGHT_HAND to Vector3.NEG_Y), SkeletonBone.HAND_Z to mapOf(BodyPart.LEFT_HAND to Vector3.NEG_Z, BodyPart.RIGHT_HAND to Vector3.NEG_Z), - SkeletonBone.ELBOW_OFFSET to mapOf(), ) private val BONE_OFFSET_TO_VALUES: Map> = BONE_VALUE_TO_OFFSETS diff --git a/solarxr-protocol b/solarxr-protocol index f6e845ab72..76eebe263c 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit f6e845ab720f134340bb67537a4d89bd376cae99 +Subproject commit 76eebe263c539a2812fe28ff248a6a56221641a5 From f7c821f3167ec4e6c4f3c1057b6b7803fdea7b08 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 15 Jul 2026 02:00:40 +0200 Subject: [PATCH 247/350] Better output trackers --- .../src/main/java/dev/slimevr/app-context.kt | 2 +- .../java/dev/slimevr/driver/behaviours.kt | 143 +++++++++++++----- .../main/java/dev/slimevr/driver/module.kt | 10 +- .../slimevr/outputtrackertoggle/behaviours.kt | 14 +- .../dev/slimevr/solarxr/outputtrackers.kt | 2 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 132 +++++++++++++++- 6 files changed, 246 insertions(+), 57 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index 9fd9cec10f..48a15b357c 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -1,11 +1,11 @@ package dev.slimevr -import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.bvh.BVHManager import dev.slimevr.config.AppConfig import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.networkprofile.NetworkProfileManager +import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resets.ResetsManager import dev.slimevr.serial.SerialServer diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index de9284cfce..2de74bf8d2 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -6,12 +6,92 @@ import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.TrackerStatus -class DriverBaseBehaviour : DriverBridgeBehaviour { - override fun reduce(state: DriverBridgeState, action: DriverBridgeActions): DriverBridgeState = when (action) { +class DriverOutgoingTrackersBehaviour : DriverBridgeBehaviour { + + override fun observe(receiver: DriverBridge) { + // Should be safe: StateFlow never delivers two emissions concurrently to the same collector. + val subscribedTrackers = mutableSetOf() + + receiver.appContext.skeleton.computed.onEach { skeleton -> + val enabledBodyParts = + receiver.appContext.outputTrackerToggle.context.state.value.trackers + val serverState = receiver.appContext.server.context.state.value + + skeleton.forEach { (part, state) -> + val closestTracker = + serverState.trackers.values.find { it.context.state.value.bodyPart == part }?.context?.state?.value + ?: return@forEach + val enabled = + enabledBodyParts.contains(part) // TODO: add paused tracking here + if (closestTracker.origin == DeviceOrigin.DRIVER) return@forEach + val closestDevice = + serverState.devices[closestTracker.deviceId]?.context?.state?.value + ?: error("device should exists") + + val newTracker = subscribedTrackers.add(part.value) + if (newTracker) { + receiver.outbound.emit( + DriverBridgeOutbound.TrackerAdded( + trackerId = part.value.toInt(), + part = part, + serial = closestTracker.hardwareId, + name = closestTracker.customName ?: closestTracker.name, + manufacturer = closestDevice.manufacturer, + ), + ) + } + + if (enabled) { + receiver.outbound.emit( + DriverBridgeOutbound.TrackerPosition( + trackerId = part.value.toInt(), + rotation = state.rotation, + position = state.tailPosition, + ), + ) + + receiver.outbound.emit( + DriverBridgeOutbound.TrackerStatus( + trackerId = part.value.toInt(), + battery = closestDevice.batteryLevel, + charging = closestDevice.batteryVoltage != null && closestDevice.batteryVoltage >= 4.3f, + status = closestTracker.status, + ), + ) + } else { + receiver.outbound.emit( + DriverBridgeOutbound.TrackerPosition( + trackerId = part.value.toInt(), + rotation = Quaternion.NULL, + position = Vector3.NULL, + ), + ) + + receiver.outbound.emit( + DriverBridgeOutbound.TrackerStatus( + trackerId = part.value.toInt(), + status = TrackerStatus.DISCONNECTED, + battery = 0f, + charging = false, + ), + ) + } + } + }.launchIn(receiver.context.scope) + } +} + +class DriverIncomingTrackersBehaviour : DriverBridgeBehaviour { + override fun reduce( + state: DriverBridgeState, + action: DriverBridgeActions, + ): DriverBridgeState = when (action) { is DriverBridgeActions.AddTracker -> state.copy(trackers = state.trackers + (action.id to action.trackerId)) is DriverBridgeActions.UpdateProtocolVersion -> state.copy(protocolVersion = action.version) } @@ -22,7 +102,13 @@ class DriverBaseBehaviour : DriverBridgeBehaviour { } receiver.inbound.on { event -> - handleTrackerAdded(receiver, event.id, event.name, event.manufacturer, event.serial) + handleTrackerAdded( + receiver, + event.id, + event.name, + event.manufacturer, + event.serial, + ) } receiver.inbound.on { event -> @@ -39,7 +125,9 @@ class DriverBaseBehaviour : DriverBridgeBehaviour { receiver.inbound.on { event -> val trackerId = receiver.context.state.value.trackers[event.id] ?: return@on receiver.appContext.server.getTracker(trackerId)?.let { tracker -> - val device = tracker.appContext.server.getDevice(tracker.context.state.value.deviceId) ?: error("could not find device") + val device = + tracker.appContext.server.getDevice(tracker.context.state.value.deviceId) + ?: error("could not find device") device.context.dispatch( DeviceActions.Update { copy( @@ -50,41 +138,15 @@ class DriverBaseBehaviour : DriverBridgeBehaviour { ) } } - - // Should be safe: StateFlow never delivers two emissions concurrently to the same collector. - val subscribedTrackers = mutableSetOf() - - receiver.appContext.server.context.state - .onEach { state -> - state.trackers.values.forEach { tracker -> - val ts = tracker.context.state.value - if (ts.origin == DeviceOrigin.DRIVER) return@forEach - if (subscribedTrackers.add(ts.id)) { - receiver.outbound.emit( - DriverBridgeOutbound.TrackerAdded( - trackerId = ts.id, - serial = ts.hardwareId, - name = ts.customName ?: ts.name, - ), - ) - tracker.context.state - .onEach { trackerState -> - receiver.outbound.emit( - DriverBridgeOutbound.TrackerPosition( - trackerId = trackerState.id, - rotation = trackerState.rawRotation, - position = trackerState.position, - ), - ) - } - .launchIn(receiver.context.scope) - } - } - } - .launchIn(receiver.context.scope) } - private fun handleTrackerAdded(receiver: DriverBridge, id: Int, name: String, manufacturer: String, serial: String) { + private fun handleTrackerAdded( + receiver: DriverBridge, + id: Int, + name: String, + manufacturer: String, + serial: String, + ) { val server = receiver.appContext.server val scope = server.context.scope val existingTracker = server.context.state.value.trackers.values @@ -125,7 +187,12 @@ class DriverBaseBehaviour : DriverBridgeBehaviour { Pair(newDevice, tracker) } - receiver.context.dispatch(DriverBridgeActions.AddTracker(id, tracker.context.state.value.id)) + receiver.context.dispatch( + DriverBridgeActions.AddTracker( + id, + tracker.context.state.value.id, + ), + ) tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) device.context.dispatch(DeviceActions.Update { copy(protocolVersion = 0) }) } diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt index 04b9f9c58e..55ac063f58 100644 --- a/server/core/src/main/java/dev/slimevr/driver/module.kt +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -10,6 +10,7 @@ import dev.slimevr.tracker.TrackerActions import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope +import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.TrackerStatus data class DriverBridgeState( @@ -31,7 +32,8 @@ sealed interface DriverBridgeInbound { } sealed interface DriverBridgeOutbound { - data class TrackerAdded(val trackerId: Int, val serial: String, val name: String) : DriverBridgeOutbound + data class TrackerAdded(val trackerId: Int, val part: BodyPart, val serial: String, val name: String, val manufacturer: String) : DriverBridgeOutbound + data class TrackerStatus(val trackerId: Int, val status: solarxr_protocol.datatypes.TrackerStatus, val battery: Float?, val charging: Boolean) : DriverBridgeOutbound data class TrackerPosition(val trackerId: Int, val rotation: Quaternion, val position: Vector3?) : DriverBridgeOutbound } @@ -54,15 +56,13 @@ class DriverBridge( dispose() appContext.server.context.dispatch(VRServerActions.DriverDisconnected(id)) context.state.value.trackers.forEach { (_, id) -> - appContext.server.getTracker(id)?.let { - it.context.dispatch(TrackerActions.SetStatus(TrackerStatus.DISCONNECTED)) - } + appContext.server.getTracker(id)?.context?.dispatch(TrackerActions.SetStatus(TrackerStatus.DISCONNECTED)) } } companion object { fun create(id: Int, appContext: AppContextProvider, scope: CoroutineScope): DriverBridge { - val behaviours = listOf(DriverBaseBehaviour()) + val behaviours = listOf(DriverOutgoingTrackersBehaviour(), DriverIncomingTrackersBehaviour()) val managedContext = ManagedContext.create( initialState = DriverBridgeState( diff --git a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt index 2508248183..bb67b751a3 100644 --- a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt @@ -69,11 +69,13 @@ class OutputTrackerToggleBasicBehaviour : OutputTrackerToggleBehaviour { receiver.context.dispatch(OutputTrackerToggleActions.SetOutputTrackers(outputTrackers)) // Update SolarXR - receiver.server.sendSolarxrRpc(OutputTrackersSettingsResponse( - automaticTrackerToggle = true, - trackers = outputTrackers, - sendDerivedVelocity = config.sendDerivedVelocity, - ),) + receiver.server.sendSolarxrRpc( + OutputTrackersSettingsResponse( + automaticTrackerToggle = true, + trackers = outputTrackers, + sendDerivedVelocity = config.sendDerivedVelocity, + ), + ) } .launchIn(receiver.context.scope) } @@ -82,5 +84,5 @@ class OutputTrackerToggleBasicBehaviour : OutputTrackerToggleBehaviour { is OutputTrackerToggleActions.SetOutputTrackers -> { state.copy(trackers = action.trackers) } - } + } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/outputtrackers.kt b/server/core/src/main/java/dev/slimevr/solarxr/outputtrackers.kt index 0e1f4a7721..e67ece6e4e 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/outputtrackers.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/outputtrackers.kt @@ -1,9 +1,9 @@ package dev.slimevr.solarxr -import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.config.OutputTrackersConfig import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions +import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import solarxr_protocol.rpc.ChangeOutputTrackersSettingsRequest import solarxr_protocol.rpc.OutputTrackersSettingsRequest import solarxr_protocol.rpc.OutputTrackersSettingsResponse diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 060e679e56..24f7b1c837 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -4,9 +4,11 @@ import dev.slimevr.AppContextProvider import dev.slimevr.AppLogger import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform +import dev.slimevr.desktop.platform.Battery import dev.slimevr.desktop.platform.Position import dev.slimevr.desktop.platform.ProtobufMessage import dev.slimevr.desktop.platform.TrackerAdded +import dev.slimevr.desktop.platform.TrackerStatus import dev.slimevr.desktop.platform.Version import dev.slimevr.driver.DriverBridge import dev.slimevr.driver.DriverBridgeInbound @@ -20,10 +22,57 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import solarxr_protocol.datatypes.BodyPart import java.nio.file.Path import kotlin.io.path.Path import kotlin.io.path.exists +/** + * SteamVR tracker roles to protobuf enum + */ +public enum class TrackerRole( + public val `value`: UByte, +) { + NONE(0.toUByte()), + WAIST(1.toUByte()), + LEFT_FOOT(2.toUByte()), + RIGHT_FOOT(3.toUByte()), + CHEST(4.toUByte()), + LEFT_KNEE(5.toUByte()), + RIGHT_KNEE(6.toUByte()), + LEFT_ELBOW(7.toUByte()), + RIGHT_ELBOW(8.toUByte()), + LEFT_SHOULDER(9.toUByte()), + RIGHT_SHOULDER(10.toUByte()), + LEFT_HAND(11.toUByte()), + RIGHT_HAND(12.toUByte()), + LEFT_CONTROLLER(13.toUByte()), + RIGHT_CONTROLLER(14.toUByte()), + HEAD(15.toUByte()), + NECK(16.toUByte()), + CAMERA(17.toUByte()), + KEYBOARD(18.toUByte()), + HMD(19.toUByte()), + BEACON(20.toUByte()), + GENERIC_CONTROLLER(21.toUByte()), + ; + + public companion object { + public fun fromValue(`value`: UByte): TrackerRole? = entries.firstOrNull { it.value == value } + } +} + +val bodyPartToRole = mapOf( + BodyPart.CHEST to TrackerRole.CHEST, + BodyPart.LEFT_UPPER_ARM to TrackerRole.LEFT_ELBOW, + BodyPart.RIGHT_UPPER_ARM to TrackerRole.RIGHT_ELBOW, + BodyPart.HIP to TrackerRole.WAIST, + BodyPart.LEFT_UPPER_LEG to TrackerRole.LEFT_KNEE, + BodyPart.RIGHT_UPPER_LEG to TrackerRole.RIGHT_KNEE, + BodyPart.LEFT_FOOT to TrackerRole.LEFT_FOOT, + BodyPart.RIGHT_FOOT to TrackerRole.RIGHT_FOOT, +) + const val PROTOCOL_VERSION = 2 private fun getBindingsProviderPath(): Path? { @@ -83,13 +132,63 @@ suspend fun handleDriverConnection( send(ProtobufMessage.ADAPTER.encode(msg)) } - val bridge = DriverBridge.create(id = appContext.server.nextHandle(), appContext = appContext, scope = this) + val bridge = DriverBridge.create( + id = appContext.server.nextHandle(), + appContext = appContext, + scope = this, + ) bridge.outbound.on { event -> - sendMsg(ProtobufMessage(tracker_added = TrackerAdded(tracker_id = event.trackerId, tracker_serial = event.serial, tracker_name = event.name))) + sendMsg( + ProtobufMessage( + tracker_added = TrackerAdded( + tracker_id = event.trackerId, + tracker_serial = event.serial, + tracker_name = event.name, + tracker_role = bodyPartToRole.getOrDefault(event.part, TrackerRole.NONE).value.toInt(), + manufacturer = event.manufacturer, + ), + ), + ) } bridge.outbound.on { event -> - sendMsg(ProtobufMessage(position = Position(tracker_id = event.trackerId, qx = event.rotation.x, qy = event.rotation.y, qz = event.rotation.z, qw = event.rotation.w))) + sendMsg( + ProtobufMessage( + position = Position( + tracker_id = event.trackerId, + qx = event.rotation.x, + qy = event.rotation.y, + qz = event.rotation.z, + qw = event.rotation.w, + x = event.position?.x, + y = event.position?.y, + z = event.position?.z, + ), + ), + ) + } + + bridge.outbound.on { event -> + sendMsg( + ProtobufMessage( + tracker_status = TrackerStatus( + tracker_id = event.trackerId, + status = when (event.status) { + solarxr_protocol.datatypes.TrackerStatus.OK -> TrackerStatus.Status.OK + solarxr_protocol.datatypes.TrackerStatus.ERROR -> TrackerStatus.Status.ERROR + solarxr_protocol.datatypes.TrackerStatus.OCCLUDED -> TrackerStatus.Status.OCCLUDED + solarxr_protocol.datatypes.TrackerStatus.DISCONNECTED, solarxr_protocol.datatypes.TrackerStatus.TIMED_OUT -> TrackerStatus.Status.DISCONNECTED + solarxr_protocol.datatypes.TrackerStatus.BUSY -> TrackerStatus.Status.BUSY + else -> TrackerStatus.Status.ERROR + }, + ), + battery = Battery( + tracker_id = event.trackerId, + battery_level = event.battery ?: 0f, + is_charging = event.charging, + ), + ), + ) } sendMsg(ProtobufMessage(version = Version(protocol_version = PROTOCOL_VERSION))) @@ -100,6 +199,8 @@ suspend fun handleDriverConnection( msg.version?.let { ver -> bridge.inbound.emit(DriverBridgeInbound.Version(ver.protocol_version)) if (ver.protocol_version >= 2) { + // FIXME: multiple launch could be created here bc nothing prevent protocol from changing or getting called again during runtime + // causing a memory leak safeLaunch { startBindingProvider() } @@ -116,14 +217,33 @@ suspend fun handleDriverConnection( ) } msg.battery?.let { bat -> - bridge.inbound.emit(DriverBridgeInbound.TrackerBattery(id = bat.tracker_id, batteryLevel = bat.battery_level, charging = bat.is_charging)) + bridge.inbound.emit( + DriverBridgeInbound.TrackerBattery( + id = bat.tracker_id, + batteryLevel = bat.battery_level * 100, + charging = bat.is_charging, + ), + ) } msg.position?.let { pos -> bridge.inbound.emit( DriverBridgeInbound.TrackerPosition( id = pos.tracker_id, - rotation = Quaternion(w = pos.qw, x = pos.qx, y = pos.qy, z = pos.qz), - position = if (pos.x != null && pos.y != null && pos.z != null) Vector3(pos.x, pos.y, pos.z) else null, + rotation = Quaternion( + w = pos.qw, + x = pos.qx, + y = pos.qy, + z = pos.qz, + ), + position = if (pos.x != null && pos.y != null && pos.z != null) { + Vector3( + pos.x, + pos.y, + pos.z, + ) + } else { + null + }, ), ) } From dbe0acd9fabe60365548d94fd9ef086615f4615f Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 15 Jul 2026 10:17:43 +0200 Subject: [PATCH 248/350] Pause Tracking --- .../java/dev/slimevr/skeleton/behaviours.kt | 12 ++++- .../main/java/dev/slimevr/skeleton/module.kt | 10 ++++- .../dev/slimevr/solarxr/skeletonsettings.kt | 44 ++++++++++++++++--- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index e29100b88f..9ba120aaca 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -111,6 +111,13 @@ class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour } } +class PauseTrackingBehaviour : SkeletonBehaviour { + override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { + is SkeletonActions.PauseTracking -> state.copy(paused = action.pause) + else -> state + } +} + class ComputedSkeletonBehaviour( val hz: Float = 100f, // TODO behaviours like smoothing will behave different based on hz val processors: List = emptyList(), @@ -138,7 +145,10 @@ class ComputedSkeletonBehaviour( // val ikOutput = solver.solve(fk, targets) // receiver.computed.value = ikOutput - receiver.computed.value = fk + + if (!targetState.paused) { + receiver.computed.value = fk + } } catch (e: CancellationException) { throw e } catch (e: Exception) { diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index b9a3048f1e..37ab26b9fe 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -34,7 +34,7 @@ data class BoneState( // get() = tailPosition - headPosition } -data class SkeletonState(val rawBones: Map, val skeletonHeight: Float) +data class SkeletonState(val rawBones: Map, val skeletonHeight: Float, val paused: Boolean) val DEFAULT_SKELETON_STATE: SkeletonState = run { val bones = DEFAULT_BONE_OFFSETS.entries.associate { (bodyPart, tailOffset) -> @@ -44,7 +44,11 @@ val DEFAULT_SKELETON_STATE: SkeletonState = run { } bodyPart to BoneState(bodyPart = bodyPart, offset = tailOffset, rotation = restRotation) } - SkeletonState(rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, offset = bone.offset, rawPosition = Vector3.NULL) }, skeletonHeight = DEFAULT_HEIGHT) + SkeletonState( + rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, offset = bone.offset, rawPosition = Vector3.NULL) }, + skeletonHeight = DEFAULT_HEIGHT, + paused = false, + ) } fun buildBones( @@ -73,6 +77,7 @@ sealed interface SkeletonActions { data class SetBoneRotation(val bodyPart: BodyPart, val rotation: Quaternion) : SkeletonActions data class SetBonePosition(val bodyPart: BodyPart, val position: Vector3) : SkeletonActions data class SetProportions(val lengths: Map) : SkeletonActions + data class PauseTracking(val pause: Boolean) : SkeletonActions } typealias SkeletonContext = Context @@ -87,6 +92,7 @@ class Skeleton( companion object { fun create(scope: CoroutineScope, ctx: Phase1ContextProvider): Skeleton { val behaviours = listOf( + PauseTrackingBehaviour(), BoneTransformBehaviour(), ProportionsBehaviour(), ScaledProportionsBehaviour(ctx.config.userConfig), diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt index f84f377590..6adf2aa4be 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt @@ -6,12 +6,16 @@ import dev.slimevr.config.SkeletonConfig import dev.slimevr.config.SkeletonFilteringConfig import dev.slimevr.config.SkeletonRatiosConfig import dev.slimevr.config.SkeletonTogglesConfig +import dev.slimevr.skeleton.SkeletonActions import solarxr_protocol.rpc.ChangeSkeletonSettingsRequest +import solarxr_protocol.rpc.SetPauseTrackingRequest import solarxr_protocol.rpc.SkeletonFiltering import solarxr_protocol.rpc.SkeletonRatios import solarxr_protocol.rpc.SkeletonSettingsRequest import solarxr_protocol.rpc.SkeletonSettingsResponse import solarxr_protocol.rpc.SkeletonToggles +import solarxr_protocol.rpc.TrackingPauseStateRequest +import solarxr_protocol.rpc.TrackingPauseStateResponse class SkeletonSettingsBehaviour( private val settings: Settings, @@ -71,12 +75,18 @@ class SkeletonSettingsBehaviour( } ?: oldConfig.toggles, ratios = req.ratios?.let { SkeletonRatiosConfig( - imputeSpineFromUpperToLower = it.imputeSpineFromUpperToLower ?: oldConfig.ratios.imputeSpineFromUpperToLower, - imputeSpineCurvature = it.imputeSpineCurvature ?: oldConfig.ratios.imputeSpineCurvature, - interpolateHipWithKnees = it.interpolateHipWithKnees ?: oldConfig.ratios.interpolateHipWithKnees, - interpolateComputedKneesWithAnkles = it.interpolateComputedKneesWithAnkles ?: oldConfig.ratios.interpolateComputedKneesWithAnkles, - interpolateKneesWithAnkles = it.interpolateKneesWithAnkles ?: oldConfig.ratios.interpolateKneesWithAnkles, - skatingCorrectionStrength = it.skatingCorrectionStrength ?: oldConfig.ratios.skatingCorrectionStrength, + imputeSpineFromUpperToLower = it.imputeSpineFromUpperToLower + ?: oldConfig.ratios.imputeSpineFromUpperToLower, + imputeSpineCurvature = it.imputeSpineCurvature + ?: oldConfig.ratios.imputeSpineCurvature, + interpolateHipWithKnees = it.interpolateHipWithKnees + ?: oldConfig.ratios.interpolateHipWithKnees, + interpolateComputedKneesWithAnkles = it.interpolateComputedKneesWithAnkles + ?: oldConfig.ratios.interpolateComputedKneesWithAnkles, + interpolateKneesWithAnkles = it.interpolateKneesWithAnkles + ?: oldConfig.ratios.interpolateKneesWithAnkles, + skatingCorrectionStrength = it.skatingCorrectionStrength + ?: oldConfig.ratios.skatingCorrectionStrength, ) } ?: oldConfig.ratios, filtering = req.filtering?.let { @@ -90,5 +100,27 @@ class SkeletonSettingsBehaviour( }, ) } + + receiver.rpcDispatcher.on { + receiver.appContext.skeleton.context.dispatch( + SkeletonActions.PauseTracking( + it.pauseTracking ?: false, + ), + ) + + receiver.sendRpc( + TrackingPauseStateResponse( + trackingPaused = it.pauseTracking, + ), + ) + } + + receiver.rpcDispatcher.on { + receiver.sendRpc( + TrackingPauseStateResponse( + trackingPaused = receiver.appContext.skeleton.context.state.value.paused, + ), + ) + } } } From 3452e2b000f2e1031bb011e35cd8c39215494963 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 15 Jul 2026 10:58:12 +0200 Subject: [PATCH 249/350] Fix Datafeed tests --- .../src/test/java/dev/slimevr/TestServer.kt | 16 +++++++++ .../HeightCalibrationTest.kt | 34 +++++++++---------- .../java/dev/slimevr/solarxr/DataFeedTest.kt | 5 +++ .../desktop/trackingchecklist/behaviours.kt | 3 +- 4 files changed, 40 insertions(+), 18 deletions(-) diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 134c362962..1c7daf8a9d 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -13,8 +13,11 @@ import dev.slimevr.config.UserConfigData import dev.slimevr.config.UserConfigState import dev.slimevr.context.Context import dev.slimevr.firmware.FirmwareManager +import dev.slimevr.heightcalibration.HeightCalibrationActions import dev.slimevr.heightcalibration.HeightCalibrationManager +import dev.slimevr.heightcalibration.HeightCalibrationState import dev.slimevr.networkprofile.NetworkProfileManager +import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resets.ResetsBasicBehaviour import dev.slimevr.resets.ResetsManager @@ -35,6 +38,7 @@ import dev.slimevr.vrchat.VRCConfigManager import dev.slimevr.vrcosc.VRCOSCManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import solarxr_protocol.rpc.UserHeightCalibrationStatus fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create( openPort = { loc, _, _ -> SerialPortHandle(loc, "Fake $loc", {}, {}) }, @@ -114,6 +118,17 @@ fun buildTestSettings(scope: CoroutineScope): Settings { return Settings(context, scope, NoopConfigStorage, "settings") } +fun buildTestHeightCalibration(server: VRServer, userConfig: UserConfig, scope: CoroutineScope): HeightCalibrationManager { + val initialState = HeightCalibrationState(status = UserHeightCalibrationStatus.NONE, currentHeight = 1.6f, canDoUserHeightCalibration = true) + val context = Context.create( + initialState = initialState, + scope = scope, + behaviours = emptyList(), + name = "Settings[test]", + ) + return HeightCalibrationManager(context, server, userConfig) +} + private object NoopConfigStorage : ConfigStorage { override suspend fun read(path: String): String? = null override suspend fun write(path: String, content: String) = Unit @@ -144,6 +159,7 @@ abstract class TestAppContext : AppContextProvider { override val vrcOscManager: VRCOSCManager get() = error("not used in test") override val resetsManager: ResetsManager get() = error("not used in test") override val tapDetectionManager: TapDetectionManager get() = error("not used in test") + override val outputTrackerToggle: OutputTrackerToggleManager get() = error("not used in test") override fun startObserving() {} override suspend fun dispose() = Unit } diff --git a/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt index 7dd4e4b510..19a7f6d476 100644 --- a/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt @@ -38,7 +38,7 @@ private val FLOOR_POSITION = Vector3(0f, 0.05f, 0f) // Position comfortably above rise threshold (1.2m) with zero floor level private val STANDING_POSITION = Vector3(0f, 1.7f, 0f) -private fun makeContext(scope: kotlinx.coroutines.CoroutineScope) = HeightCalibrationContext.create( +fun testHeightCalibrationContext(scope: kotlinx.coroutines.CoroutineScope) = HeightCalibrationContext.create( initialState = INITIAL_HEIGHT_CALIBRATION_STATE, scope = scope, behaviours = listOf(CalibrationBehaviour()), @@ -93,7 +93,7 @@ private suspend fun TestScope.completeHeightPhase( class HeightCalibrationReducerTest { @Test fun `Update changes status and height`() = runTest { - val context = makeContext(backgroundScope) + val context = testHeightCalibrationContext(backgroundScope) context.dispatch(HeightCalibrationActions.Update(UserHeightCalibrationStatus.RECORDING_HEIGHT, 1.65f)) @@ -105,7 +105,7 @@ class HeightCalibrationReducerTest { class HeightCalibrationSessionTest { @Test fun `session starts in RECORDING_FLOOR`() = runTest { - val context = makeContext(backgroundScope) + val context = testHeightCalibrationContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -117,7 +117,7 @@ class HeightCalibrationSessionTest { @Test fun `controller too high does not change status`() = runTest { - val context = makeContext(backgroundScope) + val context = testHeightCalibrationContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -131,7 +131,7 @@ class HeightCalibrationSessionTest { @Test fun `controller not pointing down transitions to WAITING_FOR_CONTROLLER_PITCH`() = runTest { - val context = makeContext(backgroundScope) + val context = testHeightCalibrationContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -145,7 +145,7 @@ class HeightCalibrationSessionTest { @Test fun `stable floor transitions to WAITING_FOR_RISE`() = runTest { - val context = makeContext(backgroundScope) + val context = testHeightCalibrationContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -159,7 +159,7 @@ class HeightCalibrationSessionTest { @Test fun `HMD below rise threshold stays WAITING_FOR_RISE`() = runTest { - val context = makeContext(backgroundScope) + val context = testHeightCalibrationContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -175,7 +175,7 @@ class HeightCalibrationSessionTest { @Test fun `HMD not leveled transitions to WAITING_FOR_FW_LOOK`() = runTest { - val context = makeContext(backgroundScope) + val context = testHeightCalibrationContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -191,7 +191,7 @@ class HeightCalibrationSessionTest { @Test fun `stable HMD at valid height transitions to DONE`() = runTest { - val context = makeContext(backgroundScope) + val context = testHeightCalibrationContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -207,7 +207,7 @@ class HeightCalibrationSessionTest { @Test fun `stable HMD below HEIGHT_MIN transitions to ERROR_TOO_SMALL`() = runTest { - val context = makeContext(backgroundScope) + val context = testHeightCalibrationContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -222,7 +222,7 @@ class HeightCalibrationSessionTest { @Test fun `stable HMD above HEIGHT_MAX transitions to ERROR_TOO_HIGH`() = runTest { - val context = makeContext(backgroundScope) + val context = testHeightCalibrationContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -237,7 +237,7 @@ class HeightCalibrationSessionTest { @Test fun `unstable floor sample resets controller stability window`() = runTest { - val context = makeContext(backgroundScope) + val context = testHeightCalibrationContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -263,7 +263,7 @@ class HeightCalibrationSessionTest { @Test fun `out-of-threshold HMD sample resets height stability window`() = runTest { - val context = makeContext(backgroundScope) + val context = testHeightCalibrationContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -290,7 +290,7 @@ class HeightCalibrationSessionTest { @Test fun `timeout fires ERROR_TIMEOUT`() = runTest { - val context = makeContext(backgroundScope) + val context = testHeightCalibrationContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = safeLaunch { runCalibrationSession(context, buildTestUserConfig(backgroundScope), hmdFlow, controllerFlow) } @@ -303,7 +303,7 @@ class HeightCalibrationSessionTest { @Test fun `controller pitch recovery leads to WAITING_FOR_RISE`() = runTest { - val context = makeContext(backgroundScope) + val context = testHeightCalibrationContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -321,7 +321,7 @@ class HeightCalibrationSessionTest { @Test fun `leveling HMD after WAITING_FOR_FW_LOOK transitions to RECORDING_HEIGHT`() = runTest { - val context = makeContext(backgroundScope) + val context = testHeightCalibrationContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) @@ -341,7 +341,7 @@ class HeightCalibrationSessionTest { @Test fun `HMD rising above threshold transitions to RECORDING_HEIGHT`() = runTest { - val context = makeContext(backgroundScope) + val context = testHeightCalibrationContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) val job = launchSession(context, hmdFlow, controllerFlow) diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt index 246c140e00..4fe19ac811 100644 --- a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -2,9 +2,11 @@ package dev.slimevr.solarxr import dev.slimevr.EventDispatcher import dev.slimevr.TestAppContext +import dev.slimevr.buildTestHeightCalibration import dev.slimevr.buildTestResetsManager import dev.slimevr.buildTestSettings import dev.slimevr.buildTestSkeleton +import dev.slimevr.buildTestUserConfig import dev.slimevr.buildTestVrServer import dev.slimevr.context.Context import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -21,11 +23,14 @@ private fun testConn(backgroundScope: kotlinx.coroutines.CoroutineScope, onSend: val server = buildTestVrServer(backgroundScope) val skeleton = buildTestSkeleton(backgroundScope) val settings = buildTestSettings(backgroundScope) + val userSettings = buildTestUserConfig(backgroundScope) val resetsManager = buildTestResetsManager(server, settings, backgroundScope) + val heightCalibrationManager = buildTestHeightCalibration(server, userSettings, backgroundScope) val appContext = object : TestAppContext() { override val server = server override val skeleton = skeleton override val resetsManager = resetsManager + override val heightCalibrationManager = heightCalibrationManager } val context = Context.create( initialState = SolarXRBridgeState(dataFeedConfigs = listOf()), diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt b/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt index 24f551d007..b07fdcda35 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.withContext import solarxr_protocol.rpc.TrackingChecklistSteamVRDisconnected import solarxr_protocol.rpc.TrackingChecklistStep import solarxr_protocol.rpc.TrackingChecklistStepId +import solarxr_protocol.rpc.TrackingChecklistStepVisibility import java.io.IOException data class Process(val pid: ULong, val name: String) @@ -150,7 +151,7 @@ class SteamVRCheckBehaviour(private val server: VRServer) : TrackingChecklistBeh .launchIn(receiver.context.scope) standableState .map { installed -> - TrackingChecklistStep(valid = !installed, enabled = true) + TrackingChecklistStep(valid = !installed, enabled = true, visibility = TrackingChecklistStepVisibility.WHEN_INVALID) } .distinctUntilChanged() .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.STANDABLE_INSTALLED, step)) } From 51fda0ad7f18fad5cf4c1186b7f3643a8d117f5a Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 15 Jul 2026 11:54:50 +0200 Subject: [PATCH 250/350] Fix wifi provisioning --- server/core/src/main/java/dev/slimevr/provisioning/phases.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/provisioning/phases.kt b/server/core/src/main/java/dev/slimevr/provisioning/phases.kt index 310f8d5419..c79e1e215e 100644 --- a/server/core/src/main/java/dev/slimevr/provisioning/phases.kt +++ b/server/core/src/main/java/dev/slimevr/provisioning/phases.kt @@ -4,11 +4,11 @@ import dev.slimevr.VRServer import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions import dev.slimevr.firmware.waitForConnected -import dev.slimevr.hid.isCompatibleHidDevice import dev.slimevr.serial.MAC_REGEX import dev.slimevr.serial.SerialConnection import dev.slimevr.serial.SerialConnectionActions import dev.slimevr.serial.SerialServer +import dev.slimevr.serial.isKnownSerialBoard import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.filter @@ -31,7 +31,7 @@ internal suspend fun selectAndOpenPort( serialServer.context.state .mapNotNull { state -> state.availablePorts.entries.firstOrNull { (_, info) -> - isCompatibleHidDevice( + isKnownSerialBoard( info.vendorId, info.productId, ) From bb9436483034e6dce0b9adbf341fd7eae780ffa2 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 15 Jul 2026 12:18:34 +0200 Subject: [PATCH 251/350] Fix packet out of order error bc of heartbeat sending packet num 0 --- .../src/main/java/dev/slimevr/udp/behaviours.kt | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index b733bc57c5..3f8c4d63bc 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -44,17 +44,14 @@ class PacketBehaviour : UDPConnectionBehaviour { receiver.packetEvents.onAny { packet -> val state = receiver.context.state.value val now = System.currentTimeMillis() - if (now - state.lastPacket > CONNECTION_TIMEOUT_MS && packet.packetNumber == 0L) { - receiver.context.dispatch(UDPConnectionActions.LastPacket(packetNum = 0, time = now)) + val num = packet.packetNumber + if (num == 0L && now - state.lastPacket > CONNECTION_TIMEOUT_MS) { AppLogger.udp.info("[${state.address}] Reconnecting") - } else if (packet.packetNumber != null && packet.packetNumber < state.lastPacketNum) { - // Note: Packet number is nullable for bundled packets, as only the bundle packet itself has the number - // the packets inside of it do not - AppLogger.udp.warn("[${state.address}] WARN: Received packet with wrong packet number") + } else if (num != null && num != 0L && num <= state.lastPacketNum) { + AppLogger.udp.warn("[${state.address}] Received packet with wrong packet number") return@onAny - } else { - receiver.context.dispatch(UDPConnectionActions.LastPacket(packetNum = packet.packetNumber, time = now)) } + receiver.context.dispatch(UDPConnectionActions.LastPacket(packetNum = num, time = now)) } } } From 47bd59b4755ffea1aef4370e660466b9770da2a8 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 15 Jul 2026 12:33:25 +0200 Subject: [PATCH 252/350] Make proper check for canDoHeightCalibration --- .../slimevr/heightcalibration/behaviours.kt | 37 ++++++++++++++++++- .../dev/slimevr/heightcalibration/module.kt | 5 ++- .../HeightCalibrationTest.kt | 2 +- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt index b93f3f7ebc..913f12d8ec 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt @@ -1,14 +1,22 @@ -@file:OptIn(kotlinx.coroutines.FlowPreview::class) +@file:OptIn(kotlinx.coroutines.FlowPreview::class, kotlinx.coroutines.ExperimentalCoroutinesApi::class) package dev.slimevr.heightcalibration import dev.slimevr.config.UserConfig import dev.slimevr.config.UserConfigActions import dev.slimevr.skeleton.computeDefaultProportionsByBone +import dev.slimevr.tracker.TrackerState import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.sample import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.withTimeoutOrNull +import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.UserHeightCalibrationStatus import kotlin.math.PI import kotlin.math.cos @@ -54,12 +62,37 @@ private fun isHmdLeveled(snapshot: TrackerSnapshot): Boolean { return (up dot Vector3.POS_Y) >= HEAD_ANGLE_THRESHOLD } -class CalibrationBehaviour : HeightCalibrationBehaviourType { +class BaseCalibrationBehaviour : HeightCalibrationBehaviourType { override fun reduce(state: HeightCalibrationState, action: HeightCalibrationActions) = when (action) { is HeightCalibrationActions.Update -> state.copy( status = action.status, currentHeight = action.currentHeight, ) + + is HeightCalibrationActions.SetCanCalibrate -> state.copy( + canDoUserHeightCalibration = action.canDo, + ) + } + + fun canCalibrate(trackers: List): Boolean { + val hasHmd = trackers.any { it.bodyPart == BodyPart.HEAD && it.position != null } + val hasHand = trackers.any { + (it.bodyPart == BodyPart.LEFT_HAND || it.bodyPart == BodyPart.RIGHT_HAND) && it.position != null + } + return hasHmd && hasHand + } + + override fun observe(receiver: HeightCalibrationManager) { + receiver.serverContext.context.state + .flatMapLatest { state -> + val trackers = state.trackers.values.toList() + if (trackers.isEmpty()) return@flatMapLatest flowOf(false) + // React to per-tracker position/bodyPart changes, not just tracker add/remove. + combine(trackers.map { it.context.state }) { states -> canCalibrate(states.toList()) } + } + .distinctUntilChanged() + .onEach { receiver.context.dispatch(HeightCalibrationActions.SetCanCalibrate(it)) } + .launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt index ec0c3c0a9a..ac59af98e9 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt @@ -31,6 +31,7 @@ data class HeightCalibrationState( sealed interface HeightCalibrationActions { data class Update(val status: UserHeightCalibrationStatus, val currentHeight: Float) : HeightCalibrationActions + data class SetCanCalibrate(val canDo: Boolean) : HeightCalibrationActions } typealias HeightCalibrationContext = Context @@ -39,7 +40,7 @@ typealias HeightCalibrationBehaviourType = Behaviour Date: Wed, 15 Jul 2026 15:56:16 -0400 Subject: [PATCH 253/350] Nicer syntax --- .../src/main/java/dev/slimevr/driver/behaviours.kt | 4 ++-- .../main/java/dev/slimevr/desktop/ipc/protocol.kt | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 2de74bf8d2..4689344c6e 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -18,12 +18,12 @@ class DriverOutgoingTrackersBehaviour : DriverBridgeBehaviour { // Should be safe: StateFlow never delivers two emissions concurrently to the same collector. val subscribedTrackers = mutableSetOf() - receiver.appContext.skeleton.computed.onEach { skeleton -> + receiver.appContext.skeleton.computed.onEach { computedBones -> val enabledBodyParts = receiver.appContext.outputTrackerToggle.context.state.value.trackers val serverState = receiver.appContext.server.context.state.value - skeleton.forEach { (part, state) -> + computedBones.forEach { (part, state) -> val closestTracker = serverState.trackers.values.find { it.context.state.value.bodyPart == part }?.context?.state?.value ?: return@forEach diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 24f7b1c837..5ef2140c2d 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import solarxr_protocol.datatypes.BodyPart +import java.io.File import java.nio.file.Path import kotlin.io.path.Path import kotlin.io.path.exists @@ -30,8 +31,8 @@ import kotlin.io.path.exists /** * SteamVR tracker roles to protobuf enum */ -public enum class TrackerRole( - public val `value`: UByte, +enum class TrackerRole( + val value: UByte, ) { NONE(0.toUByte()), WAIST(1.toUByte()), @@ -57,8 +58,8 @@ public enum class TrackerRole( GENERIC_CONTROLLER(21.toUByte()), ; - public companion object { - public fun fromValue(`value`: UByte): TrackerRole? = entries.firstOrNull { it.value == value } + companion object { + fun fromValue(value: UByte): TrackerRole? = entries.firstOrNull { it.value == value } } } @@ -91,8 +92,7 @@ private fun getBindingsProviderPath(): Path? { // Then look through PATH to find the binary. // PATH shouldn't be null, but if it is just gracefully fail val path = System.getenv("PATH") ?: return null - val separator = System.getProperty("path.separator") - for (path in path.split(separator)) { + for (path in path.split(File.pathSeparator)) { val binaryPath = Path(path, executableName) if (binaryPath.exists()) return binaryPath } From 8c504bbac0a218c7604a4cc1b1738e2999ffe7bc Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 15 Jul 2026 18:54:04 -0400 Subject: [PATCH 254/350] Add OutputTrackerToggle tests --- .../slimevr/outputtrackertoggle/behaviours.kt | 47 +++--- .../OutputTrackerToggleTest.kt | 158 ++++++++++++++++++ 2 files changed, 186 insertions(+), 19 deletions(-) create mode 100644 server/core/src/test/java/dev/slimevr/outputtrackertoggle/OutputTrackerToggleTest.kt diff --git a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt index bb67b751a3..3bb3057b1b 100644 --- a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt @@ -1,5 +1,6 @@ package dev.slimevr.outputtrackertoggle +import dev.slimevr.config.OutputTrackersConfig import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -14,18 +15,32 @@ import solarxr_protocol.rpc.OutputTrackersSettingsResponse import kotlin.collections.filter import kotlin.collections.map -private val automaticToggleToTracker = mapOf( - BodyPart.CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST), - BodyPart.LEFT_UPPER_ARM to setOf(BodyPart.LEFT_UPPER_ARM, BodyPart.LEFT_LOWER_ARM), - BodyPart.RIGHT_UPPER_ARM to setOf(BodyPart.RIGHT_UPPER_ARM, BodyPart.RIGHT_LOWER_ARM), - BodyPart.HIP to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), - BodyPart.LEFT_UPPER_LEG to setOf(BodyPart.LEFT_UPPER_LEG), - BodyPart.RIGHT_UPPER_LEG to setOf(BodyPart.RIGHT_UPPER_LEG), - BodyPart.LEFT_FOOT to setOf(BodyPart.LEFT_LOWER_LEG, BodyPart.LEFT_FOOT), - BodyPart.RIGHT_FOOT to setOf(BodyPart.RIGHT_LOWER_LEG, BodyPart.RIGHT_FOOT), -) - class OutputTrackerToggleBasicBehaviour : OutputTrackerToggleBehaviour { + private val automaticToggleToTracker = mapOf( + BodyPart.CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST), + BodyPart.LEFT_UPPER_ARM to setOf(BodyPart.LEFT_UPPER_ARM, BodyPart.LEFT_LOWER_ARM), + BodyPart.RIGHT_UPPER_ARM to setOf(BodyPart.RIGHT_UPPER_ARM, BodyPart.RIGHT_LOWER_ARM), + BodyPart.HIP to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), + BodyPart.LEFT_UPPER_LEG to setOf(BodyPart.LEFT_UPPER_LEG), + BodyPart.RIGHT_UPPER_LEG to setOf(BodyPart.RIGHT_UPPER_LEG), + BodyPart.LEFT_FOOT to setOf(BodyPart.LEFT_UPPER_LEG, BodyPart.LEFT_LOWER_LEG, BodyPart.LEFT_FOOT), + BodyPart.RIGHT_FOOT to setOf(BodyPart.RIGHT_UPPER_LEG, BodyPart.RIGHT_LOWER_LEG, BodyPart.RIGHT_FOOT), + ) + + fun determineAutomaticOutputTrackers( + config: OutputTrackersConfig, + okBodyParts: Set, + ): List { + val automaticTrackers = automaticToggleToTracker + .filterValues { it.any { bp -> bp in okBodyParts } } + .keys + // Hands aren't toggled automatically + val handTrackers = config.trackers.filter { + it == BodyPart.LEFT_HAND || it == BodyPart.RIGHT_HAND + } + return (automaticTrackers + handTrackers).toList() + } + @OptIn(ExperimentalCoroutinesApi::class) override fun observe(receiver: OutputTrackerToggleManager) { receiver.settings.context.state @@ -56,14 +71,8 @@ class OutputTrackerToggleBasicBehaviour : OutputTrackerToggleBehaviour { .map { okBodyParts -> config to okBodyParts } } .onEach { (config, okBodyParts) -> - val automaticTrackers = automaticToggleToTracker - .filterValues { it.any { bp -> bp in okBodyParts } } - .keys - // Hands aren't toggled automatically - val handTrackers = config.trackers.filter { - it == BodyPart.LEFT_HAND || it == BodyPart.RIGHT_HAND - } - val outputTrackers = (automaticTrackers + handTrackers).toList() + // Get the output trackers based on body parts + val outputTrackers = determineAutomaticOutputTrackers(config, okBodyParts) // Update state receiver.context.dispatch(OutputTrackerToggleActions.SetOutputTrackers(outputTrackers)) diff --git a/server/core/src/test/java/dev/slimevr/outputtrackertoggle/OutputTrackerToggleTest.kt b/server/core/src/test/java/dev/slimevr/outputtrackertoggle/OutputTrackerToggleTest.kt new file mode 100644 index 0000000000..79073eaf9b --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/outputtrackertoggle/OutputTrackerToggleTest.kt @@ -0,0 +1,158 @@ +package dev.slimevr.outputtrackertoggle + +import dev.slimevr.config.OutputTrackersConfig +import solarxr_protocol.datatypes.BodyPart +import kotlin.test.Test +import kotlin.test.assertEquals + +class OutputTrackerToggleTest { + @Test + fun `Output trackers are automatically determined based off body parts`() { + data class TestCase( + val name: String, + val okBodyParts: Set, + val configuredTrackers: List, + val expected: Set, + ) + + val testCases = listOf( + TestCase( + name = "no body part", + okBodyParts = emptySet(), + configuredTrackers = emptyList(), + expected = emptySet(), + ), + TestCase( + name = "upper chest enables chest and hip", + okBodyParts = setOf(BodyPart.UPPER_CHEST), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.CHEST, + BodyPart.HIP, + ), + ), + TestCase( + name = "chest enables chest and hip", + okBodyParts = setOf(BodyPart.CHEST), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.CHEST, + BodyPart.HIP, + ), + ), + TestCase( + name = "waist enables hip", + okBodyParts = setOf(BodyPart.WAIST), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.HIP, + ), + ), + TestCase( + name = "hip enables hip", + okBodyParts = setOf(BodyPart.HIP), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.HIP, + ), + ), + TestCase( + name = "left lower arm enables left upper arm", + okBodyParts = setOf(BodyPart.LEFT_LOWER_ARM), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.LEFT_UPPER_ARM, + ), + ), + TestCase( + name = "right lower arm enables right upper arm", + okBodyParts = setOf(BodyPart.RIGHT_LOWER_ARM), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.RIGHT_UPPER_ARM, + ), + ), + TestCase( + name = "left lower leg enables left foot", + okBodyParts = setOf(BodyPart.LEFT_LOWER_LEG), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.LEFT_FOOT, + ), + ), + TestCase( + name = "right lower leg enables right foot", + okBodyParts = setOf(BodyPart.RIGHT_LOWER_LEG), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.RIGHT_FOOT, + ), + ), + TestCase( + name = "multiple automatic trackers", + okBodyParts = setOf( + BodyPart.LEFT_UPPER_ARM, + BodyPart.RIGHT_FOOT, + BodyPart.HIP, + ), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.LEFT_UPPER_ARM, + BodyPart.RIGHT_FOOT, + BodyPart.HIP, + ), + ), + TestCase( + name = "hands are always included from config", + okBodyParts = emptySet(), + configuredTrackers = listOf( + BodyPart.LEFT_HAND, + BodyPart.RIGHT_HAND, + ), + expected = setOf( + BodyPart.LEFT_HAND, + BodyPart.RIGHT_HAND, + ), + ), + TestCase( + name = "non-hand manual trackers are ignored", + okBodyParts = emptySet(), + configuredTrackers = listOf( + BodyPart.CHEST, + BodyPart.WAIST, + BodyPart.LEFT_HAND, + ), + expected = setOf( + BodyPart.LEFT_HAND, + ), + ), + TestCase( + name = "automatic trackers combined with hands", + okBodyParts = setOf( + BodyPart.LEFT_FOOT, + ), + configuredTrackers = listOf( + BodyPart.RIGHT_HAND, + ), + expected = setOf( + BodyPart.LEFT_FOOT, + BodyPart.RIGHT_HAND, + ), + ), + ) + + val behaviour = OutputTrackerToggleBasicBehaviour() + testCases.forEach { case -> + val result = behaviour.determineAutomaticOutputTrackers( + config = OutputTrackersConfig(trackers = case.configuredTrackers), + okBodyParts = case.okBodyParts, + ) + + assertEquals( + expected = case.expected, + actual = result.toSet(), + message = case.name, + ) + } + } +} From 527f5e0d990b6530f40e6d7cc3c41bb7c6b98e94 Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 16 Jul 2026 16:01:19 -0400 Subject: [PATCH 255/350] Tests fix warnings --- server/core/src/test/java/dev/slimevr/osc/CodecTest.kt | 2 +- .../src/test/java/dev/slimevr/oscquery/OscQueryModelTest.kt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/server/core/src/test/java/dev/slimevr/osc/CodecTest.kt b/server/core/src/test/java/dev/slimevr/osc/CodecTest.kt index 359658c2e8..99135d714a 100644 --- a/server/core/src/test/java/dev/slimevr/osc/CodecTest.kt +++ b/server/core/src/test/java/dev/slimevr/osc/CodecTest.kt @@ -145,7 +145,7 @@ class CodecTest { assertTrue(decodedMsg.args.isNotEmpty(), "Message has no args!") val arg = decodedMsg.args[0] assertTrue(arg is OscArg.Int, "First arg is not Int, got: ${arg::class.simpleName}") - assertEquals(99, (arg as OscArg.Int).value, "Int arg value mismatch") + assertEquals(99, (arg).value, "Int arg value mismatch") } @Test diff --git a/server/core/src/test/java/dev/slimevr/oscquery/OscQueryModelTest.kt b/server/core/src/test/java/dev/slimevr/oscquery/OscQueryModelTest.kt index d8ee11addd..5edf7ca343 100644 --- a/server/core/src/test/java/dev/slimevr/oscquery/OscQueryModelTest.kt +++ b/server/core/src/test/java/dev/slimevr/oscquery/OscQueryModelTest.kt @@ -1,6 +1,5 @@ package dev.slimevr.oscquery -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray From 35175565d3f176175f2c8910392f049ef396d003 Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 16 Jul 2026 16:21:07 -0400 Subject: [PATCH 256/350] Add TapDetection tests --- .../dev/slimevr/tapdetection/behaviours.kt | 84 +++++++------ .../slimevr/tapdetection/TapDetectionTest.kt | 111 ++++++++++++++++++ 2 files changed, 162 insertions(+), 33 deletions(-) create mode 100644 server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt index f3c37952c2..f52c7203f1 100644 --- a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt @@ -18,17 +18,11 @@ import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.ResetType import solarxr_protocol.rpc.TapDetectionSetupNotification -private const val NS_CONVERTER = 1.0e9f -private const val CLUMP_TIME_NS = 0.06f * NS_CONVERTER -private const val NEEDED_ACCEL_DELTA = 6.0f -private const val ALLOWED_BODY_ACCEL = 2.5f -private const val ALLOWED_BODY_ACCEL_SQUARED = ALLOWED_BODY_ACCEL * ALLOWED_BODY_ACCEL -private const val TAP_WINDOW_PER_TAP_NS = 0.3f * NS_CONVERTER - class TapDetectionBasicBehaviour : TapDetectionBehaviour { + data class TrackerTapDetectionState( var trackerId: Int, - var numberTrackersOverThreshold: Int = 0, + var numberTrackersOverThreshold: Int = 1, var resetType: ResetType? = null, var tapsNeeded: Int = 2, var actionDelay: Float = 0f, @@ -83,7 +77,36 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { .filter { trackerTapDetectionState.resetType != null || setupMode } .map { it.rawAcceleration } .distinctUntilChanged() - .onEach { processTapDetection(receiver, setupMode, trackersOverThreshold, trackerTapDetectionState, it) } + .onEach { + val tapTriggered = runTapDetection( + System.nanoTime(), + trackersOverThreshold, + trackerTapDetectionState, + it, + ) + + if (tapTriggered) { + receiver.context.scope.safeLaunch { + // If it's in setup mode, tap to assign + if (setupMode) { + receiver.server.sendSolarxrRpc( + TapDetectionSetupNotification( + trackerTapDetectionState.trackerId.toUShort() + ) + ) + } + + // If it has a reset to execute + trackerTapDetectionState.resetType?.let { reset -> + receiver.resetsManager.scheduleReset( + "TapDetection", + reset, + trackerTapDetectionState.actionDelay, + ) + } + } + } + } }.merge() } .launchIn(receiver.context.scope) @@ -131,20 +154,17 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { } // Logic loop for tap detection - private fun processTapDetection( - receiver: TapDetectionManager, - setupMode: Boolean, + fun runTapDetection( + now: Long, trackersOverThreshold: MutableSet, trackerTapDetectionState: TrackerTapDetectionState, trackerAcceleration: Vector3, - ) { - val now = System.nanoTime() - + ): Boolean { // Get the acceleration of the tracker and store it trackerTapDetectionState.accelList.add(trackerAcceleration.len() to now) // Remove old stored accelerations (if they are too old) - while (trackerTapDetectionState.accelList.isNotEmpty() && now - trackerTapDetectionState.accelList.first().second > CLUMP_TIME_NS) { + while (trackerTapDetectionState.accelList.isNotEmpty() && now - trackerTapDetectionState.accelList.first().second > ACCEL_WINDOW_NS) { trackerTapDetectionState.accelList.removeFirst() } @@ -174,32 +194,21 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { if (trackerTapDetectionState.tapTimestamps.isNotEmpty()) { // Remove old stored taps (if they are too old) - val totalWindowNs = (TAP_WINDOW_PER_TAP_NS * trackerTapDetectionState.tapTimestamps.size).toLong() - while (trackerTapDetectionState.tapTimestamps.isNotEmpty() && now - trackerTapDetectionState.tapTimestamps.first() > totalWindowNs) { + val totalTapWindowNs = (TAP_WINDOW_PER_TAP_NS * trackerTapDetectionState.tapTimestamps.size).toLong() + while (trackerTapDetectionState.tapTimestamps.isNotEmpty() && now - trackerTapDetectionState.tapTimestamps.first() > totalTapWindowNs) { trackerTapDetectionState.tapTimestamps.removeFirst() } if (trackerTapDetectionState.tapTimestamps.size >= trackerTapDetectionState.tapsNeeded) { - // Taps completed! - receiver.context.scope.safeLaunch { - // If it's in setup mode, tap to assign - if (setupMode) { - receiver.server.sendSolarxrRpc( - TapDetectionSetupNotification(trackerTapDetectionState.trackerId.toUShort()), - ) - } - - // If it has a reset to execute - trackerTapDetectionState.resetType?.let { reset -> - receiver.resetsManager.scheduleReset("TapDetection", reset, trackerTapDetectionState.actionDelay) - } - } - trackerTapDetectionState.accelList.clear() trackerTapDetectionState.tapTimestamps.clear() trackerTapDetectionState.waitForLowAccel = false + + return true } } + + return false } override fun reduce(state: TapDetectionState, action: TapDetectionActions) = when (action) { @@ -207,4 +216,13 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { state.copy(setupMode = action.setupMode) } } + + companion object { + const val NS_CONVERTER = 1.0e9f + const val ACCEL_WINDOW_NS = 0.06f * NS_CONVERTER + const val TAP_WINDOW_PER_TAP_NS = 0.3f * NS_CONVERTER + const val NEEDED_ACCEL_DELTA = 6.0f + const val ALLOWED_BODY_ACCEL = 2.5f + const val ALLOWED_BODY_ACCEL_SQUARED = ALLOWED_BODY_ACCEL * ALLOWED_BODY_ACCEL + } } diff --git a/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt b/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt new file mode 100644 index 0000000000..90282129b7 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt @@ -0,0 +1,111 @@ +package dev.slimevr.tapdetection + +import io.github.axisangles.ktmath.Vector3 +import kotlin.test.Test +import kotlin.test.assertEquals + +class TapDetectionTest { + data class AccelEvent( + val delaySincePrevious: Long, + val accel: Vector3, + val expectedTap: Boolean, + ) + + val highAccel = Vector3(0f, 0f, TapDetectionBasicBehaviour.NEEDED_ACCEL_DELTA * 3f) + val lowAccel = Vector3(0f, 0f, 0f) + // Accelerations needed for a tap happen with this delay + val accelDelay = (TapDetectionBasicBehaviour.ACCEL_WINDOW_NS * 0.9f).toLong() + // Delay between several taps + val tapDelay = (TapDetectionBasicBehaviour.TAP_WINDOW_PER_TAP_NS * 0.9f).toLong() + // Too long of a delay between several taps + val tapTimeoutDelay = (TapDetectionBasicBehaviour.TAP_WINDOW_PER_TAP_NS * 1.1f).toLong() + + /** + * Runs a sequence of tap events through TapDetection, asserting the results. + */ + private fun runTapSequence( + tapsNeeded: Int, + events: List, + ) { + val state = TapDetectionBasicBehaviour.TrackerTapDetectionState( + trackerId = 1, + tapsNeeded = tapsNeeded, + ) + val trackersOverThreshold = mutableSetOf() + val behaviour = TapDetectionBasicBehaviour() + + var now = 0L + events.forEach { event -> + now += event.delaySincePrevious + assertEquals( + event.expectedTap, + behaviour.runTapDetection( + now = now, + trackersOverThreshold = trackersOverThreshold, + trackerTapDetectionState = state, + trackerAcceleration = event.accel, + ), + ) + } + } + + @Test + fun `Detect double tap`() = runTapSequence( + tapsNeeded = 2, + events = listOf( + AccelEvent(0L, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + AccelEvent(tapDelay, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = true), + ), + ) + + @Test + fun `Double tap timed out`() = runTapSequence( + tapsNeeded = 2, + events = listOf( + AccelEvent(0L, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + AccelEvent(tapTimeoutDelay, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + ), + ) + + @Test + fun `Detect triple tap`() = runTapSequence( + tapsNeeded = 3, + events = listOf( + AccelEvent(0L, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + AccelEvent(tapDelay, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + AccelEvent(tapDelay, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = true), + ), + ) + + @Test + fun `Triple tap timed out`() = runTapSequence( + tapsNeeded = 3, + events = listOf( + AccelEvent(0L, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + AccelEvent(tapDelay, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + AccelEvent(tapTimeoutDelay, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + ), + ) + + @Test + fun `Tap not repeated while accel stays high`() = runTapSequence( + tapsNeeded = 2, + events = listOf( + AccelEvent(0L, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + AccelEvent(tapDelay, highAccel, expectedTap = false), + AccelEvent(tapDelay, highAccel, expectedTap = false), + AccelEvent(tapTimeoutDelay, highAccel, expectedTap = false), + ), + ) +} \ No newline at end of file From ff514faf735cf373a92b36d85d5ae68b2ff35700 Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 16 Jul 2026 18:22:53 -0400 Subject: [PATCH 257/350] Use trackerRole as protocol serial --- server/core/src/main/java/dev/slimevr/driver/behaviours.kt | 1 - server/core/src/main/java/dev/slimevr/driver/module.kt | 2 +- .../src/main/java/dev/slimevr/desktop/ipc/protocol.kt | 5 +++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 4689344c6e..2975e2de12 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -40,7 +40,6 @@ class DriverOutgoingTrackersBehaviour : DriverBridgeBehaviour { DriverBridgeOutbound.TrackerAdded( trackerId = part.value.toInt(), part = part, - serial = closestTracker.hardwareId, name = closestTracker.customName ?: closestTracker.name, manufacturer = closestDevice.manufacturer, ), diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt index 55ac063f58..3ba52421dd 100644 --- a/server/core/src/main/java/dev/slimevr/driver/module.kt +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -32,7 +32,7 @@ sealed interface DriverBridgeInbound { } sealed interface DriverBridgeOutbound { - data class TrackerAdded(val trackerId: Int, val part: BodyPart, val serial: String, val name: String, val manufacturer: String) : DriverBridgeOutbound + data class TrackerAdded(val trackerId: Int, val part: BodyPart, val name: String, val manufacturer: String) : DriverBridgeOutbound data class TrackerStatus(val trackerId: Int, val status: solarxr_protocol.datatypes.TrackerStatus, val battery: Float?, val charging: Boolean) : DriverBridgeOutbound data class TrackerPosition(val trackerId: Int, val rotation: Quaternion, val position: Vector3?) : DriverBridgeOutbound } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 5ef2140c2d..bb3c1b322a 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -139,13 +139,14 @@ suspend fun handleDriverConnection( ) bridge.outbound.on { event -> + val trackerRole = bodyPartToRole.getOrDefault(event.part, TrackerRole.NONE) sendMsg( ProtobufMessage( tracker_added = TrackerAdded( tracker_id = event.trackerId, - tracker_serial = event.serial, + tracker_serial = "human://${trackerRole}", tracker_name = event.name, - tracker_role = bodyPartToRole.getOrDefault(event.part, TrackerRole.NONE).value.toInt(), + tracker_role = trackerRole.value.toInt(), manufacturer = event.manufacturer, ), ), From 7d46e903fe6a148060e83e1fb7043f2a1e31e316 Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 16 Jul 2026 20:18:34 -0400 Subject: [PATCH 258/350] Brainstorming skeleton processors --- .../java/dev/slimevr/skeleton/behaviours.kt | 5 +-- .../main/java/dev/slimevr/skeleton/module.kt | 12 +++--- .../java/dev/slimevr/skeleton/processors.kt | 41 ++++++++++++++----- .../dev/slimevr/skeleton/skatingcorrection.kt | 4 -- 4 files changed, 38 insertions(+), 24 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 9ba120aaca..8d09ddb487 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -34,7 +34,7 @@ class BoneTransformBehaviour : SkeletonBehaviour { } } -class ProportionsBehaviour : SkeletonBehaviour { +class ProportionsBehaviour(private val userConfig: UserConfig) : SkeletonBehaviour { override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { is SkeletonActions.SetProportions -> { val bones = action.lengths.toBoneOffsets() @@ -46,9 +46,7 @@ class ProportionsBehaviour : SkeletonBehaviour { else -> state } -} -class ScaledProportionsBehaviour(private val userConfig: UserConfig) : SkeletonBehaviour { override fun observe(receiver: Skeleton) { userConfig.context.state .map { state -> state.data.proportions } @@ -130,7 +128,6 @@ class ComputedSkeletonBehaviour( delay(intervalMs) val targetState = receiver.context.state.value val processed = processors - .filter { processor -> processor.enabled } .fold(targetState) { state, processor -> processor.process(state) } // TODO: Add a constrain processor (maybe not needed) val rootHead = Vector3(0f, targetState.skeletonHeight, 0f) // FIXME WRONG diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 37ab26b9fe..fd3733b65d 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -16,6 +16,7 @@ data class RawBone( val offset: Vector3, val rawRotation: Quaternion, val rawPosition: Vector3, + val isReceivingData : Boolean, // TODO set it with an action or whatever ) data class BoneState( @@ -45,7 +46,7 @@ val DEFAULT_SKELETON_STATE: SkeletonState = run { bodyPart to BoneState(bodyPart = bodyPart, offset = tailOffset, rotation = restRotation) } SkeletonState( - rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, offset = bone.offset, rawPosition = Vector3.NULL) }, + rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, offset = bone.offset, rawPosition = Vector3.NULL, isReceivingData = false) }, skeletonHeight = DEFAULT_HEIGHT, paused = false, ) @@ -94,14 +95,15 @@ class Skeleton( val behaviours = listOf( PauseTrackingBehaviour(), BoneTransformBehaviour(), - ProportionsBehaviour(), - ScaledProportionsBehaviour(ctx.config.userConfig), + ProportionsBehaviour(ctx.config.userConfig), HeightLogBehaviour(), // YouSpinMeRightRoundBehaviour(inputHz = 50f), ComputedSkeletonBehaviour( processors = listOf( -// PredictionProcessor(predictionAmount = 0.3f), - SmoothingProcessor(smoothing = 0.3f), + FallbackProcessor(), + ImputeSpineProcessor(ctx.config.settings), + PredictionProcessor(ctx.config.settings), + SmoothingProcessor(ctx.config.settings), ), ), ) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt index 4997ba1634..a81762ba63 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt @@ -1,24 +1,37 @@ package dev.slimevr.skeleton +import dev.slimevr.config.Settings import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.FilteringType interface SkeletonProcessor { - var enabled: Boolean fun process(state: SkeletonState): SkeletonState } -private const val SMOOTH_MIN = 0.66f -private const val SMOOTH_MAX = 0.99f -class SmoothingProcessor(var smoothing: Float) : SkeletonProcessor { - override var enabled: Boolean = true +class FallbackProcessor : SkeletonProcessor { + override fun process(state: SkeletonState): SkeletonState { + return state + } +} + +class ImputeSpineProcessor(val settings: Settings) : SkeletonProcessor { + override fun process(state: SkeletonState): SkeletonState { + return state + } +} + +class SmoothingProcessor(val settings: Settings) : SkeletonProcessor { private var smoothedRotations: Map = emptyMap() private var smoothedLengths: Map = emptyMap() // TODO this isn't linear. Do we want linear smoothing like in main? override fun process(state: SkeletonState): SkeletonState { - val alpha = 1 - (SMOOTH_MIN + smoothing.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN)) + val config = settings.context.state.value.data.skeletonConfig.filtering + if (config.type == FilteringType.NONE) return state + + val alpha = 1 - (SMOOTH_MIN + config.amount.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN)) smoothedRotations = state.rawBones.mapValues { (bodyPart, bone) -> (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() @@ -36,11 +49,14 @@ class SmoothingProcessor(var smoothing: Float) : SkeletonProcessor { }, ) } -} -class PredictionProcessor(var predictionAmount: Float) : SkeletonProcessor { - override var enabled: Boolean = true + companion object { + private const val SMOOTH_MIN = 0.66f + private const val SMOOTH_MAX = 0.99f + } +} +class PredictionProcessor(val settings: Settings) : SkeletonProcessor { private data class BoneVelocity( val lastRotation: Quaternion, val rotationDelta: Quaternion, @@ -51,6 +67,9 @@ class PredictionProcessor(var predictionAmount: Float) : SkeletonProcessor { private var velocities: Map = emptyMap() override fun process(state: SkeletonState): SkeletonState { + val config = settings.context.state.value.data.skeletonConfig.filtering + if (config.type == FilteringType.NONE) return state + val newVelocities = mutableMapOf() val newBones = state.rawBones.mapValues { (bodyPart, bone) -> val prev = velocities[bodyPart] @@ -69,10 +88,10 @@ class PredictionProcessor(var predictionAmount: Float) : SkeletonProcessor { prev.offsetDelta } newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, rotationDelta, bone.offset, lengthDelta) - val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, predictionAmount).unit() + val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, config.amount).unit() bone.copy( rawRotation = (scaledDelta * bone.rawRotation).unit(), - offset = bone.offset + lengthDelta * predictionAmount, + offset = bone.offset + lengthDelta * config.amount, ) } velocities = newVelocities diff --git a/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt b/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt index ef0fd4b7a1..b3503961b8 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt @@ -76,8 +76,6 @@ fun centerOfMass(bones: Map): Vector3 = BODY_PART_MASSES.en // Probably not a SkeletonProcessor, maybe computed processor or smth class SkatingCorrectionProcessor : SkeletonProcessor { - override var enabled: Boolean = true - // Placeholder val curPositions: Map = emptyMap() @@ -160,8 +158,6 @@ class SkatingCorrectionProcessor : SkeletonProcessor { } class FloorClipProcessor : SkeletonProcessor { - override var enabled: Boolean = true - fun getDisplacement(footPos: Vector3): Vector3 = Vector3(0f, footPos.y.coerceAtMost(0f), 0f) override fun process(state: SkeletonState): SkeletonState { From a1c4de81523dda3b3554eb7cb5dbae21d0629306 Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 17 Jul 2026 13:41:01 -0400 Subject: [PATCH 259/350] Fix filtering type switching --- .../core/src/main/java/dev/slimevr/skeleton/processors.kt | 8 ++++---- .../src/main/java/dev/slimevr/solarxr/skeletonsettings.kt | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt index a81762ba63..57758b6cb0 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt @@ -29,7 +29,7 @@ class SmoothingProcessor(val settings: Settings) : SkeletonProcessor { // TODO this isn't linear. Do we want linear smoothing like in main? override fun process(state: SkeletonState): SkeletonState { val config = settings.context.state.value.data.skeletonConfig.filtering - if (config.type == FilteringType.NONE) return state + if (config.type != FilteringType.SMOOTHING) return state val alpha = 1 - (SMOOTH_MIN + config.amount.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN)) @@ -51,8 +51,8 @@ class SmoothingProcessor(val settings: Settings) : SkeletonProcessor { } companion object { - private const val SMOOTH_MIN = 0.66f - private const val SMOOTH_MAX = 0.99f + private const val SMOOTH_MIN = 0.7f + private const val SMOOTH_MAX = 0.95f } } @@ -68,7 +68,7 @@ class PredictionProcessor(val settings: Settings) : SkeletonProcessor { override fun process(state: SkeletonState): SkeletonState { val config = settings.context.state.value.data.skeletonConfig.filtering - if (config.type == FilteringType.NONE) return state + if (config.type != FilteringType.PREDICTION) return state val newVelocities = mutableMapOf() val newBones = state.rawBones.mapValues { (bodyPart, bone) -> diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt index 6adf2aa4be..c6064ee092 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt @@ -8,6 +8,7 @@ import dev.slimevr.config.SkeletonRatiosConfig import dev.slimevr.config.SkeletonTogglesConfig import dev.slimevr.skeleton.SkeletonActions import solarxr_protocol.rpc.ChangeSkeletonSettingsRequest +import solarxr_protocol.rpc.FilteringType import solarxr_protocol.rpc.SetPauseTrackingRequest import solarxr_protocol.rpc.SkeletonFiltering import solarxr_protocol.rpc.SkeletonRatios @@ -91,7 +92,7 @@ class SkeletonSettingsBehaviour( } ?: oldConfig.ratios, filtering = req.filtering?.let { SkeletonFilteringConfig( - type = it.type ?: oldConfig.filtering.type, + type = it.type ?: FilteringType.NONE, amount = it.amount ?: oldConfig.filtering.amount, ) } ?: oldConfig.filtering, From 349666b8cd897fa75fbd63c088e757b2f183bcbf Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 17 Jul 2026 14:51:21 -0400 Subject: [PATCH 260/350] SpotlessApply --- .../main/java/dev/slimevr/skeleton/module.kt | 4 +- .../java/dev/slimevr/skeleton/processors.kt | 16 +- .../dev/slimevr/tapdetection/behaviours.kt | 10 +- .../OutputTrackerToggleTest.kt | 292 +++++++++--------- .../slimevr/tapdetection/TapDetectionTest.kt | 198 ++++++------ .../java/dev/slimevr/desktop/ipc/protocol.kt | 2 +- 6 files changed, 262 insertions(+), 260 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index fd3733b65d..825c512ece 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -16,7 +16,7 @@ data class RawBone( val offset: Vector3, val rawRotation: Quaternion, val rawPosition: Vector3, - val isReceivingData : Boolean, // TODO set it with an action or whatever + val isReceivingData: Boolean, // TODO set it with an action or whatever ) data class BoneState( @@ -102,7 +102,7 @@ class Skeleton( processors = listOf( FallbackProcessor(), ImputeSpineProcessor(ctx.config.settings), - PredictionProcessor(ctx.config.settings), + PredictionProcessor(ctx.config.settings), SmoothingProcessor(ctx.config.settings), ), ), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt index 57758b6cb0..2a9ca9a43c 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt @@ -11,15 +11,11 @@ interface SkeletonProcessor { } class FallbackProcessor : SkeletonProcessor { - override fun process(state: SkeletonState): SkeletonState { - return state - } + override fun process(state: SkeletonState): SkeletonState = state } class ImputeSpineProcessor(val settings: Settings) : SkeletonProcessor { - override fun process(state: SkeletonState): SkeletonState { - return state - } + override fun process(state: SkeletonState): SkeletonState = state } class SmoothingProcessor(val settings: Settings) : SkeletonProcessor { @@ -50,10 +46,10 @@ class SmoothingProcessor(val settings: Settings) : SkeletonProcessor { ) } - companion object { - private const val SMOOTH_MIN = 0.7f - private const val SMOOTH_MAX = 0.95f - } + companion object { + private const val SMOOTH_MIN = 0.63f + private const val SMOOTH_MAX = 0.94f + } } class PredictionProcessor(val settings: Settings) : SkeletonProcessor { diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt index f52c7203f1..26f5182e3d 100644 --- a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt @@ -91,8 +91,8 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { if (setupMode) { receiver.server.sendSolarxrRpc( TapDetectionSetupNotification( - trackerTapDetectionState.trackerId.toUShort() - ) + trackerTapDetectionState.trackerId.toUShort(), + ), ) } @@ -217,12 +217,12 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { } } - companion object { - const val NS_CONVERTER = 1.0e9f + companion object { + const val NS_CONVERTER = 1.0e9f const val ACCEL_WINDOW_NS = 0.06f * NS_CONVERTER const val TAP_WINDOW_PER_TAP_NS = 0.3f * NS_CONVERTER const val NEEDED_ACCEL_DELTA = 6.0f const val ALLOWED_BODY_ACCEL = 2.5f const val ALLOWED_BODY_ACCEL_SQUARED = ALLOWED_BODY_ACCEL * ALLOWED_BODY_ACCEL - } + } } diff --git a/server/core/src/test/java/dev/slimevr/outputtrackertoggle/OutputTrackerToggleTest.kt b/server/core/src/test/java/dev/slimevr/outputtrackertoggle/OutputTrackerToggleTest.kt index 79073eaf9b..05a326ebe6 100644 --- a/server/core/src/test/java/dev/slimevr/outputtrackertoggle/OutputTrackerToggleTest.kt +++ b/server/core/src/test/java/dev/slimevr/outputtrackertoggle/OutputTrackerToggleTest.kt @@ -6,153 +6,153 @@ import kotlin.test.Test import kotlin.test.assertEquals class OutputTrackerToggleTest { - @Test - fun `Output trackers are automatically determined based off body parts`() { - data class TestCase( - val name: String, - val okBodyParts: Set, - val configuredTrackers: List, - val expected: Set, - ) + @Test + fun `Output trackers are automatically determined based off body parts`() { + data class TestCase( + val name: String, + val okBodyParts: Set, + val configuredTrackers: List, + val expected: Set, + ) - val testCases = listOf( - TestCase( - name = "no body part", - okBodyParts = emptySet(), - configuredTrackers = emptyList(), - expected = emptySet(), - ), - TestCase( - name = "upper chest enables chest and hip", - okBodyParts = setOf(BodyPart.UPPER_CHEST), - configuredTrackers = emptyList(), - expected = setOf( - BodyPart.CHEST, - BodyPart.HIP, - ), - ), - TestCase( - name = "chest enables chest and hip", - okBodyParts = setOf(BodyPart.CHEST), - configuredTrackers = emptyList(), - expected = setOf( - BodyPart.CHEST, - BodyPart.HIP, - ), - ), - TestCase( - name = "waist enables hip", - okBodyParts = setOf(BodyPart.WAIST), - configuredTrackers = emptyList(), - expected = setOf( - BodyPart.HIP, - ), - ), - TestCase( - name = "hip enables hip", - okBodyParts = setOf(BodyPart.HIP), - configuredTrackers = emptyList(), - expected = setOf( - BodyPart.HIP, - ), - ), - TestCase( - name = "left lower arm enables left upper arm", - okBodyParts = setOf(BodyPart.LEFT_LOWER_ARM), - configuredTrackers = emptyList(), - expected = setOf( - BodyPart.LEFT_UPPER_ARM, - ), - ), - TestCase( - name = "right lower arm enables right upper arm", - okBodyParts = setOf(BodyPart.RIGHT_LOWER_ARM), - configuredTrackers = emptyList(), - expected = setOf( - BodyPart.RIGHT_UPPER_ARM, - ), - ), - TestCase( - name = "left lower leg enables left foot", - okBodyParts = setOf(BodyPart.LEFT_LOWER_LEG), - configuredTrackers = emptyList(), - expected = setOf( - BodyPart.LEFT_FOOT, - ), - ), - TestCase( - name = "right lower leg enables right foot", - okBodyParts = setOf(BodyPart.RIGHT_LOWER_LEG), - configuredTrackers = emptyList(), - expected = setOf( - BodyPart.RIGHT_FOOT, - ), - ), - TestCase( - name = "multiple automatic trackers", - okBodyParts = setOf( - BodyPart.LEFT_UPPER_ARM, - BodyPart.RIGHT_FOOT, - BodyPart.HIP, - ), - configuredTrackers = emptyList(), - expected = setOf( - BodyPart.LEFT_UPPER_ARM, - BodyPart.RIGHT_FOOT, - BodyPart.HIP, - ), - ), - TestCase( - name = "hands are always included from config", - okBodyParts = emptySet(), - configuredTrackers = listOf( - BodyPart.LEFT_HAND, - BodyPart.RIGHT_HAND, - ), - expected = setOf( - BodyPart.LEFT_HAND, - BodyPart.RIGHT_HAND, - ), - ), - TestCase( - name = "non-hand manual trackers are ignored", - okBodyParts = emptySet(), - configuredTrackers = listOf( - BodyPart.CHEST, - BodyPart.WAIST, - BodyPart.LEFT_HAND, - ), - expected = setOf( - BodyPart.LEFT_HAND, - ), - ), - TestCase( - name = "automatic trackers combined with hands", - okBodyParts = setOf( - BodyPart.LEFT_FOOT, - ), - configuredTrackers = listOf( - BodyPart.RIGHT_HAND, - ), - expected = setOf( - BodyPart.LEFT_FOOT, - BodyPart.RIGHT_HAND, - ), - ), - ) + val testCases = listOf( + TestCase( + name = "no body part", + okBodyParts = emptySet(), + configuredTrackers = emptyList(), + expected = emptySet(), + ), + TestCase( + name = "upper chest enables chest and hip", + okBodyParts = setOf(BodyPart.UPPER_CHEST), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.CHEST, + BodyPart.HIP, + ), + ), + TestCase( + name = "chest enables chest and hip", + okBodyParts = setOf(BodyPart.CHEST), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.CHEST, + BodyPart.HIP, + ), + ), + TestCase( + name = "waist enables hip", + okBodyParts = setOf(BodyPart.WAIST), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.HIP, + ), + ), + TestCase( + name = "hip enables hip", + okBodyParts = setOf(BodyPart.HIP), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.HIP, + ), + ), + TestCase( + name = "left lower arm enables left upper arm", + okBodyParts = setOf(BodyPart.LEFT_LOWER_ARM), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.LEFT_UPPER_ARM, + ), + ), + TestCase( + name = "right lower arm enables right upper arm", + okBodyParts = setOf(BodyPart.RIGHT_LOWER_ARM), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.RIGHT_UPPER_ARM, + ), + ), + TestCase( + name = "left lower leg enables left foot", + okBodyParts = setOf(BodyPart.LEFT_LOWER_LEG), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.LEFT_FOOT, + ), + ), + TestCase( + name = "right lower leg enables right foot", + okBodyParts = setOf(BodyPart.RIGHT_LOWER_LEG), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.RIGHT_FOOT, + ), + ), + TestCase( + name = "multiple automatic trackers", + okBodyParts = setOf( + BodyPart.LEFT_UPPER_ARM, + BodyPart.RIGHT_FOOT, + BodyPart.HIP, + ), + configuredTrackers = emptyList(), + expected = setOf( + BodyPart.LEFT_UPPER_ARM, + BodyPart.RIGHT_FOOT, + BodyPart.HIP, + ), + ), + TestCase( + name = "hands are always included from config", + okBodyParts = emptySet(), + configuredTrackers = listOf( + BodyPart.LEFT_HAND, + BodyPart.RIGHT_HAND, + ), + expected = setOf( + BodyPart.LEFT_HAND, + BodyPart.RIGHT_HAND, + ), + ), + TestCase( + name = "non-hand manual trackers are ignored", + okBodyParts = emptySet(), + configuredTrackers = listOf( + BodyPart.CHEST, + BodyPart.WAIST, + BodyPart.LEFT_HAND, + ), + expected = setOf( + BodyPart.LEFT_HAND, + ), + ), + TestCase( + name = "automatic trackers combined with hands", + okBodyParts = setOf( + BodyPart.LEFT_FOOT, + ), + configuredTrackers = listOf( + BodyPart.RIGHT_HAND, + ), + expected = setOf( + BodyPart.LEFT_FOOT, + BodyPart.RIGHT_HAND, + ), + ), + ) - val behaviour = OutputTrackerToggleBasicBehaviour() - testCases.forEach { case -> - val result = behaviour.determineAutomaticOutputTrackers( - config = OutputTrackersConfig(trackers = case.configuredTrackers), - okBodyParts = case.okBodyParts, - ) + val behaviour = OutputTrackerToggleBasicBehaviour() + testCases.forEach { case -> + val result = behaviour.determineAutomaticOutputTrackers( + config = OutputTrackersConfig(trackers = case.configuredTrackers), + okBodyParts = case.okBodyParts, + ) - assertEquals( - expected = case.expected, - actual = result.toSet(), - message = case.name, - ) - } - } + assertEquals( + expected = case.expected, + actual = result.toSet(), + message = case.name, + ) + } + } } diff --git a/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt b/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt index 90282129b7..9866373622 100644 --- a/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt +++ b/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt @@ -5,107 +5,113 @@ import kotlin.test.Test import kotlin.test.assertEquals class TapDetectionTest { - data class AccelEvent( - val delaySincePrevious: Long, - val accel: Vector3, - val expectedTap: Boolean, - ) + data class AccelEvent( + val delaySincePrevious: Long, + val accel: Vector3, + val expectedTap: Boolean, + ) - val highAccel = Vector3(0f, 0f, TapDetectionBasicBehaviour.NEEDED_ACCEL_DELTA * 3f) - val lowAccel = Vector3(0f, 0f, 0f) - // Accelerations needed for a tap happen with this delay - val accelDelay = (TapDetectionBasicBehaviour.ACCEL_WINDOW_NS * 0.9f).toLong() - // Delay between several taps - val tapDelay = (TapDetectionBasicBehaviour.TAP_WINDOW_PER_TAP_NS * 0.9f).toLong() - // Too long of a delay between several taps - val tapTimeoutDelay = (TapDetectionBasicBehaviour.TAP_WINDOW_PER_TAP_NS * 1.1f).toLong() + // Accel high enough to trigger a tap + val highAccel = Vector3(0f, 0f, TapDetectionBasicBehaviour.NEEDED_ACCEL_DELTA * 3f) - /** - * Runs a sequence of tap events through TapDetection, asserting the results. - */ - private fun runTapSequence( - tapsNeeded: Int, - events: List, - ) { - val state = TapDetectionBasicBehaviour.TrackerTapDetectionState( - trackerId = 1, - tapsNeeded = tapsNeeded, - ) - val trackersOverThreshold = mutableSetOf() - val behaviour = TapDetectionBasicBehaviour() + // Low accel needed as rest between taps + val lowAccel = Vector3(0f, 0f, 0f) - var now = 0L - events.forEach { event -> - now += event.delaySincePrevious - assertEquals( - event.expectedTap, - behaviour.runTapDetection( - now = now, - trackersOverThreshold = trackersOverThreshold, - trackerTapDetectionState = state, - trackerAcceleration = event.accel, - ), - ) - } - } + // Accelerations needed for a tap happen with this delay + val accelDelay = (TapDetectionBasicBehaviour.ACCEL_WINDOW_NS * 0.9f).toLong() - @Test - fun `Detect double tap`() = runTapSequence( - tapsNeeded = 2, - events = listOf( - AccelEvent(0L, lowAccel, expectedTap = false), - AccelEvent(accelDelay, highAccel, expectedTap = false), - AccelEvent(tapDelay, lowAccel, expectedTap = false), - AccelEvent(accelDelay, highAccel, expectedTap = true), - ), - ) + // Delay between several taps + val tapDelay = (TapDetectionBasicBehaviour.TAP_WINDOW_PER_TAP_NS * 0.9f).toLong() - @Test - fun `Double tap timed out`() = runTapSequence( - tapsNeeded = 2, - events = listOf( - AccelEvent(0L, lowAccel, expectedTap = false), - AccelEvent(accelDelay, highAccel, expectedTap = false), - AccelEvent(tapTimeoutDelay, lowAccel, expectedTap = false), - AccelEvent(accelDelay, highAccel, expectedTap = false), - ), - ) + // Too long of a delay between several taps + val tapTimeoutDelay = (TapDetectionBasicBehaviour.TAP_WINDOW_PER_TAP_NS * 1.1f).toLong() - @Test - fun `Detect triple tap`() = runTapSequence( - tapsNeeded = 3, - events = listOf( - AccelEvent(0L, lowAccel, expectedTap = false), - AccelEvent(accelDelay, highAccel, expectedTap = false), - AccelEvent(tapDelay, lowAccel, expectedTap = false), - AccelEvent(accelDelay, highAccel, expectedTap = false), - AccelEvent(tapDelay, lowAccel, expectedTap = false), - AccelEvent(accelDelay, highAccel, expectedTap = true), - ), - ) + /** + * Runs a sequence of tap events through TapDetection, asserting the results. + */ + private fun runTapSequence( + tapsNeeded: Int, + events: List, + ) { + val state = TapDetectionBasicBehaviour.TrackerTapDetectionState( + trackerId = 1, + tapsNeeded = tapsNeeded, + ) + val trackersOverThreshold = mutableSetOf() + val behaviour = TapDetectionBasicBehaviour() - @Test - fun `Triple tap timed out`() = runTapSequence( - tapsNeeded = 3, - events = listOf( - AccelEvent(0L, lowAccel, expectedTap = false), - AccelEvent(accelDelay, highAccel, expectedTap = false), - AccelEvent(tapDelay, lowAccel, expectedTap = false), - AccelEvent(accelDelay, highAccel, expectedTap = false), - AccelEvent(tapTimeoutDelay, lowAccel, expectedTap = false), - AccelEvent(accelDelay, highAccel, expectedTap = false), - ), - ) + var now = 0L + events.forEach { event -> + now += event.delaySincePrevious + assertEquals( + event.expectedTap, + behaviour.runTapDetection( + now = now, + trackersOverThreshold = trackersOverThreshold, + trackerTapDetectionState = state, + trackerAcceleration = event.accel, + ), + ) + } + } - @Test - fun `Tap not repeated while accel stays high`() = runTapSequence( - tapsNeeded = 2, - events = listOf( - AccelEvent(0L, lowAccel, expectedTap = false), - AccelEvent(accelDelay, highAccel, expectedTap = false), - AccelEvent(tapDelay, highAccel, expectedTap = false), - AccelEvent(tapDelay, highAccel, expectedTap = false), - AccelEvent(tapTimeoutDelay, highAccel, expectedTap = false), - ), - ) -} \ No newline at end of file + @Test + fun `Detect double tap`() = runTapSequence( + tapsNeeded = 2, + events = listOf( + AccelEvent(0L, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + AccelEvent(tapDelay, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = true), + ), + ) + + @Test + fun `Double tap timed out`() = runTapSequence( + tapsNeeded = 2, + events = listOf( + AccelEvent(0L, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + AccelEvent(tapTimeoutDelay, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + ), + ) + + @Test + fun `Detect triple tap`() = runTapSequence( + tapsNeeded = 3, + events = listOf( + AccelEvent(0L, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + AccelEvent(tapDelay, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + AccelEvent(tapDelay, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = true), + ), + ) + + @Test + fun `Triple tap timed out`() = runTapSequence( + tapsNeeded = 3, + events = listOf( + AccelEvent(0L, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + AccelEvent(tapDelay, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + AccelEvent(tapTimeoutDelay, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + ), + ) + + @Test + fun `Tap not repeated while accel stays high`() = runTapSequence( + tapsNeeded = 2, + events = listOf( + AccelEvent(0L, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + AccelEvent(tapDelay, highAccel, expectedTap = false), + AccelEvent(tapDelay, highAccel, expectedTap = false), + AccelEvent(tapTimeoutDelay, highAccel, expectedTap = false), + ), + ) +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index bb3c1b322a..3524d3e08e 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -144,7 +144,7 @@ suspend fun handleDriverConnection( ProtobufMessage( tracker_added = TrackerAdded( tracker_id = event.trackerId, - tracker_serial = "human://${trackerRole}", + tracker_serial = "human://$trackerRole", tracker_name = event.name, tracker_role = trackerRole.value.toInt(), manufacturer = event.manufacturer, From bf6d8eb50d2ed07bedeeed79f888710450b61d89 Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 17 Jul 2026 15:33:53 -0400 Subject: [PATCH 261/350] Fix trackerassignment and add default mountingOrientation --- .../main/java/dev/slimevr/config/settings.kt | 14 +++--- .../main/java/dev/slimevr/hid/behaviours.kt | 3 +- .../dev/slimevr/solarxr/trackerassignment.kt | 10 ++++- .../java/dev/slimevr/tracker/behaviours.kt | 45 +++++++++++++++++++ .../main/java/dev/slimevr/tracker/module.kt | 2 + .../main/java/dev/slimevr/udp/behaviours.kt | 14 ++---- 6 files changed, 67 insertions(+), 21 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index a2b4918e39..e7bb7def70 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -37,7 +37,7 @@ data class TrackersConfig( @Serializable data class HidConfig( - val trackersOverHid: Boolean = false, + val trackersOverHid: Boolean = false, // TODO ) @Serializable @@ -48,7 +48,7 @@ data class OutputTrackersConfig( */ @Serializable(with = BodyPartListSerializer::class) val trackers: List = listOf(), - val sendDerivedVelocity: Boolean = false, + val sendDerivedVelocity: Boolean = false, // TODO ) @Serializable @@ -81,14 +81,14 @@ data class ResetsConfig( /** Always reset mounting for feet */ val resetMountingFeet: Boolean = false, /** Reset mode used for the arms */ - val armsResetMode: ArmsResetMode = ArmsResetMode.BACK, + val armsResetMode: ArmsResetMode = ArmsResetMode.BACK, // TODO /** Yaw reset smoothing time in seconds */ - val yawResetSmoothTime: Float = 0.0f, + val yawResetSmoothTime: Float = 0.0f, // TODO /** Save automatic mounting reset calibration */ - val saveMountingReset: Boolean = false, + val saveMountingReset: Boolean = false, // TODO /** Reset the HMD's pitch upon full reset */ - val resetHmdPitch: Boolean = false, - val lastMountingMethod: MountingMethods = MountingMethods.AUTOMATIC, + val resetHmdPitch: Boolean = false, // TODO + val lastMountingMethod: MountingMethods = MountingMethods.AUTOMATIC, // TODO ) // Used in SkeletonConfig diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index abadfe27b4..c231bc60a0 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -109,8 +109,7 @@ class HIDDeviceInfoBehaviour : HIDReceiverBehaviour { } receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, tracker.context.state.value.id)) // HID does not have a rest calibration signal - tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType, completedRestCalibration = true, magStatus = packet.magStatus) }) - tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) + tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType, completedRestCalibration = true, magStatus = packet.magStatus, status = TrackerStatus.OK) }) } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt b/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt index cb90c5ae89..2dad01c508 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt @@ -21,11 +21,17 @@ class AssignTrackerBehaviour( TrackerActions.Update { copy( bodyPart = bodyPart, - customName = req.displayName, - mountingOrientation = mountingOrientation, + customName = req.displayName ?: customName, ) }, ) + + // Override default mounting orientation set from changing the bodyPart + if (mountingOrientation != null) { + tracker.context.dispatch( + TrackerActions.SetMountingOrientation(mountingOrientation), + ) + } } } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 22380f293a..a9e8c2a058 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive +import solarxr_protocol.datatypes.BodyPart import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch @@ -67,6 +68,8 @@ class TrackerBasicBehaviour : TrackerBehaviour { ) } + is TrackerActions.SetMountingOrientation -> state.copy(mountingOrientation = action.mountingOrientation) + is TrackerActions.FullReset -> { val headingCorrection = estimateHeadingCorrect( state.rawRotation, @@ -129,6 +132,48 @@ class TrackerBasicBehaviour : TrackerBehaviour { } } +class TrackerDefaultMountingOrientationBehaviour : TrackerBehaviour { + /** + * Returns the default mounting orientation for the body part + */ + private fun defaultMountingForBodyPart(bodyPart: BodyPart?): Quaternion = when (bodyPart) { + BodyPart.LEFT_LOWER_ARM, BodyPart.LEFT_HAND, + BodyPart.LEFT_INDEX_PROXIMAL, BodyPart.LEFT_INDEX_INTERMEDIATE, + BodyPart.LEFT_INDEX_DISTAL, BodyPart.LEFT_MIDDLE_PROXIMAL, + BodyPart.LEFT_MIDDLE_INTERMEDIATE, BodyPart.LEFT_MIDDLE_DISTAL, + BodyPart.LEFT_RING_PROXIMAL, BodyPart.LEFT_RING_INTERMEDIATE, + BodyPart.LEFT_RING_DISTAL, BodyPart.LEFT_LITTLE_PROXIMAL, + BodyPart.LEFT_LITTLE_INTERMEDIATE, BodyPart.LEFT_LITTLE_DISTAL, + BodyPart.LEFT_SHOULDER, + -> Quaternion.SLIMEVR.LEFT + + BodyPart.RIGHT_LOWER_ARM, BodyPart.RIGHT_HAND, + BodyPart.RIGHT_INDEX_PROXIMAL, BodyPart.RIGHT_INDEX_INTERMEDIATE, + BodyPart.RIGHT_INDEX_DISTAL, BodyPart.RIGHT_MIDDLE_PROXIMAL, + BodyPart.RIGHT_MIDDLE_INTERMEDIATE, BodyPart.RIGHT_MIDDLE_DISTAL, + BodyPart.RIGHT_RING_PROXIMAL, BodyPart.RIGHT_RING_INTERMEDIATE, + BodyPart.RIGHT_RING_DISTAL, BodyPart.RIGHT_LITTLE_PROXIMAL, + BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_DISTAL, + BodyPart.RIGHT_SHOULDER, + -> Quaternion.SLIMEVR.RIGHT + + BodyPart.LEFT_UPPER_ARM, BodyPart.LEFT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_LEFT + + BodyPart.RIGHT_UPPER_ARM, BodyPart.RIGHT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_RIGHT + + else -> Quaternion.SLIMEVR.FRONT + } + + override fun observe(receiver: Tracker) { + receiver.context.state + .map { it.bodyPart } + .distinctUntilChanged() + .onEach { + receiver.context.dispatch(TrackerActions.SetMountingOrientation(defaultMountingForBodyPart(it))) + }.launchIn(receiver.context.scope) + } +} + class TrackerTPSBehaviour : TrackerBehaviour { @OptIn(ExperimentalAtomicApi::class) override fun observe(receiver: Tracker) { diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index d32a1556f3..dab5ac5eaa 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -45,6 +45,7 @@ sealed interface TrackerActions { data class SetMagStatus(val status: MagnetometerStatus) : TrackerActions data class SetStatus(val status: TrackerStatus) : TrackerActions data class SetRotation(val rotation: Quaternion? = null, val acceleration: Vector3? = null, val magnetometer: Vector3? = null) : TrackerActions + data class SetMountingOrientation(val mountingOrientation: HeadingAlignment?) : TrackerActions data class FullReset(val referenceRotation: Quaternion) : TrackerActions data class YawReset(val referenceRotation: Quaternion) : TrackerActions data class MountingReset(val referenceRotation: Quaternion) : TrackerActions @@ -104,6 +105,7 @@ class Tracker( val behaviours = listOf( TrackerBasicBehaviour(), + TrackerDefaultMountingOrientationBehaviour(), TrackerConfigBehaviour(settings, hardwareId), TrackerTPSBehaviour(), TrackerToSkeletonBehaviour(), diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 3f8c4d63bc..6ce8f09fd4 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -334,21 +334,15 @@ class SensorInfoBehaviour : UDPConnectionBehaviour { val existingTracker = receiver.getTracker(event.data.sensorId) if (existingTracker != null) { - existingTracker.context.dispatchAll( - listOf( - TrackerActions.Update { copy(sensorType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration) }, - TrackerActions.SetStatus(event.data.status), - ), + existingTracker.context.dispatch( + TrackerActions.Update { copy(sensorType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration, status = event.data.status) }, ) return@onPacket } val (tracker, isNew) = assignTracker(receiver, device, event) - tracker.context.dispatchAll( - listOf( - TrackerActions.Update { copy(sensorType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration) }, - TrackerActions.SetStatus(event.data.status), - ), + tracker.context.dispatch( + TrackerActions.Update { copy(sensorType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration, status = event.data.status) }, ) if (isNew && tracker.context.state.value.magStatus == MagnetometerStatus.NOT_SUPPORTED) { tracker.context.dispatch( From 7433048d527d1c2a42285613ffded86ea42f000d Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 17 Jul 2026 15:34:58 -0400 Subject: [PATCH 262/350] Fix null fallbacks when receiving from SolarXR false will be null, but also 0 and the first value of an enum --- .../dev/slimevr/solarxr/outputtrackers.kt | 3 +-- .../main/java/dev/slimevr/solarxr/resets.kt | 6 ++--- .../dev/slimevr/solarxr/skeletonsettings.kt | 27 +++++++------------ .../java/dev/slimevr/solarxr/tapdetection.kt | 21 +++++++-------- .../src/main/java/dev/slimevr/solarxr/vmc.kt | 12 ++++----- .../main/java/dev/slimevr/solarxr/vrcosc.kt | 7 +++-- 6 files changed, 32 insertions(+), 44 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/outputtrackers.kt b/server/core/src/main/java/dev/slimevr/solarxr/outputtrackers.kt index e67ece6e4e..db6623531d 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/outputtrackers.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/outputtrackers.kt @@ -27,13 +27,12 @@ class OutputTrackersBehaviour( // Receive config receiver.rpcDispatcher.on { req -> - val oldConfig = settings.context.state.value.data.outputTrackersConfig settings.context.dispatch( SettingsActions.Update { copy( outputTrackersConfig = OutputTrackersConfig( automaticTrackerToggle = req.automaticTrackerToggle == true, - trackers = req.trackers ?: oldConfig.trackers, + trackers = req.trackers ?: listOf(), sendDerivedVelocity = req.sendDerivedVelocity == true, ), ) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/resets.kt b/server/core/src/main/java/dev/slimevr/solarxr/resets.kt index 6b63ef5de2..81334e26b3 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/resets.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/resets.kt @@ -3,6 +3,7 @@ package dev.slimevr.solarxr import dev.slimevr.config.ResetsConfig import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions +import solarxr_protocol.rpc.ArmsResetMode import solarxr_protocol.rpc.ChangeResetsSettingsRequest import solarxr_protocol.rpc.ResetsSettingsRequest import solarxr_protocol.rpc.ResetsSettingsResponse @@ -27,14 +28,13 @@ class ResetsBehaviour( // Receive config receiver.rpcDispatcher.on { req -> - val oldConfig = settings.context.state.value.data.resetsConfig settings.context.dispatch( SettingsActions.Update { copy( resetsConfig = ResetsConfig( resetMountingFeet = req.resetMountingFeet == true, - armsResetMode = req.armsResetMode ?: oldConfig.armsResetMode, - yawResetSmoothTime = req.yawResetSmoothTime ?: oldConfig.yawResetSmoothTime, + armsResetMode = req.armsResetMode ?: ArmsResetMode.BACK, + yawResetSmoothTime = req.yawResetSmoothTime ?: 0f, saveMountingReset = req.saveMountingReset == true, resetHmdPitch = req.resetHmdPitch == true, ), diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt index c6064ee092..3e954948be 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt @@ -56,7 +56,6 @@ class SkeletonSettingsBehaviour( // Receive config receiver.rpcDispatcher.on { req -> - val oldConfig = settings.context.state.value.data.skeletonConfig settings.context.dispatch( SettingsActions.Update { copy( @@ -73,29 +72,23 @@ class SkeletonSettingsBehaviour( enforceConstraints = it.enforceConstraints == true, correctConstraints = it.correctConstraints == true, ) - } ?: oldConfig.toggles, + } ?: skeletonConfig.toggles, ratios = req.ratios?.let { SkeletonRatiosConfig( - imputeSpineFromUpperToLower = it.imputeSpineFromUpperToLower - ?: oldConfig.ratios.imputeSpineFromUpperToLower, - imputeSpineCurvature = it.imputeSpineCurvature - ?: oldConfig.ratios.imputeSpineCurvature, - interpolateHipWithKnees = it.interpolateHipWithKnees - ?: oldConfig.ratios.interpolateHipWithKnees, - interpolateComputedKneesWithAnkles = it.interpolateComputedKneesWithAnkles - ?: oldConfig.ratios.interpolateComputedKneesWithAnkles, - interpolateKneesWithAnkles = it.interpolateKneesWithAnkles - ?: oldConfig.ratios.interpolateKneesWithAnkles, - skatingCorrectionStrength = it.skatingCorrectionStrength - ?: oldConfig.ratios.skatingCorrectionStrength, + imputeSpineFromUpperToLower = it.imputeSpineFromUpperToLower ?: 0f, + imputeSpineCurvature = it.imputeSpineCurvature ?: 0f, + interpolateHipWithKnees = it.interpolateHipWithKnees ?: 0f, + interpolateComputedKneesWithAnkles = it.interpolateComputedKneesWithAnkles ?: 0f, + interpolateKneesWithAnkles = it.interpolateKneesWithAnkles ?: 0f, + skatingCorrectionStrength = it.skatingCorrectionStrength ?: 0f, ) - } ?: oldConfig.ratios, + } ?: skeletonConfig.ratios, filtering = req.filtering?.let { SkeletonFilteringConfig( type = it.type ?: FilteringType.NONE, - amount = it.amount ?: oldConfig.filtering.amount, + amount = it.amount ?: 0f, ) - } ?: oldConfig.filtering, + } ?: skeletonConfig.filtering, ), ) }, diff --git a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt index faf9799a2f..8f2db1b6b7 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt @@ -39,24 +39,23 @@ class TapDetectionBehaviour( // Receive config receiver.rpcDispatcher.on { req -> - val oldConfig = settings.context.state.value.data.tapDetectionConfig settings.context.dispatch( SettingsActions.Update { copy( tapDetectionConfig = TapDetectionConfig( - yawResetDelay = req.yawResetDelay ?: oldConfig.yawResetDelay, - fullResetDelay = req.fullResetDelay ?: oldConfig.fullResetDelay, - mountingResetDelay = req.mountingResetDelay ?: oldConfig.mountingResetDelay, + yawResetDelay = req.yawResetDelay ?: 0f, + fullResetDelay = req.fullResetDelay ?: 0f, + mountingResetDelay = req.mountingResetDelay ?: 0f, yawResetEnabled = req.yawResetEnabled == true, fullResetEnabled = req.fullResetEnabled == true, mountingResetEnabled = req.mountingResetEnabled == true, - yawResetTaps = req.yawResetTaps?.toInt() ?: oldConfig.yawResetTaps, - fullResetTaps = req.fullResetTaps?.toInt() ?: oldConfig.fullResetTaps, - mountingResetTaps = req.mountingResetTaps?.toInt() ?: oldConfig.mountingResetTaps, - yawResetBodyPart = req.yawResetTracker ?: oldConfig.yawResetBodyPart, - fullResetBodyPart = req.fullResetTracker ?: oldConfig.fullResetBodyPart, - mountingResetBodyPart = req.mountingResetTracker ?: oldConfig.mountingResetBodyPart, - numberTrackersOverThreshold = req.numberTrackersOverThreshold?.toInt() ?: oldConfig.numberTrackersOverThreshold, + yawResetTaps = req.yawResetTaps?.toInt() ?: 2, + fullResetTaps = req.fullResetTaps?.toInt() ?: 2, + mountingResetTaps = req.mountingResetTaps?.toInt() ?: 2, + yawResetBodyPart = req.yawResetTracker, + fullResetBodyPart = req.fullResetTracker, + mountingResetBodyPart = req.mountingResetTracker, + numberTrackersOverThreshold = req.numberTrackersOverThreshold?.toInt() ?: 1, ), ) }, diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt index 87b7b250e3..059350bbfe 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt @@ -41,15 +41,14 @@ class VmcBehaviour( // Receive VMC config receiver.rpcDispatcher.on { req -> - val oldConfig = settings.context.state.value.data.vmcConfig settings.context.dispatch( SettingsActions.Update { copy( - vmcConfig = oldConfig.copy( + vmcConfig = vmcConfig.copy( enabled = req.enabled == true, - portIn = req.portIn?.toInt() ?: oldConfig.portIn, - portOut = req.portOut?.toInt() ?: oldConfig.portOut, - address = req.address ?: oldConfig.address, + portIn = req.portIn?.toInt() ?: vmcConfig.portIn, + portOut = req.portOut?.toInt() ?: vmcConfig.portOut, + address = req.address ?: vmcConfig.address, mirrorTracking = req.mirrorTracking == true, anchorAtHips = req.anchorHip == true, ), @@ -60,11 +59,10 @@ class VmcBehaviour( // Receive VRM json receiver.rpcDispatcher.on { req -> - val oldConfig = settings.context.state.value.data.vmcConfig settings.context.dispatch( SettingsActions.Update { copy( - vmcConfig = oldConfig.copy(vrmJson = req.vrmJson), + vmcConfig = vmcConfig.copy(vrmJson = req.vrmJson), ) }, ) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt index 49fa43946a..4c68fb31fc 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt @@ -57,16 +57,15 @@ internal class VrcOscBehaviour( } receiver.rpcDispatcher.on { req -> - val oldConfig = settings.context.state.value.data.vrcOscConfig settings.context.dispatch( SettingsActions.Update { copy( vrcOscConfig = VRCOSCConfig( enabled = req.enabled == true, useManualNetwork = req.useManualNetwork == true, - portIn = req.portIn?.toInt() ?: oldConfig.portIn, - portOut = req.portOut?.toInt() ?: oldConfig.portOut, - address = req.address ?: oldConfig.address, + portIn = req.portIn?.toInt() ?: vrcOscConfig.portIn, + portOut = req.portOut?.toInt() ?: vrcOscConfig.portOut, + address = req.address ?: vrcOscConfig.address, ), ) }, From e4014679e033b0cf3b833c0942d1add36606bc4c Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 17 Jul 2026 16:45:49 -0400 Subject: [PATCH 263/350] Use UPPER_CHEST instead of CHEST for chest output --- .../settings/pages/components/OutputSettings.tsx | 4 ++-- .../java/dev/slimevr/outputtrackertoggle/behaviours.kt | 2 +- server/core/src/main/java/dev/slimevr/vrcosc/input.kt | 2 ++ .../src/main/java/dev/slimevr/vrcosc/output-encoder.kt | 2 +- server/core/src/test/java/dev/slimevr/TestServer.kt | 2 +- .../outputtrackertoggle/OutputTrackerToggleTest.kt | 8 ++++---- .../src/main/java/dev/slimevr/desktop/ipc/protocol.kt | 2 +- solarxr-protocol | 2 +- 8 files changed, 13 insertions(+), 11 deletions(-) diff --git a/gui/app/src/components/settings/pages/components/OutputSettings.tsx b/gui/app/src/components/settings/pages/components/OutputSettings.tsx index 0a0d64a9aa..6412c8889f 100644 --- a/gui/app/src/components/settings/pages/components/OutputSettings.tsx +++ b/gui/app/src/components/settings/pages/components/OutputSettings.tsx @@ -59,7 +59,7 @@ function togglesToBodyParts( ): BodyPart[] { const toggleToBodyPart: [boolean, BodyPart][] = [ [trackers.waist, BodyPart.HIP], - [trackers.chest, BodyPart.CHEST], + [trackers.chest, BodyPart.UPPER_CHEST], [trackers.leftFoot, BodyPart.LEFT_FOOT], [trackers.rightFoot, BodyPart.RIGHT_FOOT], [trackers.leftKnee, BodyPart.LEFT_UPPER_LEG], @@ -81,7 +81,7 @@ function bodyPartsToTrackers( const set = new Set(bodyParts); return { waist: set.has(BodyPart.HIP), - chest: set.has(BodyPart.CHEST), + chest: set.has(BodyPart.UPPER_CHEST), leftFoot: set.has(BodyPart.LEFT_FOOT), rightFoot: set.has(BodyPart.RIGHT_FOOT), leftKnee: set.has(BodyPart.LEFT_UPPER_LEG), diff --git a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt index 3bb3057b1b..0bf9790360 100644 --- a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt @@ -17,7 +17,7 @@ import kotlin.collections.map class OutputTrackerToggleBasicBehaviour : OutputTrackerToggleBehaviour { private val automaticToggleToTracker = mapOf( - BodyPart.CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST), + BodyPart.UPPER_CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST), BodyPart.LEFT_UPPER_ARM to setOf(BodyPart.LEFT_UPPER_ARM, BodyPart.LEFT_LOWER_ARM), BodyPart.RIGHT_UPPER_ARM to setOf(BodyPart.RIGHT_UPPER_ARM, BodyPart.RIGHT_LOWER_ARM), BodyPart.HIP to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt index e150a5aa4e..3b854dcac8 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt @@ -71,6 +71,8 @@ private class VRSystemTrackerRegistry( ) }, ) + // TODO : what's the mounting orientation of these trackers, or is it even used? + // setting the bodyPart will automatically set it, which may or may not be a problem. trackerIds[tracker] = trackerId return runtimeTracker } diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt index 059260adfb..3d5bd49d7e 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt @@ -19,7 +19,7 @@ private val trackerIdsByBodyPart = mapOf( BodyPart.RIGHT_FOOT to 3, BodyPart.LEFT_UPPER_LEG to 4, BodyPart.RIGHT_UPPER_LEG to 5, - BodyPart.CHEST to 6, + BodyPart.UPPER_CHEST to 6, BodyPart.LEFT_UPPER_ARM to 7, BodyPart.RIGHT_UPPER_ARM to 8, ) diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 1c7daf8a9d..07c8313a50 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -83,7 +83,7 @@ fun buildTestSkeleton(scope: CoroutineScope): Skeleton { val context = Context.create( initialState = DEFAULT_SKELETON_STATE, scope = scope, - behaviours = listOf(ProportionsBehaviour()), + behaviours = listOf(ProportionsBehaviour(buildTestUserConfig(scope))), name = "TestSkeleton", ) val skeleton = Skeleton(context, MutableStateFlow(buildBones(context.state.value))) diff --git a/server/core/src/test/java/dev/slimevr/outputtrackertoggle/OutputTrackerToggleTest.kt b/server/core/src/test/java/dev/slimevr/outputtrackertoggle/OutputTrackerToggleTest.kt index 05a326ebe6..a7cd6a7f65 100644 --- a/server/core/src/test/java/dev/slimevr/outputtrackertoggle/OutputTrackerToggleTest.kt +++ b/server/core/src/test/java/dev/slimevr/outputtrackertoggle/OutputTrackerToggleTest.kt @@ -23,20 +23,20 @@ class OutputTrackerToggleTest { expected = emptySet(), ), TestCase( - name = "upper chest enables chest and hip", + name = "upper chest enables upper chest and hip", okBodyParts = setOf(BodyPart.UPPER_CHEST), configuredTrackers = emptyList(), expected = setOf( - BodyPart.CHEST, + BodyPart.UPPER_CHEST, BodyPart.HIP, ), ), TestCase( - name = "chest enables chest and hip", + name = "chest enables upper chest and hip", okBodyParts = setOf(BodyPart.CHEST), configuredTrackers = emptyList(), expected = setOf( - BodyPart.CHEST, + BodyPart.UPPER_CHEST, BodyPart.HIP, ), ), diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 3524d3e08e..b5db0578e5 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -64,7 +64,7 @@ enum class TrackerRole( } val bodyPartToRole = mapOf( - BodyPart.CHEST to TrackerRole.CHEST, + BodyPart.UPPER_CHEST to TrackerRole.CHEST, BodyPart.LEFT_UPPER_ARM to TrackerRole.LEFT_ELBOW, BodyPart.RIGHT_UPPER_ARM to TrackerRole.RIGHT_ELBOW, BodyPart.HIP to TrackerRole.WAIST, diff --git a/solarxr-protocol b/solarxr-protocol index 76eebe263c..2f00c44f44 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 76eebe263c539a2812fe28ff248a6a56221641a5 +Subproject commit 2f00c44f448b14b4be2f1ef7b3fda4e0cb4353cf From 01362ca2de1fbd83a2e47aa76185445788ca1620 Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 17 Jul 2026 17:32:54 -0400 Subject: [PATCH 264/350] Keep SetStatus separate for debug --- .../src/main/java/dev/slimevr/hid/behaviours.kt | 3 ++- .../src/main/java/dev/slimevr/udp/behaviours.kt | 14 ++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index c231bc60a0..abadfe27b4 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -109,7 +109,8 @@ class HIDDeviceInfoBehaviour : HIDReceiverBehaviour { } receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, tracker.context.state.value.id)) // HID does not have a rest calibration signal - tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType, completedRestCalibration = true, magStatus = packet.magStatus, status = TrackerStatus.OK) }) + tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType, completedRestCalibration = true, magStatus = packet.magStatus) }) + tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) } } } diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 6ce8f09fd4..3f8c4d63bc 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -334,15 +334,21 @@ class SensorInfoBehaviour : UDPConnectionBehaviour { val existingTracker = receiver.getTracker(event.data.sensorId) if (existingTracker != null) { - existingTracker.context.dispatch( - TrackerActions.Update { copy(sensorType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration, status = event.data.status) }, + existingTracker.context.dispatchAll( + listOf( + TrackerActions.Update { copy(sensorType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration) }, + TrackerActions.SetStatus(event.data.status), + ), ) return@onPacket } val (tracker, isNew) = assignTracker(receiver, device, event) - tracker.context.dispatch( - TrackerActions.Update { copy(sensorType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration, status = event.data.status) }, + tracker.context.dispatchAll( + listOf( + TrackerActions.Update { copy(sensorType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration) }, + TrackerActions.SetStatus(event.data.status), + ), ) if (isNew && tracker.context.state.value.magStatus == MagnetometerStatus.NOT_SUPPORTED) { tracker.context.dispatch( From a6020ae4df47e57a33c27642afabc0d4761fad9f Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 17 Jul 2026 18:43:14 -0400 Subject: [PATCH 265/350] Handle driver tracker mapping more generously --- .../java/dev/slimevr/driver/behaviours.kt | 73 ++++++++++--------- .../main/java/dev/slimevr/driver/module.kt | 2 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 4 +- 3 files changed, 40 insertions(+), 39 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 2975e2de12..b95613e39d 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -10,43 +10,52 @@ import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.TrackerStatus class DriverOutgoingTrackersBehaviour : DriverBridgeBehaviour { + // Keys should match with protocol.kt:bodyPartToRole + val bodyPartToNearest: Map> = mapOf( + BodyPart.UPPER_CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST), + BodyPart.HIP to setOf(BodyPart.HIP, BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), + BodyPart.LEFT_UPPER_LEG to setOf(BodyPart.LEFT_UPPER_LEG), + BodyPart.RIGHT_UPPER_LEG to setOf(BodyPart.RIGHT_UPPER_LEG), + BodyPart.LEFT_FOOT to setOf(BodyPart.LEFT_FOOT, BodyPart.LEFT_LOWER_LEG), + BodyPart.RIGHT_FOOT to setOf(BodyPart.RIGHT_FOOT, BodyPart.RIGHT_LOWER_LEG), + BodyPart.LEFT_UPPER_ARM to setOf(BodyPart.LEFT_UPPER_ARM, BodyPart.LEFT_LOWER_ARM), + BodyPart.RIGHT_UPPER_ARM to setOf(BodyPart.RIGHT_UPPER_ARM, BodyPart.RIGHT_LOWER_ARM), + BodyPart.LEFT_HAND to setOf(BodyPart.LEFT_HAND, BodyPart.LEFT_LOWER_ARM), + BodyPart.RIGHT_HAND to setOf(BodyPart.RIGHT_HAND, BodyPart.RIGHT_LOWER_ARM), + ) override fun observe(receiver: DriverBridge) { // Should be safe: StateFlow never delivers two emissions concurrently to the same collector. val subscribedTrackers = mutableSetOf() receiver.appContext.skeleton.computed.onEach { computedBones -> - val enabledBodyParts = - receiver.appContext.outputTrackerToggle.context.state.value.trackers + val enabledBodyParts = receiver.appContext.outputTrackerToggle.context.state.value.trackers val serverState = receiver.appContext.server.context.state.value computedBones.forEach { (part, state) -> - val closestTracker = - serverState.trackers.values.find { it.context.state.value.bodyPart == part }?.context?.state?.value - ?: return@forEach - val enabled = - enabledBodyParts.contains(part) // TODO: add paused tracking here - if (closestTracker.origin == DeviceOrigin.DRIVER) return@forEach - val closestDevice = - serverState.devices[closestTracker.deviceId]?.context?.state?.value - ?: error("device should exists") - - val newTracker = subscribedTrackers.add(part.value) - if (newTracker) { - receiver.outbound.emit( - DriverBridgeOutbound.TrackerAdded( - trackerId = part.value.toInt(), - part = part, - name = closestTracker.customName ?: closestTracker.name, - manufacturer = closestDevice.manufacturer, - ), - ) - } + if (enabledBodyParts.contains(part)) { + val closestTracker = bodyPartToNearest[part].orEmpty() + .firstNotNullOfOrNull { fallbackPart -> + serverState.trackers.values + .find { it.context.state.value.bodyPart == fallbackPart && it.context.state.value.origin != DeviceOrigin.DRIVER } + ?.context?.state?.value + } + val closestDevice = serverState.devices[closestTracker?.deviceId]?.context?.state?.value + + val newTracker = subscribedTrackers.add(part.value) + if (newTracker) { + receiver.outbound.emit( + DriverBridgeOutbound.TrackerAdded( + trackerId = part.value.toInt(), + part = part, + ), + ) + } - if (enabled) { receiver.outbound.emit( DriverBridgeOutbound.TrackerPosition( trackerId = part.value.toInt(), @@ -58,26 +67,18 @@ class DriverOutgoingTrackersBehaviour : DriverBridgeBehaviour { receiver.outbound.emit( DriverBridgeOutbound.TrackerStatus( trackerId = part.value.toInt(), - battery = closestDevice.batteryLevel, - charging = closestDevice.batteryVoltage != null && closestDevice.batteryVoltage >= 4.3f, - status = closestTracker.status, + battery = closestDevice?.batteryLevel, + charging = closestDevice?.batteryVoltage != null && closestDevice.batteryVoltage >= 4.3f, + status = closestTracker?.status ?: TrackerStatus.OK, ), ) } else { - receiver.outbound.emit( - DriverBridgeOutbound.TrackerPosition( - trackerId = part.value.toInt(), - rotation = Quaternion.NULL, - position = Vector3.NULL, - ), - ) - receiver.outbound.emit( DriverBridgeOutbound.TrackerStatus( trackerId = part.value.toInt(), - status = TrackerStatus.DISCONNECTED, battery = 0f, charging = false, + status = TrackerStatus.DISCONNECTED, ), ) } diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt index 3ba52421dd..b4f7938729 100644 --- a/server/core/src/main/java/dev/slimevr/driver/module.kt +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -32,7 +32,7 @@ sealed interface DriverBridgeInbound { } sealed interface DriverBridgeOutbound { - data class TrackerAdded(val trackerId: Int, val part: BodyPart, val name: String, val manufacturer: String) : DriverBridgeOutbound + data class TrackerAdded(val trackerId: Int, val part: BodyPart) : DriverBridgeOutbound data class TrackerStatus(val trackerId: Int, val status: solarxr_protocol.datatypes.TrackerStatus, val battery: Float?, val charging: Boolean) : DriverBridgeOutbound data class TrackerPosition(val trackerId: Int, val rotation: Quaternion, val position: Vector3?) : DriverBridgeOutbound } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index b5db0578e5..871a99aafb 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -145,9 +145,9 @@ suspend fun handleDriverConnection( tracker_added = TrackerAdded( tracker_id = event.trackerId, tracker_serial = "human://$trackerRole", - tracker_name = event.name, + tracker_name = "SlimeVR $trackerRole Virtual Tracker", tracker_role = trackerRole.value.toInt(), - manufacturer = event.manufacturer, + manufacturer = "SlimeVR", ), ), ) From 5da133cffc25db5afcaa849ee3dfbfed7e73c0d2 Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 17 Jul 2026 21:54:50 -0400 Subject: [PATCH 266/350] Fallback skeleton processor --- .../java/dev/slimevr/resets/behaviours.kt | 7 +- .../java/dev/slimevr/skeleton/behaviours.kt | 11 +- .../dev/slimevr/skeleton/bodypartstructure.kt | 13 ++ .../main/java/dev/slimevr/skeleton/module.kt | 11 +- .../java/dev/slimevr/skeleton/processors.kt | 126 +++++++++++++++++- .../dev/slimevr/tapdetection/behaviours.kt | 9 +- .../java/dev/slimevr/tracker/behaviours.kt | 34 ++++- 7 files changed, 190 insertions(+), 21 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt index 6ea245a39c..a79ae0d752 100644 --- a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt @@ -1,6 +1,5 @@ package dev.slimevr.resets -import dev.slimevr.config.MountingMethods import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChangedBy @@ -8,8 +7,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapLatest import solarxr_protocol.rpc.ResetType -const val MOUNTING_RESET_TIMEOUT = 120 * 1000L // 120 seconds - class ResetsBasicBehaviour : ResetsBehaviour { override fun reduce(state: ResetsState, action: ResetsActions) = when (action) { // Clear the states of the `canDoXReset`s to false @@ -42,4 +39,8 @@ class ResetsMountingTimeoutBehaviour : ResetsBehaviour { } .launchIn(receiver.context.scope) } + + companion object { + const val MOUNTING_RESET_TIMEOUT = 120 * 1000L // 120 seconds + } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 8d09ddb487..58810072a7 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -19,14 +19,21 @@ class BoneTransformBehaviour : SkeletonBehaviour { is SkeletonActions.SetBoneRotation -> { val bones = state.rawBones.toMutableMap() val bone = bones[action.bodyPart] ?: return state - bones[action.bodyPart] = bone.copy(rawRotation = action.rotation) + bones[action.bodyPart] = bone.copy(rawRotation = action.rotation, isActive = true) state.copy(rawBones = bones) } is SkeletonActions.SetBonePosition -> { val bones = state.rawBones.toMutableMap() val bone = bones[action.bodyPart] ?: return state - bones[action.bodyPart] = bone.copy(rawPosition = action.position) + bones[action.bodyPart] = bone.copy(rawPosition = action.position, isActive = true) + state.copy(rawBones = bones) + } + + is SkeletonActions.DisableBone -> { + val bones = state.rawBones.toMutableMap() + val bone = bones[action.bodyPart] ?: return state + bones[action.bodyPart] = bone.copy(isActive = false) state.copy(rawBones = bones) } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt b/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt index 8399e399ed..ae6e4aecfa 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt @@ -64,6 +64,19 @@ val BODY_PART_HIERARCHY_MAP = mapOf( BodyPart.RIGHT_LOWER_LEG to arrayOf(BodyPart.RIGHT_FOOT), ) +val BODY_PART_PARENT_MAP: Map = BODY_PART_HIERARCHY_MAP + .flatMap { (parent, children) -> children.map { child -> child to parent } } + .toMap() + +inline fun findBodyPartParent(rootChild: BodyPart, predicate: (BodyPart) -> Boolean): BodyPart? { + var current = BODY_PART_PARENT_MAP[rootChild] + while (current != null) { + if (predicate(current)) return current + current = BODY_PART_PARENT_MAP[current] + } + return null +} + private suspend fun SequenceScope>.visitBodyPart(parentBone: BodyPart?, bone: BodyPart, onlyChildren: Boolean) { if (!onlyChildren) { yield(Pair(parentBone, bone)) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 825c512ece..99c7afe2f7 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -16,7 +16,7 @@ data class RawBone( val offset: Vector3, val rawRotation: Quaternion, val rawPosition: Vector3, - val isReceivingData: Boolean, // TODO set it with an action or whatever + val isActive: Boolean, ) data class BoneState( @@ -39,14 +39,10 @@ data class SkeletonState(val rawBones: Map, val skeletonHeigh val DEFAULT_SKELETON_STATE: SkeletonState = run { val bones = DEFAULT_BONE_OFFSETS.entries.associate { (bodyPart, tailOffset) -> - val restRotation = when (bodyPart) { - BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT -> Quaternion.rotationAroundXAxis(FastMath.HALF_PI) - else -> Quaternion.IDENTITY - } - bodyPart to BoneState(bodyPart = bodyPart, offset = tailOffset, rotation = restRotation) + bodyPart to BoneState(bodyPart = bodyPart, offset = tailOffset) } SkeletonState( - rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, offset = bone.offset, rawPosition = Vector3.NULL, isReceivingData = false) }, + rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, offset = bone.offset, rawPosition = Vector3.NULL, isActive = false) }, skeletonHeight = DEFAULT_HEIGHT, paused = false, ) @@ -77,6 +73,7 @@ fun buildBones( sealed interface SkeletonActions { data class SetBoneRotation(val bodyPart: BodyPart, val rotation: Quaternion) : SkeletonActions data class SetBonePosition(val bodyPart: BodyPart, val position: Vector3) : SkeletonActions + data class DisableBone(val bodyPart: BodyPart) : SkeletonActions data class SetProportions(val lengths: Map) : SkeletonActions data class PauseTracking(val pause: Boolean) : SkeletonActions } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt index 2a9ca9a43c..9f197327cc 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt @@ -10,10 +10,134 @@ interface SkeletonProcessor { fun process(state: SkeletonState): SkeletonState } +/** + * Handles replacing rotations of rawBones that are not actively receiving data by falling back + * to either another rawBone's rotation, their parent's yaw or the identity rotation. + */ class FallbackProcessor : SkeletonProcessor { - override fun process(state: SkeletonState): SkeletonState = state + private val leftFingerChains = listOf( + listOf(BodyPart.LEFT_THUMB_DISTAL, BodyPart.LEFT_THUMB_PROXIMAL, BodyPart.LEFT_THUMB_METACARPAL), + listOf(BodyPart.LEFT_INDEX_DISTAL, BodyPart.LEFT_INDEX_INTERMEDIATE, BodyPart.LEFT_INDEX_PROXIMAL), + listOf(BodyPart.LEFT_MIDDLE_DISTAL, BodyPart.LEFT_MIDDLE_INTERMEDIATE, BodyPart.LEFT_MIDDLE_PROXIMAL), + listOf(BodyPart.LEFT_RING_DISTAL, BodyPart.LEFT_RING_INTERMEDIATE, BodyPart.LEFT_RING_PROXIMAL), + listOf(BodyPart.LEFT_LITTLE_DISTAL, BodyPart.LEFT_LITTLE_INTERMEDIATE, BodyPart.LEFT_LITTLE_PROXIMAL), + ) + private val rightFingerChains = listOf( + listOf(BodyPart.RIGHT_THUMB_DISTAL, BodyPart.RIGHT_THUMB_PROXIMAL, BodyPart.RIGHT_THUMB_METACARPAL), + listOf(BodyPart.RIGHT_INDEX_DISTAL, BodyPart.RIGHT_INDEX_INTERMEDIATE, BodyPart.RIGHT_INDEX_PROXIMAL), + listOf(BodyPart.RIGHT_MIDDLE_DISTAL, BodyPart.RIGHT_MIDDLE_INTERMEDIATE, BodyPart.RIGHT_MIDDLE_PROXIMAL), + listOf(BodyPart.RIGHT_RING_DISTAL, BodyPart.RIGHT_RING_INTERMEDIATE, BodyPart.RIGHT_RING_PROXIMAL), + listOf(BodyPart.RIGHT_LITTLE_DISTAL, BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_PROXIMAL), + ) + + private fun putFingerFallbacks( + map: MutableMap>, + chain: List, + hand: BodyPart, + ) { + chain.forEachIndexed { i, segment -> + map[segment] = chain.drop(i + 1) + hand + } + } + + /** + * First element is the BodyPart whose rawBone is not actively receiving data. + * Second element contains a set of BodyParts whose rotation should be used as a fallback prioritized from first to last. + */ + private val missingToFallbacks = mapOf( + BodyPart.HEAD to setOf(BodyPart.NECK), + BodyPart.NECK to setOf(BodyPart.HEAD), + + // TODO could be removed once ImputeSpineProcessor is done vvv + BodyPart.UPPER_CHEST to setOf(BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), + BodyPart.CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.WAIST, BodyPart.HIP), + BodyPart.WAIST to setOf(BodyPart.CHEST, BodyPart.HIP, BodyPart.UPPER_CHEST), + BodyPart.HIP to setOf(BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), + + BodyPart.LEFT_HIP to setOf(BodyPart.HIP, BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), + BodyPart.RIGHT_HIP to setOf(BodyPart.HIP, BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), + + BodyPart.LEFT_SHOULDER to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), + BodyPart.RIGHT_SHOULDER to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), + + BodyPart.LEFT_UPPER_ARM to setOf(BodyPart.LEFT_LOWER_ARM), + BodyPart.RIGHT_UPPER_ARM to setOf(BodyPart.RIGHT_LOWER_ARM), + BodyPart.LEFT_LOWER_ARM to setOf(BodyPart.LEFT_UPPER_ARM), + BodyPart.RIGHT_LOWER_ARM to setOf(BodyPart.RIGHT_UPPER_ARM), + BodyPart.LEFT_HAND to setOf(BodyPart.LEFT_LOWER_ARM, BodyPart.LEFT_UPPER_ARM), + BodyPart.RIGHT_HAND to setOf(BodyPart.RIGHT_LOWER_ARM, BodyPart.RIGHT_UPPER_ARM), + + // TODO could be removed once ImputeFingersProcessor is done vvv + BodyPart.LEFT_THUMB_METACARPAL to setOf(BodyPart.LEFT_HAND), + BodyPart.LEFT_THUMB_PROXIMAL to setOf(BodyPart.LEFT_HAND), + BodyPart.LEFT_THUMB_DISTAL to setOf(BodyPart.LEFT_HAND), + BodyPart.LEFT_INDEX_PROXIMAL to setOf(BodyPart.LEFT_HAND), + BodyPart.LEFT_INDEX_INTERMEDIATE to setOf(BodyPart.LEFT_HAND), + BodyPart.LEFT_INDEX_DISTAL to setOf(BodyPart.LEFT_HAND), + BodyPart.LEFT_MIDDLE_PROXIMAL to setOf(BodyPart.LEFT_HAND), + BodyPart.LEFT_MIDDLE_INTERMEDIATE to setOf(BodyPart.LEFT_HAND), + BodyPart.LEFT_MIDDLE_DISTAL to setOf(BodyPart.LEFT_HAND), + BodyPart.LEFT_RING_PROXIMAL to setOf(BodyPart.LEFT_HAND), + BodyPart.LEFT_RING_INTERMEDIATE to setOf(BodyPart.LEFT_HAND), + BodyPart.LEFT_RING_DISTAL to setOf(BodyPart.LEFT_HAND), + BodyPart.LEFT_LITTLE_PROXIMAL to setOf(BodyPart.LEFT_HAND), + BodyPart.LEFT_LITTLE_INTERMEDIATE to setOf(BodyPart.LEFT_HAND), + BodyPart.LEFT_LITTLE_DISTAL to setOf(BodyPart.LEFT_HAND), + BodyPart.RIGHT_THUMB_METACARPAL to setOf(BodyPart.RIGHT_HAND), + BodyPart.RIGHT_THUMB_PROXIMAL to setOf(BodyPart.RIGHT_HAND), + BodyPart.RIGHT_THUMB_DISTAL to setOf(BodyPart.RIGHT_HAND), + BodyPart.RIGHT_INDEX_PROXIMAL to setOf(BodyPart.RIGHT_HAND), + BodyPart.RIGHT_INDEX_INTERMEDIATE to setOf(BodyPart.RIGHT_HAND), + BodyPart.RIGHT_INDEX_DISTAL to setOf(BodyPart.RIGHT_HAND), + BodyPart.RIGHT_MIDDLE_PROXIMAL to setOf(BodyPart.RIGHT_HAND), + BodyPart.RIGHT_MIDDLE_INTERMEDIATE to setOf(BodyPart.RIGHT_HAND), + BodyPart.RIGHT_MIDDLE_DISTAL to setOf(BodyPart.RIGHT_HAND), + BodyPart.RIGHT_RING_PROXIMAL to setOf(BodyPart.RIGHT_HAND), + BodyPart.RIGHT_RING_INTERMEDIATE to setOf(BodyPart.RIGHT_HAND), + BodyPart.RIGHT_RING_DISTAL to setOf(BodyPart.RIGHT_HAND), + BodyPart.RIGHT_LITTLE_PROXIMAL to setOf(BodyPart.RIGHT_HAND), + BodyPart.RIGHT_LITTLE_INTERMEDIATE to setOf(BodyPart.RIGHT_HAND), + BodyPart.RIGHT_LITTLE_DISTAL to setOf(BodyPart.RIGHT_HAND), + ) + + override fun process(state: SkeletonState): SkeletonState { + val rawBones = state.rawBones + + return state.copy( + rawBones = rawBones.mapValues { (bodyPart, bone) -> + if (bone.isActive) return@mapValues bone + + bone.copy(rawRotation = computeFallbackRotation(rawBones, bodyPart)) + }, + ) + } + + private fun computeFallbackRotation(rawBones: Map, bodyPart: BodyPart): Quaternion { + // Fallback bone's rotation + missingToFallbacks[bodyPart] + ?.firstNotNullOfOrNull { part -> + rawBones[part]?.takeIf { it.isActive } + } + ?.let { return it.rawRotation } + + // First active parent bone's yaw + rawBones[ + findBodyPartParent(bodyPart) { part -> + rawBones[part]?.isActive == true + }, + ]?.let { return it.rawRotation.project(Vector3.POS_Y).unit() } + + // Fallback to identity + return Quaternion.IDENTITY + } } +/** + * Handles imputing the rotation of spine bones that are not actively receiving data from the rotations + * of nearby bones. + * + * Similar to FallbackProcessor but specialized for the spine, using more data and math for better results. + */ class ImputeSpineProcessor(val settings: Settings) : SkeletonProcessor { override fun process(state: SkeletonState): SkeletonState = state } diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt index 26f5182e3d..2daa811845 100644 --- a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt @@ -70,7 +70,14 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { .firstOrNull { it in trackersBodyParts } ?: BodyPart.RIGHT_UPPER_LEG trackers.map { tracker -> - val trackerTapDetectionState = createTrackerTapDetectionState(tapDetectionConfig, setupMode, tracker, yawResetBodyPart, fullResetBodyPart, mountingResetBodyPart) + val trackerTapDetectionState = createTrackerTapDetectionState( + tapDetectionConfig, + setupMode, + tracker, + yawResetBodyPart, + fullResetBodyPart, + mountingResetBodyPart, + ) // Inner flow emits whenever a tracker's rawAcceleration is updated tracker.context.state diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index a9e8c2a058..59db381d2a 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -3,15 +3,18 @@ package dev.slimevr.tracker import dev.slimevr.skeleton.SkeletonActions import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.TrackerStatus import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch @@ -201,17 +204,34 @@ class TrackerTPSBehaviour : TrackerBehaviour { } class TrackerToSkeletonBehaviour : TrackerBehaviour { + var lastBodyPartSent: BodyPart? = null + + @OptIn(ExperimentalCoroutinesApi::class) override fun observe(receiver: Tracker) { receiver.context.state - .filter { it.bodyPart != null } - .map { Pair(it.bodyPart, it.rotation) } - .distinctUntilChanged() - .onEach { (bodyPart, rotation) -> - bodyPart?.let { + .distinctUntilChangedBy { it.status to it.bodyPart } + .onEach { _ -> + // Tell the skeleton the tracker has stopped sending data to the last bone it was sending data to. + lastBodyPartSent?.let { receiver.appContext.skeleton.context.dispatch( - SkeletonActions.SetBoneRotation(it, rotation), + SkeletonActions.DisableBone(it), ) + lastBodyPartSent = null } - }.launchIn(receiver.context.scope) + } + .flatMapLatest { _ -> + receiver.context.state + .distinctUntilChangedBy { it.rotation } + .onEach { trackerState -> + if (trackerState.bodyPart != null && trackerState.status == TrackerStatus.OK) { + // Send data to the skeleton + receiver.appContext.skeleton.context.dispatch( + SkeletonActions.SetBoneRotation(trackerState.bodyPart, trackerState.rotation), + ) + lastBodyPartSent = trackerState.bodyPart + } + } + } + .launchIn(receiver.context.scope) } } From 577036eb9ceccffe9a32d46668542ad8bfcb1a04 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Mon, 20 Jul 2026 05:50:57 -0400 Subject: [PATCH 267/350] Start CCD IK implementation --- .../src/main/java/dev/slimevr/skeleton/ik.kt | 47 +++++++++++++++++++ .../main/java/dev/slimevr/skeleton/module.kt | 36 +++++++++----- 2 files changed, 70 insertions(+), 13 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/ik.kt diff --git a/server/core/src/main/java/dev/slimevr/skeleton/ik.kt b/server/core/src/main/java/dev/slimevr/skeleton/ik.kt new file mode 100644 index 0000000000..9656cd50af --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/ik.kt @@ -0,0 +1,47 @@ +package dev.slimevr.skeleton + +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import solarxr_protocol.datatypes.BodyPart + +fun chainDistanceFromTarget(bones: Map, chain: List, target: Vector3): Float { + val endBone = bones[chain.last()] + requireNotNull(endBone) + return (target - endBone.tailPosition).len() +} + +fun chainCanReach(bones: Map, chain: List, target: Vector3): Boolean { + val rootBone = bones[chain.first()] + requireNotNull(rootBone) + val chainLength = chain.fold(0f) { acc, bodyPart -> + val currentBone = requireNotNull(bones[bodyPart]) + acc + currentBone.offset.len() + } + return (target - rootBone.headPosition).len() <= chainLength +} + +// Single loop over a chain +fun ccdIk(rawBones: Map, bones: Map, chain: List, target: Vector3): Map { + val workingBones = rawBones.toMutableMap() + + // Reversed index, i increases backwards up the chain + for ((i, bodyPart) in chain.reversed().withIndex()) { + val currentRawBone = requireNotNull(rawBones[bodyPart]) + val currentBone = requireNotNull(bones[bodyPart]) + + val localOffset = currentBone.localTailPosition + val localTarget = target - currentBone.headPosition + val rotationChange = Quaternion.fromTo(localOffset, localTarget).pow( + (i.toFloat() / chain.size) * 0.5f, + ) + + workingBones[bodyPart] = currentRawBone.copy( + rawRotation = currentBone.rotation * rotationChange, + ) + } + + // FIXME: This feels weird, we should probably consume the skeleton root position + // independent of the bones + val skeletonRootPos = bones[BodyPart.HEAD]?.headPosition ?: Vector3.NULL + return buildBones(workingBones, skeletonRootPos) +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 99c7afe2f7..7538e40be3 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -31,8 +31,8 @@ data class BoneState( get() = parentBone?.let { it.rotation.inv() * rotation } ?: rotation val localHeadPosition: Vector3 get() = parentBone?.let { headPosition - it.tailPosition } ?: headPosition -// val localTailPosition: Vector3 -// get() = tailPosition - headPosition + val localTailPosition: Vector3 + get() = tailPosition - headPosition } data class SkeletonState(val rawBones: Map, val skeletonHeight: Float, val paused: Boolean) @@ -48,28 +48,38 @@ val DEFAULT_SKELETON_STATE: SkeletonState = run { ) } +fun buildBone(bone: RawBone, parentBone: BoneState?, originPosition: Vector3 = Vector3.NULL): BoneState { + val head = parentBone?.tailPosition ?: originPosition + return BoneState( + bodyPart = bone.bodyPart, + offset = bone.offset, + headPosition = head, + rotation = bone.rawRotation, + tailPosition = head + bone.rawRotation.sandwich(bone.offset), + parentBone = parentBone, + ) +} + fun buildBones( - state: SkeletonState, + state: Map, rootHead: Vector3 = Vector3.NULL, hierarchy: Sequence> = iterateBodyPartHierarchy(), ): Map { val result = mutableMapOf() hierarchy.forEach { (parentPart, childPart) -> - val rawBone = state.rawBones[childPart] ?: return@forEach + val rawBone = state[childPart] ?: return@forEach val parentBone = parentPart?.let { result[it] } - val head = parentBone?.tailPosition ?: rootHead - result[childPart] = BoneState( - bodyPart = rawBone.bodyPart, - offset = rawBone.offset, - headPosition = head, - rotation = rawBone.rawRotation, - tailPosition = head + rawBone.rawRotation.sandwich(rawBone.offset), - parentBone = parentBone, - ) + result[childPart] = buildBone(rawBone, parentBone, rootHead) } return result } +fun buildBones( + state: SkeletonState, + rootHead: Vector3 = Vector3.NULL, + hierarchy: Sequence> = iterateBodyPartHierarchy(), +): Map = buildBones(state.rawBones, rootHead, hierarchy) + sealed interface SkeletonActions { data class SetBoneRotation(val bodyPart: BodyPart, val rotation: Quaternion) : SkeletonActions data class SetBonePosition(val bodyPart: BodyPart, val position: Vector3) : SkeletonActions From b9776ab89852ec42a5cb1a96951bdc3c6dd24fcc Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Mon, 20 Jul 2026 05:52:02 -0400 Subject: [PATCH 268/350] Start constraint implementation --- .../java/dev/slimevr/skeleton/constraint.kt | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/constraint.kt diff --git a/server/core/src/main/java/dev/slimevr/skeleton/constraint.kt b/server/core/src/main/java/dev/slimevr/skeleton/constraint.kt new file mode 100644 index 0000000000..89fc03da56 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/constraint.kt @@ -0,0 +1,111 @@ +package dev.slimevr.skeleton + +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import kotlin.math.abs +import kotlin.math.min +import kotlin.math.sign +import kotlin.math.sin +import kotlin.math.sqrt + +// TODO Actually rewrite, currently still using original server code +interface Constraint { + fun apply(localRotation: Quaternion): Quaternion +} + +class CompleteConstraint : Constraint { + override fun apply(localRotation: Quaternion): Quaternion = localRotation +} + +data class TwistSwingConstraint( + val twist: Float = 0.0f, + val swing: Float = 0.0f, + val allowedDeviation: Float = 0f, + val maxDeviationFromTracker: Float = 15f, +) : Constraint { + override fun apply(localRotation: Quaternion): Quaternion { + var (swingQ, twistQ) = decompose(localRotation, Vector3.NEG_Y) + swingQ = constrain(swingQ, swing) + twistQ = constrain(twistQ, twist) + + return swingQ * twistQ + } +} + +data class HingeConstraint( + val min: Float = 0.0f, + val max: Float = 0.0f, + val allowedDeviation: Float = 0f, + val maxDeviationFromTracker: Float = 15f, +) : Constraint { + override fun apply(localRotation: Quaternion): Quaternion { + val (_, hingeAxisRot) = decompose(localRotation, Vector3.NEG_X) + return constrain(hingeAxisRot, min, max, Vector3.NEG_X) + } +} + +data class LooseHingeConstraint( + val min: Float = 0.0f, + val max: Float = 0.0f, + val allowedDeviation: Float = 0f, + val maxDeviationFromTracker: Float = 15f, +) : Constraint { + override fun apply(localRotation: Quaternion): Quaternion { + var (nonHingeRot, hingeAxisRot) = decompose(localRotation, Vector3.NEG_X) + hingeAxisRot = constrain(hingeAxisRot, min, max, Vector3.NEG_X) + nonHingeRot = constrain(nonHingeRot, allowedDeviation) + return nonHingeRot * hingeAxisRot + } +} + +private fun decompose( + rotation: Quaternion, + twistAxis: Vector3, +): Pair { + val projection = rotation.project(twistAxis).unit() + val twist = Quaternion(sqrt(1.0f - projection.xyz.lenSq()) * if (rotation.w >= 0f) 1f else -1f, projection.xyz).unit() + val swing = (rotation * twist.inv()).unit() + return Pair(swing, twist) +} + +private fun constrain(rotation: Quaternion, angle: Float): Quaternion { + // Use angle to get the maximum magnitude the vector part of rotation can be + // before it has violated a constraint. + // Multiplying by 0.5 uniquely maps angles 0-180 degrees to 0-1 which works + // nicely with unit quaternions. + val magnitude = sin(angle * 0.5f) + val magnitudeSqr = magnitude * magnitude + val sign = if (rotation.w >= 0f) 1f else -1f + var vector = rotation.xyz + var rot = rotation + + if (vector.lenSq() > magnitudeSqr) { + vector = vector.unit() * magnitude + rot = Quaternion(sqrt(1f - magnitudeSqr) * sign, vector) + } + + return rot.unit() +} + +private fun constrain(rotation: Quaternion, minAngle: Float, maxAngle: Float, axis: Vector3): Quaternion { + val magnitudeMin = sin(minAngle * 0.5f) + val magnitudeMax = sin(maxAngle * 0.5f) + val magnitudeSqrMin = magnitudeMin * magnitudeMin * if (minAngle >= 0f) 1f else -1f + val magnitudeSqrMax = magnitudeMax * magnitudeMax * if (maxAngle >= 0f) 1f else -1f + var vector = rotation.xyz + var rot = rotation + + val rotMagnitude = vector.lenSq() * if (vector.dot(axis) * sign(rot.w) < 0) -1f else 1f + if (rotMagnitude !in magnitudeSqrMin..magnitudeSqrMax) { + val distToMin = min(abs(rotMagnitude - magnitudeSqrMin), abs(rotMagnitude + magnitudeSqrMin)) + val distToMax = min(abs(rotMagnitude - magnitudeSqrMax), abs(rotMagnitude + magnitudeSqrMax)) + + val magnitude = if (distToMin < distToMax) magnitudeMin else magnitudeMax + val magnitudeSqr = abs(if (distToMin < distToMax) magnitudeSqrMin else magnitudeSqrMax) + vector = vector.unit() * -magnitude + + rot = Quaternion(sqrt(1.0f - magnitudeSqr), vector) + } + + return rot.unit() +} From f986f507aefe1a014b34a5693aed8373f753a6b2 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Mon, 20 Jul 2026 05:54:03 -0400 Subject: [PATCH 269/350] Rename RawBone to BoneInput --- .../java/dev/slimevr/skeleton/behaviours.kt | 16 +++++++------- .../src/main/java/dev/slimevr/skeleton/ik.kt | 6 ++--- .../main/java/dev/slimevr/skeleton/module.kt | 13 +++++------ .../java/dev/slimevr/skeleton/processors.kt | 22 +++++++++---------- .../dev/slimevr/skeleton/skatingcorrection.kt | 4 ++-- .../slimevr/solarxr/skeletonproportions.kt | 2 +- 6 files changed, 31 insertions(+), 32 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 58810072a7..93f1aa9c94 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -17,24 +17,24 @@ import kotlin.math.sin class BoneTransformBehaviour : SkeletonBehaviour { override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { is SkeletonActions.SetBoneRotation -> { - val bones = state.rawBones.toMutableMap() + val bones = state.boneInputs.toMutableMap() val bone = bones[action.bodyPart] ?: return state bones[action.bodyPart] = bone.copy(rawRotation = action.rotation, isActive = true) - state.copy(rawBones = bones) + state.copy(boneInputs = bones) } is SkeletonActions.SetBonePosition -> { - val bones = state.rawBones.toMutableMap() + val bones = state.boneInputs.toMutableMap() val bone = bones[action.bodyPart] ?: return state bones[action.bodyPart] = bone.copy(rawPosition = action.position, isActive = true) - state.copy(rawBones = bones) + state.copy(boneInputs = bones) } is SkeletonActions.DisableBone -> { - val bones = state.rawBones.toMutableMap() + val bones = state.boneInputs.toMutableMap() val bone = bones[action.bodyPart] ?: return state bones[action.bodyPart] = bone.copy(isActive = false) - state.copy(rawBones = bones) + state.copy(boneInputs = bones) } else -> state @@ -45,10 +45,10 @@ class ProportionsBehaviour(private val userConfig: UserConfig) : SkeletonBehavio override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { is SkeletonActions.SetProportions -> { val bones = action.lengths.toBoneOffsets() - val newBones = state.rawBones.mapValues { (bodyPart, bone) -> + val newBones = state.boneInputs.mapValues { (bodyPart, bone) -> bone.copy(offset = bones[bodyPart] ?: bone.offset) } - state.copy(rawBones = newBones, skeletonHeight = action.lengths.height()) + state.copy(boneInputs = newBones, skeletonHeight = action.lengths.height()) } else -> state diff --git a/server/core/src/main/java/dev/slimevr/skeleton/ik.kt b/server/core/src/main/java/dev/slimevr/skeleton/ik.kt index 9656cd50af..5fe4805046 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/ik.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/ik.kt @@ -21,12 +21,12 @@ fun chainCanReach(bones: Map, chain: List, target } // Single loop over a chain -fun ccdIk(rawBones: Map, bones: Map, chain: List, target: Vector3): Map { - val workingBones = rawBones.toMutableMap() +fun ccdIk(boneInputs: Map, bones: Map, chain: List, target: Vector3): Map { + val workingBones = boneInputs.toMutableMap() // Reversed index, i increases backwards up the chain for ((i, bodyPart) in chain.reversed().withIndex()) { - val currentRawBone = requireNotNull(rawBones[bodyPart]) + val currentRawBone = requireNotNull(boneInputs[bodyPart]) val currentBone = requireNotNull(bones[bodyPart]) val localOffset = currentBone.localTailPosition diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 7538e40be3..b7d19e9acc 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -1,6 +1,5 @@ package dev.slimevr.skeleton -import com.jme3.math.FastMath import dev.slimevr.Phase1ContextProvider import dev.slimevr.context.Behaviour import dev.slimevr.context.Context @@ -11,7 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.SkeletonBone -data class RawBone( +data class BoneInput( val bodyPart: BodyPart, val offset: Vector3, val rawRotation: Quaternion, @@ -35,20 +34,20 @@ data class BoneState( get() = tailPosition - headPosition } -data class SkeletonState(val rawBones: Map, val skeletonHeight: Float, val paused: Boolean) +data class SkeletonState(val boneInputs: Map, val skeletonHeight: Float, val paused: Boolean) val DEFAULT_SKELETON_STATE: SkeletonState = run { val bones = DEFAULT_BONE_OFFSETS.entries.associate { (bodyPart, tailOffset) -> bodyPart to BoneState(bodyPart = bodyPart, offset = tailOffset) } SkeletonState( - rawBones = bones.mapValues { (_, bone) -> RawBone(rawRotation = bone.rotation, bodyPart = bone.bodyPart, offset = bone.offset, rawPosition = Vector3.NULL, isActive = false) }, + boneInputs = bones.mapValues { (_, bone) -> BoneInput(rawRotation = bone.rotation, bodyPart = bone.bodyPart, offset = bone.offset, rawPosition = Vector3.NULL, isActive = false) }, skeletonHeight = DEFAULT_HEIGHT, paused = false, ) } -fun buildBone(bone: RawBone, parentBone: BoneState?, originPosition: Vector3 = Vector3.NULL): BoneState { +fun buildBone(bone: BoneInput, parentBone: BoneState?, originPosition: Vector3 = Vector3.NULL): BoneState { val head = parentBone?.tailPosition ?: originPosition return BoneState( bodyPart = bone.bodyPart, @@ -61,7 +60,7 @@ fun buildBone(bone: RawBone, parentBone: BoneState?, originPosition: Vector3 = V } fun buildBones( - state: Map, + state: Map, rootHead: Vector3 = Vector3.NULL, hierarchy: Sequence> = iterateBodyPartHierarchy(), ): Map { @@ -78,7 +77,7 @@ fun buildBones( state: SkeletonState, rootHead: Vector3 = Vector3.NULL, hierarchy: Sequence> = iterateBodyPartHierarchy(), -): Map = buildBones(state.rawBones, rootHead, hierarchy) +): Map = buildBones(state.boneInputs, rootHead, hierarchy) sealed interface SkeletonActions { data class SetBoneRotation(val bodyPart: BodyPart, val rotation: Quaternion) : SkeletonActions diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt index 9f197327cc..7c3a51aaec 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt @@ -101,10 +101,10 @@ class FallbackProcessor : SkeletonProcessor { ) override fun process(state: SkeletonState): SkeletonState { - val rawBones = state.rawBones + val rawBones = state.boneInputs return state.copy( - rawBones = rawBones.mapValues { (bodyPart, bone) -> + boneInputs = rawBones.mapValues { (bodyPart, bone) -> if (bone.isActive) return@mapValues bone bone.copy(rawRotation = computeFallbackRotation(rawBones, bodyPart)) @@ -112,18 +112,18 @@ class FallbackProcessor : SkeletonProcessor { ) } - private fun computeFallbackRotation(rawBones: Map, bodyPart: BodyPart): Quaternion { + private fun computeFallbackRotation(boneInputs: Map, bodyPart: BodyPart): Quaternion { // Fallback bone's rotation missingToFallbacks[bodyPart] ?.firstNotNullOfOrNull { part -> - rawBones[part]?.takeIf { it.isActive } + boneInputs[part]?.takeIf { it.isActive } } ?.let { return it.rawRotation } // First active parent bone's yaw - rawBones[ + boneInputs[ findBodyPartParent(bodyPart) { part -> - rawBones[part]?.isActive == true + boneInputs[part]?.isActive == true }, ]?.let { return it.rawRotation.project(Vector3.POS_Y).unit() } @@ -153,15 +153,15 @@ class SmoothingProcessor(val settings: Settings) : SkeletonProcessor { val alpha = 1 - (SMOOTH_MIN + config.amount.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN)) - smoothedRotations = state.rawBones.mapValues { (bodyPart, bone) -> + smoothedRotations = state.boneInputs.mapValues { (bodyPart, bone) -> (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() } - smoothedLengths = state.rawBones.mapValues { (bodyPart, bone) -> + smoothedLengths = state.boneInputs.mapValues { (bodyPart, bone) -> val prev = smoothedLengths[bodyPart] ?: bone.offset prev + (bone.offset - prev) * alpha } return state.copy( - rawBones = state.rawBones.mapValues { (bodyPart, bone) -> + boneInputs = state.boneInputs.mapValues { (bodyPart, bone) -> bone.copy( rawRotation = smoothedRotations[bodyPart] ?: bone.rawRotation, offset = smoothedLengths[bodyPart] ?: bone.offset, @@ -191,7 +191,7 @@ class PredictionProcessor(val settings: Settings) : SkeletonProcessor { if (config.type != FilteringType.PREDICTION) return state val newVelocities = mutableMapOf() - val newBones = state.rawBones.mapValues { (bodyPart, bone) -> + val newBones = state.boneInputs.mapValues { (bodyPart, bone) -> val prev = velocities[bodyPart] if (prev == null) { newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, Quaternion.IDENTITY, bone.offset, Vector3.NULL) @@ -215,6 +215,6 @@ class PredictionProcessor(val settings: Settings) : SkeletonProcessor { ) } velocities = newVelocities - return state.copy(rawBones = newBones) + return state.copy(boneInputs = newBones) } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt b/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt index b3503961b8..7784668b79 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt @@ -161,8 +161,8 @@ class FloorClipProcessor : SkeletonProcessor { fun getDisplacement(footPos: Vector3): Vector3 = Vector3(0f, footPos.y.coerceAtMost(0f), 0f) override fun process(state: SkeletonState): SkeletonState { - val newBones = state.rawBones.mapValues { (bodyPart, bone) -> } - state.rawBones[BodyPart.LEFT_FOOT]?.let { + val newBones = state.boneInputs.mapValues { (bodyPart, bone) -> } + state.boneInputs[BodyPart.LEFT_FOOT]?.let { val correction = getDisplacement(it.offset) // TODO Apply displacement onto skeleton } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonproportions.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeletonproportions.kt index a3a7843cbe..9a8e52ef53 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeletonproportions.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeletonproportions.kt @@ -24,7 +24,7 @@ class SkeletonProportionsBehaviour( private fun buildConfigResponse(): SkeletonProportionsResponse { val proportions = userConfig.context.state.value.data.proportions - val bones = skeleton.context.state.value.rawBones + val bones = skeleton.context.state.value.boneInputs val skeletonParts = bones.mapValues { it.value.offset }.toBoneValues().map { (offset, bone) -> SkeletonPart(offset, bone) } val expanded = configToBoneValues(proportions) val userHeight = if (expanded.isNotEmpty()) { From 5c4c64e629fd0055d83cc6f04e6a5a9c700f2b69 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Mon, 20 Jul 2026 06:20:09 -0400 Subject: [PATCH 270/350] Add missing quaternion unitization to CCD IK --- server/core/src/main/java/dev/slimevr/skeleton/ik.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/ik.kt b/server/core/src/main/java/dev/slimevr/skeleton/ik.kt index 5fe4805046..765b34d69c 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/ik.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/ik.kt @@ -33,7 +33,7 @@ fun ccdIk(boneInputs: Map, bones: Map, val localTarget = target - currentBone.headPosition val rotationChange = Quaternion.fromTo(localOffset, localTarget).pow( (i.toFloat() / chain.size) * 0.5f, - ) + ).unit() workingBones[bodyPart] = currentRawBone.copy( rawRotation = currentBone.rotation * rotationChange, From 9332ff8e0281dea0e90bed477b6bc274a65b1b24 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 20 Jul 2026 19:52:47 +0200 Subject: [PATCH 271/350] Keybinds --- gui/app/public/i18n/en/translation.ftl | 21 ++ gui/app/src/App.tsx | 2 + .../components/commons/KeybindRecorder.tsx | 128 +++++++++ .../commons/KeybindRecorderModal.tsx | 93 ++++++ .../src/components/commons/KeybindRow.scss | 65 +++++ .../src/components/commons/KeybindsRow.tsx | 79 ++++++ .../src/components/commons/NumberSelector.tsx | 86 +++--- .../components/settings/SettingsLayout.tsx | 4 + .../components/settings/SettingsSidebar.tsx | 5 + .../settings/pages/KeybindSettings.scss | 15 + .../settings/pages/KeybindSettings.tsx | 265 ++++++++++++++++++ gui/app/tailwind.config.ts | 7 +- .../java/dev/slimevr/config/serializers.kt | 16 ++ .../main/java/dev/slimevr/config/settings.kt | 20 ++ .../main/java/dev/slimevr/feature-flags.kt | 3 + .../core/src/main/java/dev/slimevr/logger.kt | 1 + .../main/java/dev/slimevr/solarxr/keybinds.kt | 58 ++++ .../main/java/dev/slimevr/solarxr/module.kt | 1 + .../java/dev/slimevr/tracker/behaviours.kt | 4 +- server/desktop/build.gradle.kts | 6 + .../src/main/java/dev/slimevr/desktop/Main.kt | 34 ++- .../java/dev/slimevr/desktop/ipc/solarxr.kt | 24 ++ .../java/dev/slimevr/desktop/keybind/gnome.kt | 72 +++++ .../dev/slimevr/desktop/keybind/keybinds.kt | 154 ++++++++++ solarxr-protocol | 2 +- 25 files changed, 1116 insertions(+), 49 deletions(-) create mode 100644 gui/app/src/components/commons/KeybindRecorder.tsx create mode 100644 gui/app/src/components/commons/KeybindRecorderModal.tsx create mode 100644 gui/app/src/components/commons/KeybindRow.scss create mode 100644 gui/app/src/components/commons/KeybindsRow.tsx create mode 100644 gui/app/src/components/settings/pages/KeybindSettings.scss create mode 100644 gui/app/src/components/settings/pages/KeybindSettings.tsx create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/keybinds.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/keybind/gnome.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt diff --git a/gui/app/public/i18n/en/translation.ftl b/gui/app/public/i18n/en/translation.ftl index b49a1e03c8..850b5f2e45 100644 --- a/gui/app/public/i18n/en/translation.ftl +++ b/gui/app/public/i18n/en/translation.ftl @@ -562,6 +562,27 @@ settings-stay_aligned-debug-label = Debugging settings-stay_aligned-debug-description = Please include your settings when reporting problems about Stay Aligned. settings-stay_aligned-debug-copy-label = Copy settings to clipboard +## Keybinds Page +settings-keybinds = Keybind settings +settings-keybinds-description = Change keybinds for various shortcuts +keybind_config-keybind_name = Keybind +keybind_config-keybind_value = Combination +keybind_config-keybind_delay = Delay before trigger (s) +settings-keybinds_full-reset = Full Reset +settings-keybinds_yaw-reset = Yaw Reset +settings-keybinds_mounting-reset = Mounting Reset +settings-keybinds_feet-mounting-reset = Feet Mounting Reset +settings-keybinds_pause-tracking = Pause Tracking +settings-keybinds_reset-all-button = Reset all +settings-keybinds-system-managed-description = Your desktop manages global shortcuts itself, so they have to be changed from your system settings. +settings-keybinds-open-system-settings-button = Open system settings +settings-keybinds-unsupported-description = Global keybinds are not supported on this platform. +settings-sidebar-keybinds = Keybinds +settings-keybinds-recorder-modal-title = Assign keybind for +settings-keybinds-recorder-modal-reset-button = Reset +settings-keybinds-recorder-modal-unbind-button = Unbind +settings-keybinds-recorder-modal-done-button = Done + ## FK/Tracking settings settings-general-fk_settings = Tracking Settings diff --git a/gui/app/src/App.tsx b/gui/app/src/App.tsx index 2d7524707d..0cb842cfeb 100644 --- a/gui/app/src/App.tsx +++ b/gui/app/src/App.tsx @@ -44,6 +44,7 @@ import { useDiscordPresence } from './hooks/discord-presence'; import { withSentryReactRouterV6Routing } from '@sentry/react'; import { ScaledProportionsPage } from './components/onboarding/pages/body-proportions/ScaledProportions'; import { AdvancedSettings } from './components/settings/pages/AdvancedSettings'; +import { KeybindSettings } from './components/settings/pages/KeybindSettings'; import { FirmwareUpdate } from './components/firmware-update/FirmwareUpdate'; import { ConnectionLost } from './components/onboarding/pages/ConnectionLost'; import { VRCWarningsPage } from './components/vrc/VRCWarningsPage'; @@ -143,6 +144,7 @@ function Layout() { } /> } /> } /> + } /> = { + CONTROL: 'CTRL', + META: 'SUPER', + OS: 'SUPER', +}; + +export const KeybindRecorder = forwardRef< + HTMLInputElement, + { + keys: string[]; + onKeysChange: (v: string[]) => void; + error?: string; + } +>(function KeybindRecorder({ keys, onKeysChange, error }) { + const [localKeys, setLocalKeys] = useState(keys); + const [isRecording, setIsRecording] = useState(false); + const [oldKeys, setOldKeys] = useState([]); + const [invalidSlot, setInvalidSlot] = useState(null); + const [errorText, setErrorText] = useState(''); + const inputRef = useRef(null); + const displayKeys = isRecording ? localKeys : keys; + const activeIndex = isRecording ? displayKeys.length : -1; + const displayError = errorText || error; + + const { clearErrors } = useFormContext(); + + const handleKeyDown = (e: React.KeyboardEvent) => { + e.preventDefault(); + const rawKey = e.key.toUpperCase(); + const key = keyAliases[rawKey] ?? rawKey; + const errorMsg = excludedKeys.includes(key) + ? `Cannot use ${key}!` + : displayKeys.includes(key) + ? `${key} is a Duplicate Key!` + : null; + if (errorMsg) { + setErrorText(errorMsg); + setInvalidSlot(activeIndex); + setTimeout(() => { + setInvalidSlot(null); + }, 350); + return; + } + + if (displayKeys.length < maxKeybindLength) { + const updatedKeys = [...displayKeys, key]; + setLocalKeys(updatedKeys); + onKeysChange(updatedKeys); + if (updatedKeys.length == maxKeybindLength) { + inputRef.current?.blur(); + } + } + }; + + const handleOnBlur = () => { + setIsRecording(false); + if (displayKeys.length < maxKeybindLength - 2 || error) { + onKeysChange(oldKeys); + setLocalKeys(oldKeys); + } + }; + + const handleOnFocus = () => { + clearErrors('keybinds'); + const initialKeys: string[] = []; + setOldKeys(keys); + setLocalKeys(initialKeys); + onKeysChange(initialKeys); + setIsRecording(true); + }; + + return ( +
+
+ +
+ {Array.from({ length: maxKeybindLength }).map((_, i) => { + const key = displayKeys[i]; + const isActive = isRecording && i === activeIndex; + const isInvalid = invalidSlot === i; + return ( +
+
+ {key ?? ''} +
+
+ {i < maxKeybindLength - 1 ? '+' : ''} +
+
+ ); + })} +
+
+ {displayError && ( +
+ {`${errorText} ${error}`} +
+ )} +
+ ); +}); diff --git a/gui/app/src/components/commons/KeybindRecorderModal.tsx b/gui/app/src/components/commons/KeybindRecorderModal.tsx new file mode 100644 index 0000000000..e465bf8ed5 --- /dev/null +++ b/gui/app/src/components/commons/KeybindRecorderModal.tsx @@ -0,0 +1,93 @@ +import { BaseModal } from './BaseModal'; +import { + Controller, + Control, + useFormContext, + FieldValues, + FieldPath, +} from 'react-hook-form'; +import { KeybindRecorder } from './KeybindRecorder'; +import { Typography } from './Typography'; +import { Button } from './Button'; +import './KeybindRow.scss'; +import { useLocalization } from '@fluent/react'; + +export function KeybindRecorderModal({ + id, + control, + name, + isVisisble, + onClose, + onUnbind, + onSubmit, +}: { + id?: string; + control: Control; + name: FieldPath; + isVisisble: boolean; + onClose: () => void; + onUnbind: () => void; + onSubmit: () => void; +}) { + const { l10n } = useLocalization(); + const keybindlocalization = 'settings-keybinds_' + id; + const { + formState: { errors }, + resetField, + handleSubmit, + } = useFormContext(); + + return ( + +
+ + {l10n.getString('settings-keybinds-recorder-modal-title')}{' '} + {l10n.getString(keybindlocalization)} + + ( + + )} + /> +
+
+
+
+
+
+
+
+ ); +} diff --git a/gui/app/src/components/commons/KeybindRow.scss b/gui/app/src/components/commons/KeybindRow.scss new file mode 100644 index 0000000000..aac57c22d7 --- /dev/null +++ b/gui/app/src/components/commons/KeybindRow.scss @@ -0,0 +1,65 @@ +.keybind-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + height: auto; + align-items: center; + gap: 10px; +} + +@keyframes keyslot { + 0%, + 100% { + transform: scale(1); + opacity: 0.6; + } + + 50% { + transform: scale(1.08); + opacity: 1; + } +} + +@keyframes shake { + 0% { + transform: translate(1px, 1px) rotate(0deg); + } + 10% { + transform: translate(-1px, -2px) rotate(-1deg); + } + 20% { + transform: translate(-3px, 0px) rotate(1deg); + } + 30% { + transform: translate(3px, 2px) rotate(0deg); + } + 40% { + transform: translate(1px, -1px) rotate(1deg); + } + 50% { + transform: translate(-1px, 2px) rotate(-1deg); + } + 60% { + transform: translate(-3px, 1px) rotate(0deg); + } + 70% { + transform: translate(3px, 1px) rotate(-1deg); + } + 80% { + transform: translate(-1px, -1px) rotate(1deg); + } + 90% { + transform: translate(1px, 2px) rotate(0deg); + } + 100% { + transform: translate(1px, -2px) rotate(-1deg); + } +} + +.keyslot-animate { + animation: keyslot 1s ease-in-out infinite; +} + +.keyslot-invalid { + animation: shake 0.35s ease; +} diff --git a/gui/app/src/components/commons/KeybindsRow.tsx b/gui/app/src/components/commons/KeybindsRow.tsx new file mode 100644 index 0000000000..0c5e930c55 --- /dev/null +++ b/gui/app/src/components/commons/KeybindsRow.tsx @@ -0,0 +1,79 @@ +import { Typography } from './Typography'; +import './KeybindRow.scss'; +import { Control, FieldPath, FieldValues, useWatch } from 'react-hook-form'; +import { NumberSelector } from './NumberSelector'; +import { useLocaleConfig } from '@/i18n/config'; + +function KeyBindKeyList({ keybind }: { keybind: string[] }) { + if (keybind.length <= 1) { + return ( +
+ Click to edit keybind +
+ ); + } + return keybind.map((key, i) => { + return ( +
+
+ {key ?? ''} +
+
+ {i < keybind.length - 1 ? '+' : ''} +
+
+ ); + }); +} + +export function KeybindsRow({ + id, + control, + index, + openKeybindRecorderModal, +}: { + id?: string; + control: Control; + index: number; + openKeybindRecorderModal: (index: number) => void; +}) { + const binding = useWatch({ + control, + name: `keybinds.${index}.binding` as FieldPath, + }) as string[] | undefined; + const { currentLocales } = useLocaleConfig(); + const secondsFormat = new Intl.NumberFormat(currentLocales, { + style: 'unit', + unit: 'second', + unitDisplay: 'narrow', + maximumFractionDigits: 2, + }); + + const handleOpenModal = () => { + openKeybindRecorderModal(index); + }; + + return ( +
+ +
+
+ {binding != null && } +
+
+ } + valueLabelFormat={(value) => secondsFormat.format(value)} + min={0} + max={10} + step={0.2} + /> +
+ ); +} diff --git a/gui/app/src/components/commons/NumberSelector.tsx b/gui/app/src/components/commons/NumberSelector.tsx index 93553824bf..4a34af4643 100644 --- a/gui/app/src/components/commons/NumberSelector.tsx +++ b/gui/app/src/components/commons/NumberSelector.tsx @@ -57,60 +57,62 @@ export function NumberSelector({ ( -
- {label} -
-
- {doubleStep !== undefined && ( + render={({ field: { onChange, value } }) => { + return ( +
+ {label?.length != 0 ? {label} : <>} +
+
+ {doubleStep !== undefined && ( + + )} - )} - -
-
- {valueLabelFormat ? valueLabelFormat(value) : value} -
-
- - {doubleStep !== undefined && ( +
+
+ {valueLabelFormat ? valueLabelFormat(value) : value} +
+
- )} + {doubleStep !== undefined && ( + + )} +
-
- )} + ); + }} /> ); } diff --git a/gui/app/src/components/settings/SettingsLayout.tsx b/gui/app/src/components/settings/SettingsLayout.tsx index 8b97d7b208..46810de566 100644 --- a/gui/app/src/components/settings/SettingsLayout.tsx +++ b/gui/app/src/components/settings/SettingsLayout.tsx @@ -58,6 +58,10 @@ export function SettingSelectorMobile() { label: l10n.getString('settings-sidebar-advanced'), value: { url: '/settings/advanced' }, }, + { + label: l10n.getString('settings-sidebar-keybinds'), + value: { url: '/settings/keybinds' }, + }, { label: l10n.getString('navbar-onboarding'), value: { url: '/onboarding/home' }, diff --git a/gui/app/src/components/settings/SettingsSidebar.tsx b/gui/app/src/components/settings/SettingsSidebar.tsx index d501795692..67ad2be272 100644 --- a/gui/app/src/components/settings/SettingsSidebar.tsx +++ b/gui/app/src/components/settings/SettingsSidebar.tsx @@ -144,6 +144,11 @@ export function SettingsSidebar() { to="/settings/advanced" id="settings-sidebar-advanced" /> +
diff --git a/gui/app/src/components/settings/pages/KeybindSettings.scss b/gui/app/src/components/settings/pages/KeybindSettings.scss new file mode 100644 index 0000000000..b73610c1a0 --- /dev/null +++ b/gui/app/src/components/settings/pages/KeybindSettings.scss @@ -0,0 +1,15 @@ +.keybind-settings { + display: grid; + + grid-template: + 'n v d ' auto + 'k k k ' auto + 'b . . ' auto + / 1fr 1fr 90px; + + grid-template-columns: subgrid; + grid-template-rows: subgrid; + + align-items: left; + gap: 10px; +} diff --git a/gui/app/src/components/settings/pages/KeybindSettings.tsx b/gui/app/src/components/settings/pages/KeybindSettings.tsx new file mode 100644 index 0000000000..14571e479c --- /dev/null +++ b/gui/app/src/components/settings/pages/KeybindSettings.tsx @@ -0,0 +1,265 @@ +import { KeybindRecorderModal } from '@/components/commons/KeybindRecorderModal'; +import { + SettingsPageLayout, + SettingsPagePaneLayout, +} from '@/components/settings/SettingsPageLayout'; +import { WrenchIcon } from '@/components/commons/icon/WrenchIcon'; +import { Typography } from '@/components/commons/Typography'; +import { useLocalization } from '@fluent/react'; +import './KeybindSettings.scss'; +import { Button } from '@/components/commons/Button'; +import { KeybindsRow } from '@/components/commons/KeybindsRow'; +import { useWebsocketAPI } from '@/hooks/websocket-api'; +import { ReactNode, useEffect, useRef, useState } from 'react'; +import { + ChangeKeybindRequestT, + KeybindRequestT, + KeybindResponseT, + KeybindSupport, + KeybindT, + OpenKeybindSettingsRequestT, + RpcMessage, +} from 'solarxr-protocol'; +import { FormProvider, useFieldArray, useForm } from 'react-hook-form'; + +export type KeybindForm = { + keybinds: { + id: number; + name: string; + binding: string[]; + delay: number; + }[]; +}; + +export function KeybindSettings() { + const { l10n } = useLocalization(); + const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); + const [isOpen, setIsOpen] = useState(false); + const [defaultKeybindsState, setDefaultKeybindsState] = useState( + { + keybinds: [], + } + ); + const currentIndex = useRef(null); + const [support, setSupport] = useState( + KeybindSupport.UNSUPPORTED + ); + + const methods = useForm({ + defaultValues: defaultKeybindsState, + }); + + const { + control, + handleSubmit, + reset, + setValue, + getValues, + setError, + clearErrors, + resetField, + watch, + } = methods; + + const { fields } = useFieldArray({ + control, + name: 'keybinds', + }); + + const onSubmit = () => { + const value = getValues(); + if (checkDuplicates(value)) { + return; + } + clearErrors('keybinds'); + + value.keybinds.forEach((kb) => { + const changeKeybindRequest = new ChangeKeybindRequestT(); + + const keybind = new KeybindT(); + keybind.keybindId = kb.id; + keybind.keybindValue = kb.binding.join('+'); + keybind.keybindDelay = kb.delay; + + changeKeybindRequest.keybind = keybind; + + sendRPCPacket(RpcMessage.ChangeKeybindRequest, changeKeybindRequest); + setIsOpen(false); + }); + }; + + const checkDuplicates = (value: KeybindForm) => { + const normalized = value.keybinds + .filter((kb) => kb.binding.length > 0) + .map((kb) => JSON.stringify([...kb.binding].sort())); + + const unique = new Set(normalized); + + if (unique.size !== normalized.length) { + setError('keybinds', { + type: 'manual', + message: 'Duplicate keybind combinations are not allowed', + }); + return true; + } + + return false; + }; + + const handleOpenSystemSettingsButton = () => { + sendRPCPacket( + RpcMessage.OpenKeybindSettingsRequest, + new OpenKeybindSettingsRequestT() + ); + }; + + useRPCPacket( + RpcMessage.KeybindResponse, + ({ keybind, defaultKeybinds, support }: KeybindResponseT) => { + setSupport(support); + if (!keybind) return; + + const mappedDefaults = defaultKeybinds.map((kb) => ({ + id: kb.keybindId, + name: kb.keybindNameId?.toString() ?? '', + binding: kb.keybindValue?.toString().split('+') ?? [], + delay: kb.keybindDelay, + })); + + setDefaultKeybindsState({ keybinds: mappedDefaults }); + reset({ keybinds: mappedDefaults }); + + const mapped = keybind.map((kb) => ({ + id: kb.keybindId, + name: kb.keybindNameId?.toString() ?? '', + binding: kb.keybindValue?.toString().split('+') ?? [], + delay: kb.keybindDelay, + })); + + mapped.forEach((keybind, index) => { + setValue(`keybinds.${index}.binding`, keybind.binding); + setValue(`keybinds.${index}.delay`, keybind.delay); + }); + } + ); + + const handleOpenRecorderModal = (index: number) => { + currentIndex.current = index; + setIsOpen(true); + }; + + const onClose = () => { + if (currentIndex.current != null) { + resetField(`keybinds.${currentIndex.current}.binding`); + } + setIsOpen(false); + }; + + // Captured so the modal's callbacks keep the index they were opened with + const editedIndex = currentIndex.current; + + const createKeybindRows = (): ReactNode => { + return fields.map((field, index) => { + return ( +
+ +
+ ); + }); + }; + + useEffect(() => { + sendRPCPacket(RpcMessage.KeybindRequest, new KeybindRequestT()); + }, []); + + // Only the recorder modal submits, so a delay change would otherwise never reach the server + useEffect(() => { + const subscription = watch((_, { name, type }) => { + if (type === 'change' && name?.endsWith('.delay')) onSubmit(); + }); + return () => subscription.unsubscribe(); + }, [watch]); + + return ( + + } id="keybinds"> +
+ +
+ {l10n + .getString('settings-keybinds-description') + .split('\n') + .map((line, i) => ( + {line} + ))} +
+ {support === KeybindSupport.UNSUPPORTED && ( + + )} + {support === KeybindSupport.SYSTEM_MANAGED && ( +
+ +
+
+
+ )} + {support === KeybindSupport.APP_MANAGED && ( + <> + +
+ + + + {createKeybindRows()} +
+
+
+ {editedIndex != null && ( + + setValue(`keybinds.${editedIndex}.binding`, []) + } + onSubmit={onSubmit} + /> + )} +
+ + )} +
+
+
+ ); +} diff --git a/gui/app/tailwind.config.ts b/gui/app/tailwind.config.ts index 07004e88cd..f3a9de3fde 100644 --- a/gui/app/tailwind.config.ts +++ b/gui/app/tailwind.config.ts @@ -183,6 +183,7 @@ const config = { lg: '1300px', xl: '1600px', tall: { raw: '(min-height: 860px)' }, + 'keybinds-small': { raw: 'not (min-width: 1230px)' }, }, extend: { colors: { @@ -236,10 +237,10 @@ const config = { }, }, 'timer-tick': { - "0%, 40%": { + '0%, 40%': { transform: 'scale(1)', }, - "20%": { + '20%': { transform: 'scale(1.3)', }, }, @@ -294,7 +295,7 @@ const config = { light: `linear-gradient(135deg, ${colors['light-accent'][100]} 50%, ${colors['light-background'][700]} 50% 100%)`, 'trans-flag': `linear-gradient(135deg, ${colors['trans-blue'][800]} 40%, ${colors['trans-blue'][700]} 40% 70%, ${colors['trans-blue'][600]} 70% 100%)`, 'asexual-flag': `linear-gradient(135deg, ${colors['asexual'][100]} 30%, ${colors['asexual'][200]} 30% 50%, ${colors['asexual'][300]} 50% 70%, ${colors['asexual'][400]} 70% 100%)`, - 'snep': `linear-gradient(135deg, ${colors['snep'][100]} 40%, ${colors['snep'][200]} 40% 70%, ${colors['snep'][300]} 70% 100%)`, + snep: `linear-gradient(135deg, ${colors['snep'][100]} 40%, ${colors['snep'][200]} 40% 70%, ${colors['snep'][300]} 70% 100%)`, }, animation: { 'spin-ccw': 'spin-ccw 1s linear infinite', diff --git a/server/core/src/main/java/dev/slimevr/config/serializers.kt b/server/core/src/main/java/dev/slimevr/config/serializers.kt index c3a5dfecc9..4cc77d94de 100644 --- a/server/core/src/main/java/dev/slimevr/config/serializers.kt +++ b/server/core/src/main/java/dev/slimevr/config/serializers.kt @@ -16,6 +16,22 @@ import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonPrimitive import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.KeybindId + +object KeybindIdSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("KeybindId", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: KeybindId) { + (encoder as JsonEncoder).encodeJsonElement(JsonPrimitive(value.name)) + } + + override fun deserialize(decoder: Decoder): KeybindId { + val element = (decoder as JsonDecoder).decodeJsonElement() + // Falling back to a real keybind would silently turn an unknown entry into a duplicate + return KeybindId.entries.firstOrNull { it.name == element.jsonPrimitive.content } + ?: KeybindId.NONE + } +} object BodyPartSerializer : KSerializer { @OptIn(ExperimentalSerializationApi::class) diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index e7bb7def70..4336ced056 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -16,6 +16,7 @@ import kotlinx.serialization.json.jsonPrimitive import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.ArmsResetMode import solarxr_protocol.rpc.FilteringType +import solarxr_protocol.rpc.KeybindId private const val SETTINGS_CONFIG_VERSION = 2 @@ -91,6 +92,24 @@ data class ResetsConfig( val lastMountingMethod: MountingMethods = MountingMethods.AUTOMATIC, // TODO ) +@Serializable +data class KeybindConfig( + @Serializable(with = KeybindIdSerializer::class) + val id: KeybindId, + /** Key combination, keys joined with '+' (e.g. "CTRL+ALT+SHIFT+Y") */ + val binding: String, + /** Delay in seconds before the keybind triggers */ + val delay: Float = 0f, +) + +fun defaultKeybinds(): List = listOf( + KeybindConfig(KeybindId.FULL_RESET, "CTRL+ALT+SHIFT+Y"), + KeybindConfig(KeybindId.YAW_RESET, "CTRL+ALT+SHIFT+U"), + KeybindConfig(KeybindId.MOUNTING_RESET, "CTRL+ALT+SHIFT+I"), + KeybindConfig(KeybindId.FEET_MOUNTING_RESET, "CTRL+ALT+SHIFT+P"), + KeybindConfig(KeybindId.PAUSE_TRACKING, "CTRL+ALT+SHIFT+O"), +) + // Used in SkeletonConfig @Serializable data class SkeletonTogglesConfig( @@ -170,6 +189,7 @@ data class SettingsConfigState( val skeletonConfig: SkeletonConfig = SkeletonConfig(), val vrcOscConfig: VRCOSCConfig = VRCOSCConfig(), val vmcConfig: VMCConfig = VMCConfig(), + val keybinds: List = defaultKeybinds(), ) private fun migrateSettingsConfig(json: JsonObject): JsonObject { diff --git a/server/core/src/main/java/dev/slimevr/feature-flags.kt b/server/core/src/main/java/dev/slimevr/feature-flags.kt index bfbd943966..526c63c61f 100644 --- a/server/core/src/main/java/dev/slimevr/feature-flags.kt +++ b/server/core/src/main/java/dev/slimevr/feature-flags.kt @@ -1,7 +1,10 @@ package dev.slimevr +import solarxr_protocol.rpc.KeybindSupport + data class FeatureFlags( var steam: Boolean = false, var skipCheckUdev: Boolean = false, var udevRulesInstalled: Boolean? = null, + var keybindSupport: KeybindSupport = KeybindSupport.UNSUPPORTED, ) diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt index 070a74b4d5..b03bd11b0c 100644 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logger.kt @@ -24,6 +24,7 @@ object AppLogger { val oscQuery = logger("OSCQuery") val install = logger("Install") val resets = logger("Resets") + val keybind = logger("Keybind") val coroutines = noCoLogger("Coroutines") init { diff --git a/server/core/src/main/java/dev/slimevr/solarxr/keybinds.kt b/server/core/src/main/java/dev/slimevr/solarxr/keybinds.kt new file mode 100644 index 0000000000..50927dc3e7 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/keybinds.kt @@ -0,0 +1,58 @@ +package dev.slimevr.solarxr + +import dev.slimevr.config.KeybindConfig +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions +import dev.slimevr.config.defaultKeybinds +import solarxr_protocol.rpc.ChangeKeybindRequest +import solarxr_protocol.rpc.Keybind +import solarxr_protocol.rpc.KeybindId +import solarxr_protocol.rpc.KeybindRequest +import solarxr_protocol.rpc.KeybindResponse + +private fun keybindNameId(id: KeybindId): String = id.name.lowercase().replace('_', '-') + +private fun keybindConfigToProto(config: KeybindConfig): Keybind = Keybind( + keybindId = config.id, + keybindNameId = keybindNameId(config.id), + keybindValue = config.binding, + keybindDelay = config.delay, +) + +class KeybindsBehaviour( + private val settings: Settings, +) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { + receiver.rpcDispatcher.on { + val keybinds = settings.context.state.value.data.keybinds + receiver.sendRpc( + KeybindResponse( + keybind = keybinds.map { keybindConfigToProto(it) }, + defaultKeybinds = defaultKeybinds().map { keybindConfigToProto(it) }, + support = receiver.appContext.featureFlags.keybindSupport, + ), + ) + } + + receiver.rpcDispatcher.on { req -> + val keybind = req.keybind ?: return@on + val id = keybind.keybindId?.takeUnless { it == KeybindId.NONE } ?: return@on + settings.context.dispatch( + SettingsActions.Update { + copy( + keybinds = keybinds.map { + if (it.id == id) { + it.copy( + binding = keybind.keybindValue ?: it.binding, + delay = keybind.keybindDelay ?: 0f, + ) + } else { + it + } + }, + ) + }, + ) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index ad5a8f012e..a8b95e6a08 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -86,6 +86,7 @@ class SolarXRBridge( add(KnownTrackersBehaviour(appContext.config.settings)) add(BvhBehaviour(appContext.bvhManager)) add(InstalledInfoBehaviour()) + add(KeybindsBehaviour(appContext.config.settings)) add(SessionCalibrationBehaviour(appContext.resetsManager)) } diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 59db381d2a..6ff847cd7a 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -1,5 +1,6 @@ package dev.slimevr.tracker +import dev.slimevr.AppLogger import dev.slimevr.skeleton.SkeletonActions import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion @@ -7,7 +8,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -196,7 +196,7 @@ class TrackerTPSBehaviour : TrackerBehaviour { receiver.context.dispatch(TrackerActions.Update { copy(tps = tps.toUShort()) }) mark = TimeSource.Monotonic.markNow() } catch (e: Exception) { - dev.slimevr.AppLogger.coroutines.error(e, "Error in TrackerTPSBehaviour") + AppLogger.coroutines.error(e, "Error in TrackerTPSBehaviour") } } } diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index 45cddc94f7..ceafe6146f 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -88,6 +88,10 @@ dependencies { implementation("org.hid4java:hid4java:0.8.0") implementation("io.klogging:klogging:0.11.7") + // Global keybinds: JIntellitype on Windows, dbus xdg-desktop-portal on Linux + implementation("com.melloware:jintellitype:1.+") + implementation("com.github.HannahPadd:DbusGlobalShortcutsWayland:v0.1.0") + val ktorVersion = "3.4.1" implementation("io.ktor:ktor-client-core:$ktorVersion") implementation("io.ktor:ktor-client-cio:$ktorVersion") @@ -103,6 +107,8 @@ tasks.shadowJar { exclude(dependency("com.fazecast:jSerialComm:.*")) exclude(dependency("net.java.dev.jna:.*:.*")) exclude(dependency("com.google.flatbuffers:flatbuffers-java:.*")) + exclude(dependency("com.melloware:jintellitype:.*")) + exclude(dependency("com.github.HannahPadd:DbusGlobalShortcutsWayland:.*")) exclude(project(":solarxr-protocol:generated")) } 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 8a0f024299..feb64e96ce 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -17,6 +17,9 @@ import dev.slimevr.desktop.install.executeShellCommand import dev.slimevr.desktop.install.runInstaller import dev.slimevr.desktop.ipc.createIpcServers import dev.slimevr.desktop.ipc.createSolarXRWebsocketServer +import dev.slimevr.desktop.keybind.createDesktopKeybindManager +import dev.slimevr.desktop.keybind.isGnome +import dev.slimevr.desktop.keybind.resolveGnomeAppId import dev.slimevr.desktop.networkprofile.setupDesktopNetworkProfileChecker import dev.slimevr.desktop.serial.DesktopFirmwareFlasher import dev.slimevr.desktop.serial.createDesktopSerialServer @@ -38,8 +41,12 @@ import dev.slimevr.udp.UdpServer import dev.slimevr.util.safeLaunch import dev.slimevr.vmc.VMCManager import dev.slimevr.vrcosc.VRCOSCManager +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import solarxr_protocol.rpc.KeybindSupport fun main(args: Array) = runBlocking { contextDebugEnabled = System.getProperty("slimevr.debug.context") == "true" || @@ -60,6 +67,20 @@ fun main(args: Array) = runBlocking { } if (CURRENT_PLATFORM != Platform.LINUX) featureFlags.skipCheckUdev = true + // Windows registers hotkeys itself, and on GNOME we can rewrite the triggers it stores in dconf. + // Other compositors own the bindings, so there the user rebinds from the system settings. + featureFlags.keybindSupport = when (CURRENT_PLATFORM) { + Platform.WINDOWS -> KeybindSupport.APP_MANAGED + + Platform.LINUX -> if (isGnome() && resolveGnomeAppId() != null) { + KeybindSupport.APP_MANAGED + } else { + KeybindSupport.SYSTEM_MANAGED + } + + else -> KeybindSupport.UNSUPPORTED + } + val isInstallDisabled = System.getenv("SLIME_SERVER_DISABLE_INSTALLER")?.toIntOrNull() if (featureFlags.steam && isInstallDisabled != 1) runInstaller() @@ -125,16 +146,27 @@ fun main(args: Array) = runBlocking { tapDetectionManager = tapDetectionManager, ) + // JVM shutdown hooks fire on SIGINT/SIGTERM but coroutine `finally` blocks do not, + // so cancel the root scope on shutdown to let structured concurrency run every cleanup. + val rootJob = coroutineContext[Job]!! + Runtime.getRuntime().addShutdownHook( + Thread { + rootJob.cancel() + runBlocking { rootJob.join() } + }, + ) + try { appContext.startObserving() safeLaunch { createDesktopHIDManager(appContext, this) } + safeLaunch { createDesktopKeybindManager(appContext, this) } safeLaunch { createSolarXRWebsocketServer(appContext) } safeLaunch { createIpcServers(appContext) } safeLaunch { setupDesktopNetworkProfileChecker(this, networkProfileManager) } awaitCancellation() } finally { - appContext.dispose() + withContext(NonCancellable) { appContext.dispose() } } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt index b7cde9d9f9..b04b8329a3 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt @@ -1,7 +1,12 @@ package dev.slimevr.desktop.ipc import com.google.flatbuffers.FlatBufferBuilder +import dev.hannah.portals.PortalManager import dev.slimevr.AppContextProvider +import dev.slimevr.AppLogger +import dev.slimevr.CURRENT_PLATFORM +import dev.slimevr.Platform +import dev.slimevr.SLIMEVR_IDENTIFIER import dev.slimevr.VRServerActions import dev.slimevr.desktop.unblockSteamVRDriver import dev.slimevr.fbscodegen.runtime.JvmFlatBufferReader @@ -13,10 +18,13 @@ import dev.slimevr.util.safeLaunch import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.util.moveToByteArray +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext import solarxr_protocol.MessageBundle import solarxr_protocol.rpc.EnableSteamVRDriverRequest +import solarxr_protocol.rpc.OpenKeybindSettingsRequest import java.nio.ByteBuffer class EnableSteamVRDriverBehaviour : SolarXRBridgeBehaviour { @@ -31,6 +39,21 @@ class EnableSteamVRDriverBehaviour : SolarXRBridgeBehaviour { } } +class OpenKeybindSettingsBehaviour : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { + receiver.rpcDispatcher.on { + if (CURRENT_PLATFORM != Platform.LINUX) return@on + receiver.context.scope.safeLaunch { + withContext(Dispatchers.IO) { + runCatching { + PortalManager(SLIMEVR_IDENTIFIER).openGlobalShortcutsSettings() + }.onFailure { AppLogger.keybind.error(it, "Failed to open global shortcuts settings") } + } + } + } + } +} + suspend fun handleSolarXRBridge( appContext: AppContextProvider, messages: Flow, @@ -43,6 +66,7 @@ suspend fun handleSolarXRBridge( extraBehaviours = { _ -> buildList { add(EnableSteamVRDriverBehaviour()) + add(OpenKeybindSettingsBehaviour()) } }, ) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/keybind/gnome.kt b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/gnome.kt new file mode 100644 index 0000000000..0485dbd651 --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/gnome.kt @@ -0,0 +1,72 @@ +package dev.slimevr.desktop.keybind + +import dev.slimevr.AppLogger +import dev.slimevr.config.KeybindConfig +import dev.slimevr.desktop.install.executeShellCommand +import java.io.File + +private const val GLOBAL_SHORTCUTS_PATH = "/org/gnome/settings-daemon/global-shortcuts" + +private val MODIFIERS = linkedMapOf( + "SHIFT" to "", + "CTRL" to "", + "ALT" to "", + "SUPER" to "", +) + +fun isGnome(): Boolean = System.getenv("XDG_CURRENT_DESKTOP")?.uppercase()?.contains("GNOME") == true + +fun resolveGnomeAppId(): String? { + val cgroup = runCatching { File("/proc/self/cgroup").readText() }.getOrNull() ?: return null + + val scope = cgroup.lineSequence() + .map { line -> line.substringAfterLast('/', "") } + .firstOrNull { it.startsWith("app-") && it.endsWith(".scope") } + ?: return null + + // app--.scope, where instance is the pid that launched it + return scope + .removePrefix("app-") + .removeSuffix(".scope") + .substringBeforeLast('-') + .ifEmpty { null } +} + +/** Converts our `CTRL+ALT+SHIFT+Y` form into GTK's `y` form. */ +private fun toGnomeAccelerator(binding: String): String? { + val parts = binding.split('+').map { it.trim().uppercase() }.filter { it.isNotEmpty() } + val key = parts.firstOrNull { it !in MODIFIERS } ?: return null + + val modifiers = parts.filter { it in MODIFIERS }.toSet() + return MODIFIERS.filterKeys { it in modifiers }.values.joinToString("") + key.lowercase() +} + +private fun escapeGVariant(value: String): String = value.replace("\\", "\\\\").replace("'", "\\'") + +private fun buildGnomeShortcutsVariant(keybinds: List): String = keybinds + .mapNotNull { keybind -> + val accelerator = toGnomeAccelerator(keybind.binding) ?: return@mapNotNull null + "('${escapeGVariant(keybind.id.name)}', {'shortcuts': <['${escapeGVariant(accelerator)}']>, " + + "'description': <'${escapeGVariant(keybindLabel(keybind.id))}'>})" + } + .joinToString(", ", prefix = "[", postfix = "]") + +suspend fun gnomeShortcutsExist(appId: String): Boolean { + val (exitCode, output) = executeShellCommand("dconf", "read", "$GLOBAL_SHORTCUTS_PATH/$appId/shortcuts") ?: return false + return exitCode == 0 && output.isNotBlank() +} + +suspend fun writeGnomeShortcuts(appId: String, keybinds: List): Boolean { + val bound = keybinds.filter { toGnomeAccelerator(it.binding) != null } + if (bound.isEmpty()) return false + + val variant = buildGnomeShortcutsVariant(bound) + val (exitCode, output) = executeShellCommand("dconf", "write", "$GLOBAL_SHORTCUTS_PATH/$appId/shortcuts", variant) + ?: return false + + if (exitCode != 0) { + AppLogger.keybind.warn("Failed to write GNOME shortcuts for $appId: ${output.trim()}") + return false + } + return true +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt new file mode 100644 index 0000000000..3aa62fe9dd --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt @@ -0,0 +1,154 @@ +package dev.slimevr.desktop.keybind + +import com.melloware.jintellitype.JIntellitype +import dev.hannah.portals.PortalManager +import dev.hannah.portals.globalShortcuts.GlobalShortcutsHandler +import dev.hannah.portals.globalShortcuts.Shortcut +import dev.hannah.portals.globalShortcuts.ShortcutTuple +import dev.slimevr.AppContextProvider +import dev.slimevr.AppLogger +import dev.slimevr.CURRENT_PLATFORM +import dev.slimevr.Platform +import dev.slimevr.SLIMEVR_IDENTIFIER +import dev.slimevr.config.KeybindConfig +import dev.slimevr.skeleton.SkeletonActions +import dev.slimevr.util.safeLaunch +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.KeybindId +import solarxr_protocol.rpc.ResetType + +private const val KEYBIND_SOURCE = "Keybind" + +private val FEET_BODY_PARTS = listOf(BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT) + +private fun currentKeybinds(appContext: AppContextProvider): List = appContext.config.settings.context.state.value.data.keybinds + +private fun bindingsFlow(appContext: AppContextProvider): Flow> = appContext.config.settings.context.state + .map { it.data.keybinds } + .distinctUntilChangedBy { keybinds -> keybinds.map { it.id to it.binding } } + +fun keybindLabel(id: KeybindId): String = id.name + .split('_') + .joinToString(" ") { word -> word.lowercase().replaceFirstChar { it.uppercase() } } + +private fun toJIntellitypeAccelerator(binding: String): String = binding + .split('+') + .joinToString("+") { if (it == "SUPER") "WIN" else it } + +private fun triggerKeybind(appContext: AppContextProvider, scope: CoroutineScope, id: KeybindId) { + val delaySeconds = currentKeybinds(appContext).find { it.id == id }?.delay ?: 0f + scope.safeLaunch { + when (id) { + KeybindId.FULL_RESET -> + appContext.resetsManager.scheduleReset(KEYBIND_SOURCE, ResetType.FULL, delaySeconds) + + KeybindId.YAW_RESET -> + appContext.resetsManager.scheduleReset(KEYBIND_SOURCE, ResetType.YAW, delaySeconds) + + KeybindId.MOUNTING_RESET -> + appContext.resetsManager.scheduleReset(KEYBIND_SOURCE, ResetType.MOUNTING, delaySeconds) + + KeybindId.FEET_MOUNTING_RESET -> + appContext.resetsManager.scheduleReset(KEYBIND_SOURCE, ResetType.MOUNTING, delaySeconds, FEET_BODY_PARTS) + + KeybindId.PAUSE_TRACKING -> { + if (delaySeconds > 0f) delay((delaySeconds * 1000).toLong()) + val skeleton = appContext.skeleton.context + skeleton.dispatch(SkeletonActions.PauseTracking(!skeleton.state.value.paused)) + } + + KeybindId.NONE -> Unit + } + } +} + +suspend fun createDesktopKeybindManager(appContext: AppContextProvider, scope: CoroutineScope) { + when (CURRENT_PLATFORM) { + Platform.WINDOWS -> setupWindowsKeybinds(appContext, scope) + Platform.LINUX -> setupLinuxKeybinds(appContext, scope) + else -> AppLogger.keybind.info("Keybinds are not supported on $CURRENT_PLATFORM") + } +} + +private suspend fun setupWindowsKeybinds(appContext: AppContextProvider, scope: CoroutineScope) { + val instance = try { + JIntellitype.getInstance() + } catch (e: Throwable) { + AppLogger.keybind.error(e, "Failed to initialize JIntellitype, keybinds will be disabled") + return + } + + instance.addHotKeyListener { identifier -> + KeybindId.fromValue(identifier.toUByte())?.let { triggerKeybind(appContext, scope, it) } + } + + var registered: List = emptyList() + bindingsFlow(appContext).onEach { keybinds -> + registered.forEach { runCatching { instance.unregisterHotKey(it.id.value.toInt()) } } + keybinds.forEach { keybind -> + runCatching { instance.registerHotKey(keybind.id.value.toInt(), toJIntellitypeAccelerator(keybind.binding)) } + .onFailure { AppLogger.keybind.warn("Failed to bind ${keybind.id.name} to ${keybind.binding}") } + } + registered = keybinds + }.launchIn(scope) +} + +private suspend fun setupLinuxKeybinds(appContext: AppContextProvider, scope: CoroutineScope) { + var handler: GlobalShortcutsHandler? = null + + // Closing a handler disconnects its dbus connection, so every session gets its own manager + suspend fun bind(keybinds: List) { + runCatching { handler?.close() } + val shortcuts = keybinds.map { keybind -> + ShortcutTuple(keybind.id.name, Shortcut(keybindLabel(keybind.id), keybind.binding).shortcut) + }.toMutableList() + + handler = runCatching { + PortalManager(SLIMEVR_IDENTIFIER).globalShortcutsRequest(shortcuts).apply { + onShortcutActivated = { shortcutId -> + KeybindId.entries.firstOrNull { it.name == shortcutId }?.let { triggerKeybind(appContext, scope, it) } + } + } + }.onFailure { + AppLogger.keybind.error(it, "Failed to register global shortcuts, keybinds will not work") + }.getOrNull() + } + + val gnomeAppId = if (isGnome()) resolveGnomeAppId() else null + if (gnomeAppId == null) { + // The compositor owns the triggers and we have no way to change them, so bind once and + // leave it alone. The user rebinds from their system settings instead. + bind(currentKeybinds(appContext)) + } else { + // GNOME keeps the triggers in dconf and only reads them when a session is created, so a + // change means rewriting dconf and starting a new session. Its stored values also win over + // the ones we pass, so push the config on startup too or the gui would disagree with what + // actually fires. The exception is a first run, where the portal seeds them and prompts. + val needsSeeding = !gnomeShortcutsExist(gnomeAppId) + + var isInitialBind = true + bindingsFlow(appContext).onEach { keybinds -> + if (!(isInitialBind && needsSeeding)) { + writeGnomeShortcuts(gnomeAppId, keybinds) + } + bind(keybinds) + isInitialBind = false + }.launchIn(scope) + } + + scope.safeLaunch { + try { + awaitCancellation() + } finally { + runCatching { handler?.close() } + } + } +} diff --git a/solarxr-protocol b/solarxr-protocol index 2f00c44f44..c412241496 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 2f00c44f448b14b4be2f1ef7b3fda4e0cb4353cf +Subproject commit c41224149617edb21bcd231c14a82a589670cdc9 From 0e6e2d7de186f5787844b333e34782f0c6d02a22 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Mon, 20 Jul 2026 20:50:28 +0200 Subject: [PATCH 272/350] Keybinds properly working / rebindin --- .../components/commons/KeybindRecorder.tsx | 4 +- .../main/java/dev/slimevr/solarxr/keybinds.kt | 36 +++++++++---- .../java/dev/slimevr/desktop/keybind/gnome.kt | 28 +++++++++-- .../dev/slimevr/desktop/keybind/keybinds.kt | 50 ++++++++++++------- 4 files changed, 85 insertions(+), 33 deletions(-) diff --git a/gui/app/src/components/commons/KeybindRecorder.tsx b/gui/app/src/components/commons/KeybindRecorder.tsx index 4d44743b68..369fa192c0 100644 --- a/gui/app/src/components/commons/KeybindRecorder.tsx +++ b/gui/app/src/components/commons/KeybindRecorder.tsx @@ -52,6 +52,8 @@ export const KeybindRecorder = forwardRef< return; } + setErrorText(''); + if (displayKeys.length < maxKeybindLength) { const updatedKeys = [...displayKeys, key]; setLocalKeys(updatedKeys); @@ -120,7 +122,7 @@ export const KeybindRecorder = forwardRef<
{displayError && (
- {`${errorText} ${error}`} + {displayError}
)}
diff --git a/server/core/src/main/java/dev/slimevr/solarxr/keybinds.kt b/server/core/src/main/java/dev/slimevr/solarxr/keybinds.kt index 50927dc3e7..307e1b2df9 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/keybinds.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/keybinds.kt @@ -4,6 +4,11 @@ import dev.slimevr.config.KeybindConfig import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions import dev.slimevr.config.defaultKeybinds +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import solarxr_protocol.rpc.ChangeKeybindRequest import solarxr_protocol.rpc.Keybind import solarxr_protocol.rpc.KeybindId @@ -19,21 +24,34 @@ private fun keybindConfigToProto(config: KeybindConfig): Keybind = Keybind( keybindDelay = config.delay, ) +private val MODIFIER_ORDER = listOf("CTRL", "ALT", "SHIFT", "SUPER") + +fun canonicalKeybind(binding: String): String { + val parts = binding.split('+').map { it.trim().uppercase() }.filter { it.isNotEmpty() } + return (MODIFIER_ORDER.filter { it in parts } + parts.filterNot { it in MODIFIER_ORDER }).joinToString("+") +} + class KeybindsBehaviour( private val settings: Settings, ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { + fun buildResponse(keybinds: List) = KeybindResponse( + keybind = keybinds.map { keybindConfigToProto(it) }, + defaultKeybinds = defaultKeybinds().map { keybindConfigToProto(it) }, + support = receiver.appContext.featureFlags.keybindSupport, + ) + receiver.rpcDispatcher.on { - val keybinds = settings.context.state.value.data.keybinds - receiver.sendRpc( - KeybindResponse( - keybind = keybinds.map { keybindConfigToProto(it) }, - defaultKeybinds = defaultKeybinds().map { keybindConfigToProto(it) }, - support = receiver.appContext.featureFlags.keybindSupport, - ), - ) + receiver.sendRpc(buildResponse(settings.context.state.value.data.keybinds)) } + settings.context.state + .map { it.data.keybinds } + .distinctUntilChanged() + .drop(1) + .onEach { receiver.sendRpc(buildResponse(it)) } + .launchIn(receiver.context.scope) + receiver.rpcDispatcher.on { req -> val keybind = req.keybind ?: return@on val id = keybind.keybindId?.takeUnless { it == KeybindId.NONE } ?: return@on @@ -43,7 +61,7 @@ class KeybindsBehaviour( keybinds = keybinds.map { if (it.id == id) { it.copy( - binding = keybind.keybindValue ?: it.binding, + binding = keybind.keybindValue?.let(::canonicalKeybind) ?: it.binding, delay = keybind.keybindDelay ?: 0f, ) } else { diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/keybind/gnome.kt b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/gnome.kt index 0485dbd651..2035bf677e 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/keybind/gnome.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/gnome.kt @@ -3,6 +3,8 @@ package dev.slimevr.desktop.keybind import dev.slimevr.AppLogger import dev.slimevr.config.KeybindConfig import dev.slimevr.desktop.install.executeShellCommand +import dev.slimevr.solarxr.canonicalKeybind +import solarxr_protocol.rpc.KeybindId import java.io.File private const val GLOBAL_SHORTCUTS_PATH = "/org/gnome/settings-daemon/global-shortcuts" @@ -41,6 +43,27 @@ private fun toGnomeAccelerator(binding: String): String? { return MODIFIERS.filterKeys { it in modifiers }.values.joinToString("") + key.lowercase() } +private fun fromGnomeAccelerator(accelerator: String): String? { + val key = accelerator.substringAfterLast('>').uppercase().ifEmpty { return null } + val modifiers = MODIFIERS.filterValues { it in accelerator }.keys + return canonicalKeybind((modifiers + key).joinToString("+")) +} + +private val SHORTCUT_ENTRY = Regex("""\('([A-Z_]+)',\s*\{'shortcuts':\s*<\['([^']*)'\]>""") + +suspend fun readGnomeShortcuts(appId: String): Map { + val (exitCode, output) = executeShellCommand("dconf", "read", "$GLOBAL_SHORTCUTS_PATH/$appId/shortcuts") + ?: return emptyMap() + if (exitCode != 0) return emptyMap() + + return SHORTCUT_ENTRY.findAll(output).mapNotNull { match -> + val (name, accelerator) = match.destructured + val id = KeybindId.entries.firstOrNull { it.name == name } ?: return@mapNotNull null + val binding = fromGnomeAccelerator(accelerator) ?: return@mapNotNull null + id to binding + }.toMap() +} + private fun escapeGVariant(value: String): String = value.replace("\\", "\\\\").replace("'", "\\'") private fun buildGnomeShortcutsVariant(keybinds: List): String = keybinds @@ -51,11 +74,6 @@ private fun buildGnomeShortcutsVariant(keybinds: List): String = } .joinToString(", ", prefix = "[", postfix = "]") -suspend fun gnomeShortcutsExist(appId: String): Boolean { - val (exitCode, output) = executeShellCommand("dconf", "read", "$GLOBAL_SHORTCUTS_PATH/$appId/shortcuts") ?: return false - return exitCode == 0 && output.isNotBlank() -} - suspend fun writeGnomeShortcuts(appId: String, keybinds: List): Boolean { val bound = keybinds.filter { toGnomeAccelerator(it.binding) != null } if (bound.isEmpty()) return false diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt index 3aa62fe9dd..e60c7059d4 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt @@ -11,12 +11,15 @@ import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform import dev.slimevr.SLIMEVR_IDENTIFIER import dev.slimevr.config.KeybindConfig +import dev.slimevr.config.SettingsActions import dev.slimevr.skeleton.SkeletonActions import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -31,9 +34,16 @@ private val FEET_BODY_PARTS = listOf(BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT) private fun currentKeybinds(appContext: AppContextProvider): List = appContext.config.settings.context.state.value.data.keybinds +// The gui sends one request per keybind, so changing several at once (or resetting them all) arrives +// as a burst. Recreating a portal session for each one makes GNOME reject the bind and leaves +// nothing grabbed, so settle first and apply the burst as a single change. +private const val BINDING_SETTLE_MS = 300L + +@OptIn(FlowPreview::class) private fun bindingsFlow(appContext: AppContextProvider): Flow> = appContext.config.settings.context.state .map { it.data.keybinds } .distinctUntilChangedBy { keybinds -> keybinds.map { it.id to it.binding } } + .debounce(BINDING_SETTLE_MS) fun keybindLabel(id: KeybindId): String = id.name .split('_') @@ -102,8 +112,18 @@ private suspend fun setupWindowsKeybinds(appContext: AppContextProvider, scope: } private suspend fun setupLinuxKeybinds(appContext: AppContextProvider, scope: CoroutineScope) { + val gnomeAppId = if (isGnome()) resolveGnomeAppId() else null var handler: GlobalShortcutsHandler? = null + suspend fun adoptGnomeShortcuts(appId: String) { + val stored = readGnomeShortcuts(appId).ifEmpty { return } + appContext.config.settings.context.dispatch( + SettingsActions.Update { + copy(keybinds = keybinds.map { keybind -> stored[keybind.id]?.let { keybind.copy(binding = it) } ?: keybind }) + }, + ) + } + // Closing a handler disconnects its dbus connection, so every session gets its own manager suspend fun bind(keybinds: List) { runCatching { handler?.close() } @@ -116,32 +136,26 @@ private suspend fun setupLinuxKeybinds(appContext: AppContextProvider, scope: Co onShortcutActivated = { shortcutId -> KeybindId.entries.firstOrNull { it.name == shortcutId }?.let { triggerKeybind(appContext, scope, it) } } + if (gnomeAppId != null) { + onShortcutsChanged = { scope.safeLaunch { adoptGnomeShortcuts(gnomeAppId) } } + } } }.onFailure { AppLogger.keybind.error(it, "Failed to register global shortcuts, keybinds will not work") }.getOrNull() } - val gnomeAppId = if (isGnome()) resolveGnomeAppId() else null - if (gnomeAppId == null) { - // The compositor owns the triggers and we have no way to change them, so bind once and - // leave it alone. The user rebinds from their system settings instead. - bind(currentKeybinds(appContext)) - } else { - // GNOME keeps the triggers in dconf and only reads them when a session is created, so a - // change means rewriting dconf and starting a new session. Its stored values also win over - // the ones we pass, so push the config on startup too or the gui would disagree with what - // actually fires. The exception is a first run, where the portal seeds them and prompts. - val needsSeeding = !gnomeShortcutsExist(gnomeAppId) - - var isInitialBind = true - bindingsFlow(appContext).onEach { keybinds -> - if (!(isInitialBind && needsSeeding)) { + bind(currentKeybinds(appContext)) + + if (gnomeAppId != null) { + adoptGnomeShortcuts(gnomeAppId) + + bindingsFlow(appContext) + .onEach { keybinds -> writeGnomeShortcuts(gnomeAppId, keybinds) + bind(keybinds) } - bind(keybinds) - isInitialBind = false - }.launchIn(scope) + .launchIn(scope) } scope.safeLaunch { From 7d213de97c1ac6e1ca6d06c0ee1a967491dba7be Mon Sep 17 00:00:00 2001 From: Erimel Date: Mon, 20 Jul 2026 16:44:30 -0400 Subject: [PATCH 273/350] Split FallbackProcessor in 2 --- .../java/dev/slimevr/skeleton/filtering.kt | 84 ++++++ .../main/java/dev/slimevr/skeleton/module.kt | 1 + .../java/dev/slimevr/skeleton/processors.kt | 274 +++++++----------- 3 files changed, 185 insertions(+), 174 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/filtering.kt diff --git a/server/core/src/main/java/dev/slimevr/skeleton/filtering.kt b/server/core/src/main/java/dev/slimevr/skeleton/filtering.kt new file mode 100644 index 0000000000..f9f0c38bac --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/filtering.kt @@ -0,0 +1,84 @@ +package dev.slimevr.skeleton + +import dev.slimevr.config.Settings +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.FilteringType + +class PredictionProcessor(val settings: Settings) : SkeletonProcessor { + private data class BoneVelocity( + val lastRotation: Quaternion, + val rotationDelta: Quaternion, + val lastOffset: Vector3, + val offsetDelta: Vector3, + ) + + private var velocities: Map = emptyMap() + + override fun process(state: SkeletonState): SkeletonState { + val config = settings.context.state.value.data.skeletonConfig.filtering + if (config.type != FilteringType.PREDICTION) return state + + val newVelocities = mutableMapOf() + val newBones = state.boneInputs.mapValues { (bodyPart, bone) -> + val prev = velocities[bodyPart] + if (prev == null) { + newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, Quaternion.IDENTITY, bone.offset, Vector3.NULL) + return@mapValues bone + } + val rotationDelta = if (bone.rawRotation !== prev.lastRotation) { + bone.rawRotation * prev.lastRotation.inv() + } else { + prev.rotationDelta + } + val lengthDelta = if (bone.offset != prev.lastOffset) { + bone.offset - prev.lastOffset + } else { + prev.offsetDelta + } + newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, rotationDelta, bone.offset, lengthDelta) + val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, config.amount).unit() + bone.copy( + rawRotation = (scaledDelta * bone.rawRotation).unit(), + offset = bone.offset + lengthDelta * config.amount, + ) + } + velocities = newVelocities + return state.copy(boneInputs = newBones) + } +} + +class SmoothingProcessor(val settings: Settings) : SkeletonProcessor { + private var smoothedRotations: Map = emptyMap() + private var smoothedLengths: Map = emptyMap() + + // TODO this isn't linear. Do we want linear smoothing like in main? + override fun process(state: SkeletonState): SkeletonState { + val config = settings.context.state.value.data.skeletonConfig.filtering + if (config.type != FilteringType.SMOOTHING) return state + + val alpha = 1 - (SMOOTH_MIN + config.amount.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN)) + + smoothedRotations = state.boneInputs.mapValues { (bodyPart, bone) -> + (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() + } + smoothedLengths = state.boneInputs.mapValues { (bodyPart, bone) -> + val prev = smoothedLengths[bodyPart] ?: bone.offset + prev + (bone.offset - prev) * alpha + } + return state.copy( + boneInputs = state.boneInputs.mapValues { (bodyPart, bone) -> + bone.copy( + rawRotation = smoothedRotations[bodyPart] ?: bone.rawRotation, + offset = smoothedLengths[bodyPart] ?: bone.offset, + ) + }, + ) + } + + companion object { + private const val SMOOTH_MIN = 0.63f + private const val SMOOTH_MAX = 0.94f + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index b7d19e9acc..70ad16e486 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -108,6 +108,7 @@ class Skeleton( processors = listOf( FallbackProcessor(), ImputeSpineProcessor(ctx.config.settings), + BoneLinkProcessor(), PredictionProcessor(ctx.config.settings), SmoothingProcessor(ctx.config.settings), ), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt index 7c3a51aaec..246a031f01 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt @@ -1,220 +1,146 @@ package dev.slimevr.skeleton import dev.slimevr.config.Settings +import dev.slimevr.config.SkeletonRatiosConfig import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.BodyPart -import solarxr_protocol.rpc.FilteringType interface SkeletonProcessor { fun process(state: SkeletonState): SkeletonState } /** - * Handles replacing rotations of rawBones that are not actively receiving data by falling back - * to either another rawBone's rotation, their parent's yaw or the identity rotation. + * Handles replacing rotations of boneInputs that are not actively receiving data by either + * falling back to their parent's yaw or the identity rotation. */ class FallbackProcessor : SkeletonProcessor { - private val leftFingerChains = listOf( - listOf(BodyPart.LEFT_THUMB_DISTAL, BodyPart.LEFT_THUMB_PROXIMAL, BodyPart.LEFT_THUMB_METACARPAL), - listOf(BodyPart.LEFT_INDEX_DISTAL, BodyPart.LEFT_INDEX_INTERMEDIATE, BodyPart.LEFT_INDEX_PROXIMAL), - listOf(BodyPart.LEFT_MIDDLE_DISTAL, BodyPart.LEFT_MIDDLE_INTERMEDIATE, BodyPart.LEFT_MIDDLE_PROXIMAL), - listOf(BodyPart.LEFT_RING_DISTAL, BodyPart.LEFT_RING_INTERMEDIATE, BodyPart.LEFT_RING_PROXIMAL), - listOf(BodyPart.LEFT_LITTLE_DISTAL, BodyPart.LEFT_LITTLE_INTERMEDIATE, BodyPart.LEFT_LITTLE_PROXIMAL), - ) - private val rightFingerChains = listOf( - listOf(BodyPart.RIGHT_THUMB_DISTAL, BodyPart.RIGHT_THUMB_PROXIMAL, BodyPart.RIGHT_THUMB_METACARPAL), - listOf(BodyPart.RIGHT_INDEX_DISTAL, BodyPart.RIGHT_INDEX_INTERMEDIATE, BodyPart.RIGHT_INDEX_PROXIMAL), - listOf(BodyPart.RIGHT_MIDDLE_DISTAL, BodyPart.RIGHT_MIDDLE_INTERMEDIATE, BodyPart.RIGHT_MIDDLE_PROXIMAL), - listOf(BodyPart.RIGHT_RING_DISTAL, BodyPart.RIGHT_RING_INTERMEDIATE, BodyPart.RIGHT_RING_PROXIMAL), - listOf(BodyPart.RIGHT_LITTLE_DISTAL, BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_PROXIMAL), - ) - - private fun putFingerFallbacks( - map: MutableMap>, - chain: List, - hand: BodyPart, - ) { - chain.forEachIndexed { i, segment -> - map[segment] = chain.drop(i + 1) + hand - } - } - - /** - * First element is the BodyPart whose rawBone is not actively receiving data. - * Second element contains a set of BodyParts whose rotation should be used as a fallback prioritized from first to last. - */ - private val missingToFallbacks = mapOf( - BodyPart.HEAD to setOf(BodyPart.NECK), - BodyPart.NECK to setOf(BodyPart.HEAD), - - // TODO could be removed once ImputeSpineProcessor is done vvv - BodyPart.UPPER_CHEST to setOf(BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), - BodyPart.CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.WAIST, BodyPart.HIP), - BodyPart.WAIST to setOf(BodyPart.CHEST, BodyPart.HIP, BodyPart.UPPER_CHEST), - BodyPart.HIP to setOf(BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), - - BodyPart.LEFT_HIP to setOf(BodyPart.HIP, BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), - BodyPart.RIGHT_HIP to setOf(BodyPart.HIP, BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), - - BodyPart.LEFT_SHOULDER to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), - BodyPart.RIGHT_SHOULDER to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), - - BodyPart.LEFT_UPPER_ARM to setOf(BodyPart.LEFT_LOWER_ARM), - BodyPart.RIGHT_UPPER_ARM to setOf(BodyPart.RIGHT_LOWER_ARM), - BodyPart.LEFT_LOWER_ARM to setOf(BodyPart.LEFT_UPPER_ARM), - BodyPart.RIGHT_LOWER_ARM to setOf(BodyPart.RIGHT_UPPER_ARM), - BodyPart.LEFT_HAND to setOf(BodyPart.LEFT_LOWER_ARM, BodyPart.LEFT_UPPER_ARM), - BodyPart.RIGHT_HAND to setOf(BodyPart.RIGHT_LOWER_ARM, BodyPart.RIGHT_UPPER_ARM), - - // TODO could be removed once ImputeFingersProcessor is done vvv - BodyPart.LEFT_THUMB_METACARPAL to setOf(BodyPart.LEFT_HAND), - BodyPart.LEFT_THUMB_PROXIMAL to setOf(BodyPart.LEFT_HAND), - BodyPart.LEFT_THUMB_DISTAL to setOf(BodyPart.LEFT_HAND), - BodyPart.LEFT_INDEX_PROXIMAL to setOf(BodyPart.LEFT_HAND), - BodyPart.LEFT_INDEX_INTERMEDIATE to setOf(BodyPart.LEFT_HAND), - BodyPart.LEFT_INDEX_DISTAL to setOf(BodyPart.LEFT_HAND), - BodyPart.LEFT_MIDDLE_PROXIMAL to setOf(BodyPart.LEFT_HAND), - BodyPart.LEFT_MIDDLE_INTERMEDIATE to setOf(BodyPart.LEFT_HAND), - BodyPart.LEFT_MIDDLE_DISTAL to setOf(BodyPart.LEFT_HAND), - BodyPart.LEFT_RING_PROXIMAL to setOf(BodyPart.LEFT_HAND), - BodyPart.LEFT_RING_INTERMEDIATE to setOf(BodyPart.LEFT_HAND), - BodyPart.LEFT_RING_DISTAL to setOf(BodyPart.LEFT_HAND), - BodyPart.LEFT_LITTLE_PROXIMAL to setOf(BodyPart.LEFT_HAND), - BodyPart.LEFT_LITTLE_INTERMEDIATE to setOf(BodyPart.LEFT_HAND), - BodyPart.LEFT_LITTLE_DISTAL to setOf(BodyPart.LEFT_HAND), - BodyPart.RIGHT_THUMB_METACARPAL to setOf(BodyPart.RIGHT_HAND), - BodyPart.RIGHT_THUMB_PROXIMAL to setOf(BodyPart.RIGHT_HAND), - BodyPart.RIGHT_THUMB_DISTAL to setOf(BodyPart.RIGHT_HAND), - BodyPart.RIGHT_INDEX_PROXIMAL to setOf(BodyPart.RIGHT_HAND), - BodyPart.RIGHT_INDEX_INTERMEDIATE to setOf(BodyPart.RIGHT_HAND), - BodyPart.RIGHT_INDEX_DISTAL to setOf(BodyPart.RIGHT_HAND), - BodyPart.RIGHT_MIDDLE_PROXIMAL to setOf(BodyPart.RIGHT_HAND), - BodyPart.RIGHT_MIDDLE_INTERMEDIATE to setOf(BodyPart.RIGHT_HAND), - BodyPart.RIGHT_MIDDLE_DISTAL to setOf(BodyPart.RIGHT_HAND), - BodyPart.RIGHT_RING_PROXIMAL to setOf(BodyPart.RIGHT_HAND), - BodyPart.RIGHT_RING_INTERMEDIATE to setOf(BodyPart.RIGHT_HAND), - BodyPart.RIGHT_RING_DISTAL to setOf(BodyPart.RIGHT_HAND), - BodyPart.RIGHT_LITTLE_PROXIMAL to setOf(BodyPart.RIGHT_HAND), - BodyPart.RIGHT_LITTLE_INTERMEDIATE to setOf(BodyPart.RIGHT_HAND), - BodyPart.RIGHT_LITTLE_DISTAL to setOf(BodyPart.RIGHT_HAND), - ) - override fun process(state: SkeletonState): SkeletonState { - val rawBones = state.boneInputs + val boneInputs = state.boneInputs return state.copy( - boneInputs = rawBones.mapValues { (bodyPart, bone) -> + boneInputs = boneInputs.mapValues { (bodyPart, bone) -> if (bone.isActive) return@mapValues bone - bone.copy(rawRotation = computeFallbackRotation(rawBones, bodyPart)) + val firstActiveParentBone = boneInputs[findBodyPartParent(bodyPart) { boneInputs[it]?.isActive == true }] + val fallbackRotation = firstActiveParentBone?.rawRotation?.project(Vector3.POS_Y)?.unit() ?: Quaternion.IDENTITY + bone.copy(rawRotation = fallbackRotation) }, ) } - - private fun computeFallbackRotation(boneInputs: Map, bodyPart: BodyPart): Quaternion { - // Fallback bone's rotation - missingToFallbacks[bodyPart] - ?.firstNotNullOfOrNull { part -> - boneInputs[part]?.takeIf { it.isActive } - } - ?.let { return it.rawRotation } - - // First active parent bone's yaw - boneInputs[ - findBodyPartParent(bodyPart) { part -> - boneInputs[part]?.isActive == true - }, - ]?.let { return it.rawRotation.project(Vector3.POS_Y).unit() } - - // Fallback to identity - return Quaternion.IDENTITY - } } /** * Handles imputing the rotation of spine bones that are not actively receiving data from the rotations * of nearby bones. * - * Similar to FallbackProcessor but specialized for the spine, using more data and math for better results. + * Similar to FallbackProcessor specifically for the waist and hip. */ class ImputeSpineProcessor(val settings: Settings) : SkeletonProcessor { - override fun process(state: SkeletonState): SkeletonState = state -} - -class SmoothingProcessor(val settings: Settings) : SkeletonProcessor { - private var smoothedRotations: Map = emptyMap() - private var smoothedLengths: Map = emptyMap() + private val bonesToImpute = listOf(BodyPart.WAIST, BodyPart.HIP) - // TODO this isn't linear. Do we want linear smoothing like in main? override fun process(state: SkeletonState): SkeletonState { - val config = settings.context.state.value.data.skeletonConfig.filtering - if (config.type != FilteringType.SMOOTHING) return state + val ratios = settings.context.state.value.data.skeletonConfig.ratios + val imputeSpineFromUpperToLower = ratios.imputeSpineFromUpperToLower + val imputeSpineCurvature = ratios.imputeSpineCurvature + val boneInputs = state.boneInputs - val alpha = 1 - (SMOOTH_MIN + config.amount.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN)) - - smoothedRotations = state.boneInputs.mapValues { (bodyPart, bone) -> - (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() - } - smoothedLengths = state.boneInputs.mapValues { (bodyPart, bone) -> - val prev = smoothedLengths[bodyPart] ?: bone.offset - prev + (bone.offset - prev) * alpha - } return state.copy( - boneInputs = state.boneInputs.mapValues { (bodyPart, bone) -> - bone.copy( - rawRotation = smoothedRotations[bodyPart] ?: bone.rawRotation, - offset = smoothedLengths[bodyPart] ?: bone.offset, - ) + boneInputs = boneInputs.mapValues { (bodyPart, bone) -> + if (bone.isActive || bodyPart !in bonesToImpute) return@mapValues bone + + bone.copy(rawRotation = bone.rawRotation) }, ) } - companion object { - private const val SMOOTH_MIN = 0.63f - private const val SMOOTH_MAX = 0.94f + private fun imputeBoneRotation(fromRotation: Quaternion, toRotation: Quaternion): Quaternion { + return Quaternion.IDENTITY + } + + private fun getAverageLegRotation(boneInputs: Map): Quaternion? { + val leftLeg = boneInputs[BodyPart.LEFT_UPPER_LEG]?.takeIf { it.isActive } ?: return null + val rightLeg = boneInputs[BodyPart.RIGHT_UPPER_LEG]?.takeIf { it.isActive } ?: return null + return leftLeg.rawRotation.lerpQ(rightLeg.rawRotation, 0.5f) } } -class PredictionProcessor(val settings: Settings) : SkeletonProcessor { - private data class BoneVelocity( - val lastRotation: Quaternion, - val rotationDelta: Quaternion, - val lastOffset: Vector3, - val offsetDelta: Vector3, +/** + * Handles copying bones rotations to their linked bones. + * + * Useful for bones whose sole purpose is offsets or bones that rarely have trackers on them. + */ +class BoneLinkProcessor : SkeletonProcessor { + /** + * First element is the linked BodyPart. + * + * Second element is the BodyPart the first element is linked to. + */ + private val linkedToSource = mapOf( + BodyPart.HEAD to BodyPart.NECK, + BodyPart.NECK to BodyPart.HEAD, + + BodyPart.UPPER_CHEST to BodyPart.CHEST, + BodyPart.CHEST to BodyPart.UPPER_CHEST, + + BodyPart.LEFT_HIP to BodyPart.HIP, + BodyPart.RIGHT_HIP to BodyPart.HIP, + + BodyPart.LEFT_SHOULDER to BodyPart.UPPER_CHEST, + BodyPart.RIGHT_SHOULDER to BodyPart.UPPER_CHEST, + + BodyPart.LEFT_UPPER_ARM to BodyPart.LEFT_SHOULDER, + BodyPart.RIGHT_UPPER_ARM to BodyPart.RIGHT_SHOULDER, + + BodyPart.LEFT_LOWER_ARM to BodyPart.LEFT_UPPER_ARM, + BodyPart.RIGHT_LOWER_ARM to BodyPart.RIGHT_UPPER_ARM, + + BodyPart.LEFT_HAND to BodyPart.LEFT_LOWER_ARM, + BodyPart.RIGHT_HAND to BodyPart.RIGHT_LOWER_ARM, + + // TODO can be removed once ImputeFingersProcessor is done + BodyPart.LEFT_THUMB_METACARPAL to BodyPart.LEFT_HAND, + BodyPart.LEFT_THUMB_PROXIMAL to BodyPart.LEFT_THUMB_METACARPAL, + BodyPart.LEFT_THUMB_DISTAL to BodyPart.LEFT_THUMB_PROXIMAL, + BodyPart.LEFT_INDEX_PROXIMAL to BodyPart.LEFT_HAND, + BodyPart.LEFT_INDEX_INTERMEDIATE to BodyPart.LEFT_INDEX_PROXIMAL, + BodyPart.LEFT_INDEX_DISTAL to BodyPart.LEFT_INDEX_INTERMEDIATE, + BodyPart.LEFT_MIDDLE_PROXIMAL to BodyPart.LEFT_HAND, + BodyPart.LEFT_MIDDLE_INTERMEDIATE to BodyPart.LEFT_MIDDLE_PROXIMAL, + BodyPart.LEFT_MIDDLE_DISTAL to BodyPart.LEFT_MIDDLE_INTERMEDIATE, + BodyPart.LEFT_RING_PROXIMAL to BodyPart.LEFT_HAND, + BodyPart.LEFT_RING_INTERMEDIATE to BodyPart.LEFT_RING_PROXIMAL, + BodyPart.LEFT_RING_DISTAL to BodyPart.LEFT_RING_INTERMEDIATE, + BodyPart.LEFT_LITTLE_PROXIMAL to BodyPart.LEFT_HAND, + BodyPart.LEFT_LITTLE_INTERMEDIATE to BodyPart.LEFT_LITTLE_PROXIMAL, + BodyPart.LEFT_LITTLE_DISTAL to BodyPart.LEFT_LITTLE_INTERMEDIATE, + BodyPart.RIGHT_THUMB_METACARPAL to BodyPart.RIGHT_HAND, + BodyPart.RIGHT_THUMB_PROXIMAL to BodyPart.RIGHT_THUMB_METACARPAL, + BodyPart.RIGHT_THUMB_DISTAL to BodyPart.RIGHT_THUMB_PROXIMAL, + BodyPart.RIGHT_INDEX_PROXIMAL to BodyPart.RIGHT_HAND, + BodyPart.RIGHT_INDEX_INTERMEDIATE to BodyPart.RIGHT_INDEX_PROXIMAL, + BodyPart.RIGHT_INDEX_DISTAL to BodyPart.RIGHT_INDEX_INTERMEDIATE, + BodyPart.RIGHT_MIDDLE_PROXIMAL to BodyPart.RIGHT_HAND, + BodyPart.RIGHT_MIDDLE_INTERMEDIATE to BodyPart.RIGHT_MIDDLE_PROXIMAL, + BodyPart.RIGHT_MIDDLE_DISTAL to BodyPart.RIGHT_MIDDLE_INTERMEDIATE, + BodyPart.RIGHT_RING_PROXIMAL to BodyPart.RIGHT_HAND, + BodyPart.RIGHT_RING_INTERMEDIATE to BodyPart.RIGHT_RING_PROXIMAL, + BodyPart.RIGHT_RING_DISTAL to BodyPart.RIGHT_RING_INTERMEDIATE, + BodyPart.RIGHT_LITTLE_PROXIMAL to BodyPart.RIGHT_HAND, + BodyPart.RIGHT_LITTLE_INTERMEDIATE to BodyPart.RIGHT_LITTLE_PROXIMAL, + BodyPart.RIGHT_LITTLE_DISTAL to BodyPart.RIGHT_LITTLE_INTERMEDIATE, ) - private var velocities: Map = emptyMap() - override fun process(state: SkeletonState): SkeletonState { - val config = settings.context.state.value.data.skeletonConfig.filtering - if (config.type != FilteringType.PREDICTION) return state - - val newVelocities = mutableMapOf() - val newBones = state.boneInputs.mapValues { (bodyPart, bone) -> - val prev = velocities[bodyPart] - if (prev == null) { - newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, Quaternion.IDENTITY, bone.offset, Vector3.NULL) - return@mapValues bone - } - val rotationDelta = if (bone.rawRotation !== prev.lastRotation) { - bone.rawRotation * prev.lastRotation.inv() - } else { - prev.rotationDelta - } - val lengthDelta = if (bone.offset != prev.lastOffset) { - bone.offset - prev.lastOffset - } else { - prev.offsetDelta - } - newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, rotationDelta, bone.offset, lengthDelta) - val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, config.amount).unit() - bone.copy( - rawRotation = (scaledDelta * bone.rawRotation).unit(), - offset = bone.offset + lengthDelta * config.amount, - ) + val updatedBoneInputs = state.boneInputs.toMutableMap() + + for ((bodyPart, bone) in state.boneInputs) { + if (bone.isActive || bodyPart !in linkedToSource) continue + + val sourceBone = updatedBoneInputs[linkedToSource[bodyPart]] + updatedBoneInputs[bodyPart] = bone.copy(rawRotation = sourceBone?.rawRotation ?: bone.rawRotation) } - velocities = newVelocities - return state.copy(boneInputs = newBones) + + return state.copy(boneInputs = updatedBoneInputs) } -} +} \ No newline at end of file From d0c2a27288647a3d6b4ef4c2b6463c5744afc9a8 Mon Sep 17 00:00:00 2001 From: Erimel Date: Mon, 20 Jul 2026 17:09:23 -0400 Subject: [PATCH 274/350] Extract ImputeFingersProcessor from BoneLinkProcessor --- .../main/java/dev/slimevr/skeleton/fingers.kt | 61 +++++++++++++++++++ .../main/java/dev/slimevr/skeleton/module.kt | 1 + .../java/dev/slimevr/skeleton/processors.kt | 35 +---------- 3 files changed, 65 insertions(+), 32 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/fingers.kt diff --git a/server/core/src/main/java/dev/slimevr/skeleton/fingers.kt b/server/core/src/main/java/dev/slimevr/skeleton/fingers.kt new file mode 100644 index 0000000000..6cbd75a085 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/fingers.kt @@ -0,0 +1,61 @@ +package dev.slimevr.skeleton + +import solarxr_protocol.datatypes.BodyPart +import kotlin.collections.get + +/** + * Handles the finger tracking logic. + */ +class ImputeFingersProcessor : SkeletonProcessor { + /** + * First element is the linked BodyPart. + * + * Second element is the BodyPart the first element is linked to. + */ + private val fingerToSource = mapOf( + BodyPart.LEFT_THUMB_METACARPAL to BodyPart.LEFT_HAND, + BodyPart.LEFT_THUMB_PROXIMAL to BodyPart.LEFT_THUMB_METACARPAL, + BodyPart.LEFT_THUMB_DISTAL to BodyPart.LEFT_THUMB_PROXIMAL, + BodyPart.LEFT_INDEX_PROXIMAL to BodyPart.LEFT_HAND, + BodyPart.LEFT_INDEX_INTERMEDIATE to BodyPart.LEFT_INDEX_PROXIMAL, + BodyPart.LEFT_INDEX_DISTAL to BodyPart.LEFT_INDEX_INTERMEDIATE, + BodyPart.LEFT_MIDDLE_PROXIMAL to BodyPart.LEFT_HAND, + BodyPart.LEFT_MIDDLE_INTERMEDIATE to BodyPart.LEFT_MIDDLE_PROXIMAL, + BodyPart.LEFT_MIDDLE_DISTAL to BodyPart.LEFT_MIDDLE_INTERMEDIATE, + BodyPart.LEFT_RING_PROXIMAL to BodyPart.LEFT_HAND, + BodyPart.LEFT_RING_INTERMEDIATE to BodyPart.LEFT_RING_PROXIMAL, + BodyPart.LEFT_RING_DISTAL to BodyPart.LEFT_RING_INTERMEDIATE, + BodyPart.LEFT_LITTLE_PROXIMAL to BodyPart.LEFT_HAND, + BodyPart.LEFT_LITTLE_INTERMEDIATE to BodyPart.LEFT_LITTLE_PROXIMAL, + BodyPart.LEFT_LITTLE_DISTAL to BodyPart.LEFT_LITTLE_INTERMEDIATE, + BodyPart.RIGHT_THUMB_METACARPAL to BodyPart.RIGHT_HAND, + BodyPart.RIGHT_THUMB_PROXIMAL to BodyPart.RIGHT_THUMB_METACARPAL, + BodyPart.RIGHT_THUMB_DISTAL to BodyPart.RIGHT_THUMB_PROXIMAL, + BodyPart.RIGHT_INDEX_PROXIMAL to BodyPart.RIGHT_HAND, + BodyPart.RIGHT_INDEX_INTERMEDIATE to BodyPart.RIGHT_INDEX_PROXIMAL, + BodyPart.RIGHT_INDEX_DISTAL to BodyPart.RIGHT_INDEX_INTERMEDIATE, + BodyPart.RIGHT_MIDDLE_PROXIMAL to BodyPart.RIGHT_HAND, + BodyPart.RIGHT_MIDDLE_INTERMEDIATE to BodyPart.RIGHT_MIDDLE_PROXIMAL, + BodyPart.RIGHT_MIDDLE_DISTAL to BodyPart.RIGHT_MIDDLE_INTERMEDIATE, + BodyPart.RIGHT_RING_PROXIMAL to BodyPart.RIGHT_HAND, + BodyPart.RIGHT_RING_INTERMEDIATE to BodyPart.RIGHT_RING_PROXIMAL, + BodyPart.RIGHT_RING_DISTAL to BodyPart.RIGHT_RING_INTERMEDIATE, + BodyPart.RIGHT_LITTLE_PROXIMAL to BodyPart.RIGHT_HAND, + BodyPart.RIGHT_LITTLE_INTERMEDIATE to BodyPart.RIGHT_LITTLE_PROXIMAL, + BodyPart.RIGHT_LITTLE_DISTAL to BodyPart.RIGHT_LITTLE_INTERMEDIATE, + ) + + // TODO : There's more math to do here. Reference the original code. + override fun process(state: SkeletonState): SkeletonState { + val updatedBoneInputs = state.boneInputs.toMutableMap() + + for ((bodyPart, bone) in state.boneInputs) { + if (bone.isActive || bodyPart !in fingerToSource) continue + + val sourceBone = updatedBoneInputs[fingerToSource[bodyPart]] + updatedBoneInputs[bodyPart] = bone.copy(rawRotation = sourceBone?.rawRotation ?: bone.rawRotation) + } + + return state.copy(boneInputs = updatedBoneInputs) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 70ad16e486..72f68faee2 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -109,6 +109,7 @@ class Skeleton( FallbackProcessor(), ImputeSpineProcessor(ctx.config.settings), BoneLinkProcessor(), + ImputeFingersProcessor(), PredictionProcessor(ctx.config.settings), SmoothingProcessor(ctx.config.settings), ), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt index 246a031f01..a66d5170d4 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt @@ -86,6 +86,9 @@ class BoneLinkProcessor : SkeletonProcessor { BodyPart.LEFT_HIP to BodyPart.HIP, BodyPart.RIGHT_HIP to BodyPart.HIP, + BodyPart.LEFT_FOOT to BodyPart.LEFT_LOWER_LEG, + BodyPart.RIGHT_FOOT to BodyPart.RIGHT_LOWER_LEG, + BodyPart.LEFT_SHOULDER to BodyPart.UPPER_CHEST, BodyPart.RIGHT_SHOULDER to BodyPart.UPPER_CHEST, @@ -97,38 +100,6 @@ class BoneLinkProcessor : SkeletonProcessor { BodyPart.LEFT_HAND to BodyPart.LEFT_LOWER_ARM, BodyPart.RIGHT_HAND to BodyPart.RIGHT_LOWER_ARM, - - // TODO can be removed once ImputeFingersProcessor is done - BodyPart.LEFT_THUMB_METACARPAL to BodyPart.LEFT_HAND, - BodyPart.LEFT_THUMB_PROXIMAL to BodyPart.LEFT_THUMB_METACARPAL, - BodyPart.LEFT_THUMB_DISTAL to BodyPart.LEFT_THUMB_PROXIMAL, - BodyPart.LEFT_INDEX_PROXIMAL to BodyPart.LEFT_HAND, - BodyPart.LEFT_INDEX_INTERMEDIATE to BodyPart.LEFT_INDEX_PROXIMAL, - BodyPart.LEFT_INDEX_DISTAL to BodyPart.LEFT_INDEX_INTERMEDIATE, - BodyPart.LEFT_MIDDLE_PROXIMAL to BodyPart.LEFT_HAND, - BodyPart.LEFT_MIDDLE_INTERMEDIATE to BodyPart.LEFT_MIDDLE_PROXIMAL, - BodyPart.LEFT_MIDDLE_DISTAL to BodyPart.LEFT_MIDDLE_INTERMEDIATE, - BodyPart.LEFT_RING_PROXIMAL to BodyPart.LEFT_HAND, - BodyPart.LEFT_RING_INTERMEDIATE to BodyPart.LEFT_RING_PROXIMAL, - BodyPart.LEFT_RING_DISTAL to BodyPart.LEFT_RING_INTERMEDIATE, - BodyPart.LEFT_LITTLE_PROXIMAL to BodyPart.LEFT_HAND, - BodyPart.LEFT_LITTLE_INTERMEDIATE to BodyPart.LEFT_LITTLE_PROXIMAL, - BodyPart.LEFT_LITTLE_DISTAL to BodyPart.LEFT_LITTLE_INTERMEDIATE, - BodyPart.RIGHT_THUMB_METACARPAL to BodyPart.RIGHT_HAND, - BodyPart.RIGHT_THUMB_PROXIMAL to BodyPart.RIGHT_THUMB_METACARPAL, - BodyPart.RIGHT_THUMB_DISTAL to BodyPart.RIGHT_THUMB_PROXIMAL, - BodyPart.RIGHT_INDEX_PROXIMAL to BodyPart.RIGHT_HAND, - BodyPart.RIGHT_INDEX_INTERMEDIATE to BodyPart.RIGHT_INDEX_PROXIMAL, - BodyPart.RIGHT_INDEX_DISTAL to BodyPart.RIGHT_INDEX_INTERMEDIATE, - BodyPart.RIGHT_MIDDLE_PROXIMAL to BodyPart.RIGHT_HAND, - BodyPart.RIGHT_MIDDLE_INTERMEDIATE to BodyPart.RIGHT_MIDDLE_PROXIMAL, - BodyPart.RIGHT_MIDDLE_DISTAL to BodyPart.RIGHT_MIDDLE_INTERMEDIATE, - BodyPart.RIGHT_RING_PROXIMAL to BodyPart.RIGHT_HAND, - BodyPart.RIGHT_RING_INTERMEDIATE to BodyPart.RIGHT_RING_PROXIMAL, - BodyPart.RIGHT_RING_DISTAL to BodyPart.RIGHT_RING_INTERMEDIATE, - BodyPart.RIGHT_LITTLE_PROXIMAL to BodyPart.RIGHT_HAND, - BodyPart.RIGHT_LITTLE_INTERMEDIATE to BodyPart.RIGHT_LITTLE_PROXIMAL, - BodyPart.RIGHT_LITTLE_DISTAL to BodyPart.RIGHT_LITTLE_INTERMEDIATE, ) override fun process(state: SkeletonState): SkeletonState { From 74ee1bb580849084bae7690192a8b9a453e2b403 Mon Sep 17 00:00:00 2001 From: Erimel Date: Mon, 20 Jul 2026 18:06:29 -0400 Subject: [PATCH 275/350] Impute spine WIP --- .../dev/slimevr/skeleton/bodypartstructure.kt | 4 +- .../java/dev/slimevr/skeleton/filtering.kt | 128 +++++++++--------- .../main/java/dev/slimevr/skeleton/fingers.kt | 96 ++++++------- .../main/java/dev/slimevr/skeleton/module.kt | 2 +- .../java/dev/slimevr/skeleton/processors.kt | 108 +++++++++------ .../java/dev/slimevr/tracker/behaviours.kt | 1 + 6 files changed, 186 insertions(+), 153 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt b/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt index ae6e4aecfa..140b6d5c53 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt @@ -68,8 +68,8 @@ val BODY_PART_PARENT_MAP: Map = BODY_PART_HIERARCHY_MAP .flatMap { (parent, children) -> children.map { child -> child to parent } } .toMap() -inline fun findBodyPartParent(rootChild: BodyPart, predicate: (BodyPart) -> Boolean): BodyPart? { - var current = BODY_PART_PARENT_MAP[rootChild] +inline fun BodyPart.findFirstParent(predicate: (BodyPart) -> Boolean): BodyPart? { + var current = BODY_PART_PARENT_MAP[this] while (current != null) { if (predicate(current)) return current current = BODY_PART_PARENT_MAP[current] diff --git a/server/core/src/main/java/dev/slimevr/skeleton/filtering.kt b/server/core/src/main/java/dev/slimevr/skeleton/filtering.kt index f9f0c38bac..4b9761e767 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/filtering.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/filtering.kt @@ -7,78 +7,78 @@ import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.FilteringType class PredictionProcessor(val settings: Settings) : SkeletonProcessor { - private data class BoneVelocity( - val lastRotation: Quaternion, - val rotationDelta: Quaternion, - val lastOffset: Vector3, - val offsetDelta: Vector3, - ) + private data class BoneVelocity( + val lastRotation: Quaternion, + val rotationDelta: Quaternion, + val lastOffset: Vector3, + val offsetDelta: Vector3, + ) - private var velocities: Map = emptyMap() + private var velocities: Map = emptyMap() - override fun process(state: SkeletonState): SkeletonState { - val config = settings.context.state.value.data.skeletonConfig.filtering - if (config.type != FilteringType.PREDICTION) return state + override fun process(state: SkeletonState): SkeletonState { + val config = settings.context.state.value.data.skeletonConfig.filtering + if (config.type != FilteringType.PREDICTION) return state - val newVelocities = mutableMapOf() - val newBones = state.boneInputs.mapValues { (bodyPart, bone) -> - val prev = velocities[bodyPart] - if (prev == null) { - newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, Quaternion.IDENTITY, bone.offset, Vector3.NULL) - return@mapValues bone - } - val rotationDelta = if (bone.rawRotation !== prev.lastRotation) { - bone.rawRotation * prev.lastRotation.inv() - } else { - prev.rotationDelta - } - val lengthDelta = if (bone.offset != prev.lastOffset) { - bone.offset - prev.lastOffset - } else { - prev.offsetDelta - } - newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, rotationDelta, bone.offset, lengthDelta) - val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, config.amount).unit() - bone.copy( - rawRotation = (scaledDelta * bone.rawRotation).unit(), - offset = bone.offset + lengthDelta * config.amount, - ) - } - velocities = newVelocities - return state.copy(boneInputs = newBones) - } + val newVelocities = mutableMapOf() + val newBones = state.boneInputs.mapValues { (bodyPart, bone) -> + val prev = velocities[bodyPart] + if (prev == null) { + newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, Quaternion.IDENTITY, bone.offset, Vector3.NULL) + return@mapValues bone + } + val rotationDelta = if (bone.rawRotation !== prev.lastRotation) { + bone.rawRotation * prev.lastRotation.inv() + } else { + prev.rotationDelta + } + val lengthDelta = if (bone.offset != prev.lastOffset) { + bone.offset - prev.lastOffset + } else { + prev.offsetDelta + } + newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, rotationDelta, bone.offset, lengthDelta) + val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, config.amount).unit() + bone.copy( + rawRotation = (scaledDelta * bone.rawRotation).unit(), + offset = bone.offset + lengthDelta * config.amount, + ) + } + velocities = newVelocities + return state.copy(boneInputs = newBones) + } } class SmoothingProcessor(val settings: Settings) : SkeletonProcessor { - private var smoothedRotations: Map = emptyMap() - private var smoothedLengths: Map = emptyMap() + private var smoothedRotations: Map = emptyMap() + private var smoothedLengths: Map = emptyMap() - // TODO this isn't linear. Do we want linear smoothing like in main? - override fun process(state: SkeletonState): SkeletonState { - val config = settings.context.state.value.data.skeletonConfig.filtering - if (config.type != FilteringType.SMOOTHING) return state + // TODO this isn't linear. Do we want linear smoothing like in main? + override fun process(state: SkeletonState): SkeletonState { + val config = settings.context.state.value.data.skeletonConfig.filtering + if (config.type != FilteringType.SMOOTHING) return state - val alpha = 1 - (SMOOTH_MIN + config.amount.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN)) + val alpha = 1 - (SMOOTH_MIN + config.amount.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN)) - smoothedRotations = state.boneInputs.mapValues { (bodyPart, bone) -> - (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() - } - smoothedLengths = state.boneInputs.mapValues { (bodyPart, bone) -> - val prev = smoothedLengths[bodyPart] ?: bone.offset - prev + (bone.offset - prev) * alpha - } - return state.copy( - boneInputs = state.boneInputs.mapValues { (bodyPart, bone) -> - bone.copy( - rawRotation = smoothedRotations[bodyPart] ?: bone.rawRotation, - offset = smoothedLengths[bodyPart] ?: bone.offset, - ) - }, - ) - } + smoothedRotations = state.boneInputs.mapValues { (bodyPart, bone) -> + (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() + } + smoothedLengths = state.boneInputs.mapValues { (bodyPart, bone) -> + val prev = smoothedLengths[bodyPart] ?: bone.offset + prev + (bone.offset - prev) * alpha + } + return state.copy( + boneInputs = state.boneInputs.mapValues { (bodyPart, bone) -> + bone.copy( + rawRotation = smoothedRotations[bodyPart] ?: bone.rawRotation, + offset = smoothedLengths[bodyPart] ?: bone.offset, + ) + }, + ) + } - companion object { - private const val SMOOTH_MIN = 0.63f - private const val SMOOTH_MAX = 0.94f - } + companion object { + private const val SMOOTH_MIN = 0.63f + private const val SMOOTH_MAX = 0.94f + } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/fingers.kt b/server/core/src/main/java/dev/slimevr/skeleton/fingers.kt index 6cbd75a085..f44c83f156 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/fingers.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/fingers.kt @@ -7,55 +7,55 @@ import kotlin.collections.get * Handles the finger tracking logic. */ class ImputeFingersProcessor : SkeletonProcessor { - /** - * First element is the linked BodyPart. - * - * Second element is the BodyPart the first element is linked to. - */ - private val fingerToSource = mapOf( - BodyPart.LEFT_THUMB_METACARPAL to BodyPart.LEFT_HAND, - BodyPart.LEFT_THUMB_PROXIMAL to BodyPart.LEFT_THUMB_METACARPAL, - BodyPart.LEFT_THUMB_DISTAL to BodyPart.LEFT_THUMB_PROXIMAL, - BodyPart.LEFT_INDEX_PROXIMAL to BodyPart.LEFT_HAND, - BodyPart.LEFT_INDEX_INTERMEDIATE to BodyPart.LEFT_INDEX_PROXIMAL, - BodyPart.LEFT_INDEX_DISTAL to BodyPart.LEFT_INDEX_INTERMEDIATE, - BodyPart.LEFT_MIDDLE_PROXIMAL to BodyPart.LEFT_HAND, - BodyPart.LEFT_MIDDLE_INTERMEDIATE to BodyPart.LEFT_MIDDLE_PROXIMAL, - BodyPart.LEFT_MIDDLE_DISTAL to BodyPart.LEFT_MIDDLE_INTERMEDIATE, - BodyPart.LEFT_RING_PROXIMAL to BodyPart.LEFT_HAND, - BodyPart.LEFT_RING_INTERMEDIATE to BodyPart.LEFT_RING_PROXIMAL, - BodyPart.LEFT_RING_DISTAL to BodyPart.LEFT_RING_INTERMEDIATE, - BodyPart.LEFT_LITTLE_PROXIMAL to BodyPart.LEFT_HAND, - BodyPart.LEFT_LITTLE_INTERMEDIATE to BodyPart.LEFT_LITTLE_PROXIMAL, - BodyPart.LEFT_LITTLE_DISTAL to BodyPart.LEFT_LITTLE_INTERMEDIATE, - BodyPart.RIGHT_THUMB_METACARPAL to BodyPart.RIGHT_HAND, - BodyPart.RIGHT_THUMB_PROXIMAL to BodyPart.RIGHT_THUMB_METACARPAL, - BodyPart.RIGHT_THUMB_DISTAL to BodyPart.RIGHT_THUMB_PROXIMAL, - BodyPart.RIGHT_INDEX_PROXIMAL to BodyPart.RIGHT_HAND, - BodyPart.RIGHT_INDEX_INTERMEDIATE to BodyPart.RIGHT_INDEX_PROXIMAL, - BodyPart.RIGHT_INDEX_DISTAL to BodyPart.RIGHT_INDEX_INTERMEDIATE, - BodyPart.RIGHT_MIDDLE_PROXIMAL to BodyPart.RIGHT_HAND, - BodyPart.RIGHT_MIDDLE_INTERMEDIATE to BodyPart.RIGHT_MIDDLE_PROXIMAL, - BodyPart.RIGHT_MIDDLE_DISTAL to BodyPart.RIGHT_MIDDLE_INTERMEDIATE, - BodyPart.RIGHT_RING_PROXIMAL to BodyPart.RIGHT_HAND, - BodyPart.RIGHT_RING_INTERMEDIATE to BodyPart.RIGHT_RING_PROXIMAL, - BodyPart.RIGHT_RING_DISTAL to BodyPart.RIGHT_RING_INTERMEDIATE, - BodyPart.RIGHT_LITTLE_PROXIMAL to BodyPart.RIGHT_HAND, - BodyPart.RIGHT_LITTLE_INTERMEDIATE to BodyPart.RIGHT_LITTLE_PROXIMAL, - BodyPart.RIGHT_LITTLE_DISTAL to BodyPart.RIGHT_LITTLE_INTERMEDIATE, - ) + /** + * First element is the linked BodyPart. + * + * Second element is the BodyPart the first element is linked to. + */ + private val fingerToSource = mapOf( + BodyPart.LEFT_THUMB_METACARPAL to BodyPart.LEFT_HAND, + BodyPart.LEFT_THUMB_PROXIMAL to BodyPart.LEFT_THUMB_METACARPAL, + BodyPart.LEFT_THUMB_DISTAL to BodyPart.LEFT_THUMB_PROXIMAL, + BodyPart.LEFT_INDEX_PROXIMAL to BodyPart.LEFT_HAND, + BodyPart.LEFT_INDEX_INTERMEDIATE to BodyPart.LEFT_INDEX_PROXIMAL, + BodyPart.LEFT_INDEX_DISTAL to BodyPart.LEFT_INDEX_INTERMEDIATE, + BodyPart.LEFT_MIDDLE_PROXIMAL to BodyPart.LEFT_HAND, + BodyPart.LEFT_MIDDLE_INTERMEDIATE to BodyPart.LEFT_MIDDLE_PROXIMAL, + BodyPart.LEFT_MIDDLE_DISTAL to BodyPart.LEFT_MIDDLE_INTERMEDIATE, + BodyPart.LEFT_RING_PROXIMAL to BodyPart.LEFT_HAND, + BodyPart.LEFT_RING_INTERMEDIATE to BodyPart.LEFT_RING_PROXIMAL, + BodyPart.LEFT_RING_DISTAL to BodyPart.LEFT_RING_INTERMEDIATE, + BodyPart.LEFT_LITTLE_PROXIMAL to BodyPart.LEFT_HAND, + BodyPart.LEFT_LITTLE_INTERMEDIATE to BodyPart.LEFT_LITTLE_PROXIMAL, + BodyPart.LEFT_LITTLE_DISTAL to BodyPart.LEFT_LITTLE_INTERMEDIATE, + BodyPart.RIGHT_THUMB_METACARPAL to BodyPart.RIGHT_HAND, + BodyPart.RIGHT_THUMB_PROXIMAL to BodyPart.RIGHT_THUMB_METACARPAL, + BodyPart.RIGHT_THUMB_DISTAL to BodyPart.RIGHT_THUMB_PROXIMAL, + BodyPart.RIGHT_INDEX_PROXIMAL to BodyPart.RIGHT_HAND, + BodyPart.RIGHT_INDEX_INTERMEDIATE to BodyPart.RIGHT_INDEX_PROXIMAL, + BodyPart.RIGHT_INDEX_DISTAL to BodyPart.RIGHT_INDEX_INTERMEDIATE, + BodyPart.RIGHT_MIDDLE_PROXIMAL to BodyPart.RIGHT_HAND, + BodyPart.RIGHT_MIDDLE_INTERMEDIATE to BodyPart.RIGHT_MIDDLE_PROXIMAL, + BodyPart.RIGHT_MIDDLE_DISTAL to BodyPart.RIGHT_MIDDLE_INTERMEDIATE, + BodyPart.RIGHT_RING_PROXIMAL to BodyPart.RIGHT_HAND, + BodyPart.RIGHT_RING_INTERMEDIATE to BodyPart.RIGHT_RING_PROXIMAL, + BodyPart.RIGHT_RING_DISTAL to BodyPart.RIGHT_RING_INTERMEDIATE, + BodyPart.RIGHT_LITTLE_PROXIMAL to BodyPart.RIGHT_HAND, + BodyPart.RIGHT_LITTLE_INTERMEDIATE to BodyPart.RIGHT_LITTLE_PROXIMAL, + BodyPart.RIGHT_LITTLE_DISTAL to BodyPart.RIGHT_LITTLE_INTERMEDIATE, + ) - // TODO : There's more math to do here. Reference the original code. - override fun process(state: SkeletonState): SkeletonState { - val updatedBoneInputs = state.boneInputs.toMutableMap() + // TODO : There's more math to do here. Reference the original code. + override fun process(state: SkeletonState): SkeletonState { + val updatedBoneInputs = state.boneInputs.toMutableMap() - for ((bodyPart, bone) in state.boneInputs) { - if (bone.isActive || bodyPart !in fingerToSource) continue + for ((bodyPart, bone) in state.boneInputs) { + if (bone.isActive || bodyPart !in fingerToSource) continue - val sourceBone = updatedBoneInputs[fingerToSource[bodyPart]] - updatedBoneInputs[bodyPart] = bone.copy(rawRotation = sourceBone?.rawRotation ?: bone.rawRotation) - } + val sourceBone = updatedBoneInputs[fingerToSource[bodyPart]] + updatedBoneInputs[bodyPart] = bone.copy(rawRotation = sourceBone?.rawRotation ?: bone.rawRotation) + } - return state.copy(boneInputs = updatedBoneInputs) - } -} \ No newline at end of file + return state.copy(boneInputs = updatedBoneInputs) + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 72f68faee2..a25a744c94 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -107,8 +107,8 @@ class Skeleton( ComputedSkeletonBehaviour( processors = listOf( FallbackProcessor(), - ImputeSpineProcessor(ctx.config.settings), BoneLinkProcessor(), + ImputeSpineProcessor(ctx.config.settings), ImputeFingersProcessor(), PredictionProcessor(ctx.config.settings), SmoothingProcessor(ctx.config.settings), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt index a66d5170d4..410ed95868 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt @@ -1,7 +1,6 @@ package dev.slimevr.skeleton import dev.slimevr.config.Settings -import dev.slimevr.config.SkeletonRatiosConfig import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.BodyPart @@ -22,7 +21,7 @@ class FallbackProcessor : SkeletonProcessor { boneInputs = boneInputs.mapValues { (bodyPart, bone) -> if (bone.isActive) return@mapValues bone - val firstActiveParentBone = boneInputs[findBodyPartParent(bodyPart) { boneInputs[it]?.isActive == true }] + val firstActiveParentBone = boneInputs[bodyPart.findFirstParent { boneInputs[it]?.isActive == true }] val fallbackRotation = firstActiveParentBone?.rawRotation?.project(Vector3.POS_Y)?.unit() ?: Quaternion.IDENTITY bone.copy(rawRotation = fallbackRotation) }, @@ -30,41 +29,6 @@ class FallbackProcessor : SkeletonProcessor { } } -/** - * Handles imputing the rotation of spine bones that are not actively receiving data from the rotations - * of nearby bones. - * - * Similar to FallbackProcessor specifically for the waist and hip. - */ -class ImputeSpineProcessor(val settings: Settings) : SkeletonProcessor { - private val bonesToImpute = listOf(BodyPart.WAIST, BodyPart.HIP) - - override fun process(state: SkeletonState): SkeletonState { - val ratios = settings.context.state.value.data.skeletonConfig.ratios - val imputeSpineFromUpperToLower = ratios.imputeSpineFromUpperToLower - val imputeSpineCurvature = ratios.imputeSpineCurvature - val boneInputs = state.boneInputs - - return state.copy( - boneInputs = boneInputs.mapValues { (bodyPart, bone) -> - if (bone.isActive || bodyPart !in bonesToImpute) return@mapValues bone - - bone.copy(rawRotation = bone.rawRotation) - }, - ) - } - - private fun imputeBoneRotation(fromRotation: Quaternion, toRotation: Quaternion): Quaternion { - return Quaternion.IDENTITY - } - - private fun getAverageLegRotation(boneInputs: Map): Quaternion? { - val leftLeg = boneInputs[BodyPart.LEFT_UPPER_LEG]?.takeIf { it.isActive } ?: return null - val rightLeg = boneInputs[BodyPart.RIGHT_UPPER_LEG]?.takeIf { it.isActive } ?: return null - return leftLeg.rawRotation.lerpQ(rightLeg.rawRotation, 0.5f) - } -} - /** * Handles copying bones rotations to their linked bones. * @@ -114,4 +78,72 @@ class BoneLinkProcessor : SkeletonProcessor { return state.copy(boneInputs = updatedBoneInputs) } -} \ No newline at end of file +} + +/** + * Handles imputing the rotation of spine bones that are not actively receiving data from the rotations + * of nearby bones. + * + * Similar to FallbackProcessor specifically for the waist and hip. + */ +class ImputeSpineProcessor(val settings: Settings) : SkeletonProcessor { + + + override fun process(state: SkeletonState): SkeletonState { + val boneInputs = state.boneInputs + val ratios = settings.context.state.value.data.skeletonConfig.ratios + val imputeSpineFromUpperToLower = ratios.imputeSpineFromUpperToLower + val imputeSpineCurvature = ratios.imputeSpineCurvature + + val chestBone = boneInputs[BodyPart.CHEST] + val waistBone = boneInputs[BodyPart.WAIST]?.takeIf { it.isActive } + val hipBone = boneInputs[BodyPart.HIP]?.takeIf { it.isActive } + val leftUpperLegBone = boneInputs[BodyPart.LEFT_UPPER_LEG] + val rightUpperLegBone = boneInputs[BodyPart.RIGHT_UPPER_LEG] + val missingSpineParts = buildList { + if (waistBone == null) add(BodyPart.WAIST) + if (hipBone == null) add(BodyPart.HIP) + } + + val chestRotation = chestBone?.rawRotation ?: Quaternion.IDENTITY + val waistRotation = waistBone?.rawRotation + val hipRotation = hipBone?.rawRotation + val upperLegsRotation = leftUpperLegBone?.rawRotation?.lerpQ( + rightUpperLegBone?.rawRotation ?: Quaternion.IDENTITY, + 0.5f + ) ?: Quaternion.IDENTITY + + return state.copy( + boneInputs = boneInputs.mapValues { (bodyPart, bone) -> + val chainIndex = missingSpineParts.indexOf(bodyPart) + if (chainIndex == -1) return@mapValues bone + + // Nearest active bone above and below this one in the chain + val fromRotation = if (bodyPart == BodyPart.WAIST) chestRotation else waistRotation ?: chestRotation + val toRotation = if (bodyPart == BodyPart.WAIST) hipRotation ?: upperLegsRotation else upperLegsRotation + + // TODO remap properly both ratio and curvature + val remapCenter = if (bodyPart == BodyPart.WAIST) 0.3f else if (waistRotation != null) 0.4f else 0.5f + val ratio = remap(imputeSpineFromUpperToLower, remapCenter) + val curvature = imputeSpineCurvature + + val interpolateRatio = if(missingSpineParts.count() <= 1) { + // Single missing bone; just use the ratio directly + ratio + } else { + ratio + ((chainIndex - ratio) * curvature) + } + + bone.copy(rawRotation = fromRotation.interpQ(toRotation, interpolateRatio)) + }, + ) + } + + private val DEFAULT_UPPER_LOWER = 0.5f + private fun remap(value: Float, newCenter: Float): Float = + if (value <= DEFAULT_UPPER_LOWER) { + value * (newCenter / DEFAULT_UPPER_LOWER) + } else { + newCenter + (value - DEFAULT_UPPER_LOWER) * ((1f - newCenter) / (1f - DEFAULT_UPPER_LOWER)) + } +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 6ff847cd7a..1d48c7b198 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -93,6 +93,7 @@ class TrackerBasicBehaviour : TrackerBehaviour { // TODO: Immediately apply calibration on `state.rawRotation` so we don't // need to worry about desync of `state.rotation` + // (+1 from Erimel) state.copy(sessionCalibration = sessionCalibration) } From 56fcfe15bf9a084ae503668b4abf60f65aad1179 Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 21 Jul 2026 16:27:31 -0400 Subject: [PATCH 276/350] Finish ImputeSpineProcessor --- gui/app/public/i18n/en/translation.ftl | 4 +- .../java/dev/slimevr/skeleton/bonegeometry.kt | 1 + .../main/java/dev/slimevr/skeleton/fingers.kt | 2 +- .../main/java/dev/slimevr/skeleton/module.kt | 10 +- .../java/dev/slimevr/skeleton/processors.kt | 188 +++++++++++++----- 5 files changed, 151 insertions(+), 54 deletions(-) diff --git a/gui/app/public/i18n/en/translation.ftl b/gui/app/public/i18n/en/translation.ftl index 850b5f2e45..a83ab70dc4 100644 --- a/gui/app/public/i18n/en/translation.ftl +++ b/gui/app/public/i18n/en/translation.ftl @@ -633,8 +633,8 @@ settings-general-fk_settings-arm_fk-forward = Forward settings-general-fk_settings-arm_fk-forward-description = Expects your arms to be raised forward at 90 degrees. Useful for VTubing. settings-general-fk_settings-skeleton_settings-ratios = Skeleton ratios settings-general-fk_settings-skeleton_settings-ratios-description = Change the values of skeleton settings. You may need to adjust your proportions after changing these. -settings-general-fk_settings-skeleton_settings-impute_spine_from_upper_to_lower = Impute missing spine trackers from upper to lower -settings-general-fk_settings-skeleton_settings-impute_spine_curvature = Spine curvature when missing adjacent spine trackers +settings-general-fk_settings-skeleton_settings-impute_spine_from_upper_to_lower = Estimate missing spine trackers from upper to lower +settings-general-fk_settings-skeleton_settings-impute_spine_curvature = Spine curvature of adjacent missing spine trackers settings-general-fk_settings-skeleton_settings-interpolate_hip_with_knees = Interpolate the hip with the knees settings-general-fk_settings-skeleton_settings-interpolate_computed_knees_with_ankles = Interpolate the computed knees with the ankles settings-general-fk_settings-skeleton_settings-interpolate_knees_with_ankles = Interpolate the knees with the ankles diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt b/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt index c7e4ea1271..1512e0f211 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt @@ -3,6 +3,7 @@ package dev.slimevr.skeleton import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.BodyPart +// TODO Make fingers not all the same lengths https://github.com/SlimeVR/SlimeVR-Server/pull/1781 private val FINGER_OFFSETS = ( iterateBodyPartHierarchy(BodyPart.LEFT_HAND, true) + iterateBodyPartHierarchy(BodyPart.RIGHT_HAND, true) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/fingers.kt b/server/core/src/main/java/dev/slimevr/skeleton/fingers.kt index f44c83f156..fd3d042ab1 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/fingers.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/fingers.kt @@ -6,7 +6,7 @@ import kotlin.collections.get /** * Handles the finger tracking logic. */ -class ImputeFingersProcessor : SkeletonProcessor { +class FingersImputeProcessor : SkeletonProcessor { /** * First element is the linked BodyPart. * diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index a25a744c94..8783775bbf 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -89,6 +89,9 @@ sealed interface SkeletonActions { typealias SkeletonContext = Context typealias SkeletonBehaviour = Behaviour +interface SkeletonProcessor { + fun process(state: SkeletonState): SkeletonState +} class Skeleton( val context: SkeletonContext, @@ -106,10 +109,11 @@ class Skeleton( // YouSpinMeRightRoundBehaviour(inputHz = 50f), ComputedSkeletonBehaviour( processors = listOf( - FallbackProcessor(), + BoneFallbackProcessor(), + SpineLinkProcessor(), BoneLinkProcessor(), - ImputeSpineProcessor(ctx.config.settings), - ImputeFingersProcessor(), + SpineImputeProcessor(ctx.config.settings), + FingersImputeProcessor(), PredictionProcessor(ctx.config.settings), SmoothingProcessor(ctx.config.settings), ), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt index 410ed95868..750fefe9a7 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt @@ -5,15 +5,11 @@ import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.BodyPart -interface SkeletonProcessor { - fun process(state: SkeletonState): SkeletonState -} - /** * Handles replacing rotations of boneInputs that are not actively receiving data by either * falling back to their parent's yaw or the identity rotation. */ -class FallbackProcessor : SkeletonProcessor { +class BoneFallbackProcessor : SkeletonProcessor { override fun process(state: SkeletonState): SkeletonState { val boneInputs = state.boneInputs @@ -30,9 +26,7 @@ class FallbackProcessor : SkeletonProcessor { } /** - * Handles copying bones rotations to their linked bones. - * - * Useful for bones whose sole purpose is offsets or bones that rarely have trackers on them. + * Handles copying source bones' rotations to their linked bones. */ class BoneLinkProcessor : SkeletonProcessor { /** @@ -44,9 +38,6 @@ class BoneLinkProcessor : SkeletonProcessor { BodyPart.HEAD to BodyPart.NECK, BodyPart.NECK to BodyPart.HEAD, - BodyPart.UPPER_CHEST to BodyPart.CHEST, - BodyPart.CHEST to BodyPart.UPPER_CHEST, - BodyPart.LEFT_HIP to BodyPart.HIP, BodyPart.RIGHT_HIP to BodyPart.HIP, @@ -80,70 +71,171 @@ class BoneLinkProcessor : SkeletonProcessor { } } +class SpineLinkProcessor : SkeletonProcessor { + /** + * First element is the BodyPart whose rawBone is not actively receiving data. + * Second element contains a set of BodyParts whose rotation should be used as a fallback prioritized from first to last. + */ + private val missingToFallbacks = mapOf( + BodyPart.UPPER_CHEST to setOf(BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), + BodyPart.CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.WAIST, BodyPart.HIP), + BodyPart.WAIST to setOf(BodyPart.CHEST, BodyPart.HIP, BodyPart.UPPER_CHEST), + BodyPart.HIP to setOf(BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), + ) + + override fun process(state: SkeletonState): SkeletonState { + val boneInputs = state.boneInputs + + return state.copy( + boneInputs = boneInputs.mapValues { (bodyPart, bone) -> + if (bone.isActive) return@mapValues bone + + val closestActiveBone = missingToFallbacks[bodyPart] + ?.firstNotNullOfOrNull { part -> + boneInputs[part]?.takeIf { it.isActive } + } + bone.copy( + rawRotation = closestActiveBone?.rawRotation ?: bone.rawRotation, + ) + }, + ) + } +} + /** * Handles imputing the rotation of spine bones that are not actively receiving data from the rotations * of nearby bones. * * Similar to FallbackProcessor specifically for the waist and hip. */ -class ImputeSpineProcessor(val settings: Settings) : SkeletonProcessor { +class SpineImputeProcessor(val settings: Settings) : SkeletonProcessor { + private val chestSet = setOf(BodyPart.CHEST) + private val hipSet = setOf(BodyPart.HIP) + private val waistSet = setOf(BodyPart.WAIST) + private val upperLegsSet = setOf(BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG) + /** + * Used to skew the impute ratio for certain bone combinations. + * + * First element is a Double containing the missing bone to the source bones. + * + * Second element is how reliable that pair is. Higher = missing bone relies more on source bones. + */ + private val combinationToReliability = mapOf( + (BodyPart.WAIST to chestSet) to 0.6f, + (BodyPart.WAIST to hipSet) to 1.0f, + (BodyPart.WAIST to upperLegsSet) to 1.0f, + (BodyPart.HIP to chestSet) to 1.0f, + (BodyPart.HIP to waistSet) to 0.8f, + (BodyPart.HIP to upperLegsSet) to 1.0f, + ) override fun process(state: SkeletonState): SkeletonState { val boneInputs = state.boneInputs val ratios = settings.context.state.value.data.skeletonConfig.ratios - val imputeSpineFromUpperToLower = ratios.imputeSpineFromUpperToLower - val imputeSpineCurvature = ratios.imputeSpineCurvature - - val chestBone = boneInputs[BodyPart.CHEST] - val waistBone = boneInputs[BodyPart.WAIST]?.takeIf { it.isActive } - val hipBone = boneInputs[BodyPart.HIP]?.takeIf { it.isActive } - val leftUpperLegBone = boneInputs[BodyPart.LEFT_UPPER_LEG] - val rightUpperLegBone = boneInputs[BodyPart.RIGHT_UPPER_LEG] + + val hasChest = boneInputs[BodyPart.UPPER_CHEST]?.isActive == true || boneInputs[BodyPart.CHEST]?.isActive == true + val hasWaist = boneInputs[BodyPart.WAIST]?.isActive == true + val hasHip = boneInputs[BodyPart.HIP]?.isActive == true + val hasUpperLegs = boneInputs[BodyPart.LEFT_UPPER_LEG]?.isActive == true && boneInputs[BodyPart.RIGHT_UPPER_LEG]?.isActive == true val missingSpineParts = buildList { - if (waistBone == null) add(BodyPart.WAIST) - if (hipBone == null) add(BodyPart.HIP) + if (!hasWaist) add(BodyPart.WAIST) + if (!hasHip) add(BodyPart.HIP) } - val chestRotation = chestBone?.rawRotation ?: Quaternion.IDENTITY - val waistRotation = waistBone?.rawRotation - val hipRotation = hipBone?.rawRotation - val upperLegsRotation = leftUpperLegBone?.rawRotation?.lerpQ( - rightUpperLegBone?.rawRotation ?: Quaternion.IDENTITY, - 0.5f - ) ?: Quaternion.IDENTITY - return state.copy( boneInputs = boneInputs.mapValues { (bodyPart, bone) -> val chainIndex = missingSpineParts.indexOf(bodyPart) if (chainIndex == -1) return@mapValues bone - // Nearest active bone above and below this one in the chain - val fromRotation = if (bodyPart == BodyPart.WAIST) chestRotation else waistRotation ?: chestRotation - val toRotation = if (bodyPart == BodyPart.WAIST) hipRotation ?: upperLegsRotation else upperLegsRotation + // Get the first active bones above and below this one in the chain + val (fromBodyPart, toBodyPart) = when (bodyPart) { + BodyPart.WAIST -> { + val from = chestSet.takeIf { hasChest } ?: return@mapValues bone + val to = when { + hasHip -> hipSet + hasUpperLegs -> upperLegsSet + else -> return@mapValues bone + } + from to to + } + + BodyPart.HIP -> { + val from = when { + hasWaist -> waistSet + hasChest -> chestSet + else -> return@mapValues bone + } + val to = upperLegsSet.takeIf { hasUpperLegs } ?: return@mapValues bone + from to to + } + + else -> error("Invalid missing spine body part $bodyPart") + } - // TODO remap properly both ratio and curvature - val remapCenter = if (bodyPart == BodyPart.WAIST) 0.3f else if (waistRotation != null) 0.4f else 0.5f - val ratio = remap(imputeSpineFromUpperToLower, remapCenter) - val curvature = imputeSpineCurvature + val fromReliability = combinationToReliability[(bodyPart to fromBodyPart)] ?: error("Invalid from body part combination $bodyPart, $fromBodyPart") + val toReliability = combinationToReliability[(bodyPart to toBodyPart)] ?: error("Invalid to body part combination $bodyPart, $toBodyPart") - val interpolateRatio = if(missingSpineParts.count() <= 1) { - // Single missing bone; just use the ratio directly - ratio - } else { - ratio + ((chainIndex - ratio) * curvature) - } + val interpolateRatio = interpolateRatio( + chainIndex, + missingSpineParts.size, + ratios.imputeSpineFromUpperToLower, + ratios.imputeSpineCurvature, + fromReliability, + toReliability, + ) + + val fromRotation = resolveRotation(boneInputs, fromBodyPart) + val toRotation = resolveRotation(boneInputs, toBodyPart) bone.copy(rawRotation = fromRotation.interpQ(toRotation, interpolateRatio)) }, ) } - private val DEFAULT_UPPER_LOWER = 0.5f - private fun remap(value: Float, newCenter: Float): Float = - if (value <= DEFAULT_UPPER_LOWER) { - value * (newCenter / DEFAULT_UPPER_LOWER) + /** + * Returns the interpolation ratio modified with the reliability and curve. + * If a single bone is missing, ratio is reliability adjusted. + * At 0.0 curve, ratio is raw. + * At 0.5 curve, ratio is reliability adjusted. + * At 1.0 curve, ratio is 0 for the first in chain and 1 for the last in chain. + */ + private fun interpolateRatio(chainIndex: Int, chainSize: Int, fromUpperToLower: Float, curvature: Float, fromReliability: Float, toReliability: Float): Float { + val reliabilityAdjusted = remapRatioWithReliability(fromUpperToLower, fromReliability, toReliability) + if (chainSize <= 1) return reliabilityAdjusted // Single missing bone; reliability adjusted + + return if (curvature <= 0.5f) { + // Raw to reliability adjusted + lerp(fromUpperToLower, reliabilityAdjusted, curvature * 2f) } else { - newCenter + (value - DEFAULT_UPPER_LOWER) * ((1f - newCenter) / (1f - DEFAULT_UPPER_LOWER)) + // Reliability adjusted to max curve + val maxCurve = chainIndex / (chainSize - 1).toFloat() + lerp(reliabilityAdjusted, maxCurve, (curvature - 0.5f) * 2f) } + } + + /** + * Remaps a ratio This assumes a default ratio of 50%. + */ + private fun remapRatioWithReliability(ratio: Float, fromReliability: Float, toReliability: Float): Float { + val reliability = (fromReliability / toReliability) * 0.5f + + return if (ratio <= 0.5f) { + ratio * 2f * reliability + } else { + reliability + (ratio - 0.5f) * 2f * (1f - reliability) + } + } + + private fun lerp(from: Float, to: Float, t: Float): Float = from + (to - from) * t + + /** + * Returns the average rotation of the BodyParts. + */ + private fun resolveRotation(boneInputs: Map, bodyParts: Set): Quaternion { + val rotations = bodyParts.mapNotNull { boneInputs[it]?.rawRotation } + return rotations.reduceIndexedOrNull { index, acc, rotation -> + acc.lerpQ(rotation, 1f / (index + 1)) + } ?: Quaternion.IDENTITY + } } From 44d9c68f78356916cac3ba68edb71f90c087f588 Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 21 Jul 2026 16:33:52 -0400 Subject: [PATCH 277/350] Move processors into their own package --- .../java/dev/slimevr/skeleton/filtering.kt | 76 ------ .../main/java/dev/slimevr/skeleton/module.kt | 11 +- .../java/dev/slimevr/skeleton/processors.kt | 241 ------------------ .../skeleton/processors/bonefallback.kt | 28 ++ .../slimevr/skeleton/processors/bonelink.kt | 55 ++++ .../skeleton/processors/boneprediction.kt | 52 ++++ .../skeleton/processors/bonesmoothing.kt | 43 ++++ .../fingersimpute.kt} | 5 +- .../skeleton/processors/spineimpute.kt | 146 +++++++++++ .../slimevr/skeleton/processors/spinelink.kt | 36 +++ 10 files changed, 373 insertions(+), 320 deletions(-) delete mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors.kt create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/bonefallback.kt create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/bonelink.kt create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/boneprediction.kt create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/bonesmoothing.kt rename server/core/src/main/java/dev/slimevr/skeleton/{fingers.kt => processors/fingersimpute.kt} (94%) create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/spineimpute.kt create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/spinelink.kt diff --git a/server/core/src/main/java/dev/slimevr/skeleton/filtering.kt b/server/core/src/main/java/dev/slimevr/skeleton/filtering.kt index 4b9761e767..5e5f1a3351 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/filtering.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/filtering.kt @@ -6,79 +6,3 @@ import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.FilteringType -class PredictionProcessor(val settings: Settings) : SkeletonProcessor { - private data class BoneVelocity( - val lastRotation: Quaternion, - val rotationDelta: Quaternion, - val lastOffset: Vector3, - val offsetDelta: Vector3, - ) - - private var velocities: Map = emptyMap() - - override fun process(state: SkeletonState): SkeletonState { - val config = settings.context.state.value.data.skeletonConfig.filtering - if (config.type != FilteringType.PREDICTION) return state - - val newVelocities = mutableMapOf() - val newBones = state.boneInputs.mapValues { (bodyPart, bone) -> - val prev = velocities[bodyPart] - if (prev == null) { - newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, Quaternion.IDENTITY, bone.offset, Vector3.NULL) - return@mapValues bone - } - val rotationDelta = if (bone.rawRotation !== prev.lastRotation) { - bone.rawRotation * prev.lastRotation.inv() - } else { - prev.rotationDelta - } - val lengthDelta = if (bone.offset != prev.lastOffset) { - bone.offset - prev.lastOffset - } else { - prev.offsetDelta - } - newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, rotationDelta, bone.offset, lengthDelta) - val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, config.amount).unit() - bone.copy( - rawRotation = (scaledDelta * bone.rawRotation).unit(), - offset = bone.offset + lengthDelta * config.amount, - ) - } - velocities = newVelocities - return state.copy(boneInputs = newBones) - } -} - -class SmoothingProcessor(val settings: Settings) : SkeletonProcessor { - private var smoothedRotations: Map = emptyMap() - private var smoothedLengths: Map = emptyMap() - - // TODO this isn't linear. Do we want linear smoothing like in main? - override fun process(state: SkeletonState): SkeletonState { - val config = settings.context.state.value.data.skeletonConfig.filtering - if (config.type != FilteringType.SMOOTHING) return state - - val alpha = 1 - (SMOOTH_MIN + config.amount.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN)) - - smoothedRotations = state.boneInputs.mapValues { (bodyPart, bone) -> - (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() - } - smoothedLengths = state.boneInputs.mapValues { (bodyPart, bone) -> - val prev = smoothedLengths[bodyPart] ?: bone.offset - prev + (bone.offset - prev) * alpha - } - return state.copy( - boneInputs = state.boneInputs.mapValues { (bodyPart, bone) -> - bone.copy( - rawRotation = smoothedRotations[bodyPart] ?: bone.rawRotation, - offset = smoothedLengths[bodyPart] ?: bone.offset, - ) - }, - ) - } - - companion object { - private const val SMOOTH_MIN = 0.63f - private const val SMOOTH_MAX = 0.94f - } -} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 8783775bbf..7bb7b5f1b9 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -3,6 +3,13 @@ package dev.slimevr.skeleton import dev.slimevr.Phase1ContextProvider import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.skeleton.processors.BoneFallbackProcessor +import dev.slimevr.skeleton.processors.BoneLinkProcessor +import dev.slimevr.skeleton.processors.BonePredictionProcessor +import dev.slimevr.skeleton.processors.BoneSmoothingProcessor +import dev.slimevr.skeleton.processors.FingersImputeProcessor +import dev.slimevr.skeleton.processors.SpineImputeProcessor +import dev.slimevr.skeleton.processors.SpineLinkProcessor import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope @@ -114,8 +121,8 @@ class Skeleton( BoneLinkProcessor(), SpineImputeProcessor(ctx.config.settings), FingersImputeProcessor(), - PredictionProcessor(ctx.config.settings), - SmoothingProcessor(ctx.config.settings), + BonePredictionProcessor(ctx.config.settings), + BoneSmoothingProcessor(ctx.config.settings), ), ), ) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors.kt deleted file mode 100644 index 750fefe9a7..0000000000 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors.kt +++ /dev/null @@ -1,241 +0,0 @@ -package dev.slimevr.skeleton - -import dev.slimevr.config.Settings -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import solarxr_protocol.datatypes.BodyPart - -/** - * Handles replacing rotations of boneInputs that are not actively receiving data by either - * falling back to their parent's yaw or the identity rotation. - */ -class BoneFallbackProcessor : SkeletonProcessor { - override fun process(state: SkeletonState): SkeletonState { - val boneInputs = state.boneInputs - - return state.copy( - boneInputs = boneInputs.mapValues { (bodyPart, bone) -> - if (bone.isActive) return@mapValues bone - - val firstActiveParentBone = boneInputs[bodyPart.findFirstParent { boneInputs[it]?.isActive == true }] - val fallbackRotation = firstActiveParentBone?.rawRotation?.project(Vector3.POS_Y)?.unit() ?: Quaternion.IDENTITY - bone.copy(rawRotation = fallbackRotation) - }, - ) - } -} - -/** - * Handles copying source bones' rotations to their linked bones. - */ -class BoneLinkProcessor : SkeletonProcessor { - /** - * First element is the linked BodyPart. - * - * Second element is the BodyPart the first element is linked to. - */ - private val linkedToSource = mapOf( - BodyPart.HEAD to BodyPart.NECK, - BodyPart.NECK to BodyPart.HEAD, - - BodyPart.LEFT_HIP to BodyPart.HIP, - BodyPart.RIGHT_HIP to BodyPart.HIP, - - BodyPart.LEFT_FOOT to BodyPart.LEFT_LOWER_LEG, - BodyPart.RIGHT_FOOT to BodyPart.RIGHT_LOWER_LEG, - - BodyPart.LEFT_SHOULDER to BodyPart.UPPER_CHEST, - BodyPart.RIGHT_SHOULDER to BodyPart.UPPER_CHEST, - - BodyPart.LEFT_UPPER_ARM to BodyPart.LEFT_SHOULDER, - BodyPart.RIGHT_UPPER_ARM to BodyPart.RIGHT_SHOULDER, - - BodyPart.LEFT_LOWER_ARM to BodyPart.LEFT_UPPER_ARM, - BodyPart.RIGHT_LOWER_ARM to BodyPart.RIGHT_UPPER_ARM, - - BodyPart.LEFT_HAND to BodyPart.LEFT_LOWER_ARM, - BodyPart.RIGHT_HAND to BodyPart.RIGHT_LOWER_ARM, - ) - - override fun process(state: SkeletonState): SkeletonState { - val updatedBoneInputs = state.boneInputs.toMutableMap() - - for ((bodyPart, bone) in state.boneInputs) { - if (bone.isActive || bodyPart !in linkedToSource) continue - - val sourceBone = updatedBoneInputs[linkedToSource[bodyPart]] - updatedBoneInputs[bodyPart] = bone.copy(rawRotation = sourceBone?.rawRotation ?: bone.rawRotation) - } - - return state.copy(boneInputs = updatedBoneInputs) - } -} - -class SpineLinkProcessor : SkeletonProcessor { - /** - * First element is the BodyPart whose rawBone is not actively receiving data. - * Second element contains a set of BodyParts whose rotation should be used as a fallback prioritized from first to last. - */ - private val missingToFallbacks = mapOf( - BodyPart.UPPER_CHEST to setOf(BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), - BodyPart.CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.WAIST, BodyPart.HIP), - BodyPart.WAIST to setOf(BodyPart.CHEST, BodyPart.HIP, BodyPart.UPPER_CHEST), - BodyPart.HIP to setOf(BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), - ) - - override fun process(state: SkeletonState): SkeletonState { - val boneInputs = state.boneInputs - - return state.copy( - boneInputs = boneInputs.mapValues { (bodyPart, bone) -> - if (bone.isActive) return@mapValues bone - - val closestActiveBone = missingToFallbacks[bodyPart] - ?.firstNotNullOfOrNull { part -> - boneInputs[part]?.takeIf { it.isActive } - } - bone.copy( - rawRotation = closestActiveBone?.rawRotation ?: bone.rawRotation, - ) - }, - ) - } -} - -/** - * Handles imputing the rotation of spine bones that are not actively receiving data from the rotations - * of nearby bones. - * - * Similar to FallbackProcessor specifically for the waist and hip. - */ -class SpineImputeProcessor(val settings: Settings) : SkeletonProcessor { - private val chestSet = setOf(BodyPart.CHEST) - private val hipSet = setOf(BodyPart.HIP) - private val waistSet = setOf(BodyPart.WAIST) - private val upperLegsSet = setOf(BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG) - - /** - * Used to skew the impute ratio for certain bone combinations. - * - * First element is a Double containing the missing bone to the source bones. - * - * Second element is how reliable that pair is. Higher = missing bone relies more on source bones. - */ - private val combinationToReliability = mapOf( - (BodyPart.WAIST to chestSet) to 0.6f, - (BodyPart.WAIST to hipSet) to 1.0f, - (BodyPart.WAIST to upperLegsSet) to 1.0f, - (BodyPart.HIP to chestSet) to 1.0f, - (BodyPart.HIP to waistSet) to 0.8f, - (BodyPart.HIP to upperLegsSet) to 1.0f, - ) - - override fun process(state: SkeletonState): SkeletonState { - val boneInputs = state.boneInputs - val ratios = settings.context.state.value.data.skeletonConfig.ratios - - val hasChest = boneInputs[BodyPart.UPPER_CHEST]?.isActive == true || boneInputs[BodyPart.CHEST]?.isActive == true - val hasWaist = boneInputs[BodyPart.WAIST]?.isActive == true - val hasHip = boneInputs[BodyPart.HIP]?.isActive == true - val hasUpperLegs = boneInputs[BodyPart.LEFT_UPPER_LEG]?.isActive == true && boneInputs[BodyPart.RIGHT_UPPER_LEG]?.isActive == true - val missingSpineParts = buildList { - if (!hasWaist) add(BodyPart.WAIST) - if (!hasHip) add(BodyPart.HIP) - } - - return state.copy( - boneInputs = boneInputs.mapValues { (bodyPart, bone) -> - val chainIndex = missingSpineParts.indexOf(bodyPart) - if (chainIndex == -1) return@mapValues bone - - // Get the first active bones above and below this one in the chain - val (fromBodyPart, toBodyPart) = when (bodyPart) { - BodyPart.WAIST -> { - val from = chestSet.takeIf { hasChest } ?: return@mapValues bone - val to = when { - hasHip -> hipSet - hasUpperLegs -> upperLegsSet - else -> return@mapValues bone - } - from to to - } - - BodyPart.HIP -> { - val from = when { - hasWaist -> waistSet - hasChest -> chestSet - else -> return@mapValues bone - } - val to = upperLegsSet.takeIf { hasUpperLegs } ?: return@mapValues bone - from to to - } - - else -> error("Invalid missing spine body part $bodyPart") - } - - val fromReliability = combinationToReliability[(bodyPart to fromBodyPart)] ?: error("Invalid from body part combination $bodyPart, $fromBodyPart") - val toReliability = combinationToReliability[(bodyPart to toBodyPart)] ?: error("Invalid to body part combination $bodyPart, $toBodyPart") - - val interpolateRatio = interpolateRatio( - chainIndex, - missingSpineParts.size, - ratios.imputeSpineFromUpperToLower, - ratios.imputeSpineCurvature, - fromReliability, - toReliability, - ) - - val fromRotation = resolveRotation(boneInputs, fromBodyPart) - val toRotation = resolveRotation(boneInputs, toBodyPart) - - bone.copy(rawRotation = fromRotation.interpQ(toRotation, interpolateRatio)) - }, - ) - } - - /** - * Returns the interpolation ratio modified with the reliability and curve. - * If a single bone is missing, ratio is reliability adjusted. - * At 0.0 curve, ratio is raw. - * At 0.5 curve, ratio is reliability adjusted. - * At 1.0 curve, ratio is 0 for the first in chain and 1 for the last in chain. - */ - private fun interpolateRatio(chainIndex: Int, chainSize: Int, fromUpperToLower: Float, curvature: Float, fromReliability: Float, toReliability: Float): Float { - val reliabilityAdjusted = remapRatioWithReliability(fromUpperToLower, fromReliability, toReliability) - if (chainSize <= 1) return reliabilityAdjusted // Single missing bone; reliability adjusted - - return if (curvature <= 0.5f) { - // Raw to reliability adjusted - lerp(fromUpperToLower, reliabilityAdjusted, curvature * 2f) - } else { - // Reliability adjusted to max curve - val maxCurve = chainIndex / (chainSize - 1).toFloat() - lerp(reliabilityAdjusted, maxCurve, (curvature - 0.5f) * 2f) - } - } - - /** - * Remaps a ratio This assumes a default ratio of 50%. - */ - private fun remapRatioWithReliability(ratio: Float, fromReliability: Float, toReliability: Float): Float { - val reliability = (fromReliability / toReliability) * 0.5f - - return if (ratio <= 0.5f) { - ratio * 2f * reliability - } else { - reliability + (ratio - 0.5f) * 2f * (1f - reliability) - } - } - - private fun lerp(from: Float, to: Float, t: Float): Float = from + (to - from) * t - - /** - * Returns the average rotation of the BodyParts. - */ - private fun resolveRotation(boneInputs: Map, bodyParts: Set): Quaternion { - val rotations = bodyParts.mapNotNull { boneInputs[it]?.rawRotation } - return rotations.reduceIndexedOrNull { index, acc, rotation -> - acc.lerpQ(rotation, 1f / (index + 1)) - } ?: Quaternion.IDENTITY - } -} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bonefallback.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bonefallback.kt new file mode 100644 index 0000000000..8b0dd99431 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bonefallback.kt @@ -0,0 +1,28 @@ +package dev.slimevr.skeleton.processors + +import dev.slimevr.skeleton.SkeletonProcessor +import dev.slimevr.skeleton.SkeletonState +import dev.slimevr.skeleton.findFirstParent +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import kotlin.collections.get + +/** + * Handles replacing rotations of boneInputs that are not actively receiving data by either + * falling back to their parent's yaw or the identity rotation. + */ +class BoneFallbackProcessor : SkeletonProcessor { + override fun process(state: SkeletonState): SkeletonState { + val boneInputs = state.boneInputs + + return state.copy( + boneInputs = boneInputs.mapValues { (bodyPart, bone) -> + if (bone.isActive) return@mapValues bone + + val firstActiveParentBone = boneInputs[bodyPart.findFirstParent { boneInputs[it]?.isActive == true }] + val fallbackRotation = firstActiveParentBone?.rawRotation?.project(Vector3.POS_Y)?.unit() ?: Quaternion.IDENTITY + bone.copy(rawRotation = fallbackRotation) + }, + ) + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bonelink.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bonelink.kt new file mode 100644 index 0000000000..5a7a7e6f80 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bonelink.kt @@ -0,0 +1,55 @@ +package dev.slimevr.skeleton.processors + +import dev.slimevr.skeleton.SkeletonProcessor +import dev.slimevr.skeleton.SkeletonState +import solarxr_protocol.datatypes.BodyPart +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.get +import kotlin.collections.iterator + +/** + * Handles copying source bones' rotations to their linked bones. + */ +class BoneLinkProcessor : SkeletonProcessor { + /** + * First element is the linked BodyPart. + * + * Second element is the BodyPart the first element is linked to. + */ + private val linkedToSource = mapOf( + BodyPart.HEAD to BodyPart.NECK, + BodyPart.NECK to BodyPart.HEAD, + + BodyPart.LEFT_HIP to BodyPart.HIP, + BodyPart.RIGHT_HIP to BodyPart.HIP, + + BodyPart.LEFT_FOOT to BodyPart.LEFT_LOWER_LEG, + BodyPart.RIGHT_FOOT to BodyPart.RIGHT_LOWER_LEG, + + BodyPart.LEFT_SHOULDER to BodyPart.UPPER_CHEST, + BodyPart.RIGHT_SHOULDER to BodyPart.UPPER_CHEST, + + BodyPart.LEFT_UPPER_ARM to BodyPart.LEFT_SHOULDER, + BodyPart.RIGHT_UPPER_ARM to BodyPart.RIGHT_SHOULDER, + + BodyPart.LEFT_LOWER_ARM to BodyPart.LEFT_UPPER_ARM, + BodyPart.RIGHT_LOWER_ARM to BodyPart.RIGHT_UPPER_ARM, + + BodyPart.LEFT_HAND to BodyPart.LEFT_LOWER_ARM, + BodyPart.RIGHT_HAND to BodyPart.RIGHT_LOWER_ARM, + ) + + override fun process(state: SkeletonState): SkeletonState { + val updatedBoneInputs = state.boneInputs.toMutableMap() + + for ((bodyPart, bone) in state.boneInputs) { + if (bone.isActive || bodyPart !in linkedToSource) continue + + val sourceBone = updatedBoneInputs[linkedToSource[bodyPart]] + updatedBoneInputs[bodyPart] = bone.copy(rawRotation = sourceBone?.rawRotation ?: bone.rawRotation) + } + + return state.copy(boneInputs = updatedBoneInputs) + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/boneprediction.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/boneprediction.kt new file mode 100644 index 0000000000..429a8010da --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/boneprediction.kt @@ -0,0 +1,52 @@ +package dev.slimevr.skeleton.processors + +import dev.slimevr.config.Settings +import dev.slimevr.skeleton.SkeletonProcessor +import dev.slimevr.skeleton.SkeletonState +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.FilteringType + +class BonePredictionProcessor(val settings: Settings) : SkeletonProcessor { + private data class BoneVelocity( + val lastRotation: Quaternion, + val rotationDelta: Quaternion, + val lastOffset: Vector3, + val offsetDelta: Vector3, + ) + + private var velocities: Map = emptyMap() + + override fun process(state: SkeletonState): SkeletonState { + val config = settings.context.state.value.data.skeletonConfig.filtering + if (config.type != FilteringType.PREDICTION) return state + + val newVelocities = mutableMapOf() + val newBones = state.boneInputs.mapValues { (bodyPart, bone) -> + val prev = velocities[bodyPart] + if (prev == null) { + newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, Quaternion.IDENTITY, bone.offset, Vector3.NULL) + return@mapValues bone + } + val rotationDelta = if (bone.rawRotation !== prev.lastRotation) { + bone.rawRotation * prev.lastRotation.inv() + } else { + prev.rotationDelta + } + val lengthDelta = if (bone.offset != prev.lastOffset) { + bone.offset - prev.lastOffset + } else { + prev.offsetDelta + } + newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, rotationDelta, bone.offset, lengthDelta) + val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, config.amount).unit() + bone.copy( + rawRotation = (scaledDelta * bone.rawRotation).unit(), + offset = bone.offset + lengthDelta * config.amount, + ) + } + velocities = newVelocities + return state.copy(boneInputs = newBones) + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bonesmoothing.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bonesmoothing.kt new file mode 100644 index 0000000000..3283491fec --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bonesmoothing.kt @@ -0,0 +1,43 @@ +package dev.slimevr.skeleton.processors + +import dev.slimevr.config.Settings +import dev.slimevr.skeleton.SkeletonProcessor +import dev.slimevr.skeleton.SkeletonState +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.FilteringType + +class BoneSmoothingProcessor(val settings: Settings) : SkeletonProcessor { + private var smoothedRotations: Map = emptyMap() + private var smoothedLengths: Map = emptyMap() + + // TODO this isn't linear. Do we want linear smoothing like in main? + override fun process(state: SkeletonState): SkeletonState { + val config = settings.context.state.value.data.skeletonConfig.filtering + if (config.type != FilteringType.SMOOTHING) return state + + val alpha = 1 - (SMOOTH_MIN + config.amount.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN)) + + smoothedRotations = state.boneInputs.mapValues { (bodyPart, bone) -> + (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() + } + smoothedLengths = state.boneInputs.mapValues { (bodyPart, bone) -> + val prev = smoothedLengths[bodyPart] ?: bone.offset + prev + (bone.offset - prev) * alpha + } + return state.copy( + boneInputs = state.boneInputs.mapValues { (bodyPart, bone) -> + bone.copy( + rawRotation = smoothedRotations[bodyPart] ?: bone.rawRotation, + offset = smoothedLengths[bodyPart] ?: bone.offset, + ) + }, + ) + } + + companion object { + private const val SMOOTH_MIN = 0.63f + private const val SMOOTH_MAX = 0.94f + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/fingers.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/fingersimpute.kt similarity index 94% rename from server/core/src/main/java/dev/slimevr/skeleton/fingers.kt rename to server/core/src/main/java/dev/slimevr/skeleton/processors/fingersimpute.kt index fd3d042ab1..1e1c94247d 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/fingers.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/fingersimpute.kt @@ -1,7 +1,10 @@ -package dev.slimevr.skeleton +package dev.slimevr.skeleton.processors +import dev.slimevr.skeleton.SkeletonProcessor +import dev.slimevr.skeleton.SkeletonState import solarxr_protocol.datatypes.BodyPart import kotlin.collections.get +import kotlin.collections.iterator /** * Handles the finger tracking logic. diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/spineimpute.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/spineimpute.kt new file mode 100644 index 0000000000..1379a888b4 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/spineimpute.kt @@ -0,0 +1,146 @@ +package dev.slimevr.skeleton.processors + +import dev.slimevr.config.Settings +import dev.slimevr.skeleton.BoneInput +import dev.slimevr.skeleton.SkeletonProcessor +import dev.slimevr.skeleton.SkeletonState +import io.github.axisangles.ktmath.Quaternion +import solarxr_protocol.datatypes.BodyPart + +/** + * Handles imputing the rotation of spine bones that are not actively receiving data from the rotations + * of nearby bones. + * + * Similar to FallbackProcessor specifically for the waist and hip. + */ +class SpineImputeProcessor(val settings: Settings) : SkeletonProcessor { + private val chestSet = setOf(BodyPart.CHEST) + private val hipSet = setOf(BodyPart.HIP) + private val waistSet = setOf(BodyPart.WAIST) + private val upperLegsSet = setOf(BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG) + + /** + * Used to skew the impute ratio for certain bone combinations. + * + * First element is a Double containing the missing bone to the source bones. + * + * Second element is how reliable that pair is. Higher = missing bone relies more on source bones. + */ + private val combinationToReliability = mapOf( + (BodyPart.WAIST to chestSet) to 0.6f, + (BodyPart.WAIST to hipSet) to 1.0f, + (BodyPart.WAIST to upperLegsSet) to 1.0f, + (BodyPart.HIP to chestSet) to 1.0f, + (BodyPart.HIP to waistSet) to 0.8f, + (BodyPart.HIP to upperLegsSet) to 1.0f, + ) + + override fun process(state: SkeletonState): SkeletonState { + val boneInputs = state.boneInputs + val ratios = settings.context.state.value.data.skeletonConfig.ratios + + val hasChest = boneInputs[BodyPart.UPPER_CHEST]?.isActive == true || boneInputs[BodyPart.CHEST]?.isActive == true + val hasWaist = boneInputs[BodyPart.WAIST]?.isActive == true + val hasHip = boneInputs[BodyPart.HIP]?.isActive == true + val hasUpperLegs = boneInputs[BodyPart.LEFT_UPPER_LEG]?.isActive == true && boneInputs[BodyPart.RIGHT_UPPER_LEG]?.isActive == true + val missingSpineParts = buildList { + if (!hasWaist) add(BodyPart.WAIST) + if (!hasHip) add(BodyPart.HIP) + } + + return state.copy( + boneInputs = boneInputs.mapValues { (bodyPart, bone) -> + val chainIndex = missingSpineParts.indexOf(bodyPart) + if (chainIndex == -1) return@mapValues bone + + // Get the first active bones above and below this one in the chain + val (fromBodyPart, toBodyPart) = when (bodyPart) { + BodyPart.WAIST -> { + val from = chestSet.takeIf { hasChest } ?: return@mapValues bone + val to = when { + hasHip -> hipSet + hasUpperLegs -> upperLegsSet + else -> return@mapValues bone + } + from to to + } + + BodyPart.HIP -> { + val from = when { + hasWaist -> waistSet + hasChest -> chestSet + else -> return@mapValues bone + } + val to = upperLegsSet.takeIf { hasUpperLegs } ?: return@mapValues bone + from to to + } + + else -> error("Invalid missing spine body part $bodyPart") + } + + val fromReliability = combinationToReliability[(bodyPart to fromBodyPart)] ?: error("Invalid from body part combination $bodyPart, $fromBodyPart") + val toReliability = combinationToReliability[(bodyPart to toBodyPart)] ?: error("Invalid to body part combination $bodyPart, $toBodyPart") + + val interpolateRatio = interpolateRatio( + chainIndex, + missingSpineParts.size, + ratios.imputeSpineFromUpperToLower, + ratios.imputeSpineCurvature, + fromReliability, + toReliability, + ) + + val fromRotation = resolveRotation(boneInputs, fromBodyPart) + val toRotation = resolveRotation(boneInputs, toBodyPart) + + bone.copy(rawRotation = fromRotation.interpQ(toRotation, interpolateRatio)) + }, + ) + } + + /** + * Returns the interpolation ratio modified with the reliability and curve. + * If a single bone is missing, ratio is reliability adjusted. + * At 0.0 curve, ratio is raw. + * At 0.5 curve, ratio is reliability adjusted. + * At 1.0 curve, ratio is 0 for the first in chain and 1 for the last in chain. + */ + private fun interpolateRatio(chainIndex: Int, chainSize: Int, fromUpperToLower: Float, curvature: Float, fromReliability: Float, toReliability: Float): Float { + val reliabilityAdjusted = remapRatioWithReliability(fromUpperToLower, fromReliability, toReliability) + if (chainSize <= 1) return reliabilityAdjusted // Single missing bone; reliability adjusted + + return if (curvature <= 0.5f) { + // Raw to reliability adjusted + lerp(fromUpperToLower, reliabilityAdjusted, curvature * 2f) + } else { + // Reliability adjusted to max curve + val maxCurve = chainIndex / (chainSize - 1).toFloat() + lerp(reliabilityAdjusted, maxCurve, (curvature - 0.5f) * 2f) + } + } + + /** + * Remaps a ratio This assumes a default ratio of 50%. + */ + private fun remapRatioWithReliability(ratio: Float, fromReliability: Float, toReliability: Float): Float { + val reliability = (fromReliability / toReliability) * 0.5f + + return if (ratio <= 0.5f) { + ratio * 2f * reliability + } else { + reliability + (ratio - 0.5f) * 2f * (1f - reliability) + } + } + + private fun lerp(from: Float, to: Float, t: Float): Float = from + (to - from) * t + + /** + * Returns the average rotation of the BodyParts. + */ + private fun resolveRotation(boneInputs: Map, bodyParts: Set): Quaternion { + val rotations = bodyParts.mapNotNull { boneInputs[it]?.rawRotation } + return rotations.reduceIndexedOrNull { index, acc, rotation -> + acc.lerpQ(rotation, 1f / (index + 1)) + } ?: Quaternion.IDENTITY + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/spinelink.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/spinelink.kt new file mode 100644 index 0000000000..41ee66fdf8 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/spinelink.kt @@ -0,0 +1,36 @@ +package dev.slimevr.skeleton.processors + +import dev.slimevr.skeleton.SkeletonProcessor +import dev.slimevr.skeleton.SkeletonState +import solarxr_protocol.datatypes.BodyPart + +class SpineLinkProcessor : SkeletonProcessor { + /** + * First element is the BodyPart whose rawBone is not actively receiving data. + * Second element contains a set of BodyParts whose rotation should be used as a fallback prioritized from first to last. + */ + private val missingToFallbacks = mapOf( + BodyPart.UPPER_CHEST to setOf(BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), + BodyPart.CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.WAIST, BodyPart.HIP), + BodyPart.WAIST to setOf(BodyPart.CHEST, BodyPart.HIP, BodyPart.UPPER_CHEST), + BodyPart.HIP to setOf(BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), + ) + + override fun process(state: SkeletonState): SkeletonState { + val boneInputs = state.boneInputs + + return state.copy( + boneInputs = boneInputs.mapValues { (bodyPart, bone) -> + if (bone.isActive) return@mapValues bone + + val closestActiveBone = missingToFallbacks[bodyPart] + ?.firstNotNullOfOrNull { part -> + boneInputs[part]?.takeIf { it.isActive } + } + bone.copy( + rawRotation = closestActiveBone?.rawRotation ?: bone.rawRotation, + ) + }, + ) + } +} From 2ab148aad242da5772567b455c734f3ce5400d04 Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 21 Jul 2026 17:07:13 -0400 Subject: [PATCH 278/350] Rename a bunch of files to add - separators --- .../{ContextDebug.kt => context-debug.kt} | 0 ...DebugMiddleware.kt => debug-middleware.kt} | 0 ...ingMiddleware.kt => logging-middleware.kt} | 0 ...partstructure.kt => bodypart-structure.kt} | 0 .../{bonegeometry.kt => bone-geometry.kt} | 0 .../java/dev/slimevr/skeleton/filtering.kt | 8 - .../main/java/dev/slimevr/skeleton/module.kt | 12 +- .../skeleton/processors/bone-direct-link.kt | 55 +++++++ .../skeleton/processors/bone-fallback.kt | 28 ++++ .../skeleton/processors/bone-prediction.kt | 55 +++++++ .../skeleton/processors/bone-priority-link.kt | 40 +++++ .../skeleton/processors/bone-smoothing.kt | 46 ++++++ .../skeleton/processors/bonefallback.kt | 28 ---- .../slimevr/skeleton/processors/bonelink.kt | 55 ------- .../skeleton/processors/boneprediction.kt | 52 ------- .../skeleton/processors/bonesmoothing.kt | 43 ------ .../{fingersimpute.kt => fingers-impute.kt} | 4 +- .../skeleton/processors/spine-impute.kt | 146 ++++++++++++++++++ .../skeleton/processors/spineimpute.kt | 146 ------------------ .../slimevr/skeleton/processors/spinelink.kt | 36 ----- ...ingcorrection.kt => skating-correction.kt} | 0 ...htcalibration.kt => height-calibration.kt} | 0 .../{knowntrackers.kt => known-trackers.kt} | 0 .../{outputtrackers.kt => output-trackers.kt} | 0 ...ncalibration.kt => session-calibration.kt} | 4 - ...proportions.kt => skeleton-proportions.kt} | 0 ...eletonsettings.kt => skeleton-settings.kt} | 0 .../{tapdetection.kt => tap-detection.kt} | 0 ...kerassignment.kt => tracker-assignment.kt} | 0 ...kingchecklist.kt => tracking-checklist.kt} | 0 ...ncalibration.kt => session-calibration.kt} | 0 .../util/{SafeLaunch.kt => safe-launch.kt} | 0 32 files changed, 378 insertions(+), 380 deletions(-) rename server/core/src/main/java/dev/slimevr/context/debug/{ContextDebug.kt => context-debug.kt} (100%) rename server/core/src/main/java/dev/slimevr/context/debug/{DebugMiddleware.kt => debug-middleware.kt} (100%) rename server/core/src/main/java/dev/slimevr/context/debug/{LoggingMiddleware.kt => logging-middleware.kt} (100%) rename server/core/src/main/java/dev/slimevr/skeleton/{bodypartstructure.kt => bodypart-structure.kt} (100%) rename server/core/src/main/java/dev/slimevr/skeleton/{bonegeometry.kt => bone-geometry.kt} (100%) delete mode 100644 server/core/src/main/java/dev/slimevr/skeleton/filtering.kt create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/bone-fallback.kt create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/bone-priority-link.kt create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt delete mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/bonefallback.kt delete mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/bonelink.kt delete mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/boneprediction.kt delete mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/bonesmoothing.kt rename server/core/src/main/java/dev/slimevr/skeleton/processors/{fingersimpute.kt => fingers-impute.kt} (96%) create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt delete mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/spineimpute.kt delete mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/spinelink.kt rename server/core/src/main/java/dev/slimevr/skeleton/{skatingcorrection.kt => skating-correction.kt} (100%) rename server/core/src/main/java/dev/slimevr/solarxr/{heightcalibration.kt => height-calibration.kt} (100%) rename server/core/src/main/java/dev/slimevr/solarxr/{knowntrackers.kt => known-trackers.kt} (100%) rename server/core/src/main/java/dev/slimevr/solarxr/{outputtrackers.kt => output-trackers.kt} (100%) rename server/core/src/main/java/dev/slimevr/solarxr/{sessioncalibration.kt => session-calibration.kt} (75%) rename server/core/src/main/java/dev/slimevr/solarxr/{skeletonproportions.kt => skeleton-proportions.kt} (100%) rename server/core/src/main/java/dev/slimevr/solarxr/{skeletonsettings.kt => skeleton-settings.kt} (100%) rename server/core/src/main/java/dev/slimevr/solarxr/{tapdetection.kt => tap-detection.kt} (100%) rename server/core/src/main/java/dev/slimevr/solarxr/{trackerassignment.kt => tracker-assignment.kt} (100%) rename server/core/src/main/java/dev/slimevr/solarxr/{trackingchecklist.kt => tracking-checklist.kt} (100%) rename server/core/src/main/java/dev/slimevr/tracker/{sessioncalibration.kt => session-calibration.kt} (100%) rename server/core/src/main/java/dev/slimevr/util/{SafeLaunch.kt => safe-launch.kt} (100%) diff --git a/server/core/src/main/java/dev/slimevr/context/debug/ContextDebug.kt b/server/core/src/main/java/dev/slimevr/context/debug/context-debug.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/context/debug/ContextDebug.kt rename to server/core/src/main/java/dev/slimevr/context/debug/context-debug.kt diff --git a/server/core/src/main/java/dev/slimevr/context/debug/DebugMiddleware.kt b/server/core/src/main/java/dev/slimevr/context/debug/debug-middleware.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/context/debug/DebugMiddleware.kt rename to server/core/src/main/java/dev/slimevr/context/debug/debug-middleware.kt diff --git a/server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt b/server/core/src/main/java/dev/slimevr/context/debug/logging-middleware.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/context/debug/LoggingMiddleware.kt rename to server/core/src/main/java/dev/slimevr/context/debug/logging-middleware.kt diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt b/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/skeleton/bodypartstructure.kt rename to server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt b/server/core/src/main/java/dev/slimevr/skeleton/bone-geometry.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/skeleton/bonegeometry.kt rename to server/core/src/main/java/dev/slimevr/skeleton/bone-geometry.kt diff --git a/server/core/src/main/java/dev/slimevr/skeleton/filtering.kt b/server/core/src/main/java/dev/slimevr/skeleton/filtering.kt deleted file mode 100644 index 5e5f1a3351..0000000000 --- a/server/core/src/main/java/dev/slimevr/skeleton/filtering.kt +++ /dev/null @@ -1,8 +0,0 @@ -package dev.slimevr.skeleton - -import dev.slimevr.config.Settings -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import solarxr_protocol.datatypes.BodyPart -import solarxr_protocol.rpc.FilteringType - diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 7bb7b5f1b9..f117dcc23d 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -4,12 +4,12 @@ import dev.slimevr.Phase1ContextProvider import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.skeleton.processors.BoneFallbackProcessor -import dev.slimevr.skeleton.processors.BoneLinkProcessor +import dev.slimevr.skeleton.processors.BoneDirectLinkProcessor import dev.slimevr.skeleton.processors.BonePredictionProcessor import dev.slimevr.skeleton.processors.BoneSmoothingProcessor -import dev.slimevr.skeleton.processors.FingersImputeProcessor +import dev.slimevr.skeleton.processors.FingerImputeProcessor import dev.slimevr.skeleton.processors.SpineImputeProcessor -import dev.slimevr.skeleton.processors.SpineLinkProcessor +import dev.slimevr.skeleton.processors.BonePriorityLinkProcessor import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope @@ -117,10 +117,10 @@ class Skeleton( ComputedSkeletonBehaviour( processors = listOf( BoneFallbackProcessor(), - SpineLinkProcessor(), - BoneLinkProcessor(), + BonePriorityLinkProcessor(), + BoneDirectLinkProcessor(), SpineImputeProcessor(ctx.config.settings), - FingersImputeProcessor(), + FingerImputeProcessor(), BonePredictionProcessor(ctx.config.settings), BoneSmoothingProcessor(ctx.config.settings), ), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt new file mode 100644 index 0000000000..bf00a4e40c --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt @@ -0,0 +1,55 @@ +package dev.slimevr.skeleton.processors + +import dev.slimevr.skeleton.SkeletonProcessor +import dev.slimevr.skeleton.SkeletonState +import solarxr_protocol.datatypes.BodyPart +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.get +import kotlin.collections.iterator + +/** + * Handles setting the rotation of an inactive bone with its source bone. + */ +class BoneDirectLinkProcessor : SkeletonProcessor { + /** + * First element is the linked BodyPart. + * + * Second element is the BodyPart the first element is linked to. + */ + private val linkedToSource = mapOf( + BodyPart.HEAD to BodyPart.NECK, + BodyPart.NECK to BodyPart.HEAD, + + BodyPart.LEFT_HIP to BodyPart.HIP, + BodyPart.RIGHT_HIP to BodyPart.HIP, + + BodyPart.LEFT_FOOT to BodyPart.LEFT_LOWER_LEG, + BodyPart.RIGHT_FOOT to BodyPart.RIGHT_LOWER_LEG, + + BodyPart.LEFT_SHOULDER to BodyPart.UPPER_CHEST, + BodyPart.RIGHT_SHOULDER to BodyPart.UPPER_CHEST, + + BodyPart.LEFT_UPPER_ARM to BodyPart.LEFT_LOWER_ARM, + BodyPart.RIGHT_UPPER_ARM to BodyPart.RIGHT_LOWER_ARM, + + BodyPart.LEFT_LOWER_ARM to BodyPart.LEFT_UPPER_ARM, + BodyPart.RIGHT_LOWER_ARM to BodyPart.RIGHT_UPPER_ARM, + + BodyPart.LEFT_HAND to BodyPart.LEFT_LOWER_ARM, + BodyPart.RIGHT_HAND to BodyPart.RIGHT_LOWER_ARM, + ) + + override fun process(state: SkeletonState): SkeletonState { + val updatedBoneInputs = state.boneInputs.toMutableMap() + + for ((bodyPart, bone) in state.boneInputs) { + if (bone.isActive || bodyPart !in linkedToSource) continue + + val sourceBone = updatedBoneInputs[linkedToSource[bodyPart]] + updatedBoneInputs[bodyPart] = bone.copy(rawRotation = sourceBone?.rawRotation ?: bone.rawRotation) + } + + return state.copy(boneInputs = updatedBoneInputs) + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-fallback.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-fallback.kt new file mode 100644 index 0000000000..56d04236ba --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-fallback.kt @@ -0,0 +1,28 @@ +package dev.slimevr.skeleton.processors + +import dev.slimevr.skeleton.SkeletonProcessor +import dev.slimevr.skeleton.SkeletonState +import dev.slimevr.skeleton.findFirstParent +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import kotlin.collections.get + +/** + * Handles replacing rotations of boneInputs that are not actively receiving data by either + * falling back to their parent's yaw or the identity rotation. + */ +class BoneFallbackProcessor : SkeletonProcessor { + override fun process(state: SkeletonState): SkeletonState { + val boneInputs = state.boneInputs + + return state.copy( + boneInputs = boneInputs.mapValues { (bodyPart, bone) -> + if (bone.isActive) return@mapValues bone + + val firstActiveParentBone = boneInputs[bodyPart.findFirstParent { boneInputs[it]?.isActive == true }] + val fallbackRotation = firstActiveParentBone?.rawRotation?.project(Vector3.POS_Y)?.unit() ?: Quaternion.IDENTITY + bone.copy(rawRotation = fallbackRotation) + }, + ) + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt new file mode 100644 index 0000000000..5799733f91 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt @@ -0,0 +1,55 @@ +package dev.slimevr.skeleton.processors + +import dev.slimevr.config.Settings +import dev.slimevr.skeleton.SkeletonProcessor +import dev.slimevr.skeleton.SkeletonState +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.FilteringType + +/** + * Tries to predict future rotations of bones. + */ +class BonePredictionProcessor(val settings: Settings) : SkeletonProcessor { + private data class BoneVelocity( + val lastRotation: Quaternion, + val rotationDelta: Quaternion, + val lastOffset: Vector3, + val offsetDelta: Vector3, + ) + + private var velocities: Map = emptyMap() + + override fun process(state: SkeletonState): SkeletonState { + val config = settings.context.state.value.data.skeletonConfig.filtering + if (config.type != FilteringType.PREDICTION) return state + + val newVelocities = mutableMapOf() + val newBones = state.boneInputs.mapValues { (bodyPart, bone) -> + val prev = velocities[bodyPart] + if (prev == null) { + newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, Quaternion.IDENTITY, bone.offset, Vector3.NULL) + return@mapValues bone + } + val rotationDelta = if (bone.rawRotation !== prev.lastRotation) { + bone.rawRotation * prev.lastRotation.inv() + } else { + prev.rotationDelta + } + val lengthDelta = if (bone.offset != prev.lastOffset) { + bone.offset - prev.lastOffset + } else { + prev.offsetDelta + } + newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, rotationDelta, bone.offset, lengthDelta) + val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, config.amount).unit() + bone.copy( + rawRotation = (scaledDelta * bone.rawRotation).unit(), + offset = bone.offset + lengthDelta * config.amount, + ) + } + velocities = newVelocities + return state.copy(boneInputs = newBones) + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-priority-link.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-priority-link.kt new file mode 100644 index 0000000000..a74f1a3978 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-priority-link.kt @@ -0,0 +1,40 @@ +package dev.slimevr.skeleton.processors + +import dev.slimevr.skeleton.SkeletonProcessor +import dev.slimevr.skeleton.SkeletonState +import solarxr_protocol.datatypes.BodyPart + +/** + * Handles setting the rotation of an inactive bone with the first active bone in its sources, or keeps + * the old rotation if none of them are active. + */ +class BonePriorityLinkProcessor : SkeletonProcessor { + /** + * First element is the BodyPart whose rawBone is not actively receiving data. + * Second element contains a set of BodyParts whose rotation should be used as a fallback prioritized from first to last. + */ + private val linkedToSources = mapOf( + BodyPart.UPPER_CHEST to setOf(BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), + BodyPart.CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.WAIST, BodyPart.HIP), + BodyPart.WAIST to setOf(BodyPart.CHEST, BodyPart.HIP, BodyPart.UPPER_CHEST), + BodyPart.HIP to setOf(BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), + ) + + override fun process(state: SkeletonState): SkeletonState { + val boneInputs = state.boneInputs + + return state.copy( + boneInputs = boneInputs.mapValues { (bodyPart, bone) -> + if (bone.isActive) return@mapValues bone + + val closestActiveBone = linkedToSources[bodyPart] + ?.firstNotNullOfOrNull { part -> + boneInputs[part]?.takeIf { it.isActive } + } + bone.copy( + rawRotation = closestActiveBone?.rawRotation ?: bone.rawRotation, + ) + }, + ) + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt new file mode 100644 index 0000000000..69c7e576dd --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt @@ -0,0 +1,46 @@ +package dev.slimevr.skeleton.processors + +import dev.slimevr.config.Settings +import dev.slimevr.skeleton.SkeletonProcessor +import dev.slimevr.skeleton.SkeletonState +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.FilteringType + +/** + * Running average of bones to smooth them out. + */ +class BoneSmoothingProcessor(val settings: Settings) : SkeletonProcessor { + private var smoothedRotations: Map = emptyMap() + private var smoothedLengths: Map = emptyMap() + + // TODO this isn't linear. Do we want linear smoothing like in main? + override fun process(state: SkeletonState): SkeletonState { + val config = settings.context.state.value.data.skeletonConfig.filtering + if (config.type != FilteringType.SMOOTHING) return state + + val alpha = 1 - (SMOOTH_MIN + config.amount.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN)) + + smoothedRotations = state.boneInputs.mapValues { (bodyPart, bone) -> + (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() + } + smoothedLengths = state.boneInputs.mapValues { (bodyPart, bone) -> + val prev = smoothedLengths[bodyPart] ?: bone.offset + prev + (bone.offset - prev) * alpha + } + return state.copy( + boneInputs = state.boneInputs.mapValues { (bodyPart, bone) -> + bone.copy( + rawRotation = smoothedRotations[bodyPart] ?: bone.rawRotation, + offset = smoothedLengths[bodyPart] ?: bone.offset, + ) + }, + ) + } + + companion object { + private const val SMOOTH_MIN = 0.63f + private const val SMOOTH_MAX = 0.94f + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bonefallback.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bonefallback.kt deleted file mode 100644 index 8b0dd99431..0000000000 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bonefallback.kt +++ /dev/null @@ -1,28 +0,0 @@ -package dev.slimevr.skeleton.processors - -import dev.slimevr.skeleton.SkeletonProcessor -import dev.slimevr.skeleton.SkeletonState -import dev.slimevr.skeleton.findFirstParent -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import kotlin.collections.get - -/** - * Handles replacing rotations of boneInputs that are not actively receiving data by either - * falling back to their parent's yaw or the identity rotation. - */ -class BoneFallbackProcessor : SkeletonProcessor { - override fun process(state: SkeletonState): SkeletonState { - val boneInputs = state.boneInputs - - return state.copy( - boneInputs = boneInputs.mapValues { (bodyPart, bone) -> - if (bone.isActive) return@mapValues bone - - val firstActiveParentBone = boneInputs[bodyPart.findFirstParent { boneInputs[it]?.isActive == true }] - val fallbackRotation = firstActiveParentBone?.rawRotation?.project(Vector3.POS_Y)?.unit() ?: Quaternion.IDENTITY - bone.copy(rawRotation = fallbackRotation) - }, - ) - } -} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bonelink.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bonelink.kt deleted file mode 100644 index 5a7a7e6f80..0000000000 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bonelink.kt +++ /dev/null @@ -1,55 +0,0 @@ -package dev.slimevr.skeleton.processors - -import dev.slimevr.skeleton.SkeletonProcessor -import dev.slimevr.skeleton.SkeletonState -import solarxr_protocol.datatypes.BodyPart -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.get -import kotlin.collections.iterator - -/** - * Handles copying source bones' rotations to their linked bones. - */ -class BoneLinkProcessor : SkeletonProcessor { - /** - * First element is the linked BodyPart. - * - * Second element is the BodyPart the first element is linked to. - */ - private val linkedToSource = mapOf( - BodyPart.HEAD to BodyPart.NECK, - BodyPart.NECK to BodyPart.HEAD, - - BodyPart.LEFT_HIP to BodyPart.HIP, - BodyPart.RIGHT_HIP to BodyPart.HIP, - - BodyPart.LEFT_FOOT to BodyPart.LEFT_LOWER_LEG, - BodyPart.RIGHT_FOOT to BodyPart.RIGHT_LOWER_LEG, - - BodyPart.LEFT_SHOULDER to BodyPart.UPPER_CHEST, - BodyPart.RIGHT_SHOULDER to BodyPart.UPPER_CHEST, - - BodyPart.LEFT_UPPER_ARM to BodyPart.LEFT_SHOULDER, - BodyPart.RIGHT_UPPER_ARM to BodyPart.RIGHT_SHOULDER, - - BodyPart.LEFT_LOWER_ARM to BodyPart.LEFT_UPPER_ARM, - BodyPart.RIGHT_LOWER_ARM to BodyPart.RIGHT_UPPER_ARM, - - BodyPart.LEFT_HAND to BodyPart.LEFT_LOWER_ARM, - BodyPart.RIGHT_HAND to BodyPart.RIGHT_LOWER_ARM, - ) - - override fun process(state: SkeletonState): SkeletonState { - val updatedBoneInputs = state.boneInputs.toMutableMap() - - for ((bodyPart, bone) in state.boneInputs) { - if (bone.isActive || bodyPart !in linkedToSource) continue - - val sourceBone = updatedBoneInputs[linkedToSource[bodyPart]] - updatedBoneInputs[bodyPart] = bone.copy(rawRotation = sourceBone?.rawRotation ?: bone.rawRotation) - } - - return state.copy(boneInputs = updatedBoneInputs) - } -} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/boneprediction.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/boneprediction.kt deleted file mode 100644 index 429a8010da..0000000000 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/boneprediction.kt +++ /dev/null @@ -1,52 +0,0 @@ -package dev.slimevr.skeleton.processors - -import dev.slimevr.config.Settings -import dev.slimevr.skeleton.SkeletonProcessor -import dev.slimevr.skeleton.SkeletonState -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import solarxr_protocol.datatypes.BodyPart -import solarxr_protocol.rpc.FilteringType - -class BonePredictionProcessor(val settings: Settings) : SkeletonProcessor { - private data class BoneVelocity( - val lastRotation: Quaternion, - val rotationDelta: Quaternion, - val lastOffset: Vector3, - val offsetDelta: Vector3, - ) - - private var velocities: Map = emptyMap() - - override fun process(state: SkeletonState): SkeletonState { - val config = settings.context.state.value.data.skeletonConfig.filtering - if (config.type != FilteringType.PREDICTION) return state - - val newVelocities = mutableMapOf() - val newBones = state.boneInputs.mapValues { (bodyPart, bone) -> - val prev = velocities[bodyPart] - if (prev == null) { - newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, Quaternion.IDENTITY, bone.offset, Vector3.NULL) - return@mapValues bone - } - val rotationDelta = if (bone.rawRotation !== prev.lastRotation) { - bone.rawRotation * prev.lastRotation.inv() - } else { - prev.rotationDelta - } - val lengthDelta = if (bone.offset != prev.lastOffset) { - bone.offset - prev.lastOffset - } else { - prev.offsetDelta - } - newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, rotationDelta, bone.offset, lengthDelta) - val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, config.amount).unit() - bone.copy( - rawRotation = (scaledDelta * bone.rawRotation).unit(), - offset = bone.offset + lengthDelta * config.amount, - ) - } - velocities = newVelocities - return state.copy(boneInputs = newBones) - } -} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bonesmoothing.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bonesmoothing.kt deleted file mode 100644 index 3283491fec..0000000000 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bonesmoothing.kt +++ /dev/null @@ -1,43 +0,0 @@ -package dev.slimevr.skeleton.processors - -import dev.slimevr.config.Settings -import dev.slimevr.skeleton.SkeletonProcessor -import dev.slimevr.skeleton.SkeletonState -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import solarxr_protocol.datatypes.BodyPart -import solarxr_protocol.rpc.FilteringType - -class BoneSmoothingProcessor(val settings: Settings) : SkeletonProcessor { - private var smoothedRotations: Map = emptyMap() - private var smoothedLengths: Map = emptyMap() - - // TODO this isn't linear. Do we want linear smoothing like in main? - override fun process(state: SkeletonState): SkeletonState { - val config = settings.context.state.value.data.skeletonConfig.filtering - if (config.type != FilteringType.SMOOTHING) return state - - val alpha = 1 - (SMOOTH_MIN + config.amount.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN)) - - smoothedRotations = state.boneInputs.mapValues { (bodyPart, bone) -> - (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() - } - smoothedLengths = state.boneInputs.mapValues { (bodyPart, bone) -> - val prev = smoothedLengths[bodyPart] ?: bone.offset - prev + (bone.offset - prev) * alpha - } - return state.copy( - boneInputs = state.boneInputs.mapValues { (bodyPart, bone) -> - bone.copy( - rawRotation = smoothedRotations[bodyPart] ?: bone.rawRotation, - offset = smoothedLengths[bodyPart] ?: bone.offset, - ) - }, - ) - } - - companion object { - private const val SMOOTH_MIN = 0.63f - private const val SMOOTH_MAX = 0.94f - } -} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/fingersimpute.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt similarity index 96% rename from server/core/src/main/java/dev/slimevr/skeleton/processors/fingersimpute.kt rename to server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt index 1e1c94247d..2602a8d3a9 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/fingersimpute.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt @@ -7,9 +7,9 @@ import kotlin.collections.get import kotlin.collections.iterator /** - * Handles the finger tracking logic. + * Handles rotations of inactive finger bones. */ -class FingersImputeProcessor : SkeletonProcessor { +class FingerImputeProcessor : SkeletonProcessor { /** * First element is the linked BodyPart. * diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt new file mode 100644 index 0000000000..ecef472579 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt @@ -0,0 +1,146 @@ +package dev.slimevr.skeleton.processors + +import dev.slimevr.config.Settings +import dev.slimevr.skeleton.BoneInput +import dev.slimevr.skeleton.SkeletonProcessor +import dev.slimevr.skeleton.SkeletonState +import io.github.axisangles.ktmath.Quaternion +import solarxr_protocol.datatypes.BodyPart + +/** + * Handles imputing the rotation of spine bones that are not actively receiving data from the rotations + * of nearby bones. + * + * Similar to FallbackProcessor specifically for the waist and hip. + */ +class SpineImputeProcessor(val settings: Settings) : SkeletonProcessor { + private val chestSet = setOf(BodyPart.CHEST) + private val hipSet = setOf(BodyPart.HIP) + private val waistSet = setOf(BodyPart.WAIST) + private val upperLegsSet = setOf(BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG) + + /** + * Used to skew the impute ratio for certain bone combinations. + * + * First element is a Double containing the missing bone to the source bones. + * + * Second element is how reliable that pair is. Higher = missing bone relies more on source bones. + */ + private val combinationToReliability = mapOf( + (BodyPart.WAIST to chestSet) to 0.6f, + (BodyPart.WAIST to hipSet) to 1.0f, + (BodyPart.WAIST to upperLegsSet) to 1.0f, + (BodyPart.HIP to chestSet) to 1.0f, + (BodyPart.HIP to waistSet) to 0.8f, + (BodyPart.HIP to upperLegsSet) to 1.0f, + ) + + override fun process(state: SkeletonState): SkeletonState { + val boneInputs = state.boneInputs + val ratios = settings.context.state.value.data.skeletonConfig.ratios + + val hasChest = boneInputs[BodyPart.UPPER_CHEST]?.isActive == true || boneInputs[BodyPart.CHEST]?.isActive == true + val hasWaist = boneInputs[BodyPart.WAIST]?.isActive == true + val hasHip = boneInputs[BodyPart.HIP]?.isActive == true + val hasUpperLegs = boneInputs[BodyPart.LEFT_UPPER_LEG]?.isActive == true && boneInputs[BodyPart.RIGHT_UPPER_LEG]?.isActive == true + val missingSpineParts = buildList { + if (!hasWaist) add(BodyPart.WAIST) + if (!hasHip) add(BodyPart.HIP) + } + + return state.copy( + boneInputs = boneInputs.mapValues { (bodyPart, bone) -> + val chainIndex = missingSpineParts.indexOf(bodyPart) + if (chainIndex == -1) return@mapValues bone + + // Get the first active bones above and below this one in the chain + val (fromBodyPart, toBodyPart) = when (bodyPart) { + BodyPart.WAIST -> { + val from = chestSet.takeIf { hasChest } ?: return@mapValues bone + val to = when { + hasHip -> hipSet + hasUpperLegs -> upperLegsSet + else -> return@mapValues bone + } + from to to + } + + BodyPart.HIP -> { + val from = when { + hasWaist -> waistSet + hasChest -> chestSet + else -> return@mapValues bone + } + val to = upperLegsSet.takeIf { hasUpperLegs } ?: return@mapValues bone + from to to + } + + else -> error("Invalid missing spine body part $bodyPart") + } + + val fromReliability = combinationToReliability[(bodyPart to fromBodyPart)] ?: error("Invalid from body part combination $bodyPart, $fromBodyPart") + val toReliability = combinationToReliability[(bodyPart to toBodyPart)] ?: error("Invalid to body part combination $bodyPart, $toBodyPart") + + val interpolateRatio = interpolateRatio( + chainIndex, + missingSpineParts.size, + ratios.imputeSpineFromUpperToLower, + ratios.imputeSpineCurvature, + fromReliability, + toReliability, + ) + + val fromRotation = resolveRotation(boneInputs, fromBodyPart) + val toRotation = resolveRotation(boneInputs, toBodyPart) + + bone.copy(rawRotation = fromRotation.interpQ(toRotation, interpolateRatio)) + }, + ) + } + + /** + * Returns the interpolation ratio modified with the reliability and curve. + * If a single bone is missing, ratio is reliability adjusted. + * At 0.0 curve, ratio is raw. + * At 0.5 curve, ratio is reliability adjusted. + * At 1.0 curve, ratio is 0 for the first in chain and 1 for the last in chain. + */ + private fun interpolateRatio(chainIndex: Int, chainSize: Int, fromUpperToLower: Float, curvature: Float, fromReliability: Float, toReliability: Float): Float { + val reliabilityAdjusted = remapRatioWithReliability(fromUpperToLower, fromReliability, toReliability) + if (chainSize <= 1) return reliabilityAdjusted // Single missing bone; reliability adjusted + + return if (curvature <= 0.5f) { + // Raw to reliability adjusted + lerp(fromUpperToLower, reliabilityAdjusted, curvature * 2f) + } else { + // Reliability adjusted to max curve + val maxCurve = chainIndex / (chainSize - 1).toFloat() + lerp(reliabilityAdjusted, maxCurve, (curvature - 0.5f) * 2f) + } + } + + /** + * Remaps a ratio This assumes a default ratio of 50%. + */ + private fun remapRatioWithReliability(ratio: Float, fromReliability: Float, toReliability: Float): Float { + val reliability = (fromReliability / toReliability) * 0.5f + + return if (ratio <= 0.5f) { + ratio * 2f * reliability + } else { + reliability + (ratio - 0.5f) * 2f * (1f - reliability) + } + } + + private fun lerp(from: Float, to: Float, t: Float): Float = from + (to - from) * t + + /** + * Returns the average rotation of the BodyParts. + */ + private fun resolveRotation(boneInputs: Map, bodyParts: Set): Quaternion { + val rotations = bodyParts.mapNotNull { boneInputs[it]?.rawRotation } + return rotations.reduceIndexedOrNull { index, acc, rotation -> + acc.lerpQ(rotation, 1f / (index + 1)) + } ?: Quaternion.IDENTITY + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/spineimpute.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/spineimpute.kt deleted file mode 100644 index 1379a888b4..0000000000 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/spineimpute.kt +++ /dev/null @@ -1,146 +0,0 @@ -package dev.slimevr.skeleton.processors - -import dev.slimevr.config.Settings -import dev.slimevr.skeleton.BoneInput -import dev.slimevr.skeleton.SkeletonProcessor -import dev.slimevr.skeleton.SkeletonState -import io.github.axisangles.ktmath.Quaternion -import solarxr_protocol.datatypes.BodyPart - -/** - * Handles imputing the rotation of spine bones that are not actively receiving data from the rotations - * of nearby bones. - * - * Similar to FallbackProcessor specifically for the waist and hip. - */ -class SpineImputeProcessor(val settings: Settings) : SkeletonProcessor { - private val chestSet = setOf(BodyPart.CHEST) - private val hipSet = setOf(BodyPart.HIP) - private val waistSet = setOf(BodyPart.WAIST) - private val upperLegsSet = setOf(BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG) - - /** - * Used to skew the impute ratio for certain bone combinations. - * - * First element is a Double containing the missing bone to the source bones. - * - * Second element is how reliable that pair is. Higher = missing bone relies more on source bones. - */ - private val combinationToReliability = mapOf( - (BodyPart.WAIST to chestSet) to 0.6f, - (BodyPart.WAIST to hipSet) to 1.0f, - (BodyPart.WAIST to upperLegsSet) to 1.0f, - (BodyPart.HIP to chestSet) to 1.0f, - (BodyPart.HIP to waistSet) to 0.8f, - (BodyPart.HIP to upperLegsSet) to 1.0f, - ) - - override fun process(state: SkeletonState): SkeletonState { - val boneInputs = state.boneInputs - val ratios = settings.context.state.value.data.skeletonConfig.ratios - - val hasChest = boneInputs[BodyPart.UPPER_CHEST]?.isActive == true || boneInputs[BodyPart.CHEST]?.isActive == true - val hasWaist = boneInputs[BodyPart.WAIST]?.isActive == true - val hasHip = boneInputs[BodyPart.HIP]?.isActive == true - val hasUpperLegs = boneInputs[BodyPart.LEFT_UPPER_LEG]?.isActive == true && boneInputs[BodyPart.RIGHT_UPPER_LEG]?.isActive == true - val missingSpineParts = buildList { - if (!hasWaist) add(BodyPart.WAIST) - if (!hasHip) add(BodyPart.HIP) - } - - return state.copy( - boneInputs = boneInputs.mapValues { (bodyPart, bone) -> - val chainIndex = missingSpineParts.indexOf(bodyPart) - if (chainIndex == -1) return@mapValues bone - - // Get the first active bones above and below this one in the chain - val (fromBodyPart, toBodyPart) = when (bodyPart) { - BodyPart.WAIST -> { - val from = chestSet.takeIf { hasChest } ?: return@mapValues bone - val to = when { - hasHip -> hipSet - hasUpperLegs -> upperLegsSet - else -> return@mapValues bone - } - from to to - } - - BodyPart.HIP -> { - val from = when { - hasWaist -> waistSet - hasChest -> chestSet - else -> return@mapValues bone - } - val to = upperLegsSet.takeIf { hasUpperLegs } ?: return@mapValues bone - from to to - } - - else -> error("Invalid missing spine body part $bodyPart") - } - - val fromReliability = combinationToReliability[(bodyPart to fromBodyPart)] ?: error("Invalid from body part combination $bodyPart, $fromBodyPart") - val toReliability = combinationToReliability[(bodyPart to toBodyPart)] ?: error("Invalid to body part combination $bodyPart, $toBodyPart") - - val interpolateRatio = interpolateRatio( - chainIndex, - missingSpineParts.size, - ratios.imputeSpineFromUpperToLower, - ratios.imputeSpineCurvature, - fromReliability, - toReliability, - ) - - val fromRotation = resolveRotation(boneInputs, fromBodyPart) - val toRotation = resolveRotation(boneInputs, toBodyPart) - - bone.copy(rawRotation = fromRotation.interpQ(toRotation, interpolateRatio)) - }, - ) - } - - /** - * Returns the interpolation ratio modified with the reliability and curve. - * If a single bone is missing, ratio is reliability adjusted. - * At 0.0 curve, ratio is raw. - * At 0.5 curve, ratio is reliability adjusted. - * At 1.0 curve, ratio is 0 for the first in chain and 1 for the last in chain. - */ - private fun interpolateRatio(chainIndex: Int, chainSize: Int, fromUpperToLower: Float, curvature: Float, fromReliability: Float, toReliability: Float): Float { - val reliabilityAdjusted = remapRatioWithReliability(fromUpperToLower, fromReliability, toReliability) - if (chainSize <= 1) return reliabilityAdjusted // Single missing bone; reliability adjusted - - return if (curvature <= 0.5f) { - // Raw to reliability adjusted - lerp(fromUpperToLower, reliabilityAdjusted, curvature * 2f) - } else { - // Reliability adjusted to max curve - val maxCurve = chainIndex / (chainSize - 1).toFloat() - lerp(reliabilityAdjusted, maxCurve, (curvature - 0.5f) * 2f) - } - } - - /** - * Remaps a ratio This assumes a default ratio of 50%. - */ - private fun remapRatioWithReliability(ratio: Float, fromReliability: Float, toReliability: Float): Float { - val reliability = (fromReliability / toReliability) * 0.5f - - return if (ratio <= 0.5f) { - ratio * 2f * reliability - } else { - reliability + (ratio - 0.5f) * 2f * (1f - reliability) - } - } - - private fun lerp(from: Float, to: Float, t: Float): Float = from + (to - from) * t - - /** - * Returns the average rotation of the BodyParts. - */ - private fun resolveRotation(boneInputs: Map, bodyParts: Set): Quaternion { - val rotations = bodyParts.mapNotNull { boneInputs[it]?.rawRotation } - return rotations.reduceIndexedOrNull { index, acc, rotation -> - acc.lerpQ(rotation, 1f / (index + 1)) - } ?: Quaternion.IDENTITY - } -} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/spinelink.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/spinelink.kt deleted file mode 100644 index 41ee66fdf8..0000000000 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/spinelink.kt +++ /dev/null @@ -1,36 +0,0 @@ -package dev.slimevr.skeleton.processors - -import dev.slimevr.skeleton.SkeletonProcessor -import dev.slimevr.skeleton.SkeletonState -import solarxr_protocol.datatypes.BodyPart - -class SpineLinkProcessor : SkeletonProcessor { - /** - * First element is the BodyPart whose rawBone is not actively receiving data. - * Second element contains a set of BodyParts whose rotation should be used as a fallback prioritized from first to last. - */ - private val missingToFallbacks = mapOf( - BodyPart.UPPER_CHEST to setOf(BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), - BodyPart.CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.WAIST, BodyPart.HIP), - BodyPart.WAIST to setOf(BodyPart.CHEST, BodyPart.HIP, BodyPart.UPPER_CHEST), - BodyPart.HIP to setOf(BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), - ) - - override fun process(state: SkeletonState): SkeletonState { - val boneInputs = state.boneInputs - - return state.copy( - boneInputs = boneInputs.mapValues { (bodyPart, bone) -> - if (bone.isActive) return@mapValues bone - - val closestActiveBone = missingToFallbacks[bodyPart] - ?.firstNotNullOfOrNull { part -> - boneInputs[part]?.takeIf { it.isActive } - } - bone.copy( - rawRotation = closestActiveBone?.rawRotation ?: bone.rawRotation, - ) - }, - ) - } -} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt b/server/core/src/main/java/dev/slimevr/skeleton/skating-correction.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/skeleton/skatingcorrection.kt rename to server/core/src/main/java/dev/slimevr/skeleton/skating-correction.kt diff --git a/server/core/src/main/java/dev/slimevr/solarxr/heightcalibration.kt b/server/core/src/main/java/dev/slimevr/solarxr/height-calibration.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/solarxr/heightcalibration.kt rename to server/core/src/main/java/dev/slimevr/solarxr/height-calibration.kt diff --git a/server/core/src/main/java/dev/slimevr/solarxr/knowntrackers.kt b/server/core/src/main/java/dev/slimevr/solarxr/known-trackers.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/solarxr/knowntrackers.kt rename to server/core/src/main/java/dev/slimevr/solarxr/known-trackers.kt diff --git a/server/core/src/main/java/dev/slimevr/solarxr/outputtrackers.kt b/server/core/src/main/java/dev/slimevr/solarxr/output-trackers.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/solarxr/outputtrackers.kt rename to server/core/src/main/java/dev/slimevr/solarxr/output-trackers.kt diff --git a/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/solarxr/session-calibration.kt similarity index 75% rename from server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt rename to server/core/src/main/java/dev/slimevr/solarxr/session-calibration.kt index 08de3e1bd3..c6e22ed87e 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/sessioncalibration.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/session-calibration.kt @@ -1,10 +1,6 @@ package dev.slimevr.solarxr -import dev.slimevr.VRServer import dev.slimevr.resets.ResetsManager -import dev.slimevr.tracker.TrackerActions -import io.github.axisangles.ktmath.Quaternion -import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.ResetRequest import solarxr_protocol.rpc.ResetType diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonproportions.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeleton-proportions.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/solarxr/skeletonproportions.kt rename to server/core/src/main/java/dev/slimevr/solarxr/skeleton-proportions.kt diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeleton-settings.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/solarxr/skeletonsettings.kt rename to server/core/src/main/java/dev/slimevr/solarxr/skeleton-settings.kt diff --git a/server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt b/server/core/src/main/java/dev/slimevr/solarxr/tap-detection.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/solarxr/tapdetection.kt rename to server/core/src/main/java/dev/slimevr/solarxr/tap-detection.kt diff --git a/server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt b/server/core/src/main/java/dev/slimevr/solarxr/tracker-assignment.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/solarxr/trackerassignment.kt rename to server/core/src/main/java/dev/slimevr/solarxr/tracker-assignment.kt diff --git a/server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt b/server/core/src/main/java/dev/slimevr/solarxr/tracking-checklist.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/solarxr/trackingchecklist.kt rename to server/core/src/main/java/dev/slimevr/solarxr/tracking-checklist.kt diff --git a/server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt b/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/tracker/sessioncalibration.kt rename to server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt diff --git a/server/core/src/main/java/dev/slimevr/util/SafeLaunch.kt b/server/core/src/main/java/dev/slimevr/util/safe-launch.kt similarity index 100% rename from server/core/src/main/java/dev/slimevr/util/SafeLaunch.kt rename to server/core/src/main/java/dev/slimevr/util/safe-launch.kt From 2f5a9fb089a9bd8c59747c8445152d9e336b6733 Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 21 Jul 2026 17:56:47 -0400 Subject: [PATCH 279/350] BoneYawRollAlignProcessor --- gui/app/public/i18n/en/translation.ftl | 5 +- .../pages/components/TrackingSettings.tsx | 37 ++++------- .../main/java/dev/slimevr/config/settings.kt | 6 +- .../main/java/dev/slimevr/skeleton/module.kt | 18 +++++- .../processors/bone-yaw-roll-align.kt | 63 +++++++++++++++++++ .../skeleton/processors/spine-impute.kt | 25 +++----- .../dev/slimevr/solarxr/skeleton-settings.kt | 10 ++- solarxr-protocol | 2 +- 8 files changed, 105 insertions(+), 61 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt diff --git a/gui/app/public/i18n/en/translation.ftl b/gui/app/public/i18n/en/translation.ftl index a83ab70dc4..5fe81d478b 100644 --- a/gui/app/public/i18n/en/translation.ftl +++ b/gui/app/public/i18n/en/translation.ftl @@ -635,9 +635,8 @@ settings-general-fk_settings-skeleton_settings-ratios = Skeleton ratios settings-general-fk_settings-skeleton_settings-ratios-description = Change the values of skeleton settings. You may need to adjust your proportions after changing these. settings-general-fk_settings-skeleton_settings-impute_spine_from_upper_to_lower = Estimate missing spine trackers from upper to lower settings-general-fk_settings-skeleton_settings-impute_spine_curvature = Spine curvature of adjacent missing spine trackers -settings-general-fk_settings-skeleton_settings-interpolate_hip_with_knees = Interpolate the hip with the knees -settings-general-fk_settings-skeleton_settings-interpolate_computed_knees_with_ankles = Interpolate the computed knees with the ankles -settings-general-fk_settings-skeleton_settings-interpolate_knees_with_ankles = Interpolate the knees with the ankles +settings-general-fk_settings-skeleton_settings-interpolate_hip_with_upper_legs = Interpolate the hip with the upper legs +settings-general-fk_settings-skeleton_settings-interpolate_upper_legs_with_lower_legs = Interpolate the upper legs with the lower legs settings-general-fk_settings-self_localization-title = Mocap mode settings-general-fk_settings-self_localization-description = Mocap Mode allows the skeleton to roughly track its own position without a headset or other trackers. Note that this requires feet and head trackers to work and is still experimental. diff --git a/gui/app/src/components/settings/pages/components/TrackingSettings.tsx b/gui/app/src/components/settings/pages/components/TrackingSettings.tsx index aa5a7358f8..cf4541fc67 100644 --- a/gui/app/src/components/settings/pages/components/TrackingSettings.tsx +++ b/gui/app/src/components/settings/pages/components/TrackingSettings.tsx @@ -38,9 +38,8 @@ type SkeletonForm = { ratios: { imputeSpineFromUpperToLower: number; imputeSpineCurvature: number; - interpolateHipWithKnees: number; - interpolateComputedKneesWithAnkles: number; - interpolateKneesWithAnkles: number; + interpolateHipWithUpperLegs: number; + interpolateUpperLegsWithLowerLegs: number; skatingCorrectionStrength: number; }; filtering: { @@ -73,9 +72,8 @@ const defaultValues: SkeletonForm = { ratios: { imputeSpineFromUpperToLower: 0.5, imputeSpineCurvature: 0.5, - interpolateHipWithKnees: 0.25, - interpolateComputedKneesWithAnkles: 0.85, - interpolateKneesWithAnkles: 0.0, + interpolateHipWithUpperLegs: 0.25, + interpolateUpperLegsWithLowerLegs: 0.0, skatingCorrectionStrength: 0.3, }, filtering: { amount: 0.1, type: FilteringType.NONE }, @@ -119,11 +117,9 @@ export function TrackingSettings() { ratios.imputeSpineFromUpperToLower = values.ratios.imputeSpineFromUpperToLower; ratios.imputeSpineCurvature = values.ratios.imputeSpineCurvature; - ratios.interpolateHipWithKnees = values.ratios.interpolateHipWithKnees; - ratios.interpolateComputedKneesWithAnkles = - values.ratios.interpolateComputedKneesWithAnkles; - ratios.interpolateKneesWithAnkles = - values.ratios.interpolateKneesWithAnkles; + ratios.interpolateHipWithUpperLegs = values.ratios.interpolateHipWithUpperLegs; + ratios.interpolateUpperLegsWithLowerLegs = + values.ratios.interpolateUpperLegsWithLowerLegs; ratios.skatingCorrectionStrength = values.ratios.skatingCorrectionStrength; settingsReq.ratios = ratios; @@ -486,9 +482,9 @@ export function TrackingSettings() { /> percentageFormat.format(value)} min={0.0} @@ -497,20 +493,9 @@ export function TrackingSettings() { /> percentageFormat.format(value)} - min={0.0} - max={1.0} - step={0.05} - /> - percentageFormat.format(value)} min={0.0} diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 4336ced056..fdb072ba3b 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -3,7 +3,6 @@ package dev.slimevr.config import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import io.github.axisangles.ktmath.Quaternion -import io.ktor.utils.io.core.Output import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -129,9 +128,8 @@ data class SkeletonTogglesConfig( data class SkeletonRatiosConfig( val imputeSpineFromUpperToLower: Float = 0.5f, val imputeSpineCurvature: Float = 0.5f, - val interpolateHipWithKnees: Float = 0.25f, - val interpolateComputedKneesWithAnkles: Float = 0.85f, - val interpolateKneesWithAnkles: Float = 0.00f, + val interpolateHipWithUpperLegs: Float = 0.25f, + val interpolateUpperLegsWithLowerLegs: Float = 0.8f, val skatingCorrectionStrength: Float = 0.3f, ) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index f117dcc23d..bb7a78926d 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -3,13 +3,14 @@ package dev.slimevr.skeleton import dev.slimevr.Phase1ContextProvider import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.skeleton.processors.BoneFallbackProcessor import dev.slimevr.skeleton.processors.BoneDirectLinkProcessor +import dev.slimevr.skeleton.processors.BoneFallbackProcessor import dev.slimevr.skeleton.processors.BonePredictionProcessor +import dev.slimevr.skeleton.processors.BonePriorityLinkProcessor import dev.slimevr.skeleton.processors.BoneSmoothingProcessor +import dev.slimevr.skeleton.processors.BoneYawRollAlignProcessor import dev.slimevr.skeleton.processors.FingerImputeProcessor import dev.slimevr.skeleton.processors.SpineImputeProcessor -import dev.slimevr.skeleton.processors.BonePriorityLinkProcessor import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope @@ -41,6 +42,16 @@ data class BoneState( get() = tailPosition - headPosition } +/** + * Returns the average rotation of the BodyParts. + */ +fun Map.resolveRotationFor(bodyParts: Set): Quaternion { + val rotations = bodyParts.mapNotNull { this[it]?.rawRotation } + return rotations.reduceIndexedOrNull { index, acc, rotation -> + acc.lerpQ(rotation, 1f / (index + 1)) + } ?: Quaternion.IDENTITY +} + data class SkeletonState(val boneInputs: Map, val skeletonHeight: Float, val paused: Boolean) val DEFAULT_SKELETON_STATE: SkeletonState = run { @@ -118,8 +129,9 @@ class Skeleton( processors = listOf( BoneFallbackProcessor(), BonePriorityLinkProcessor(), - BoneDirectLinkProcessor(), SpineImputeProcessor(ctx.config.settings), + BoneYawRollAlignProcessor(ctx.config.settings), + BoneDirectLinkProcessor(), FingerImputeProcessor(), BonePredictionProcessor(ctx.config.settings), BoneSmoothingProcessor(ctx.config.settings), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt new file mode 100644 index 0000000000..e87dcfbf71 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt @@ -0,0 +1,63 @@ +package dev.slimevr.skeleton.processors + +import dev.slimevr.config.Settings +import dev.slimevr.skeleton.SkeletonProcessor +import dev.slimevr.skeleton.SkeletonState +import dev.slimevr.skeleton.resolveRotationFor +import io.github.axisangles.ktmath.Quaternion +import solarxr_protocol.datatypes.BodyPart + +/** + * Handles rotating bones' yaw and roll to match other bones' yaw and roll. + */ +class BoneYawRollAlignProcessor(val settings: Settings) : SkeletonProcessor { + /** + * First value is the BodyPart to be aligned. + * + * Second value is a Pair containing a list of BodyParts to align with as well as a boolean specifying + * whether the BodyPart to be aligned must be active. + */ + private val bodyPartToSources = mapOf( + BodyPart.HIP to (setOf(BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG) to true), + BodyPart.LEFT_UPPER_LEG to (setOf(BodyPart.LEFT_LOWER_LEG) to false), + BodyPart.RIGHT_UPPER_LEG to (setOf(BodyPart.RIGHT_LOWER_LEG) to false), + ) + + override fun process(state: SkeletonState): SkeletonState { + val boneInputs = state.boneInputs + val ratios = settings.context.state.value.data.skeletonConfig.ratios + val bodyPartToMixFactor = mapOf( + BodyPart.HIP to ratios.interpolateHipWithUpperLegs, + BodyPart.LEFT_UPPER_LEG to ratios.interpolateUpperLegsWithLowerLegs, + BodyPart.RIGHT_UPPER_LEG to ratios.interpolateUpperLegsWithLowerLegs, + ) + + return state.copy( + boneInputs = boneInputs.mapValues { (bodyPart, bone) -> + val sources = bodyPartToSources[bodyPart] ?: return@mapValues bone + if (!sources.second || bone.isActive) return@mapValues bone + val mixFactor = bodyPartToMixFactor[bodyPart] ?: return@mapValues bone + + val sourceRotation = boneInputs.resolveRotationFor(sources.first) + val aligned = alignYawRoll(bone.rawRotation, sourceRotation) + val newRotation = bone.rawRotation.interpR(aligned, mixFactor) + + bone.copy(rawRotation = newRotation) + }, + ) + } + + /** + * Rotates the first Quaternion to match its yaw and roll to the rotation of + * the second Quaternion + * + * @param from the first Quaternion + * @param to the second Quaternion + * @return the rotated Quaternion + */ + private fun alignYawRoll(from: Quaternion, to: Quaternion): Quaternion { + val r = from.inv() * to + val c = Quaternion(r.w, -r.x, 0f, 0f) + return (from * r * c).unit() + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt index ecef472579..6d177116ed 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt @@ -1,10 +1,9 @@ package dev.slimevr.skeleton.processors import dev.slimevr.config.Settings -import dev.slimevr.skeleton.BoneInput import dev.slimevr.skeleton.SkeletonProcessor import dev.slimevr.skeleton.SkeletonState -import io.github.axisangles.ktmath.Quaternion +import dev.slimevr.skeleton.resolveRotationFor import solarxr_protocol.datatypes.BodyPart /** @@ -22,7 +21,7 @@ class SpineImputeProcessor(val settings: Settings) : SkeletonProcessor { /** * Used to skew the impute ratio for certain bone combinations. * - * First element is a Double containing the missing bone to the source bones. + * First element is a Pair containing the missing bone to the source bones. * * Second element is how reliable that pair is. Higher = missing bone relies more on source bones. */ @@ -54,7 +53,7 @@ class SpineImputeProcessor(val settings: Settings) : SkeletonProcessor { if (chainIndex == -1) return@mapValues bone // Get the first active bones above and below this one in the chain - val (fromBodyPart, toBodyPart) = when (bodyPart) { + val (fromBodyParts, toBodyParts) = when (bodyPart) { BodyPart.WAIST -> { val from = chestSet.takeIf { hasChest } ?: return@mapValues bone val to = when { @@ -78,8 +77,8 @@ class SpineImputeProcessor(val settings: Settings) : SkeletonProcessor { else -> error("Invalid missing spine body part $bodyPart") } - val fromReliability = combinationToReliability[(bodyPart to fromBodyPart)] ?: error("Invalid from body part combination $bodyPart, $fromBodyPart") - val toReliability = combinationToReliability[(bodyPart to toBodyPart)] ?: error("Invalid to body part combination $bodyPart, $toBodyPart") + val fromReliability = combinationToReliability[(bodyPart to fromBodyParts)] ?: error("Invalid from body part combination $bodyPart, $fromBodyParts") + val toReliability = combinationToReliability[(bodyPart to toBodyParts)] ?: error("Invalid to body part combination $bodyPart, $toBodyParts") val interpolateRatio = interpolateRatio( chainIndex, @@ -90,8 +89,8 @@ class SpineImputeProcessor(val settings: Settings) : SkeletonProcessor { toReliability, ) - val fromRotation = resolveRotation(boneInputs, fromBodyPart) - val toRotation = resolveRotation(boneInputs, toBodyPart) + val fromRotation = boneInputs.resolveRotationFor(fromBodyParts) + val toRotation = boneInputs.resolveRotationFor(toBodyParts) bone.copy(rawRotation = fromRotation.interpQ(toRotation, interpolateRatio)) }, @@ -133,14 +132,4 @@ class SpineImputeProcessor(val settings: Settings) : SkeletonProcessor { } private fun lerp(from: Float, to: Float, t: Float): Float = from + (to - from) * t - - /** - * Returns the average rotation of the BodyParts. - */ - private fun resolveRotation(boneInputs: Map, bodyParts: Set): Quaternion { - val rotations = bodyParts.mapNotNull { boneInputs[it]?.rawRotation } - return rotations.reduceIndexedOrNull { index, acc, rotation -> - acc.lerpQ(rotation, 1f / (index + 1)) - } ?: Quaternion.IDENTITY - } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeleton-settings.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeleton-settings.kt index 3e954948be..047fb8e4e8 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeleton-settings.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeleton-settings.kt @@ -41,9 +41,8 @@ class SkeletonSettingsBehaviour( ratios = SkeletonRatios( imputeSpineFromUpperToLower = config.ratios.imputeSpineFromUpperToLower, imputeSpineCurvature = config.ratios.imputeSpineCurvature, - interpolateHipWithKnees = config.ratios.interpolateHipWithKnees, - interpolateComputedKneesWithAnkles = config.ratios.interpolateComputedKneesWithAnkles, - interpolateKneesWithAnkles = config.ratios.interpolateKneesWithAnkles, + interpolateHipWithUpperLegs = config.ratios.interpolateHipWithUpperLegs, + interpolateUpperLegsWithLowerLegs = config.ratios.interpolateUpperLegsWithLowerLegs, skatingCorrectionStrength = config.ratios.skatingCorrectionStrength, ), filtering = SkeletonFiltering( @@ -77,9 +76,8 @@ class SkeletonSettingsBehaviour( SkeletonRatiosConfig( imputeSpineFromUpperToLower = it.imputeSpineFromUpperToLower ?: 0f, imputeSpineCurvature = it.imputeSpineCurvature ?: 0f, - interpolateHipWithKnees = it.interpolateHipWithKnees ?: 0f, - interpolateComputedKneesWithAnkles = it.interpolateComputedKneesWithAnkles ?: 0f, - interpolateKneesWithAnkles = it.interpolateKneesWithAnkles ?: 0f, + interpolateHipWithUpperLegs = it.interpolateHipWithUpperLegs ?: 0f, + interpolateUpperLegsWithLowerLegs = it.interpolateUpperLegsWithLowerLegs ?: 0f, skatingCorrectionStrength = it.skatingCorrectionStrength ?: 0f, ) } ?: skeletonConfig.ratios, diff --git a/solarxr-protocol b/solarxr-protocol index c412241496..b01aa81cc2 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit c41224149617edb21bcd231c14a82a589670cdc9 +Subproject commit b01aa81cc2f5afd730411435cf0f5c448f9ba5ac From 609a8d9dc6dfb3b5114ea01d31ad77dfeece2f64 Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 21 Jul 2026 18:11:30 -0400 Subject: [PATCH 280/350] BonePriorityLinkProcessor -> BoneActiveLinkProcessor --- server/core/src/main/java/dev/slimevr/skeleton/module.kt | 4 ++-- .../processors/{bone-priority-link.kt => bone-active-link.kt} | 4 +++- .../java/dev/slimevr/skeleton/processors/bone-direct-link.kt | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) rename server/core/src/main/java/dev/slimevr/skeleton/processors/{bone-priority-link.kt => bone-active-link.kt} (93%) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index bb7a78926d..1fbadd98c4 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -6,7 +6,7 @@ import dev.slimevr.context.Context import dev.slimevr.skeleton.processors.BoneDirectLinkProcessor import dev.slimevr.skeleton.processors.BoneFallbackProcessor import dev.slimevr.skeleton.processors.BonePredictionProcessor -import dev.slimevr.skeleton.processors.BonePriorityLinkProcessor +import dev.slimevr.skeleton.processors.BoneActiveLinkProcessor import dev.slimevr.skeleton.processors.BoneSmoothingProcessor import dev.slimevr.skeleton.processors.BoneYawRollAlignProcessor import dev.slimevr.skeleton.processors.FingerImputeProcessor @@ -128,7 +128,7 @@ class Skeleton( ComputedSkeletonBehaviour( processors = listOf( BoneFallbackProcessor(), - BonePriorityLinkProcessor(), + BoneActiveLinkProcessor(), SpineImputeProcessor(ctx.config.settings), BoneYawRollAlignProcessor(ctx.config.settings), BoneDirectLinkProcessor(), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-priority-link.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt similarity index 93% rename from server/core/src/main/java/dev/slimevr/skeleton/processors/bone-priority-link.kt rename to server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt index a74f1a3978..e1b168fad6 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-priority-link.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt @@ -8,10 +8,12 @@ import solarxr_protocol.datatypes.BodyPart * Handles setting the rotation of an inactive bone with the first active bone in its sources, or keeps * the old rotation if none of them are active. */ -class BonePriorityLinkProcessor : SkeletonProcessor { +class BoneActiveLinkProcessor : SkeletonProcessor { /** * First element is the BodyPart whose rawBone is not actively receiving data. + * * Second element contains a set of BodyParts whose rotation should be used as a fallback prioritized from first to last. + * Only active bones will be used. */ private val linkedToSources = mapOf( BodyPart.UPPER_CHEST to setOf(BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt index bf00a4e40c..e3e9c53a6a 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt @@ -30,8 +30,8 @@ class BoneDirectLinkProcessor : SkeletonProcessor { BodyPart.LEFT_SHOULDER to BodyPart.UPPER_CHEST, BodyPart.RIGHT_SHOULDER to BodyPart.UPPER_CHEST, - BodyPart.LEFT_UPPER_ARM to BodyPart.LEFT_LOWER_ARM, - BodyPart.RIGHT_UPPER_ARM to BodyPart.RIGHT_LOWER_ARM, + BodyPart.LEFT_UPPER_ARM to BodyPart.LEFT_SHOULDER, + BodyPart.RIGHT_UPPER_ARM to BodyPart.RIGHT_SHOULDER, BodyPart.LEFT_LOWER_ARM to BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_LOWER_ARM to BodyPart.RIGHT_UPPER_ARM, From 627600a2e0c91e9954c5c530ef435af8043486d0 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 22 Jul 2026 02:06:36 +0200 Subject: [PATCH 281/350] Improved keybinds --- gui/app/public/i18n/en/translation.ftl | 16 + gui/app/src/components/commons/Kbd.tsx | 39 +++ .../components/commons/KeybindRecorder.tsx | 276 +++++++++++----- .../commons/KeybindRecorderModal.tsx | 113 ++++--- .../src/components/commons/KeybindRow.scss | 29 +- .../src/components/commons/KeybindsRow.tsx | 176 ++++++++--- .../components/commons/icon/KeyboardIcon.tsx | 19 ++ .../src/components/commons/icon/ResetIcon.tsx | 24 ++ .../components/settings/SettingsLayout.tsx | 8 +- .../components/settings/SettingsSidebar.tsx | 10 +- .../settings/pages/KeybindSettings.scss | 15 - .../settings/pages/KeybindSettings.tsx | 298 +++++++++++------- .../dev/slimevr/android/ForegroundService.kt | 3 + .../src/main/java/dev/slimevr/app-context.kt | 4 + .../main/java/dev/slimevr/keybind/module.kt | 122 +++++++ .../main/java/dev/slimevr/solarxr/keybinds.kt | 21 +- .../main/java/dev/slimevr/solarxr/module.kt | 2 +- .../src/test/java/dev/slimevr/TestServer.kt | 2 + .../src/main/java/dev/slimevr/desktop/Main.kt | 19 +- .../dev/slimevr/desktop/keybind/keybinds.kt | 131 ++++---- solarxr-protocol | 2 +- 21 files changed, 906 insertions(+), 423 deletions(-) create mode 100644 gui/app/src/components/commons/Kbd.tsx create mode 100644 gui/app/src/components/commons/icon/KeyboardIcon.tsx delete mode 100644 gui/app/src/components/settings/pages/KeybindSettings.scss create mode 100644 server/core/src/main/java/dev/slimevr/keybind/module.kt diff --git a/gui/app/public/i18n/en/translation.ftl b/gui/app/public/i18n/en/translation.ftl index 5fe81d478b..c1af189355 100644 --- a/gui/app/public/i18n/en/translation.ftl +++ b/gui/app/public/i18n/en/translation.ftl @@ -582,6 +582,22 @@ settings-keybinds-recorder-modal-title = Assign keybind for settings-keybinds-recorder-modal-reset-button = Reset settings-keybinds-recorder-modal-unbind-button = Unbind settings-keybinds-recorder-modal-done-button = Done +settings-keybinds-recorder-modal-cancel-button = Cancel +settings-keybinds-recorder-modal-key-enter = Enter +settings-keybinds-recorder-modal-key-backspace = Backspace +settings-keybinds-recorder-modal-key-escape = Esc +settings-keybinds-already-assigned = Already used by { $name } +settings-keybinds-click-to-record = Click to record shortcut +settings-keybinds-change-shortcut = Click to change shortcut +settings-keybinds-reset-single = Reset keybind to default +settings-keybinds-recorder-hint-recording = Hold Ctrl, Alt, or Super, then press a letter or number +settings-keybinds-recorder-hint-idle = Click inside the box to record a new key combination. +settings-keybinds-error-letters-numbers-only = That key can't be used, pick a letter or number +settings-keybinds-error-add-modifier = You can only start a combination with Ctrl, Alt, or Super +settings-keybinds-error-duplicate = Duplicate keybind combinations are not allowed + + + ## FK/Tracking settings settings-general-fk_settings = Tracking Settings diff --git a/gui/app/src/components/commons/Kbd.tsx b/gui/app/src/components/commons/Kbd.tsx new file mode 100644 index 0000000000..6625d8b787 --- /dev/null +++ b/gui/app/src/components/commons/Kbd.tsx @@ -0,0 +1,39 @@ +import classNames from 'classnames'; +import { ReactNode } from 'react'; +import { Typography } from './Typography'; + +export type KbdVariant = 'default' | 'active' | 'invalid' | 'empty'; + +const KBD_VARIANTS: Record = { + default: 'bg-background-90 border-background-60', + active: 'bg-background-90 border-accent-background-30 animate-pulse', + invalid: 'bg-status-critical/20 border-status-critical animate-shake', + empty: 'bg-background-90 border-dashed border-background-60', +}; + +// A keycap-styled label +export function Kbd({ + children, + id, + variant = 'default', + className, +}: { + children?: ReactNode; + id?: string; + variant?: KbdVariant; + className?: string; +}) { + return ( + + + {children} + + + ); +} diff --git a/gui/app/src/components/commons/KeybindRecorder.tsx b/gui/app/src/components/commons/KeybindRecorder.tsx index 369fa192c0..01eb657bfd 100644 --- a/gui/app/src/components/commons/KeybindRecorder.tsx +++ b/gui/app/src/components/commons/KeybindRecorder.tsx @@ -1,128 +1,242 @@ -import { useState, forwardRef, useRef } from 'react'; +import { + forwardRef, + useReducer, + useRef, + useState, + useEffect, + useImperativeHandle, +} from 'react'; import { Typography } from './Typography'; +import { Kbd } from './Kbd'; import classNames from 'classnames'; -import { useFormContext } from 'react-hook-form'; +import { useLocalization } from '@fluent/react'; +import './KeybindRow.scss'; -const excludedKeys = [' ', 'SPACE']; -const maxKeybindLength = 4; - -// KeyboardEvent.key spells some keys differently from the combinations the server stores. -// The super/windows key comes through as Meta, or as OS on older Firefox. -const keyAliases: Record = { +const MODIFIER_ORDER = ['CTRL', 'ALT', 'SHIFT', 'SUPER']; +const NON_SHIFT_MODIFIERS = ['CTRL', 'ALT', 'SUPER']; +const MODIFIER_KEY_NAMES: Record = { CONTROL: 'CTRL', + ALT: 'ALT', + SHIFT: 'SHIFT', META: 'SUPER', OS: 'SUPER', }; +const maxKeybindLength = 5; + +function orderModifiers(mods: string[]): string[] { + return MODIFIER_ORDER.filter((m) => mods.includes(m)); +} + +export function isValidKeybind(keys: string[]): boolean { + const main = keys.filter((k) => !MODIFIER_ORDER.includes(k)); + if (main.length !== 1 || !/^[A-Z0-9]$/.test(main[0])) return false; + return keys.some((k) => NON_SHIFT_MODIFIERS.includes(k)); +} + +export function keybindKey(keys: string[]): string { + return [...keys].sort().join('+'); +} + +function resolveMainKey(code: string): string | null { + if (code.startsWith('Key')) return code.slice(3); + if (code.startsWith('Digit')) return code.slice(5); + if (code.startsWith('Numpad') && code.length === 7 && /^\d$/.test(code[6])) + return code[6]; + return null; +} + +function modifiersFromEvent(e: React.KeyboardEvent): string[] { + const mods: string[] = []; + if (e.ctrlKey) mods.push('CTRL'); + if (e.altKey) mods.push('ALT'); + if (e.shiftKey) mods.push('SHIFT'); + if (e.metaKey) mods.push('SUPER'); + return orderModifiers(mods); +} + +type RecorderState = { + preview: string[] | null; + flash: { slot: number; msgId: string } | null; +}; + +type RecorderAction = + | { type: 'preview'; keys: string[] } + | { type: 'clearPreview' } + | { type: 'flash'; slot: number; msgId: string } + | { type: 'clearFlash' }; + +function recorderReducer( + state: RecorderState, + action: RecorderAction +): RecorderState { + switch (action.type) { + case 'preview': + return { preview: action.keys, flash: null }; + case 'clearPreview': + return { ...state, preview: null }; + case 'flash': + return { ...state, flash: { slot: action.slot, msgId: action.msgId } }; + case 'clearFlash': + return { ...state, flash: null }; + } +} + export const KeybindRecorder = forwardRef< HTMLInputElement, { keys: string[]; onKeysChange: (v: string[]) => void; error?: string; + onSubmitModal?: () => void; + onUnbindModal?: () => void; + onCloseModal?: () => void; } ->(function KeybindRecorder({ keys, onKeysChange, error }) { - const [localKeys, setLocalKeys] = useState(keys); - const [isRecording, setIsRecording] = useState(false); - const [oldKeys, setOldKeys] = useState([]); - const [invalidSlot, setInvalidSlot] = useState(null); - const [errorText, setErrorText] = useState(''); +>(function KeybindRecorder( + { keys, onKeysChange, error, onSubmitModal, onUnbindModal, onCloseModal }, + ref +) { + const { l10n } = useLocalization(); + const [state, dispatch] = useReducer(recorderReducer, { + preview: null, + flash: null, + }); + const [focused, setFocused] = useState(false); const inputRef = useRef(null); - const displayKeys = isRecording ? localKeys : keys; - const activeIndex = isRecording ? displayKeys.length : -1; - const displayError = errorText || error; + useImperativeHandle(ref, () => inputRef.current!, []); + + const showing = state.preview ?? keys; + const activeIndex = showing.length; + const slotCount = state.preview + ? Math.min(showing.length + 1, maxKeybindLength) + : Math.max(showing.length, 1); + const displayError = state.flash ? l10n.getString(state.flash.msgId) : error; + + useEffect(() => { + const timer = setTimeout(() => inputRef.current?.focus(), 50); + return () => clearTimeout(timer); + }, []); - const { clearErrors } = useFormContext(); + useEffect(() => { + if (!state.flash) return; + const timer = setTimeout(() => dispatch({ type: 'clearFlash' }), 350); + return () => clearTimeout(timer); + }, [state.flash]); const handleKeyDown = (e: React.KeyboardEvent) => { e.preventDefault(); - const rawKey = e.key.toUpperCase(); - const key = keyAliases[rawKey] ?? rawKey; - const errorMsg = excludedKeys.includes(key) - ? `Cannot use ${key}!` - : displayKeys.includes(key) - ? `${key} is a Duplicate Key!` - : null; - if (errorMsg) { - setErrorText(errorMsg); - setInvalidSlot(activeIndex); - setTimeout(() => { - setInvalidSlot(null); - }, 350); + e.stopPropagation(); + + if (e.key === 'Escape') { + onCloseModal?.(); + return; + } + if (e.key === 'Backspace' || e.key === 'Delete') { + onUnbindModal?.(); + return; + } + if (e.key === 'Enter') { + onSubmitModal?.(); return; } - setErrorText(''); + const modifiers = modifiersFromEvent(e); - if (displayKeys.length < maxKeybindLength) { - const updatedKeys = [...displayKeys, key]; - setLocalKeys(updatedKeys); - onKeysChange(updatedKeys); - if (updatedKeys.length == maxKeybindLength) { - inputRef.current?.blur(); - } + if (MODIFIER_KEY_NAMES[e.key.toUpperCase()]) { + dispatch({ type: 'preview', keys: modifiers }); + return; } - }; - const handleOnBlur = () => { - setIsRecording(false); - if (displayKeys.length < maxKeybindLength - 2 || error) { - onKeysChange(oldKeys); - setLocalKeys(oldKeys); + const mainKey = resolveMainKey(e.code); + if (!mainKey) { + dispatch({ + type: 'flash', + slot: activeIndex, + msgId: 'settings-keybinds-error-letters-numbers-only', + }); + return; } + + dispatch({ type: 'clearPreview' }); + onKeysChange([...modifiers, mainKey]); }; - const handleOnFocus = () => { - clearErrors('keybinds'); - const initialKeys: string[] = []; - setOldKeys(keys); - setLocalKeys(initialKeys); - onKeysChange(initialKeys); - setIsRecording(true); + const handleKeyUp = (e: React.KeyboardEvent) => { + if (state.preview === null) return; + if ( + e.key === 'Escape' || + e.key === 'Enter' || + e.key === 'Backspace' || + e.key === 'Delete' + ) + return; + const modifiers = modifiersFromEvent(e); + dispatch( + modifiers.length > 0 + ? { type: 'preview', keys: modifiers } + : { type: 'clearPreview' } + ); }; return ( -
-
+
+
inputRef.current?.focus()} + > setFocused(true)} + onBlur={() => setFocused(false)} onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} /> -
- {Array.from({ length: maxKeybindLength }).map((_, i) => { - const key = displayKeys[i]; - const isActive = isRecording && i === activeIndex; - const isInvalid = invalidSlot === i; +
+ {Array.from({ length: slotCount }).map((_, i) => { + const key = showing[i]; + const isActive = i === activeIndex && key == null; + const isInvalid = state.flash?.slot === i; + const variant = isInvalid + ? 'invalid' + : isActive + ? 'active' + : key != null + ? 'default' + : 'empty'; return ( -
-
+ - {key ?? ''} -
-
- {i < maxKeybindLength - 1 ? '+' : ''} -
+ {key ?? (isActive ? '...' : '')} + + {i < slotCount - 1 && ( + + + + + )}
); })}
+ +
+ +
+ {displayError && ( -
- {displayError} +
+ {displayError}
)}
diff --git a/gui/app/src/components/commons/KeybindRecorderModal.tsx b/gui/app/src/components/commons/KeybindRecorderModal.tsx index e465bf8ed5..ea48006a84 100644 --- a/gui/app/src/components/commons/KeybindRecorderModal.tsx +++ b/gui/app/src/components/commons/KeybindRecorderModal.tsx @@ -6,12 +6,21 @@ import { FieldValues, FieldPath, } from 'react-hook-form'; -import { KeybindRecorder } from './KeybindRecorder'; +import { KeybindRecorder, isValidKeybind, keybindKey } from './KeybindRecorder'; +import { Kbd } from './Kbd'; import { Typography } from './Typography'; -import { Button } from './Button'; -import './KeybindRow.scss'; +import { KeyboardIcon } from './icon/KeyboardIcon'; import { useLocalization } from '@fluent/react'; +function KeybindHint({ keyId, labelId }: { keyId: string; labelId: string }) { + return ( +
+ + +
+ ); +} + export function KeybindRecorderModal({ id, control, @@ -20,6 +29,7 @@ export function KeybindRecorderModal({ onClose, onUnbind, onSubmit, + takenBindings, }: { id?: string; control: Control; @@ -28,64 +38,81 @@ export function KeybindRecorderModal({ onClose: () => void; onUnbind: () => void; onSubmit: () => void; + onReset?: () => void; + takenBindings?: { name: string; binding: string[] }[]; }) { - const { l10n } = useLocalization(); const keybindlocalization = 'settings-keybinds_' + id; - const { - formState: { errors }, - resetField, - handleSubmit, - } = useFormContext(); + const { l10n } = useLocalization(); + const { handleSubmit } = useFormContext(); + + const handleModalSubmit = () => { + handleSubmit(onSubmit)(); + }; + + const validateBinding = (value: string[]) => { + if (!value || value.length === 0) return true; + if (!isValidKeybind(value)) + return l10n.getString('settings-keybinds-error-add-modifier'); + const clash = (takenBindings ?? []).find( + (t) => keybindKey(t.binding) === keybindKey(value) + ); + return clash + ? l10n.getString('settings-keybinds-already-assigned', { + name: clash.name, + }) + : true; + }; return ( -
- - {l10n.getString('settings-keybinds-recorder-modal-title')}{' '} - {l10n.getString(keybindlocalization)} - +
+
+
+ +
+
+ + +
+
+ ( + rules={{ validate: validateBinding }} + render={({ field, fieldState }) => ( )} /> -
-
-
-
-
+ +
+ + +
diff --git a/gui/app/src/components/commons/KeybindRow.scss b/gui/app/src/components/commons/KeybindRow.scss index aac57c22d7..92e425a163 100644 --- a/gui/app/src/components/commons/KeybindRow.scss +++ b/gui/app/src/components/commons/KeybindRow.scss @@ -1,25 +1,3 @@ -.keybind-row { - display: grid; - grid-column: 1 / -1; - grid-template-columns: subgrid; - height: auto; - align-items: center; - gap: 10px; -} - -@keyframes keyslot { - 0%, - 100% { - transform: scale(1); - opacity: 0.6; - } - - 50% { - transform: scale(1.08); - opacity: 1; - } -} - @keyframes shake { 0% { transform: translate(1px, 1px) rotate(0deg); @@ -56,10 +34,7 @@ } } -.keyslot-animate { - animation: keyslot 1s ease-in-out infinite; +.animate-shake { + animation: shake 0.35s ease-in-out; } -.keyslot-invalid { - animation: shake 0.35s ease; -} diff --git a/gui/app/src/components/commons/KeybindsRow.tsx b/gui/app/src/components/commons/KeybindsRow.tsx index 0c5e930c55..ca96424ac9 100644 --- a/gui/app/src/components/commons/KeybindsRow.tsx +++ b/gui/app/src/components/commons/KeybindsRow.tsx @@ -1,46 +1,94 @@ import { Typography } from './Typography'; -import './KeybindRow.scss'; -import { Control, FieldPath, FieldValues, useWatch } from 'react-hook-form'; +import { Control, useWatch } from 'react-hook-form'; +import { KeybindForm } from '@/components/settings/pages/KeybindSettings'; import { NumberSelector } from './NumberSelector'; import { useLocaleConfig } from '@/i18n/config'; +import { Kbd } from './Kbd'; +import { RecordIcon } from './icon/RecordIcon'; +import { ResetIcon } from './icon/ResetIcon'; +import { Tooltip } from './Tooltip'; +import classNames from 'classnames'; function KeyBindKeyList({ keybind }: { keybind: string[] }) { - if (keybind.length <= 1) { + if (keybind.length === 0) { return ( -
- Click to edit keybind +
+ +
); } - return keybind.map((key, i) => { - return ( -
-
- {key ?? ''} -
-
- {i < keybind.length - 1 ? '+' : ''} + + return ( +
+ {keybind.map((key, i) => ( +
+ + {key} + + {i < keybind.length - 1 && ( + + + + + )}
-
- ); - }); + ))} +
+ ); +} + +function ResetButton({ + isModified, + onClick, +}: { + isModified?: boolean; + onClick: () => void; +}) { + return ( + } + preferedDirection="top" + > + + + ); } -export function KeybindsRow({ +export function KeybindsRow({ id, control, index, openKeybindRecorderModal, + onResetSingle, + isModified, }: { id?: string; - control: Control; + control: Control; index: number; openKeybindRecorderModal: (index: number) => void; + onResetSingle: (index: number) => void; + isModified?: boolean; }) { - const binding = useWatch({ - control, - name: `keybinds.${index}.binding` as FieldPath, - }) as string[] | undefined; + const binding = + useWatch({ + control, + name: `keybinds.${index}.binding`, + }) ?? []; + const { currentLocales } = useLocaleConfig(); const secondsFormat = new Intl.NumberFormat(currentLocales, { style: 'unit', @@ -49,31 +97,67 @@ export function KeybindsRow({ maximumFractionDigits: 2, }); - const handleOpenModal = () => { - openKeybindRecorderModal(index); - }; - return ( -
- -
-
- {binding != null && } + + +
+
+
+ onResetSingle(index)} + /> +
+
+ +
+
+ +
+ onResetSingle(index)} + /> +
-
- } - valueLabelFormat={(value) => secondsFormat.format(value)} - min={0} - max={10} - step={0.2} - /> -
+ + + +
+ } + preferedDirection="top" + > +
openKeybindRecorderModal(index)} + > + +
+
+
+ + + +
+ + + + secondsFormat.format(value)} + min={0} + max={10} + step={0.2} + /> +
+ + ); } diff --git a/gui/app/src/components/commons/icon/KeyboardIcon.tsx b/gui/app/src/components/commons/icon/KeyboardIcon.tsx new file mode 100644 index 0000000000..3886aac92a --- /dev/null +++ b/gui/app/src/components/commons/icon/KeyboardIcon.tsx @@ -0,0 +1,19 @@ +export function KeyboardIcon({ + size = 22, + className = 'fill-current', +}: { + size?: number; + className?: string; +}) { + return ( + + + + ); +} diff --git a/gui/app/src/components/commons/icon/ResetIcon.tsx b/gui/app/src/components/commons/icon/ResetIcon.tsx index 597f4ce839..8ce0923576 100644 --- a/gui/app/src/components/commons/icon/ResetIcon.tsx +++ b/gui/app/src/components/commons/icon/ResetIcon.tsx @@ -1,3 +1,27 @@ +export function ResetIcon({ + size = 16, + className = 'fill-none stroke-current', +}: { + size?: number; + className?: string; +}) { + return ( + + + + + ); +} + export function FullResetIcon({ width = 33 }: { width?: number }) { return ( +
@@ -144,11 +149,6 @@ export function SettingsSidebar() { to="/settings/advanced" id="settings-sidebar-advanced" /> -
diff --git a/gui/app/src/components/settings/pages/KeybindSettings.scss b/gui/app/src/components/settings/pages/KeybindSettings.scss deleted file mode 100644 index b73610c1a0..0000000000 --- a/gui/app/src/components/settings/pages/KeybindSettings.scss +++ /dev/null @@ -1,15 +0,0 @@ -.keybind-settings { - display: grid; - - grid-template: - 'n v d ' auto - 'k k k ' auto - 'b . . ' auto - / 1fr 1fr 90px; - - grid-template-columns: subgrid; - grid-template-rows: subgrid; - - align-items: left; - gap: 10px; -} diff --git a/gui/app/src/components/settings/pages/KeybindSettings.tsx b/gui/app/src/components/settings/pages/KeybindSettings.tsx index 14571e479c..d72e4eae69 100644 --- a/gui/app/src/components/settings/pages/KeybindSettings.tsx +++ b/gui/app/src/components/settings/pages/KeybindSettings.tsx @@ -4,13 +4,13 @@ import { SettingsPagePaneLayout, } from '@/components/settings/SettingsPageLayout'; import { WrenchIcon } from '@/components/commons/icon/WrenchIcon'; +import { WarningIcon } from '@/components/commons/icon/WarningIcon'; import { Typography } from '@/components/commons/Typography'; import { useLocalization } from '@fluent/react'; -import './KeybindSettings.scss'; import { Button } from '@/components/commons/Button'; import { KeybindsRow } from '@/components/commons/KeybindsRow'; import { useWebsocketAPI } from '@/hooks/websocket-api'; -import { ReactNode, useEffect, useRef, useState } from 'react'; +import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { ChangeKeybindRequestT, KeybindRequestT, @@ -19,26 +19,44 @@ import { KeybindT, OpenKeybindSettingsRequestT, RpcMessage, + SetKeybindRecordingRequestT, } from 'solarxr-protocol'; import { FormProvider, useFieldArray, useForm } from 'react-hook-form'; export type KeybindForm = { keybinds: { - id: number; + keybindId: number; name: string; binding: string[]; delay: number; }[]; }; +function parseBinding(val: string | Uint8Array | null | undefined): string[] { + if (!val) return []; + const str = val.toString().trim(); + if (!str) return []; + return str + .split('+') + .map((s) => s.trim()) + .filter(Boolean); +} + +function areBindingsEqual(a?: string[], b?: string[]): boolean { + if (a === b) return true; + if (!a || !b) return false; + if (a.length !== b.length) return false; + + const setB = new Set(b); + return a.every((key) => setB.has(key)); +} + export function KeybindSettings() { const { l10n } = useLocalization(); const { sendRPCPacket, useRPCPacket } = useWebsocketAPI(); const [isOpen, setIsOpen] = useState(false); const [defaultKeybindsState, setDefaultKeybindsState] = useState( - { - keybinds: [], - } + { keybinds: [] } ); const currentIndex = useRef(null); const [support, setSupport] = useState( @@ -47,6 +65,7 @@ export function KeybindSettings() { const methods = useForm({ defaultValues: defaultKeybindsState, + mode: 'onChange', }); const { @@ -55,55 +74,39 @@ export function KeybindSettings() { reset, setValue, getValues, - setError, clearErrors, - resetField, watch, } = methods; + const defaultKeybindsMap = useMemo(() => { + return new Map( + defaultKeybindsState.keybinds.map((kb) => [kb.keybindId, kb]) + ); + }, [defaultKeybindsState]); + + // Snapshot of the edited binding, restored if the modal is cancelled + const bindingSnapshot = useRef(null); + const { fields } = useFieldArray({ control, name: 'keybinds', }); - const onSubmit = () => { - const value = getValues(); - if (checkDuplicates(value)) { - return; - } - clearErrors('keybinds'); + const onSubmit = (data?: KeybindForm) => { + const value = data ?? getValues(); value.keybinds.forEach((kb) => { + const currentBindingStr = kb.binding.join('+'); const changeKeybindRequest = new ChangeKeybindRequestT(); - const keybind = new KeybindT(); - keybind.keybindId = kb.id; - keybind.keybindValue = kb.binding.join('+'); + keybind.keybindId = kb.keybindId; + keybind.keybindValue = currentBindingStr; keybind.keybindDelay = kb.delay; - changeKeybindRequest.keybind = keybind; - sendRPCPacket(RpcMessage.ChangeKeybindRequest, changeKeybindRequest); - setIsOpen(false); }); - }; - const checkDuplicates = (value: KeybindForm) => { - const normalized = value.keybinds - .filter((kb) => kb.binding.length > 0) - .map((kb) => JSON.stringify([...kb.binding].sort())); - - const unique = new Set(normalized); - - if (unique.size !== normalized.length) { - setError('keybinds', { - type: 'manual', - message: 'Duplicate keybind combinations are not allowed', - }); - return true; - } - - return false; + setIsOpen(false); }; const handleOpenSystemSettingsButton = () => { @@ -113,6 +116,19 @@ export function KeybindSettings() { ); }; + const handleResetSingle = (index: number) => { + const targetKeybind = getValues(`keybinds.${index}`); + if (!targetKeybind) return; + + const defaultKb = defaultKeybindsMap.get(targetKeybind.keybindId); + + if (defaultKb) { + setValue(`keybinds.${index}.binding`, defaultKb.binding); + setValue(`keybinds.${index}.delay`, defaultKb.delay); + handleSubmit(onSubmit)(); + } + }; + useRPCPacket( RpcMessage.KeybindResponse, ({ keybind, defaultKeybinds, support }: KeybindResponseT) => { @@ -120,55 +136,84 @@ export function KeybindSettings() { if (!keybind) return; const mappedDefaults = defaultKeybinds.map((kb) => ({ - id: kb.keybindId, + keybindId: kb.keybindId, name: kb.keybindNameId?.toString() ?? '', - binding: kb.keybindValue?.toString().split('+') ?? [], + binding: parseBinding(kb.keybindValue), delay: kb.keybindDelay, })); setDefaultKeybindsState({ keybinds: mappedDefaults }); - reset({ keybinds: mappedDefaults }); - const mapped = keybind.map((kb) => ({ - id: kb.keybindId, + const mappedSaved = keybind.map((kb) => ({ + keybindId: kb.keybindId, name: kb.keybindNameId?.toString() ?? '', - binding: kb.keybindValue?.toString().split('+') ?? [], + binding: parseBinding(kb.keybindValue), delay: kb.keybindDelay, })); - mapped.forEach((keybind, index) => { - setValue(`keybinds.${index}.binding`, keybind.binding); - setValue(`keybinds.${index}.delay`, keybind.delay); - }); + reset({ keybinds: mappedSaved }); } ); + useEffect(() => { + const req = new SetKeybindRecordingRequestT(); + req.recording = isOpen; + sendRPCPacket(RpcMessage.SetKeybindRecordingRequest, req); + }, [isOpen]); + const handleOpenRecorderModal = (index: number) => { currentIndex.current = index; + bindingSnapshot.current = getValues(`keybinds.${index}.binding`); setIsOpen(true); }; const onClose = () => { - if (currentIndex.current != null) { - resetField(`keybinds.${currentIndex.current}.binding`); + if (currentIndex.current != null && bindingSnapshot.current != null) { + setValue( + `keybinds.${currentIndex.current}.binding`, + bindingSnapshot.current + ); } setIsOpen(false); + clearErrors('keybinds'); }; - // Captured so the modal's callbacks keep the index they were opened with const editedIndex = currentIndex.current; + const takenBindings = + editedIndex != null + ? getValues('keybinds') + .filter((_, i) => i !== editedIndex) + .filter((kb) => kb.binding.length > 0) + .map((kb) => ({ + name: l10n.getString('settings-keybinds_' + kb.name), + binding: kb.binding, + })) + : []; + const createKeybindRows = (): ReactNode => { return fields.map((field, index) => { + const currentKb = watch(`keybinds.${index}`); + const defaultKb = currentKb + ? defaultKeybindsMap.get(currentKb.keybindId) + : undefined; + + const isModified = + defaultKb != null && + currentKb != null && + (currentKb.delay !== defaultKb.delay || + !areBindingsEqual(currentKb.binding, defaultKb.binding)); + return ( -
- -
+ ); }); }; @@ -177,10 +222,10 @@ export function KeybindSettings() { sendRPCPacket(RpcMessage.KeybindRequest, new KeybindRequestT()); }, []); - // Only the recorder modal submits, so a delay change would otherwise never reach the server useEffect(() => { const subscription = watch((_, { name, type }) => { - if (type === 'change' && name?.endsWith('.delay')) onSubmit(); + if (type === 'change' && name?.endsWith('.delay')) + handleSubmit(onSubmit)(); }); return () => subscription.unsubscribe(); }, [watch]); @@ -188,75 +233,100 @@ export function KeybindSettings() { return ( } id="keybinds"> -
- -
- {l10n - .getString('settings-keybinds-description') - .split('\n') - .map((line, i) => ( - {line} - ))} +
+
+ +
+ {l10n + .getString('settings-keybinds-description') + .split('\n') + .map((line, i) => ( + {line} + ))} +
+ {support === KeybindSupport.UNSUPPORTED && ( - +
+ + +
)} + {support === KeybindSupport.SYSTEM_MANAGED && ( -
+
)} + {support === KeybindSupport.APP_MANAGED && ( - <> - -
- - - - {createKeybindRows()} -
-
-
- {editedIndex != null && ( - - setValue(`keybinds.${editedIndex}.binding`, []) - } - onSubmit={onSubmit} - /> - )} -
- + +
+ + + + + + + + + + {createKeybindRows()} + +
+ + + + + +
+
+ +
+
+ + {editedIndex != null && ( + { + setValue(`keybinds.${editedIndex}.binding`, []); + handleSubmit(onSubmit)(); + }} + onSubmit={() => handleSubmit(onSubmit)()} + onReset={() => handleResetSingle(editedIndex)} + takenBindings={takenBindings} + /> + )} +
)}
diff --git a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt index 181c67c75d..4331fa016d 100644 --- a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt +++ b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt @@ -28,6 +28,7 @@ import dev.slimevr.bvh.BVHManager import dev.slimevr.config.AppConfig import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager +import dev.slimevr.keybind.KeybindManager import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.provisioning.ProvisioningManager @@ -157,12 +158,14 @@ class ForegroundService : Service() { ) val resetsManager = ResetsManager.create(ctx = phase1, scope = scope) val tapDetectionManager = TapDetectionManager.create(ctx = phase1, resetsManager = resetsManager, scope = scope) + val keybindManager = KeybindManager.create(scope = scope) val appContext = AppContext( server = server, config = config, serialServer = serialServer, featureFlags = FeatureFlags(), + keybindManager = keybindManager, skeleton = skeleton, firmwareManager = firmwareManager, vrcConfigManager = null, diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index 48a15b357c..247bd060f6 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -4,6 +4,7 @@ import dev.slimevr.bvh.BVHManager import dev.slimevr.config.AppConfig import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager +import dev.slimevr.keybind.KeybindManager import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.provisioning.ProvisioningManager @@ -31,6 +32,7 @@ data class Phase1Context( interface AppContextProvider : Phase1ContextProvider { val featureFlags: FeatureFlags + val keybindManager: KeybindManager val skeleton: Skeleton val firmwareManager: FirmwareManager val vrcConfigManager: VRCConfigManager? @@ -54,6 +56,7 @@ class AppContext( override val config: AppConfig, override val serialServer: SerialServer, override val featureFlags: FeatureFlags, + override val keybindManager: KeybindManager, override val skeleton: Skeleton, override val firmwareManager: FirmwareManager, override val vrcConfigManager: VRCConfigManager?, @@ -70,6 +73,7 @@ class AppContext( override val tapDetectionManager: TapDetectionManager, ) : AppContextProvider { override fun startObserving() { + keybindManager.startObserving(this) skeleton.startObserving() firmwareManager.startObserving() provisioningManager.startObserving() diff --git a/server/core/src/main/java/dev/slimevr/keybind/module.kt b/server/core/src/main/java/dev/slimevr/keybind/module.kt new file mode 100644 index 0000000000..5cc110be4e --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/keybind/module.kt @@ -0,0 +1,122 @@ +package dev.slimevr.keybind + +import dev.slimevr.AppContextProvider +import dev.slimevr.EventDispatcher +import dev.slimevr.config.KeybindConfig +import dev.slimevr.config.Settings +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context +import dev.slimevr.resets.ResetsManager +import dev.slimevr.skeleton.Skeleton +import dev.slimevr.skeleton.SkeletonActions +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.rpc.KeybindId +import solarxr_protocol.rpc.ResetType + +private const val KEYBIND_SOURCE = "Keybind" + +private val FEET_BODY_PARTS = listOf(BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT) + +private const val BINDING_SETTLE_MS = 300L + +data class KeybindState( + val recording: Boolean, +) + +sealed interface KeybindActions { + data class SetRecording(val recording: Boolean) : KeybindActions +} + +sealed interface KeybindEvent { + data class Fired(val id: KeybindId) : KeybindEvent + + data class Rebind(val keybinds: List) : KeybindEvent +} + +typealias KeybindContext = Context +typealias KeybindBehaviour = Behaviour + +class KeybindBasicBehaviour : KeybindBehaviour { + override fun reduce(state: KeybindState, action: KeybindActions): KeybindState = when (action) { + is KeybindActions.SetRecording -> state.copy(recording = action.recording) + } +} + +class KeybindConfigBehaviour(private val settings: Settings) : KeybindBehaviour { + @OptIn(FlowPreview::class) + override fun observe(receiver: KeybindManager) { + settings.context.state + .map { it.data.keybinds } + .distinctUntilChangedBy { keybinds -> keybinds.map { it.id to it.binding } } + .debounce(BINDING_SETTLE_MS) + .onEach { receiver.events.emit(KeybindEvent.Rebind(it)) } + .launchIn(receiver.context.scope) + } +} + +class KeybindTriggerBehaviour( + private val settings: Settings, + private val resetsManager: ResetsManager, + private val skeleton: Skeleton, +) : KeybindBehaviour { + override fun observe(receiver: KeybindManager) { + receiver.events.on { event -> + if (receiver.recording) return@on + + val delaySeconds = settings.context.state.value.data.keybinds.find { it.id == event.id }?.delay ?: 0f + when (event.id) { + KeybindId.FULL_RESET -> resetsManager.scheduleReset(KEYBIND_SOURCE, ResetType.FULL, delaySeconds) + + KeybindId.YAW_RESET -> resetsManager.scheduleReset(KEYBIND_SOURCE, ResetType.YAW, delaySeconds) + + KeybindId.MOUNTING_RESET -> resetsManager.scheduleReset(KEYBIND_SOURCE, ResetType.MOUNTING, delaySeconds) + + KeybindId.FEET_MOUNTING_RESET -> + resetsManager.scheduleReset(KEYBIND_SOURCE, ResetType.MOUNTING, delaySeconds, FEET_BODY_PARTS) + + KeybindId.PAUSE_TRACKING -> { + if (delaySeconds > 0f) delay((delaySeconds * 1000).toLong()) + skeleton.context.dispatch(SkeletonActions.PauseTracking(!skeleton.context.state.value.paused)) + } + + KeybindId.NONE -> Unit + } + } + } +} + +class KeybindManager(val context: KeybindContext) { + val recording: Boolean get() = context.state.value.recording + + val events = EventDispatcher() + + fun startObserving(appContext: AppContextProvider) { + context.behaviours.addAll( + listOf( + KeybindConfigBehaviour(appContext.config.settings), + KeybindTriggerBehaviour(appContext.config.settings, appContext.resetsManager, appContext.skeleton), + ), + ) + context.observeAll(this) + } + + companion object { + fun create(scope: CoroutineScope): KeybindManager { + val context = Context.create( + initialState = KeybindState(recording = false), + scope = scope, + behaviours = listOf(KeybindBasicBehaviour()), + name = "KeybindManager", + ) + return KeybindManager(context) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/solarxr/keybinds.kt b/server/core/src/main/java/dev/slimevr/solarxr/keybinds.kt index 307e1b2df9..0a88533409 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/keybinds.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/keybinds.kt @@ -4,6 +4,8 @@ import dev.slimevr.config.KeybindConfig import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions import dev.slimevr.config.defaultKeybinds +import dev.slimevr.keybind.KeybindActions +import dev.slimevr.keybind.KeybindManager import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn @@ -14,6 +16,7 @@ import solarxr_protocol.rpc.Keybind import solarxr_protocol.rpc.KeybindId import solarxr_protocol.rpc.KeybindRequest import solarxr_protocol.rpc.KeybindResponse +import solarxr_protocol.rpc.SetKeybindRecordingRequest private fun keybindNameId(id: KeybindId): String = id.name.lowercase().replace('_', '-') @@ -26,15 +29,29 @@ private fun keybindConfigToProto(config: KeybindConfig): Keybind = Keybind( private val MODIFIER_ORDER = listOf("CTRL", "ALT", "SHIFT", "SUPER") +private val NON_SHIFT_MODIFIERS = setOf("CTRL", "ALT", "SUPER") + fun canonicalKeybind(binding: String): String { val parts = binding.split('+').map { it.trim().uppercase() }.filter { it.isNotEmpty() } return (MODIFIER_ORDER.filter { it in parts } + parts.filterNot { it in MODIFIER_ORDER }).joinToString("+") } +fun isValidKeybind(binding: String): Boolean { + val parts = binding.split('+').map { it.trim().uppercase() }.filter { it.isNotEmpty() } + val key = parts.filterNot { it in MODIFIER_ORDER }.singleOrNull() ?: return false + if (!key.matches(Regex("[A-Z0-9]"))) return false + return parts.any { it in NON_SHIFT_MODIFIERS } +} + class KeybindsBehaviour( private val settings: Settings, + private val keybindManager: KeybindManager, ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { + receiver.rpcDispatcher.on { req -> + keybindManager.context.dispatch(KeybindActions.SetRecording(req.recording ?: false)) + } + fun buildResponse(keybinds: List) = KeybindResponse( keybind = keybinds.map { keybindConfigToProto(it) }, defaultKeybinds = defaultKeybinds().map { keybindConfigToProto(it) }, @@ -55,13 +72,15 @@ class KeybindsBehaviour( receiver.rpcDispatcher.on { req -> val keybind = req.keybind ?: return@on val id = keybind.keybindId?.takeUnless { it == KeybindId.NONE } ?: return@on + val newBinding = keybind.keybindValue?.let(::canonicalKeybind) + if (!newBinding.isNullOrEmpty() && !isValidKeybind(newBinding)) return@on settings.context.dispatch( SettingsActions.Update { copy( keybinds = keybinds.map { if (it.id == id) { it.copy( - binding = keybind.keybindValue?.let(::canonicalKeybind) ?: it.binding, + binding = newBinding ?: it.binding, delay = keybind.keybindDelay ?: 0f, ) } else { diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index a8b95e6a08..748f2a15ef 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -86,7 +86,7 @@ class SolarXRBridge( add(KnownTrackersBehaviour(appContext.config.settings)) add(BvhBehaviour(appContext.bvhManager)) add(InstalledInfoBehaviour()) - add(KeybindsBehaviour(appContext.config.settings)) + add(KeybindsBehaviour(appContext.config.settings, appContext.keybindManager)) add(SessionCalibrationBehaviour(appContext.resetsManager)) } diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 07c8313a50..e60abc56ca 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -16,6 +16,7 @@ import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationActions import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.heightcalibration.HeightCalibrationState +import dev.slimevr.keybind.KeybindManager import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.provisioning.ProvisioningManager @@ -144,6 +145,7 @@ fun buildTestAppContext(server: VRServer): AppContextProvider = object : TestApp abstract class TestAppContext : AppContextProvider { override val featureFlags: FeatureFlags = FeatureFlags() + override val keybindManager: KeybindManager get() = error("not used in test") override val skeleton: Skeleton get() = error("not used in test") override val config: AppConfig get() = error("not used in test") override val serialServer: SerialServer get() = error("not used in test") 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 feb64e96ce..ece046c9fe 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -29,6 +29,7 @@ import dev.slimevr.desktop.vrchat.createDesktopVRCConfigManager import dev.slimevr.desktop.vrchat.resolveDesktopOscQueryAddress import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationManager +import dev.slimevr.keybind.KeybindManager import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.provisioning.ProvisioningManager @@ -41,11 +42,8 @@ import dev.slimevr.udp.UdpServer import dev.slimevr.util.safeLaunch import dev.slimevr.vmc.VMCManager import dev.slimevr.vrcosc.VRCOSCManager -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import solarxr_protocol.rpc.KeybindSupport fun main(args: Array) = runBlocking { @@ -72,7 +70,7 @@ fun main(args: Array) = runBlocking { featureFlags.keybindSupport = when (CURRENT_PLATFORM) { Platform.WINDOWS -> KeybindSupport.APP_MANAGED - Platform.LINUX -> if (isGnome() && resolveGnomeAppId() != null) { + Platform.LINUX -> if (isGnome()) { KeybindSupport.APP_MANAGED } else { KeybindSupport.SYSTEM_MANAGED @@ -124,12 +122,14 @@ fun main(args: Array) = runBlocking { ) val resetsManager = ResetsManager.create(ctx = phase1, scope = this) val tapDetectionManager = TapDetectionManager.create(ctx = phase1, resetsManager = resetsManager, scope = this) + val keybindManager = KeybindManager.create(scope = this) val appContext = AppContext( server = server, config = config, serialServer = serialServer, featureFlags = featureFlags, + keybindManager = keybindManager, skeleton = skeleton, firmwareManager = firmwareManager, vrcConfigManager = vrcConfigManager, @@ -146,15 +146,6 @@ fun main(args: Array) = runBlocking { tapDetectionManager = tapDetectionManager, ) - // JVM shutdown hooks fire on SIGINT/SIGTERM but coroutine `finally` blocks do not, - // so cancel the root scope on shutdown to let structured concurrency run every cleanup. - val rootJob = coroutineContext[Job]!! - Runtime.getRuntime().addShutdownHook( - Thread { - rootJob.cancel() - runBlocking { rootJob.join() } - }, - ) try { appContext.startObserving() @@ -167,6 +158,6 @@ fun main(args: Array) = runBlocking { awaitCancellation() } finally { - withContext(NonCancellable) { appContext.dispose() } + appContext.dispose() } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt index e60c7059d4..64f39b4b5a 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt @@ -12,39 +12,24 @@ import dev.slimevr.Platform import dev.slimevr.SLIMEVR_IDENTIFIER import dev.slimevr.config.KeybindConfig import dev.slimevr.config.SettingsActions -import dev.slimevr.skeleton.SkeletonActions +import dev.slimevr.keybind.KeybindEvent import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.KeybindId -import solarxr_protocol.rpc.ResetType -private const val KEYBIND_SOURCE = "Keybind" - -private val FEET_BODY_PARTS = listOf(BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT) - -private fun currentKeybinds(appContext: AppContextProvider): List = appContext.config.settings.context.state.value.data.keybinds - -// The gui sends one request per keybind, so changing several at once (or resetting them all) arrives -// as a burst. Recreating a portal session for each one makes GNOME reject the bind and leaves -// nothing grabbed, so settle first and apply the burst as a single change. private const val BINDING_SETTLE_MS = 300L -@OptIn(FlowPreview::class) -private fun bindingsFlow(appContext: AppContextProvider): Flow> = appContext.config.settings.context.state - .map { it.data.keybinds } - .distinctUntilChangedBy { keybinds -> keybinds.map { it.id to it.binding } } - .debounce(BINDING_SETTLE_MS) - fun keybindLabel(id: KeybindId): String = id.name .split('_') .joinToString(" ") { word -> word.lowercase().replaceFirstChar { it.uppercase() } } @@ -53,33 +38,6 @@ private fun toJIntellitypeAccelerator(binding: String): String = binding .split('+') .joinToString("+") { if (it == "SUPER") "WIN" else it } -private fun triggerKeybind(appContext: AppContextProvider, scope: CoroutineScope, id: KeybindId) { - val delaySeconds = currentKeybinds(appContext).find { it.id == id }?.delay ?: 0f - scope.safeLaunch { - when (id) { - KeybindId.FULL_RESET -> - appContext.resetsManager.scheduleReset(KEYBIND_SOURCE, ResetType.FULL, delaySeconds) - - KeybindId.YAW_RESET -> - appContext.resetsManager.scheduleReset(KEYBIND_SOURCE, ResetType.YAW, delaySeconds) - - KeybindId.MOUNTING_RESET -> - appContext.resetsManager.scheduleReset(KEYBIND_SOURCE, ResetType.MOUNTING, delaySeconds) - - KeybindId.FEET_MOUNTING_RESET -> - appContext.resetsManager.scheduleReset(KEYBIND_SOURCE, ResetType.MOUNTING, delaySeconds, FEET_BODY_PARTS) - - KeybindId.PAUSE_TRACKING -> { - if (delaySeconds > 0f) delay((delaySeconds * 1000).toLong()) - val skeleton = appContext.skeleton.context - skeleton.dispatch(SkeletonActions.PauseTracking(!skeleton.state.value.paused)) - } - - KeybindId.NONE -> Unit - } - } -} - suspend fun createDesktopKeybindManager(appContext: AppContextProvider, scope: CoroutineScope) { when (CURRENT_PLATFORM) { Platform.WINDOWS -> setupWindowsKeybinds(appContext, scope) @@ -97,20 +55,36 @@ private suspend fun setupWindowsKeybinds(appContext: AppContextProvider, scope: } instance.addHotKeyListener { identifier -> - KeybindId.fromValue(identifier.toUByte())?.let { triggerKeybind(appContext, scope, it) } + KeybindId.fromValue(identifier.toUByte())?.let { id -> + scope.safeLaunch { appContext.keybindManager.events.emit(KeybindEvent.Fired(id)) } + } } var registered: List = emptyList() - bindingsFlow(appContext).onEach { keybinds -> + suspend fun apply(keybinds: List) { registered.forEach { runCatching { instance.unregisterHotKey(it.id.value.toInt()) } } - keybinds.forEach { keybind -> + registered = emptyList() + if (appContext.keybindManager.recording) return + + val active = keybinds.filter { it.binding.isNotEmpty() } + active.forEach { keybind -> runCatching { instance.registerHotKey(keybind.id.value.toInt(), toJIntellitypeAccelerator(keybind.binding)) } .onFailure { AppLogger.keybind.warn("Failed to bind ${keybind.id.name} to ${keybind.binding}") } } - registered = keybinds - }.launchIn(scope) + registered = active + } + + apply(appContext.config.settings.context.state.value.data.keybinds) + appContext.keybindManager.events.on { apply(it.keybinds) } + + appContext.keybindManager.context.state + .map { it.recording } + .distinctUntilChanged() + .onEach { apply(appContext.config.settings.context.state.value.data.keybinds) } + .launchIn(scope) } +@OptIn(FlowPreview::class) private suspend fun setupLinuxKeybinds(appContext: AppContextProvider, scope: CoroutineScope) { val gnomeAppId = if (isGnome()) resolveGnomeAppId() else null var handler: GlobalShortcutsHandler? = null @@ -127,37 +101,56 @@ private suspend fun setupLinuxKeybinds(appContext: AppContextProvider, scope: Co // Closing a handler disconnects its dbus connection, so every session gets its own manager suspend fun bind(keybinds: List) { runCatching { handler?.close() } - val shortcuts = keybinds.map { keybind -> - ShortcutTuple(keybind.id.name, Shortcut(keybindLabel(keybind.id), keybind.binding).shortcut) - }.toMutableList() + handler = null + if (appContext.keybindManager.recording) return + + val shortcuts = keybinds + .filter { it.binding.isNotEmpty() } + .map { keybind -> ShortcutTuple(keybind.id.name, Shortcut(keybindLabel(keybind.id), keybind.binding).shortcut) } + .toMutableList() + if (shortcuts.isEmpty()) return handler = runCatching { PortalManager(SLIMEVR_IDENTIFIER).globalShortcutsRequest(shortcuts).apply { onShortcutActivated = { shortcutId -> - KeybindId.entries.firstOrNull { it.name == shortcutId }?.let { triggerKeybind(appContext, scope, it) } - } - if (gnomeAppId != null) { - onShortcutsChanged = { scope.safeLaunch { adoptGnomeShortcuts(gnomeAppId) } } + KeybindId.entries.firstOrNull { it.name == shortcutId }?.let { id -> + scope.safeLaunch { appContext.keybindManager.events.emit(KeybindEvent.Fired(id)) } + } } } }.onFailure { AppLogger.keybind.error(it, "Failed to register global shortcuts, keybinds will not work") }.getOrNull() } - - bind(currentKeybinds(appContext)) - if (gnomeAppId != null) { adoptGnomeShortcuts(gnomeAppId) - - bindingsFlow(appContext) - .onEach { keybinds -> - writeGnomeShortcuts(gnomeAppId, keybinds) - bind(keybinds) - } - .launchIn(scope) } + bind(appContext.config.settings.context.state.value.data.keybinds) + + appContext.keybindManager.context.state + .map { it.recording } + .distinctUntilChanged() + .filter { it } + .onEach { + runCatching { handler?.close() } + handler = null + } + .launchIn(scope) + + combine( + appContext.keybindManager.context.state.map { it.recording }.distinctUntilChanged(), + appContext.config.settings.context.state.map { it.data.keybinds }.distinctUntilChangedBy { keybinds -> keybinds.map { it.id to it.binding } }, + ) { recording, keybinds -> recording to keybinds } + .drop(1) + .debounce(BINDING_SETTLE_MS) + .filter { (recording, _) -> !recording } + .onEach { (_, keybinds) -> + if (gnomeAppId != null) writeGnomeShortcuts(gnomeAppId, keybinds) + bind(keybinds) + } + .launchIn(scope) + scope.safeLaunch { try { awaitCancellation() diff --git a/solarxr-protocol b/solarxr-protocol index b01aa81cc2..240ee6c148 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit b01aa81cc2f5afd730411435cf0f5c448f9ba5ac +Subproject commit 240ee6c1485023f839dec3cbcf757caee3188679 From 86433dfb3d3b4ac59b408d943391c3cf3e72280a Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 22 Jul 2026 02:12:27 +0200 Subject: [PATCH 282/350] Dont forget to set feature flags for android --- .../src/main/java/dev/slimevr/android/ForegroundService.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt index 4331fa016d..bba20e8963 100644 --- a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt +++ b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt @@ -164,7 +164,11 @@ class ForegroundService : Service() { server = server, config = config, serialServer = serialServer, - featureFlags = FeatureFlags(), + featureFlags = FeatureFlags( + skipCheckUdev = true, + keybindSupport = KeybindSupport.UNSUPPORTED, + udevRulesInstalled = false + ), keybindManager = keybindManager, skeleton = skeleton, firmwareManager = firmwareManager, From bd126ec09917581ad04e7dc4246e3f4ffe071636 Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 22 Jul 2026 14:10:10 -0400 Subject: [PATCH 283/350] Rotation polarity tracking --- .../java/dev/slimevr/tracker/behaviours.kt | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 1d48c7b198..9e0134f74c 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -38,7 +38,7 @@ class TrackerBasicBehaviour : TrackerBehaviour { cal.headingCorrection, cal.attitudeAlignment, cal.headingAlignment, - ) + ).twinNearest(state.rotation) cal != null -> state.rotation @@ -91,10 +91,18 @@ class TrackerBasicBehaviour : TrackerBehaviour { attitudeAlignment = attitudeAlignment, ) - // TODO: Immediately apply calibration on `state.rawRotation` so we don't - // need to worry about desync of `state.rotation` - // (+1 from Erimel) - state.copy(sessionCalibration = sessionCalibration) + // Apply calibration on rawRotation + val rotation = applyCalibration( + state.rawRotation, + sessionCalibration.headingCorrection, + sessionCalibration.attitudeAlignment, + sessionCalibration.headingAlignment, + ) // don't track polarity on Full Reset + + state.copy( + sessionCalibration = sessionCalibration, + rotation = rotation, + ) } is TrackerActions.YawReset -> { @@ -109,8 +117,18 @@ class TrackerBasicBehaviour : TrackerBehaviour { headingCorrection = headingCorrection, ) - // TODO: Apply calibration on `state.rawRotation` -> `state.rotation` - state.copy(sessionCalibration = sessionCalibration) + // Apply calibration on rawRotation + val rotation = applyCalibration( + state.rawRotation, + sessionCalibration.headingCorrection, + sessionCalibration.attitudeAlignment, + sessionCalibration.headingAlignment, + ).twinNearest(state.rotation) + + state.copy( + sessionCalibration = sessionCalibration, + rotation = rotation, + ) } is TrackerActions.MountingReset -> { @@ -130,8 +148,18 @@ class TrackerBasicBehaviour : TrackerBehaviour { headingAlignment = headingAlignment, ) - // TODO: Apply calibration on `state.rawRotation` -> `state.rotation` - state.copy(sessionCalibration = sessionCalibration) + // Apply calibration on rawRotation + val rotation = applyCalibration( + state.rawRotation, + sessionCalibration.headingCorrection, + sessionCalibration.attitudeAlignment, + sessionCalibration.headingAlignment, + ).twinNearest(state.rotation) + + state.copy( + sessionCalibration = sessionCalibration, + rotation = rotation, + ) } } } From 0736d60c750c82a753b06f74f3fa4a97c524c929 Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 22 Jul 2026 18:28:07 -0400 Subject: [PATCH 284/350] Send both rotation and orientation solarxr --- .../src/components/commons/KeybindRow.scss | 1 - .../pages/components/TrackingSettings.tsx | 3 ++- .../widgets/SkeletonVisualizerWidget.tsx | 6 +---- gui/app/src/hooks/datafeed-config.ts | 17 ++++++++++--- gui/app/src/utils/skeletonHelper.ts | 19 ++------------- .../main/java/dev/slimevr/config/settings.kt | 6 ++--- .../main/java/dev/slimevr/skeleton/module.kt | 13 ++++++++-- .../main/java/dev/slimevr/solarxr/datafeed.kt | 24 +++++++++++-------- .../main/java/dev/slimevr/vmc/behaviours.kt | 7 ++++-- .../src/main/java/dev/slimevr/vmc/mapping.kt | 7 ------ solarxr-protocol | 2 +- 11 files changed, 53 insertions(+), 52 deletions(-) diff --git a/gui/app/src/components/commons/KeybindRow.scss b/gui/app/src/components/commons/KeybindRow.scss index 92e425a163..0fb556452f 100644 --- a/gui/app/src/components/commons/KeybindRow.scss +++ b/gui/app/src/components/commons/KeybindRow.scss @@ -37,4 +37,3 @@ .animate-shake { animation: shake 0.35s ease-in-out; } - diff --git a/gui/app/src/components/settings/pages/components/TrackingSettings.tsx b/gui/app/src/components/settings/pages/components/TrackingSettings.tsx index cf4541fc67..a90d92d447 100644 --- a/gui/app/src/components/settings/pages/components/TrackingSettings.tsx +++ b/gui/app/src/components/settings/pages/components/TrackingSettings.tsx @@ -117,7 +117,8 @@ export function TrackingSettings() { ratios.imputeSpineFromUpperToLower = values.ratios.imputeSpineFromUpperToLower; ratios.imputeSpineCurvature = values.ratios.imputeSpineCurvature; - ratios.interpolateHipWithUpperLegs = values.ratios.interpolateHipWithUpperLegs; + ratios.interpolateHipWithUpperLegs = + values.ratios.interpolateHipWithUpperLegs; ratios.interpolateUpperLegsWithLowerLegs = values.ratios.interpolateUpperLegsWithLowerLegs; ratios.skatingCorrectionStrength = values.ratios.skatingCorrectionStrength; diff --git a/gui/app/src/components/widgets/SkeletonVisualizerWidget.tsx b/gui/app/src/components/widgets/SkeletonVisualizerWidget.tsx index 3954b65235..173ceb748a 100644 --- a/gui/app/src/components/widgets/SkeletonVisualizerWidget.tsx +++ b/gui/app/src/components/widgets/SkeletonVisualizerWidget.tsx @@ -103,11 +103,7 @@ function initializePreview( scene.add(newSkeleton[0]); const hmd = bones.get(BodyPart.HEAD); - const chest = bones.get(BodyPart.UPPER_CHEST); - // Check if HMD is identity, if it's then use upper chest's rotation - const quat = isIdentity(hmd?.rotationG) - ? QuaternionFromQuatT(chest?.rotationG).normalize().invert() - : QuaternionFromQuatT(hmd?.rotationG).normalize().invert(); + const quat = QuaternionFromQuatT(hmd?.orientationG).normalize().invert(); // Project quat to (0x, 1y, 0z) const VEC_Y = new Vector3(0, 1, 0); diff --git a/gui/app/src/hooks/datafeed-config.ts b/gui/app/src/hooks/datafeed-config.ts index 588783d3f7..23ed1c2459 100644 --- a/gui/app/src/hooks/datafeed-config.ts +++ b/gui/app/src/hooks/datafeed-config.ts @@ -1,4 +1,9 @@ -import { DataFeedConfigT, DeviceDataMaskT, TrackerDataMaskT } from 'solarxr-protocol'; +import { + BoneMaskT, + DataFeedConfigT, + DeviceDataMaskT, + TrackerDataMaskT, +} from 'solarxr-protocol'; import { useConfig } from './config'; export function useDataFeedConfig() { @@ -26,7 +31,7 @@ export function useDataFeedConfig() { const dataFeedConfig = new DataFeedConfigT(); dataFeedConfig.dataMask = dataMask; - dataFeedConfig.boneMask = false; + dataFeedConfig.boneMask = null; dataFeedConfig.minimumTimeSinceLast = 1000 / feedMaxTps; dataFeedConfig.syntheticTrackersMask = trackerData; dataFeedConfig.stayAlignedPoseMask = true; @@ -45,7 +50,13 @@ export function useBonesDataFeedConfig() { const feedMaxTps = fastDataFeed ? 90 : 40; const dataFeedConfig = new DataFeedConfigT(); - dataFeedConfig.boneMask = true; + const boneMask = new BoneMaskT(); + boneMask.bodyPart = true; + boneMask.orientationG = true; + boneMask.rotationG = false; + boneMask.boneLength = true; + boneMask.headPositionG = true; + dataFeedConfig.boneMask = boneMask; dataFeedConfig.minimumTimeSinceLast = 1000 / feedMaxTps; return dataFeedConfig; } diff --git a/gui/app/src/utils/skeletonHelper.ts b/gui/app/src/utils/skeletonHelper.ts index eb77d95a48..8d8c379d9c 100644 --- a/gui/app/src/utils/skeletonHelper.ts +++ b/gui/app/src/utils/skeletonHelper.ts @@ -162,25 +162,10 @@ export class BoneKind extends Bone { return; } - const quat = QuaternionFromQuatT(this.boneT.rotationG) - // .normalize() - // .multiply( - // parentBone === undefined - // ? new Quaternion().identity() - // : QuaternionFromQuatT(parentBone.rotationG).normalize().invert().normalize() - // ) - .normalize(); - - // console.log(this.quaternion); - // console.log( - // parentBone === undefined - // ? new Vector3(0, 0, 0) - // : Vector3FromVec3fT(parentBone.headPositionG), - // Vector3FromVec3fT(this.boneT.headPositionG) - // ); + const quat = QuaternionFromQuatT(this.boneT.orientationG).normalize(); + this.position.set(0, -this.boneT.boneLength, 0); this.position.applyQuaternion(quat); - // console.log(this.position); } get boneColor(): Color { diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index fdb072ba3b..7334654ed8 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -48,7 +48,7 @@ data class OutputTrackersConfig( */ @Serializable(with = BodyPartListSerializer::class) val trackers: List = listOf(), - val sendDerivedVelocity: Boolean = false, // TODO + val sendDerivedVelocity: Boolean = false, // TODO do we actually need that or can we disable OpenVR's prediction ) @Serializable @@ -112,10 +112,10 @@ fun defaultKeybinds(): List = listOf( // Used in SkeletonConfig @Serializable data class SkeletonTogglesConfig( - val forceArmsFromHmd: Boolean = true, + val forceArmsFromHmd: Boolean = true, // TODO do we still need that with useTrackerPositions? val floorClip: Boolean = true, val skatingCorrection: Boolean = true, - val toeSnap: Boolean = false, + val toeSnap: Boolean = true, val footPlant: Boolean = true, val mocapMode: Boolean = false, val useTrackerPositions: Boolean = true, diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 1fbadd98c4..1cf40a1c75 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -34,12 +34,21 @@ data class BoneState( val tailPosition: Vector3 = Vector3.NULL, val parentBone: BoneState? = null, ) { + private val orientationOffset = if (offset.len() > 0f) { + if (offset.unit().y == 1f) { + Quaternion.I + } else { + Quaternion.fromTo(Vector3.NEG_Y, offset) + } + } else { + Quaternion.IDENTITY + } + val orientation: Quaternion = rotation * orientationOffset val localRotation: Quaternion get() = parentBone?.let { it.rotation.inv() * rotation } ?: rotation val localHeadPosition: Vector3 get() = parentBone?.let { headPosition - it.tailPosition } ?: headPosition - val localTailPosition: Vector3 - get() = tailPosition - headPosition + val localTailPosition = tailPosition - headPosition } /** diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index c0eac61d39..20f16cdd01 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -12,6 +12,7 @@ import dev.slimevr.util.safeLaunch import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import solarxr_protocol.data_feed.BoneMask import solarxr_protocol.data_feed.DataFeedConfig import solarxr_protocol.data_feed.DataFeedMessageHeader import solarxr_protocol.data_feed.DataFeedUpdate @@ -102,11 +103,12 @@ private fun createDevice( ) } -private fun createBone(bone: BoneState): solarxr_protocol.data_feed.Bone = solarxr_protocol.data_feed.Bone( - bodyPart = bone.bodyPart, - rotationG = bone.rotation.let { Quat(it.x, it.y, it.z, it.w) }, - boneLength = bone.offset.len(), - headPositionG = bone.headPosition.let { Vec3f(it.x, it.y, it.z) }, +private fun createBone(bone: BoneState, mask: BoneMask): solarxr_protocol.data_feed.Bone = solarxr_protocol.data_feed.Bone( + bodyPart = bone.bodyPart.takeIf { mask.bodyPart == true }, + orientationG = bone.orientation.let { Quat(it.x, it.y, it.z, it.w) }.takeIf { mask.orientationG == true }, + rotationG = bone.rotation.let { Quat(it.x, it.y, it.z, it.w) }.takeIf { mask.rotationG == true }, + boneLength = bone.offset.len().takeIf { mask.boneLength == true }, + headPositionG = bone.headPosition.let { Vec3f(it.x, it.y, it.z) }.takeIf { mask.headPositionG == true }, ) private fun createServerGuards(resetsManager: ResetsManager, heightCalibrationManager: HeightCalibrationManager): ServerGuards { @@ -129,13 +131,15 @@ fun createDatafeedFrame( ): DataFeedMessageHeader { val serverState = server.context.state.value val trackers = serverState.trackers.values.map { it.context.state.value } - val devices = serverState.devices.values.map { it.context.state.value } - .map { device -> createDevice(device, trackers, datafeedConfig) } - val bones = if (datafeedConfig.boneMask == true) { - skeleton.computed.value.values.map { createBone(it) } + val devices = if (datafeedConfig.dataMask?.deviceData != null) { + serverState.devices.values.map { it.context.state.value } + .map { device -> createDevice(device, trackers, datafeedConfig) } } else { null } + val bones = datafeedConfig.boneMask?.let { mask -> + skeleton.computed.value.values.map { createBone(it, mask) } + } val serverGuards = if (datafeedConfig.serverGuardsMask == true) { createServerGuards(resetsManager, heightCalibrationManager) } else { @@ -143,7 +147,7 @@ fun createDatafeedFrame( } return DataFeedMessageHeader( message = DataFeedUpdate( - devices = if (datafeedConfig.dataMask?.deviceData != null) devices else null, + devices = devices, bones = bones, serverGuards = serverGuards, index = index.toUByte(), diff --git a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt index 9ec4aeb9dc..d875474cb0 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt @@ -101,15 +101,18 @@ class VMCOutputBehaviour( yield(OscMessage("/VMC/Ext/T", listOf(OscArg.Float(time)))) yield(OscMessage("/VMC/Ext/OK", listOf(OscArg.Int(1)))) - val rootPos = vmcRootPosition(bones, config, vrm) - yield(transformMessage("/VMC/Ext/Root/Pos", "root", rootPos, Quaternion.IDENTITY)) + // Send the origin as root + yield(transformMessage("/VMC/Ext/Root/Pos", "root", Vector3.NULL, Quaternion.IDENTITY)) + // TODO UpperChest + shoulders affecting arms local rot + // TODO Don't send fingers if we don't have any tracker for them for ((targetBodyPart, unityName) in BODY_PART_TO_UNITY_BONE) { val targetParentBodyPart = VMC_BONE_PARENTS[targetBodyPart] val trackingBodyPart = if (config.mirrorTracking) vmcMirrorSource(targetBodyPart) else targetBodyPart val trackingBone = bones[trackingBodyPart] ?: continue if (targetParentBodyPart == null) { + // TODO anchorHip https://github.com/SlimeVR/SlimeVR-Server/blob/main/server/core/src/main/java/dev/slimevr/osc/VMCHandler.kt#L371 val pos = vrm?.hipLocalPosition ?: Vector3.NULL val rot = vmcLocalRotation(trackingBone, null, targetBodyPart, null, config.mirrorTracking) yield(transformMessage("/VMC/Ext/Bone/Pos", unityName, pos, rot)) diff --git a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt index ab1b82798a..bb524e054f 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt @@ -225,10 +225,3 @@ fun vmcLocalPosition( val localPosition = bone.headPosition - parent.headPosition return parentAdjusted.inv().sandwich(if (mirror) vmcMirrorPosition(localPosition) else localPosition) } - -fun vmcRootPosition(bones: Map, config: VMCConfig, vrm: VrmGeometry?): Vector3 { - val userHip = bones[BodyPart.HIP]?.headPosition ?: Vector3.NULL - val hipLocalPosition = vrm?.hipLocalPosition ?: Vector3.NULL - val targetHip = if (config.anchorAtHips) Vector3.NULL else userHip - return (if (config.mirrorTracking) vmcMirrorPosition(targetHip) else targetHip) - hipLocalPosition -} diff --git a/solarxr-protocol b/solarxr-protocol index 240ee6c148..cc9d94b3df 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 240ee6c1485023f839dec3cbcf757caee3188679 +Subproject commit cc9d94b3df1e02cfb1f5efbc5f5de5883ee684ee From d96af009d94781ccce456fc4a4861e37b9c5d211 Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 22 Jul 2026 18:56:02 -0400 Subject: [PATCH 285/350] Optimize skeleton processors and rid order assumption --- .../dev/slimevr/android/ForegroundService.kt | 2 +- .../main/java/dev/slimevr/skeleton/module.kt | 14 ++-- .../skeleton/processors/bone-active-link.kt | 25 +++--- .../skeleton/processors/bone-direct-link.kt | 7 +- .../processors/bone-yaw-roll-align.kt | 25 +++--- .../skeleton/processors/fingers-impute.kt | 7 +- .../skeleton/processors/spine-impute.kt | 79 +++++++++---------- .../src/main/java/dev/slimevr/desktop/Main.kt | 1 - 8 files changed, 79 insertions(+), 81 deletions(-) diff --git a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt index bba20e8963..523483ab62 100644 --- a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt +++ b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt @@ -167,7 +167,7 @@ class ForegroundService : Service() { featureFlags = FeatureFlags( skipCheckUdev = true, keybindSupport = KeybindSupport.UNSUPPORTED, - udevRulesInstalled = false + udevRulesInstalled = false, ), keybindManager = keybindManager, skeleton = skeleton, diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 1cf40a1c75..4ea79acaeb 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -3,10 +3,10 @@ package dev.slimevr.skeleton import dev.slimevr.Phase1ContextProvider import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.skeleton.processors.BoneActiveLinkProcessor import dev.slimevr.skeleton.processors.BoneDirectLinkProcessor import dev.slimevr.skeleton.processors.BoneFallbackProcessor import dev.slimevr.skeleton.processors.BonePredictionProcessor -import dev.slimevr.skeleton.processors.BoneActiveLinkProcessor import dev.slimevr.skeleton.processors.BoneSmoothingProcessor import dev.slimevr.skeleton.processors.BoneYawRollAlignProcessor import dev.slimevr.skeleton.processors.FingerImputeProcessor @@ -34,14 +34,10 @@ data class BoneState( val tailPosition: Vector3 = Vector3.NULL, val parentBone: BoneState? = null, ) { - private val orientationOffset = if (offset.len() > 0f) { - if (offset.unit().y == 1f) { - Quaternion.I - } else { - Quaternion.fromTo(Vector3.NEG_Y, offset) - } - } else { - Quaternion.IDENTITY + private val orientationOffset = when { + offset.len() == 0f -> Quaternion.IDENTITY + offset.unit().y == 1f -> Quaternion.I + else -> Quaternion.fromTo(Vector3.NEG_Y, offset) } val orientation: Quaternion = rotation * orientationOffset val localRotation: Quaternion diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt index e1b168fad6..72cb142cad 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt @@ -25,18 +25,19 @@ class BoneActiveLinkProcessor : SkeletonProcessor { override fun process(state: SkeletonState): SkeletonState { val boneInputs = state.boneInputs - return state.copy( - boneInputs = boneInputs.mapValues { (bodyPart, bone) -> - if (bone.isActive) return@mapValues bone + val updatedLinkedBones = linkedToSources.keys.associateWith { bodyPart -> + val bone = boneInputs.getValue(bodyPart) + if (bone.isActive) return@associateWith bone - val closestActiveBone = linkedToSources[bodyPart] - ?.firstNotNullOfOrNull { part -> - boneInputs[part]?.takeIf { it.isActive } - } - bone.copy( - rawRotation = closestActiveBone?.rawRotation ?: bone.rawRotation, - ) - }, - ) + val closestActiveBone = linkedToSources.getValue(bodyPart) + .firstNotNullOfOrNull { part -> + boneInputs[part]?.takeIf { it.isActive } + } + bone.copy( + rawRotation = closestActiveBone?.rawRotation ?: bone.rawRotation, + ) + } + + return state.copy(boneInputs = boneInputs + updatedLinkedBones) } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt index e3e9c53a6a..e8dd76b54d 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt @@ -43,10 +43,11 @@ class BoneDirectLinkProcessor : SkeletonProcessor { override fun process(state: SkeletonState): SkeletonState { val updatedBoneInputs = state.boneInputs.toMutableMap() - for ((bodyPart, bone) in state.boneInputs) { - if (bone.isActive || bodyPart !in linkedToSource) continue + for (bodyPart in linkedToSource.keys) { + val bone = updatedBoneInputs.getValue(bodyPart) + if (bone.isActive) continue - val sourceBone = updatedBoneInputs[linkedToSource[bodyPart]] + val sourceBone = updatedBoneInputs[linkedToSource.getValue(bodyPart)] updatedBoneInputs[bodyPart] = bone.copy(rawRotation = sourceBone?.rawRotation ?: bone.rawRotation) } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt index e87dcfbf71..a96f6afa19 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt @@ -15,7 +15,7 @@ class BoneYawRollAlignProcessor(val settings: Settings) : SkeletonProcessor { * First value is the BodyPart to be aligned. * * Second value is a Pair containing a list of BodyParts to align with as well as a boolean specifying - * whether the BodyPart to be aligned must be active. + * whether the BodyPart to be aligned must be inactive. */ private val bodyPartToSources = mapOf( BodyPart.HIP to (setOf(BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG) to true), @@ -32,19 +32,20 @@ class BoneYawRollAlignProcessor(val settings: Settings) : SkeletonProcessor { BodyPart.RIGHT_UPPER_LEG to ratios.interpolateUpperLegsWithLowerLegs, ) - return state.copy( - boneInputs = boneInputs.mapValues { (bodyPart, bone) -> - val sources = bodyPartToSources[bodyPart] ?: return@mapValues bone - if (!sources.second || bone.isActive) return@mapValues bone - val mixFactor = bodyPartToMixFactor[bodyPart] ?: return@mapValues bone + val updatedAlignedBones = bodyPartToSources.keys.associateWith { bodyPart -> + val bone = boneInputs.getValue(bodyPart) + val sources = bodyPartToSources.getValue(bodyPart) + if (sources.second && bone.isActive) return@associateWith bone + val mixFactor = bodyPartToMixFactor[bodyPart] ?: return@associateWith bone - val sourceRotation = boneInputs.resolveRotationFor(sources.first) - val aligned = alignYawRoll(bone.rawRotation, sourceRotation) - val newRotation = bone.rawRotation.interpR(aligned, mixFactor) + val sourceRotation = boneInputs.resolveRotationFor(sources.first) + val aligned = alignYawRoll(bone.rawRotation, sourceRotation) + val newRotation = bone.rawRotation.interpR(aligned, mixFactor) - bone.copy(rawRotation = newRotation) - }, - ) + bone.copy(rawRotation = newRotation) + } + + return state.copy(boneInputs = boneInputs + updatedAlignedBones) } /** diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt index 2602a8d3a9..7fe2150dfe 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt @@ -52,10 +52,11 @@ class FingerImputeProcessor : SkeletonProcessor { override fun process(state: SkeletonState): SkeletonState { val updatedBoneInputs = state.boneInputs.toMutableMap() - for ((bodyPart, bone) in state.boneInputs) { - if (bone.isActive || bodyPart !in fingerToSource) continue + for (bodyPart in fingerToSource.keys) { + val bone = updatedBoneInputs.getValue(bodyPart) + if (bone.isActive) continue - val sourceBone = updatedBoneInputs[fingerToSource[bodyPart]] + val sourceBone = updatedBoneInputs[fingerToSource.getValue(bodyPart)] updatedBoneInputs[bodyPart] = bone.copy(rawRotation = sourceBone?.rawRotation ?: bone.rawRotation) } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt index 6d177116ed..837518f30e 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt @@ -47,54 +47,53 @@ class SpineImputeProcessor(val settings: Settings) : SkeletonProcessor { if (!hasHip) add(BodyPart.HIP) } - return state.copy( - boneInputs = boneInputs.mapValues { (bodyPart, bone) -> - val chainIndex = missingSpineParts.indexOf(bodyPart) - if (chainIndex == -1) return@mapValues bone - - // Get the first active bones above and below this one in the chain - val (fromBodyParts, toBodyParts) = when (bodyPart) { - BodyPart.WAIST -> { - val from = chestSet.takeIf { hasChest } ?: return@mapValues bone - val to = when { - hasHip -> hipSet - hasUpperLegs -> upperLegsSet - else -> return@mapValues bone - } - from to to + val updatedSpineBones = missingSpineParts.withIndex().associate { (chainIndex, bodyPart) -> + val bone = boneInputs.getValue(bodyPart) + + // Get the first active bones above and below this one in the chain + val (fromBodyParts, toBodyParts) = when (bodyPart) { + BodyPart.WAIST -> { + val from = chestSet.takeIf { hasChest } ?: return@associate bodyPart to bone + val to = when { + hasHip -> hipSet + hasUpperLegs -> upperLegsSet + else -> return@associate bodyPart to bone } + from to to + } - BodyPart.HIP -> { - val from = when { - hasWaist -> waistSet - hasChest -> chestSet - else -> return@mapValues bone - } - val to = upperLegsSet.takeIf { hasUpperLegs } ?: return@mapValues bone - from to to + BodyPart.HIP -> { + val from = when { + hasWaist -> waistSet + hasChest -> chestSet + else -> return@associate bodyPart to bone } - - else -> error("Invalid missing spine body part $bodyPart") + val to = upperLegsSet.takeIf { hasUpperLegs } ?: return@associate bodyPart to bone + from to to } - val fromReliability = combinationToReliability[(bodyPart to fromBodyParts)] ?: error("Invalid from body part combination $bodyPart, $fromBodyParts") - val toReliability = combinationToReliability[(bodyPart to toBodyParts)] ?: error("Invalid to body part combination $bodyPart, $toBodyParts") + else -> error("Invalid missing spine body part $bodyPart") + } + + val fromReliability = combinationToReliability[(bodyPart to fromBodyParts)] ?: error("Invalid from body part combination $bodyPart, $fromBodyParts") + val toReliability = combinationToReliability[(bodyPart to toBodyParts)] ?: error("Invalid to body part combination $bodyPart, $toBodyParts") - val interpolateRatio = interpolateRatio( - chainIndex, - missingSpineParts.size, - ratios.imputeSpineFromUpperToLower, - ratios.imputeSpineCurvature, - fromReliability, - toReliability, - ) + val interpolateRatio = interpolateRatio( + chainIndex, + missingSpineParts.size, + ratios.imputeSpineFromUpperToLower, + ratios.imputeSpineCurvature, + fromReliability, + toReliability, + ) - val fromRotation = boneInputs.resolveRotationFor(fromBodyParts) - val toRotation = boneInputs.resolveRotationFor(toBodyParts) + val fromRotation = boneInputs.resolveRotationFor(fromBodyParts) + val toRotation = boneInputs.resolveRotationFor(toBodyParts) + + bodyPart to bone.copy(rawRotation = fromRotation.interpQ(toRotation, interpolateRatio)) + } - bone.copy(rawRotation = fromRotation.interpQ(toRotation, interpolateRatio)) - }, - ) + return state.copy(boneInputs = boneInputs + updatedSpineBones) } /** 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 ece046c9fe..49359dcfe0 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -146,7 +146,6 @@ fun main(args: Array) = runBlocking { tapDetectionManager = tapDetectionManager, ) - try { appContext.startObserving() From 0aa3b9966e0b0d37f34aef036025a1d1d6ad3589 Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 22 Jul 2026 19:49:04 -0400 Subject: [PATCH 286/350] Save mountingResetOrientation --- .../main/java/dev/slimevr/config/settings.kt | 3 ++- .../main/java/dev/slimevr/tracker/config.kt | 22 +++++++++++++++---- .../main/java/dev/slimevr/tracker/module.kt | 8 ++++--- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 7334654ed8..4e1628857f 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -26,6 +26,7 @@ data class TrackerConfig( val customName: String? = null, @Serializable(with = QuaternionSerializer::class) val mountingOrientation: Quaternion? = null, + val mountingResetOrientation: Quaternion? = null, val magEnabled: Boolean? = null, ) @@ -184,10 +185,10 @@ data class SettingsConfigState( val outputTrackersConfig: OutputTrackersConfig = OutputTrackersConfig(), val tapDetectionConfig: TapDetectionConfig = TapDetectionConfig(), val resetsConfig: ResetsConfig = ResetsConfig(), + val keybinds: List = defaultKeybinds(), val skeletonConfig: SkeletonConfig = SkeletonConfig(), val vrcOscConfig: VRCOSCConfig = VRCOSCConfig(), val vmcConfig: VMCConfig = VMCConfig(), - val keybinds: List = defaultKeybinds(), ) private fun migrateSettingsConfig(json: JsonObject): JsonObject { diff --git a/server/core/src/main/java/dev/slimevr/tracker/config.kt b/server/core/src/main/java/dev/slimevr/tracker/config.kt index 87d1d365b8..b71acff977 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/config.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/config.kt @@ -10,10 +10,19 @@ import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.MagnetometerStatus -fun restoreFromConfig(state: TrackerState, config: TrackerConfig): TrackerState = state.copy( +fun restoreFromConfig(state: TrackerState, config: TrackerConfig, saveMountingReset: Boolean): TrackerState = state.copy( bodyPart = config.bodyPart?.takeIf { it != BodyPart.NONE } ?: state.bodyPart, customName = config.customName ?: state.customName, mountingOrientation = config.mountingOrientation ?: state.mountingOrientation, + sessionCalibration = if (saveMountingReset) { + config.mountingResetOrientation?.let { + SessionCalibration( + headingAlignment = it, + ) + } + } else { + null + }, magStatus = when (config.magEnabled) { true -> MagnetometerStatus.ENABLED false -> MagnetometerStatus.DISABLED @@ -21,10 +30,11 @@ fun restoreFromConfig(state: TrackerState, config: TrackerConfig): TrackerState }, ) -private fun applyStateToConfig(config: TrackerConfig, state: TrackerState) = config.copy( +private fun applyStateToConfig(config: TrackerConfig, state: TrackerState, saveMountingReset: Boolean) = config.copy( bodyPart = state.bodyPart, customName = state.customName, mountingOrientation = state.mountingOrientation, + mountingResetOrientation = if (saveMountingReset) state.sessionCalibration?.headingAlignment else null, magEnabled = when (state.magStatus) { MagnetometerStatus.ENABLED -> true MagnetometerStatus.DISABLED -> false @@ -38,10 +48,14 @@ class TrackerConfigBehaviour( ) : TrackerBehaviour { override fun observe(receiver: Tracker) { receiver.context.state - .distinctUntilChangedBy { it.bodyPart to it.customName to it.mountingOrientation to it.magStatus } + .distinctUntilChangedBy { + val saveMountingReset = receiver.settings.context.state.value.data.resetsConfig.saveMountingReset + val headingAlignment = if (saveMountingReset) it.sessionCalibration?.headingAlignment else null + it.bodyPart to it.customName to it.mountingOrientation to it.magStatus to headingAlignment + } .drop(1) .onEach { state -> - settings.context.dispatch(SettingsActions.UpdateTracker(hardwareId) { applyStateToConfig(this, state) }) + settings.context.dispatch(SettingsActions.UpdateTracker(hardwareId) { applyStateToConfig(this, state, receiver.settings.context.state.value.data.resetsConfig.saveMountingReset) }) } .launchIn(receiver.context.scope) } diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index dab5ac5eaa..7ce46f9e8f 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -1,6 +1,7 @@ package dev.slimevr.tracker import dev.slimevr.AppContextProvider +import dev.slimevr.config.Settings import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.context.debug.DiffStyle @@ -57,6 +58,7 @@ typealias TrackerBehaviour = Behaviour class Tracker( val context: TrackerContext, val appContext: AppContextProvider, + val settings: Settings, ) { fun startObserving() = context.observeAll(this) @@ -72,7 +74,7 @@ class Tracker( appContext: AppContextProvider, ): Tracker { val settings = appContext.config.settings - val trackerConfigs = appContext.config.settings.context.state.value.data.trackers + val trackerConfigs = settings.context.state.value.data.trackers val savedConfig = trackerConfigs[hardwareId] val baseState = TrackerState( id = id, @@ -98,7 +100,7 @@ class Tracker( sessionCalibration = null, ) val trackerState = if (savedConfig != null) { - restoreFromConfig(baseState, savedConfig) + restoreFromConfig(baseState, savedConfig, settings.context.state.value.data.resetsConfig.saveMountingReset) } else { baseState } @@ -120,7 +122,7 @@ class Tracker( ), name = "Tracker[$hardwareId]", ) - val tracker = Tracker(context = context, appContext) + val tracker = Tracker(context = context, appContext, settings) tracker.startObserving() return tracker } From 8e19542c82918b7c2394bce53cc453f0cc7864a7 Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 23 Jul 2026 17:14:38 -0400 Subject: [PATCH 287/350] ArmsResetMode --- .../main/java/dev/slimevr/config/settings.kt | 6 +- .../java/dev/slimevr/resets/bodypart-sets.kt | 77 +++++++++++++++++++ .../main/java/dev/slimevr/resets/module.kt | 51 ++++++++++-- .../java/dev/slimevr/tracker/behaviours.kt | 58 ++++++++------ .../main/java/dev/slimevr/tracker/module.kt | 6 +- .../slimevr/tracker/session-calibration.kt | 6 +- .../slimevr/tracker/SessionCalibrationTest.kt | 32 +++++--- 7 files changed, 189 insertions(+), 47 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 4e1628857f..a915957a0e 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -82,14 +82,14 @@ data class ResetsConfig( /** Always reset mounting for feet */ val resetMountingFeet: Boolean = false, /** Reset mode used for the arms */ - val armsResetMode: ArmsResetMode = ArmsResetMode.BACK, // TODO + val armsResetMode: ArmsResetMode = ArmsResetMode.BACK, /** Yaw reset smoothing time in seconds */ val yawResetSmoothTime: Float = 0.0f, // TODO /** Save automatic mounting reset calibration */ - val saveMountingReset: Boolean = false, // TODO + val saveMountingReset: Boolean = false, /** Reset the HMD's pitch upon full reset */ val resetHmdPitch: Boolean = false, // TODO - val lastMountingMethod: MountingMethods = MountingMethods.AUTOMATIC, // TODO + val lastMountingMethod: MountingMethods = MountingMethods.AUTOMATIC, // TODO in trackingchecklist ) @Serializable diff --git a/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt b/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt new file mode 100644 index 0000000000..f5aafebb44 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt @@ -0,0 +1,77 @@ +package dev.slimevr.resets + +import solarxr_protocol.datatypes.BodyPart + +val UPPER_LEG_PARTS = setOf( + BodyPart.LEFT_UPPER_LEG, + BodyPart.RIGHT_UPPER_LEG, +) + +val LEFT_ARM_PARTS = setOf( + BodyPart.LEFT_SHOULDER, + BodyPart.LEFT_UPPER_ARM, + BodyPart.LEFT_LOWER_ARM, + BodyPart.LEFT_HAND, +) + +val RIGHT_ARM_PARTS = setOf( + BodyPart.RIGHT_SHOULDER, + BodyPart.RIGHT_UPPER_ARM, + BodyPart.RIGHT_LOWER_ARM, + BodyPart.RIGHT_HAND, +) + +val ARM_PARTS = LEFT_ARM_PARTS + RIGHT_ARM_PARTS + +val LEFT_LOWER_ARM_PARTS = setOf( + BodyPart.LEFT_LOWER_ARM, + BodyPart.LEFT_HAND, +) + +val RIGHT_LOWER_ARM_PARTS = setOf( + BodyPart.RIGHT_LOWER_ARM, + BodyPart.RIGHT_HAND, +) + +val LOWER_ARM_PARTS = LEFT_LOWER_ARM_PARTS + RIGHT_LOWER_ARM_PARTS + +val FOOT_PARTS = setOf( + BodyPart.LEFT_FOOT, + BodyPart.RIGHT_FOOT, +) + +val LEFT_FINGER_PARTS = setOf( + BodyPart.LEFT_THUMB_METACARPAL, + BodyPart.LEFT_THUMB_PROXIMAL, + BodyPart.LEFT_THUMB_DISTAL, + BodyPart.LEFT_INDEX_PROXIMAL, + BodyPart.LEFT_INDEX_INTERMEDIATE, + BodyPart.LEFT_INDEX_DISTAL, + BodyPart.LEFT_MIDDLE_PROXIMAL, + BodyPart.LEFT_MIDDLE_INTERMEDIATE, + BodyPart.LEFT_MIDDLE_DISTAL, + BodyPart.LEFT_RING_PROXIMAL, + BodyPart.LEFT_RING_INTERMEDIATE, + BodyPart.LEFT_RING_DISTAL, + BodyPart.LEFT_LITTLE_PROXIMAL, + BodyPart.LEFT_LITTLE_INTERMEDIATE, + BodyPart.LEFT_LITTLE_DISTAL, +) + +val RIGHT_FINGER_PARTS = setOf( + BodyPart.RIGHT_THUMB_METACARPAL, + BodyPart.RIGHT_THUMB_PROXIMAL, + BodyPart.RIGHT_THUMB_DISTAL, + BodyPart.RIGHT_INDEX_PROXIMAL, + BodyPart.RIGHT_INDEX_INTERMEDIATE, + BodyPart.RIGHT_INDEX_DISTAL, + BodyPart.RIGHT_MIDDLE_PROXIMAL, + BodyPart.RIGHT_MIDDLE_INTERMEDIATE, + BodyPart.RIGHT_MIDDLE_DISTAL, + BodyPart.RIGHT_RING_PROXIMAL, + BodyPart.RIGHT_RING_INTERMEDIATE, + BodyPart.RIGHT_RING_DISTAL, + BodyPart.RIGHT_LITTLE_PROXIMAL, + BodyPart.RIGHT_LITTLE_INTERMEDIATE, + BodyPart.RIGHT_LITTLE_DISTAL, +) diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index ce69b26f9e..01617ae744 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -1,5 +1,6 @@ package dev.slimevr.resets +import com.jme3.math.FastMath import dev.slimevr.AppLogger import dev.slimevr.Phase1ContextProvider import dev.slimevr.VRServer @@ -7,12 +8,12 @@ import dev.slimevr.config.MountingMethods import dev.slimevr.config.ResetsConfig import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions -import dev.slimevr.config.SettingsConfigState -import dev.slimevr.config.SettingsContext import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.tracker.TrackerActions import dev.slimevr.util.safeLaunch +import io.github.axisangles.ktmath.EulerAngles +import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -20,9 +21,11 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.rpc.ArmsResetMode import solarxr_protocol.rpc.ResetResponse import solarxr_protocol.rpc.ResetStatus import solarxr_protocol.rpc.ResetType +import kotlin.collections.contains import kotlin.collections.listOf data class ResetsState( @@ -42,6 +45,8 @@ typealias ResetsBehaviour = Behaviour class ResetsManager(val context: ResetsContext, val server: VRServer, val settings: Settings) { fun startObserving() = context.observeAll(this) + private val quarterRollLeft = EulerAngles(EulerOrder.YZX, 0f, 0f, -FastMath.HALF_PI).toQuaternion() + private val quarterRollRight = EulerAngles(EulerOrder.YZX, 0f, 0f, FastMath.HALF_PI).toQuaternion() private var resetJob: Job = Job() /** @@ -102,7 +107,7 @@ class ResetsManager(val context: ResetsContext, val server: VRServer, val settin allTrackers.filter { resetType != ResetType.MOUNTING || config.resetMountingFeet || - it.context.state.value.bodyPart !in setOf(BodyPart.LEFT_FOOT, BodyPart.RIGHT_FOOT) + it.context.state.value.bodyPart !in FOOT_PARTS } } @@ -116,13 +121,49 @@ class ResetsManager(val context: ResetsContext, val server: VRServer, val settin it.context.dispatch( when (resetType) { ResetType.YAW -> TrackerActions.YawReset(referenceRotation) - ResetType.FULL -> TrackerActions.FullReset(referenceRotation) - ResetType.MOUNTING -> TrackerActions.MountingReset(referenceRotation) + ResetType.FULL -> TrackerActions.FullReset(referenceRotation, getRestOrientation(it.context.state.value.bodyPart, config.armsResetMode)) + ResetType.MOUNTING -> TrackerActions.MountingReset(referenceRotation, getYawOffset(it.context.state.value.bodyPart, config.armsResetMode)) }, ) } } + private fun getRestOrientation(bodyPart: BodyPart?, armsResetMode: ArmsResetMode) = if (armsResetMode == ArmsResetMode.T_POSE_DOWN) { + when (bodyPart) { + in LEFT_ARM_PARTS, in LEFT_FINGER_PARTS -> quarterRollLeft + in RIGHT_ARM_PARTS, in RIGHT_FINGER_PARTS -> quarterRollRight + else -> Quaternion.IDENTITY + } + } else { + Quaternion.IDENTITY + } + + private fun getYawOffset(bodyPart: BodyPart?, armsResetMode: ArmsResetMode) = when (bodyPart) { + // Going forward + in UPPER_LEG_PARTS -> 0f + + in LOWER_ARM_PARTS if armsResetMode == ArmsResetMode.BACK -> 0f + + in ARM_PARTS if armsResetMode == ArmsResetMode.FORWARD -> 0f + + // Going left + in LEFT_ARM_PARTS if armsResetMode == ArmsResetMode.T_POSE_UP -> -FastMath.HALF_PI + + in RIGHT_ARM_PARTS if armsResetMode == ArmsResetMode.T_POSE_DOWN -> -FastMath.HALF_PI + + in RIGHT_FINGER_PARTS -> -FastMath.HALF_PI + + // Going right + in LEFT_ARM_PARTS if armsResetMode == ArmsResetMode.T_POSE_DOWN -> FastMath.HALF_PI + + in RIGHT_ARM_PARTS if armsResetMode == ArmsResetMode.T_POSE_UP -> FastMath.HALF_PI + + in LEFT_FINGER_PARTS -> FastMath.HALF_PI + + // Going back + else -> FastMath.PI + } + companion object { fun create(ctx: Phase1ContextProvider, scope: CoroutineScope): ResetsManager { val context = Context.create( diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 9e0134f74c..091a33c4cf 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -38,6 +38,7 @@ class TrackerBasicBehaviour : TrackerBehaviour { cal.headingCorrection, cal.attitudeAlignment, cal.headingAlignment, + state.restOrientation ).twinNearest(state.rotation) cal != null -> state.rotation @@ -97,10 +98,12 @@ class TrackerBasicBehaviour : TrackerBehaviour { sessionCalibration.headingCorrection, sessionCalibration.attitudeAlignment, sessionCalibration.headingAlignment, + action.restOrientation ) // don't track polarity on Full Reset state.copy( sessionCalibration = sessionCalibration, + restOrientation = action.restOrientation, rotation = rotation, ) } @@ -123,6 +126,7 @@ class TrackerBasicBehaviour : TrackerBehaviour { sessionCalibration.headingCorrection, sessionCalibration.attitudeAlignment, sessionCalibration.headingAlignment, + state.restOrientation ).twinNearest(state.rotation) state.copy( @@ -140,6 +144,7 @@ class TrackerBasicBehaviour : TrackerBehaviour { cal?.headingCorrection ?: Quaternion.IDENTITY, cal?.attitudeAlignment ?: Quaternion.IDENTITY, state.mountingOrientation ?: Quaternion.IDENTITY, + action.yawOffset, ) val sessionCalibration = state.sessionCalibration?.copy( @@ -154,6 +159,7 @@ class TrackerBasicBehaviour : TrackerBehaviour { sessionCalibration.headingCorrection, sessionCalibration.attitudeAlignment, sessionCalibration.headingAlignment, + state.restOrientation ).twinNearest(state.rotation) state.copy( @@ -169,31 +175,33 @@ class TrackerDefaultMountingOrientationBehaviour : TrackerBehaviour { * Returns the default mounting orientation for the body part */ private fun defaultMountingForBodyPart(bodyPart: BodyPart?): Quaternion = when (bodyPart) { - BodyPart.LEFT_LOWER_ARM, BodyPart.LEFT_HAND, - BodyPart.LEFT_INDEX_PROXIMAL, BodyPart.LEFT_INDEX_INTERMEDIATE, - BodyPart.LEFT_INDEX_DISTAL, BodyPart.LEFT_MIDDLE_PROXIMAL, - BodyPart.LEFT_MIDDLE_INTERMEDIATE, BodyPart.LEFT_MIDDLE_DISTAL, - BodyPart.LEFT_RING_PROXIMAL, BodyPart.LEFT_RING_INTERMEDIATE, - BodyPart.LEFT_RING_DISTAL, BodyPart.LEFT_LITTLE_PROXIMAL, - BodyPart.LEFT_LITTLE_INTERMEDIATE, BodyPart.LEFT_LITTLE_DISTAL, - BodyPart.LEFT_SHOULDER, - -> Quaternion.SLIMEVR.LEFT - - BodyPart.RIGHT_LOWER_ARM, BodyPart.RIGHT_HAND, - BodyPart.RIGHT_INDEX_PROXIMAL, BodyPart.RIGHT_INDEX_INTERMEDIATE, - BodyPart.RIGHT_INDEX_DISTAL, BodyPart.RIGHT_MIDDLE_PROXIMAL, - BodyPart.RIGHT_MIDDLE_INTERMEDIATE, BodyPart.RIGHT_MIDDLE_DISTAL, - BodyPart.RIGHT_RING_PROXIMAL, BodyPart.RIGHT_RING_INTERMEDIATE, - BodyPart.RIGHT_RING_DISTAL, BodyPart.RIGHT_LITTLE_PROXIMAL, - BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_DISTAL, - BodyPart.RIGHT_SHOULDER, - -> Quaternion.SLIMEVR.RIGHT - - BodyPart.LEFT_UPPER_ARM, BodyPart.LEFT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_LEFT - - BodyPart.RIGHT_UPPER_ARM, BodyPart.RIGHT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_RIGHT - - else -> Quaternion.SLIMEVR.FRONT +// BodyPart.LEFT_LOWER_ARM, BodyPart.LEFT_HAND, +// BodyPart.LEFT_INDEX_PROXIMAL, BodyPart.LEFT_INDEX_INTERMEDIATE, +// BodyPart.LEFT_INDEX_DISTAL, BodyPart.LEFT_MIDDLE_PROXIMAL, +// BodyPart.LEFT_MIDDLE_INTERMEDIATE, BodyPart.LEFT_MIDDLE_DISTAL, +// BodyPart.LEFT_RING_PROXIMAL, BodyPart.LEFT_RING_INTERMEDIATE, +// BodyPart.LEFT_RING_DISTAL, BodyPart.LEFT_LITTLE_PROXIMAL, +// BodyPart.LEFT_LITTLE_INTERMEDIATE, BodyPart.LEFT_LITTLE_DISTAL, +// BodyPart.LEFT_SHOULDER, +// -> Quaternion.SLIMEVR.LEFT +// +// BodyPart.RIGHT_LOWER_ARM, BodyPart.RIGHT_HAND, +// BodyPart.RIGHT_INDEX_PROXIMAL, BodyPart.RIGHT_INDEX_INTERMEDIATE, +// BodyPart.RIGHT_INDEX_DISTAL, BodyPart.RIGHT_MIDDLE_PROXIMAL, +// BodyPart.RIGHT_MIDDLE_INTERMEDIATE, BodyPart.RIGHT_MIDDLE_DISTAL, +// BodyPart.RIGHT_RING_PROXIMAL, BodyPart.RIGHT_RING_INTERMEDIATE, +// BodyPart.RIGHT_RING_DISTAL, BodyPart.RIGHT_LITTLE_PROXIMAL, +// BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_DISTAL, +// BodyPart.RIGHT_SHOULDER, +// -> Quaternion.SLIMEVR.RIGHT +// +// BodyPart.LEFT_UPPER_ARM, BodyPart.LEFT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_LEFT +// +// BodyPart.RIGHT_UPPER_ARM, BodyPart.RIGHT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_RIGHT +// +// else -> Quaternion.SLIMEVR.FRONT + // TODO fix mounting reset not mixing well with anything but BACK + else -> Quaternion.IDENTITY } override fun observe(receiver: Tracker) { diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 7ce46f9e8f..ab3fefb115 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -25,6 +25,7 @@ data class TrackerState( val bodyPart: BodyPart?, val customName: String?, val mountingOrientation: HeadingAlignment?, + val restOrientation: Quaternion, val rawRotation: RawRotation, val rotation: CalibratedRotation, val rawAcceleration: RawAcceleration, @@ -47,9 +48,9 @@ sealed interface TrackerActions { data class SetStatus(val status: TrackerStatus) : TrackerActions data class SetRotation(val rotation: Quaternion? = null, val acceleration: Vector3? = null, val magnetometer: Vector3? = null) : TrackerActions data class SetMountingOrientation(val mountingOrientation: HeadingAlignment?) : TrackerActions - data class FullReset(val referenceRotation: Quaternion) : TrackerActions + data class FullReset(val referenceRotation: Quaternion, val restOrientation: Quaternion) : TrackerActions data class YawReset(val referenceRotation: Quaternion) : TrackerActions - data class MountingReset(val referenceRotation: Quaternion) : TrackerActions + data class MountingReset(val referenceRotation: Quaternion, val yawOffset: Float) : TrackerActions } typealias TrackerContext = Context @@ -98,6 +99,7 @@ class Tracker( completedRestCalibration = false, magStatus = MagnetometerStatus.NOT_SUPPORTED, sessionCalibration = null, + restOrientation = Quaternion.IDENTITY, ) val trackerState = if (savedConfig != null) { restoreFromConfig(baseState, savedConfig, settings.context.state.value.data.resetsConfig.saveMountingReset) diff --git a/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt b/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt index 0fd1ba3a0f..d5e202be46 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt @@ -28,7 +28,8 @@ fun applyCalibration( headingCorrect: HeadingCorrection = Quaternion.IDENTITY, attitudeAlign: AttitudeAlignment = Quaternion.IDENTITY, headingAlign: HeadingAlignment = Quaternion.IDENTITY, -): CalibratedRotation = headingAlign.inv() * headingCorrect * rawRotation * attitudeAlign * headingAlign + restOrientation: Quaternion = Quaternion.IDENTITY, +): CalibratedRotation = headingAlign.inv() * headingCorrect * rawRotation * attitudeAlign * headingAlign * restOrientation // We reverse the order of headingAlign and attitudeAlign here since our // attitude alignment is within the raw heading frame of reference, so we must @@ -84,6 +85,7 @@ fun estimateHeadingAlign( headingCorrect: HeadingCorrection = Quaternion.IDENTITY, attitudeAlign: AttitudeAlignment = Quaternion.IDENTITY, headingAlign: HeadingAlignment = Quaternion.IDENTITY, + yawOffset: Float = 0.0f, ): HeadingAlignment { val refHeading = eulerHeading(referenceRotation) val rotation = applyCalibration( @@ -93,6 +95,6 @@ fun estimateHeadingAlign( headingAlign, ) val pitchRoll = (refHeading.inv() * rotation).sandwichUnitY() - val yawAngle = atan2(pitchRoll.x, pitchRoll.z) + val yawAngle = atan2(pitchRoll.x, pitchRoll.z) + yawOffset return Quaternion.rotationAroundYAxis(yawAngle) } diff --git a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt index ed03f5393e..e3c800d1c6 100644 --- a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt @@ -290,15 +290,18 @@ class SessionCalibrationTest { fun estimateHeadingAlignTests(): List { val frontRot = Quaternion(0.707f, 0.707f, 0f, 0f) return heading.flatMap { hA -> - heading.map { ref -> - DynamicTest.dynamicTest( - "( hA: $hA, ref: $ref )", - ) { - testEstimateHeadingAlign( - ref * frontRot, - hA, - ref, - ) + heading.flatMap { ref -> + radians.map { yawOffset -> + DynamicTest.dynamicTest( + "( hA: $hA, ref: $ref, yawOffset: $yawOffset )", + ) { + testEstimateHeadingAlign( + ref * frontRot, + hA, + ref, + yawOffset + ) + } } } } @@ -308,17 +311,22 @@ class SessionCalibrationTest { calibratedRotation: CalibratedRotation, headingAlign: HeadingAlignment, reference: Quaternion, + yawOffset: Float, ) { + // To undo the yawOffset that is baked into headingAlign + val yawOffsetRotation = Quaternion.rotationAroundYAxis(yawOffset) + // Only undo heading val rawRotation = undoCalibration( calibratedRotation, - headingAlign = headingAlign, + headingAlign = headingAlign * yawOffsetRotation.inv(), ) // TODO: See if we can avoid using twinNearest val estimateHeadingAlign = estimateHeadingAlign( rawRotation, reference, + yawOffset = yawOffset ) quaternionAssertEquals( headingAlign, @@ -398,6 +406,10 @@ class SessionCalibrationTest { Quaternion.rotationAroundYAxis(degreeToRadian(y)) } + val radians = step.map { d -> + degreeToRadian(d) + } + val pitch = step.map { x -> Quaternion.rotationAroundXAxis(degreeToRadian(x)) } From 05fbf64ac392bdd630756ecb3f0e41ff9fd33c88 Mon Sep 17 00:00:00 2001 From: lucas lelievre Date: Thu, 23 Jul 2026 22:04:49 +0200 Subject: [PATCH 288/350] Better event-dispatcher with liteners that die with scope --- .../java/dev/slimevr/android/ipc/ws-server.kt | 3 +- .../java/dev/slimevr/driver/behaviours.kt | 10 +-- .../main/java/dev/slimevr/event-dispatcher.kt | 44 ++++------ .../main/java/dev/slimevr/hid/behaviours.kt | 84 +++++++++---------- .../src/main/java/dev/slimevr/hid/module.kt | 5 -- .../main/java/dev/slimevr/keybind/module.kt | 2 +- .../src/main/java/dev/slimevr/solarxr/bvh.kt | 4 +- .../main/java/dev/slimevr/solarxr/datafeed.kt | 5 +- .../main/java/dev/slimevr/solarxr/firmware.kt | 4 +- .../dev/slimevr/solarxr/height-calibration.kt | 4 +- .../dev/slimevr/solarxr/installed-info.kt | 3 +- .../main/java/dev/slimevr/solarxr/keybinds.kt | 6 +- .../dev/slimevr/solarxr/known-trackers.kt | 5 +- .../src/main/java/dev/slimevr/solarxr/mag.kt | 5 +- .../dev/slimevr/solarxr/output-trackers.kt | 5 +- .../java/dev/slimevr/solarxr/provisioning.kt | 4 +- .../main/java/dev/slimevr/solarxr/resets.kt | 5 +- .../main/java/dev/slimevr/solarxr/serial.kt | 18 ++-- .../slimevr/solarxr/session-calibration.kt | 3 +- .../slimevr/solarxr/skeleton-proportions.kt | 9 +- .../dev/slimevr/solarxr/skeleton-settings.kt | 9 +- .../java/dev/slimevr/solarxr/tap-detection.kt | 7 +- .../dev/slimevr/solarxr/tracker-assignment.kt | 3 +- .../dev/slimevr/solarxr/tracking-checklist.kt | 4 +- .../src/main/java/dev/slimevr/solarxr/vmc.kt | 10 +-- .../main/java/dev/slimevr/solarxr/vrchat.kt | 4 +- .../main/java/dev/slimevr/solarxr/vrcosc.kt | 6 +- .../main/java/dev/slimevr/udp/behaviours.kt | 40 ++++----- .../main/java/dev/slimevr/udp/connection.kt | 2 +- .../src/main/java/dev/slimevr/udp/packets.kt | 12 ++- .../main/java/dev/slimevr/vrcosc/output.kt | 3 +- .../java/dev/slimevr/solarxr/DataFeedTest.kt | 3 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 8 +- .../java/dev/slimevr/desktop/ipc/solarxr.kt | 7 +- .../dev/slimevr/desktop/keybind/keybinds.kt | 2 +- 35 files changed, 174 insertions(+), 174 deletions(-) diff --git a/server/android/src/main/java/dev/slimevr/android/ipc/ws-server.kt b/server/android/src/main/java/dev/slimevr/android/ipc/ws-server.kt index 081b4f4ded..72b1768e27 100644 --- a/server/android/src/main/java/dev/slimevr/android/ipc/ws-server.kt +++ b/server/android/src/main/java/dev/slimevr/android/ipc/ws-server.kt @@ -19,6 +19,7 @@ import io.ktor.websocket.Frame import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn import solarxr_protocol.MessageBundle import java.nio.ByteBuffer @@ -44,7 +45,7 @@ suspend fun createAndroidSolarXRWebsocketServer(appContext: AppContextProvider) val fbb = FlatBufferBuilder(256) fbb.finish(bundle.encode(JvmFlatBufferWriter(fbb))) send(Frame.Binary(fin = true, data = fbb.dataBuffer().moveToByteArray())) - } + }.launchIn(this) try { flow { diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index b95613e39d..c62773d760 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -6,8 +6,6 @@ import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.BodyPart @@ -99,7 +97,7 @@ class DriverIncomingTrackersBehaviour : DriverBridgeBehaviour { override fun observe(receiver: DriverBridge) { receiver.inbound.on { event -> receiver.context.dispatch(DriverBridgeActions.UpdateProtocolVersion(event.protocolVersion)) - } + }.launchIn(receiver.context.scope) receiver.inbound.on { event -> handleTrackerAdded( @@ -109,7 +107,7 @@ class DriverIncomingTrackersBehaviour : DriverBridgeBehaviour { event.manufacturer, event.serial, ) - } + }.launchIn(receiver.context.scope) receiver.inbound.on { event -> val trackerId = receiver.context.state.value.trackers[event.id] ?: return@on @@ -120,7 +118,7 @@ class DriverIncomingTrackersBehaviour : DriverBridgeBehaviour { }, ) } - } + }.launchIn(receiver.context.scope) receiver.inbound.on { event -> val trackerId = receiver.context.state.value.trackers[event.id] ?: return@on @@ -137,7 +135,7 @@ class DriverIncomingTrackersBehaviour : DriverBridgeBehaviour { }, ) } - } + }.launchIn(receiver.context.scope) } private fun handleTrackerAdded( diff --git a/server/core/src/main/java/dev/slimevr/event-dispatcher.kt b/server/core/src/main/java/dev/slimevr/event-dispatcher.kt index 97b7e587f1..4e378721a9 100644 --- a/server/core/src/main/java/dev/slimevr/event-dispatcher.kt +++ b/server/core/src/main/java/dev/slimevr/event-dispatcher.kt @@ -1,33 +1,23 @@ package dev.slimevr -import kotlin.reflect.KClass +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.onEach -class EventDispatcher(private val keyOf: (T) -> KClass<*> = { it::class }) { - @Volatile var listeners: Map, List Unit>> = emptyMap() +// For now, it blocks all the time, no queue. Maybe it is a bad idea IDK. Future us problem +class EventDispatcher( + extraBufferCapacity: Int = 0, + onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND, +) { + private val flow = MutableSharedFlow(extraBufferCapacity = extraBufferCapacity, onBufferOverflow = onBufferOverflow) + val events: SharedFlow = flow.asSharedFlow() - @Volatile private var globalListeners: List Unit> = emptyList() + suspend fun emit(event: T) = flow.emit(event) - fun register(key: KClass<*>, callback: suspend (T) -> Unit) { - synchronized(this) { - val updated = listeners.toMutableMap() - updated[key] = (updated[key] ?: emptyList()) + callback - listeners = updated - } - } - - @Suppress("UNCHECKED_CAST") - inline fun on(crossinline callback: suspend (P) -> Unit) { - register(P::class) { callback(it as P) } - } - - fun onAny(callback: suspend (T) -> Unit) { - synchronized(this) { - globalListeners = globalListeners + callback - } - } - - suspend fun emit(event: T) { - globalListeners.forEach { it(event) } - listeners[keyOf(event)]?.forEach { it(event) } - } + inline fun on(crossinline action: suspend (P) -> Unit): Flow

= + events.filterIsInstance

().onEach { action(it) } } diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index abadfe27b4..1ed74c4f53 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -10,7 +10,7 @@ import dev.slimevr.tracker.TrackerActions import dev.slimevr.util.safeLaunch import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive +import kotlinx.coroutines.flow.launchIn import solarxr_protocol.datatypes.TrackerStatus class HIDRegistrationBehaviour : HIDReceiverBehaviour { @@ -31,10 +31,10 @@ class HIDRegistrationBehaviour : HIDReceiverBehaviour { } override fun observe(receiver: HIDReceiver) { - receiver.packetEvents.onPacket { packet -> + receiver.packetEvents.on { packet -> val state = receiver.context.state.value val existing = state.trackers[packet.hidId] - if (existing != null) return@onPacket + if (existing != null) return@on val existingDevice = receiver.appContext.server.context.state.value.devices.values .find { it.context.state.value.macAddress == packet.address && it.context.state.value.origin == DeviceOrigin.HID } @@ -42,7 +42,7 @@ class HIDRegistrationBehaviour : HIDReceiverBehaviour { if (existingDevice != null) { receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, existingDevice.context.state.value.id)) AppLogger.hid.info("Reconnected HID device ${packet.address} (hidId=${packet.hidId})") - return@onPacket + return@on } val deviceId = receiver.appContext.server.nextHandle() @@ -58,7 +58,7 @@ class HIDRegistrationBehaviour : HIDReceiverBehaviour { receiver.appContext.server.context.dispatch(VRServerActions.NewDevice(deviceId, device)) receiver.context.dispatch(HIDReceiverActions.DeviceRegistered(packet.hidId, packet.address, deviceId)) AppLogger.hid.info("Registered HID device ${packet.address} (hidId=${packet.hidId})") - } + }.launchIn(receiver.context.scope) } } @@ -73,8 +73,8 @@ class HIDDeviceInfoBehaviour : HIDReceiverBehaviour { } override fun observe(receiver: HIDReceiver) { - receiver.packetEvents.onPacket { packet -> - val device = receiver.getDevice(packet.hidId) ?: return@onPacket + receiver.packetEvents.on { packet -> + val device = receiver.getDevice(packet.hidId) ?: return@on val deviceState = device.context.state.value device.context.dispatch( @@ -111,64 +111,64 @@ class HIDDeviceInfoBehaviour : HIDReceiverBehaviour { // HID does not have a rest calibration signal tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType, completedRestCalibration = true, magStatus = packet.magStatus) }) tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) - } + }.launchIn(receiver.context.scope) } } class HIDRotationBehaviour : HIDReceiverBehaviour { override fun observe(receiver: HIDReceiver) { - receiver.packetEvents.onPacket { packet -> - val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + receiver.packetEvents.on { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@on tracker.context.dispatch(TrackerActions.SetRotation(rotation = packet.rotation, acceleration = packet.acceleration)) - } + }.launchIn(receiver.context.scope) - receiver.packetEvents.onPacket { packet -> - val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + receiver.packetEvents.on { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@on tracker.context.dispatch(TrackerActions.SetRotation(rotation = packet.rotation, acceleration = packet.acceleration)) - } + }.launchIn(receiver.context.scope) - receiver.packetEvents.onPacket { packet -> - val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + receiver.packetEvents.on { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@on tracker.context.dispatch(TrackerActions.SetRotation(rotation = packet.rotation, magnetometer = packet.magnetometer)) - } + }.launchIn(receiver.context.scope) - receiver.packetEvents.onPacket { packet -> - val tracker = receiver.getTracker(packet.hidId) ?: return@onPacket + receiver.packetEvents.on { packet -> + val tracker = receiver.getTracker(packet.hidId) ?: return@on tracker.context.dispatch(TrackerActions.SetRotation(rotation = packet.rotation, acceleration = packet.acceleration)) - } + }.launchIn(receiver.context.scope) } } class HIDBatteryBehaviour : HIDReceiverBehaviour { override fun observe(receiver: HIDReceiver) { - receiver.packetEvents.onPacket { packet -> + receiver.packetEvents.on { packet -> receiver.getDevice(packet.hidId)?.context?.dispatch( DeviceActions.Update { copy(batteryLevel = packet.batteryLevel, batteryVoltage = packet.batteryVoltage, signalStrength = packet.rssi) }, ) - } + }.launchIn(receiver.context.scope) - receiver.packetEvents.onPacket { packet -> + receiver.packetEvents.on { packet -> receiver.getDevice(packet.hidId)?.context?.dispatch( DeviceActions.Update { copy(signalStrength = packet.rssi) }, ) - } + }.launchIn(receiver.context.scope) - receiver.packetEvents.onPacket { packet -> + receiver.packetEvents.on { packet -> receiver.getDevice(packet.hidId)?.context?.dispatch( DeviceActions.Update { copy(signalStrength = packet.rssi) }, ) - } + }.launchIn(receiver.context.scope) - receiver.packetEvents.onPacket { packet -> + receiver.packetEvents.on { packet -> // -1: not yet known (keep existing value); 0: N/A (e.g. charging) if (packet.runtime >= 0) { receiver.getDevice(packet.hidId)?.context?.dispatch( DeviceActions.Update { copy(batteryRemainingRuntime = packet.runtime) }, ) } - } + }.launchIn(receiver.context.scope) } } @@ -210,41 +210,41 @@ class HIDSleepBehaviour : HIDReceiverBehaviour { armIdleTimeout(hidId) } - receiver.packetEvents.onPacket { packet -> + receiver.packetEvents.on { packet -> onPacket(packet.hidId) scheduleSleep(packet.hidId, packet.timeout) - } + }.launchIn(receiver.context.scope) - receiver.packetEvents.onPacket { packet -> + receiver.packetEvents.on { packet -> onPacket(packet.hidId) scheduleSleep(packet.hidId, packet.timeout) - } + }.launchIn(receiver.context.scope) - receiver.packetEvents.onPacket { packet -> onPacket(packet.hidId) } - receiver.packetEvents.onPacket { packet -> onPacket(packet.hidId) } - receiver.packetEvents.onPacket { packet -> onPacket(packet.hidId) } - receiver.packetEvents.onPacket { packet -> onPacket(packet.hidId) } - receiver.packetEvents.onPacket { packet -> onPacket(packet.hidId) } + receiver.packetEvents.on { packet -> onPacket(packet.hidId) }.launchIn(receiver.context.scope) + receiver.packetEvents.on { packet -> onPacket(packet.hidId) }.launchIn(receiver.context.scope) + receiver.packetEvents.on { packet -> onPacket(packet.hidId) }.launchIn(receiver.context.scope) + receiver.packetEvents.on { packet -> onPacket(packet.hidId) }.launchIn(receiver.context.scope) + receiver.packetEvents.on { packet -> onPacket(packet.hidId) }.launchIn(receiver.context.scope) } } class HIDStatusBehaviour : HIDReceiverBehaviour { override fun observe(receiver: HIDReceiver) { - receiver.packetEvents.onPacket { packet -> - if (receiver.getTracker(packet.hidId) == null) return@onPacket + receiver.packetEvents.on { packet -> + if (receiver.getTracker(packet.hidId) == null) return@on receiver.getDevice(packet.hidId)?.context?.dispatch( DeviceActions.Update { copy(status = packet.status, signalStrength = packet.rssi) }, ) - } + }.launchIn(receiver.context.scope) } } class HIDPacketLossBehaviour : HIDReceiverBehaviour { override fun observe(receiver: HIDReceiver) { - receiver.packetEvents.onPacket { packet -> + receiver.packetEvents.on { packet -> receiver.getDevice(packet.hidId)?.context?.dispatch( DeviceActions.PacketStats(packetsReceived = packet.packetsReceived.toLong(), packetsLost = packet.packetsLost.toLong()), ) - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/hid/module.kt b/server/core/src/main/java/dev/slimevr/hid/module.kt index 58d2280dbf..33a8ff28b5 100644 --- a/server/core/src/main/java/dev/slimevr/hid/module.kt +++ b/server/core/src/main/java/dev/slimevr/hid/module.kt @@ -29,11 +29,6 @@ typealias HIDReceiverContext = Context typealias HIDReceiverBehaviour = Behaviour typealias HIDPacketDispatcher = EventDispatcher -@Suppress("UNCHECKED_CAST") -inline fun HIDPacketDispatcher.onPacket(crossinline callback: suspend (T) -> Unit) { - register(T::class) { callback(it as T) } -} - class HIDReceiver( val context: HIDReceiverContext, val appContext: AppContextProvider, diff --git a/server/core/src/main/java/dev/slimevr/keybind/module.kt b/server/core/src/main/java/dev/slimevr/keybind/module.kt index 5cc110be4e..387329f600 100644 --- a/server/core/src/main/java/dev/slimevr/keybind/module.kt +++ b/server/core/src/main/java/dev/slimevr/keybind/module.kt @@ -89,7 +89,7 @@ class KeybindTriggerBehaviour( KeybindId.NONE -> Unit } - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/bvh.kt b/server/core/src/main/java/dev/slimevr/solarxr/bvh.kt index cad9543ea5..8f89666f4b 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/bvh.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/bvh.kt @@ -17,11 +17,11 @@ class BvhBehaviour(private val bvhManager: BVHManager) : SolarXRBridgeBehaviour req.path?.let { path -> bvhManager.startRecording(path) } } receiver.sendRpc(RecordBVHStatus(recording = bvhManager.isRecording)) - } + }.launchIn(receiver.context.scope) receiver.rpcDispatcher.on { receiver.sendRpc(RecordBVHStatus(recording = bvhManager.isRecording)) - } + }.launchIn(receiver.context.scope) bvhManager.context.state.drop(1) .onEach { state -> receiver.sendRpc(RecordBVHStatus(recording = state.recording)) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 20f16cdd01..dfb3b82b22 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -11,6 +11,7 @@ import dev.slimevr.tracker.TrackerState import dev.slimevr.util.safeLaunch import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.isActive import solarxr_protocol.data_feed.BoneMask import solarxr_protocol.data_feed.DataFeedConfig @@ -191,7 +192,7 @@ class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : Sola receiver.datafeedTimers = timers receiver.context.dispatch(SolarXRBridgeActions.SetConfig(dataFeeds)) - } + }.launchIn(receiver.context.scope) receiver.dataFeedDispatcher.on { event -> val config = event.config ?: return@on @@ -204,6 +205,6 @@ class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : Sola heightCalibrationManager = receiver.appContext.heightCalibrationManager, ), ) - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt index 1744e186c1..90675c34af 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/firmware.kt @@ -64,10 +64,10 @@ class FirmwareBehaviour(private val server: VRServer, private val firmwareManage else -> return@on } - } + }.launchIn(scope) receiver.rpcDispatcher.on { firmwareManager.cancelAll() - } + }.launchIn(scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/height-calibration.kt b/server/core/src/main/java/dev/slimevr/solarxr/height-calibration.kt index 025fda15a3..719372ed8d 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/height-calibration.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/height-calibration.kt @@ -14,11 +14,11 @@ class HeightCalibrationBehaviour( override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { heightCalibrationManager.start() - } + }.launchIn(receiver.context.scope) receiver.rpcDispatcher.on { heightCalibrationManager.cancel() - } + }.launchIn(receiver.context.scope) heightCalibrationManager.context.state.drop(1).onEach { state -> receiver.sendRpc( diff --git a/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt b/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt index 69eba579cd..be3b49fbc3 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/installed-info.kt @@ -1,5 +1,6 @@ package dev.slimevr.solarxr +import kotlinx.coroutines.flow.launchIn import solarxr_protocol.rpc.InstalledInfoRequest import solarxr_protocol.rpc.InstalledInfoResponse @@ -8,6 +9,6 @@ class InstalledInfoBehaviour : SolarXRBridgeBehaviour { receiver.rpcDispatcher.on { val udevRulesInstalled = receiver.appContext.featureFlags.udevRulesInstalled ?: return@on receiver.sendRpc(InstalledInfoResponse(isUdevInstalled = udevRulesInstalled)) - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/keybinds.kt b/server/core/src/main/java/dev/slimevr/solarxr/keybinds.kt index 0a88533409..27b07e5517 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/keybinds.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/keybinds.kt @@ -50,7 +50,7 @@ class KeybindsBehaviour( override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { req -> keybindManager.context.dispatch(KeybindActions.SetRecording(req.recording ?: false)) - } + }.launchIn(receiver.context.scope) fun buildResponse(keybinds: List) = KeybindResponse( keybind = keybinds.map { keybindConfigToProto(it) }, @@ -60,7 +60,7 @@ class KeybindsBehaviour( receiver.rpcDispatcher.on { receiver.sendRpc(buildResponse(settings.context.state.value.data.keybinds)) - } + }.launchIn(receiver.context.scope) settings.context.state .map { it.data.keybinds } @@ -90,6 +90,6 @@ class KeybindsBehaviour( ) }, ) - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/known-trackers.kt b/server/core/src/main/java/dev/slimevr/solarxr/known-trackers.kt index ffbc13e0a8..a240b5039a 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/known-trackers.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/known-trackers.kt @@ -2,6 +2,7 @@ package dev.slimevr.solarxr import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions +import kotlinx.coroutines.flow.launchIn import solarxr_protocol.rpc.AddUnknownDeviceRequest import solarxr_protocol.rpc.ForgetDeviceRequest @@ -12,11 +13,11 @@ class KnownTrackersBehaviour( receiver.rpcDispatcher.on { req -> val mac = req.macAddress ?: return@on settings.context.dispatch(SettingsActions.AddAllowedUdpDevice(mac)) - } + }.launchIn(receiver.context.scope) receiver.rpcDispatcher.on { req -> val mac = req.macAddress ?: return@on settings.context.dispatch(SettingsActions.RemoveAllowedUdpDevice(mac)) - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt index 0bb0751836..87b0cd6b3f 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt @@ -8,6 +8,7 @@ import dev.slimevr.udp.UDPConnectionActions import dev.slimevr.util.safeLaunch import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.withTimeout import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.rpc.ChangeMagToggleRequest @@ -80,7 +81,7 @@ class MagBehaviour( else -> return@on } - } + }.launchIn(receiver.context.scope) receiver.rpcDispatcher.on { req -> val trackerId = req.trackerId @@ -102,6 +103,6 @@ class MagBehaviour( enable = trackerState.magStatus == MagnetometerStatus.ENABLED, ), ) - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/output-trackers.kt b/server/core/src/main/java/dev/slimevr/solarxr/output-trackers.kt index db6623531d..59c27c6a56 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/output-trackers.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/output-trackers.kt @@ -4,6 +4,7 @@ import dev.slimevr.config.OutputTrackersConfig import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager +import kotlinx.coroutines.flow.launchIn import solarxr_protocol.rpc.ChangeOutputTrackersSettingsRequest import solarxr_protocol.rpc.OutputTrackersSettingsRequest import solarxr_protocol.rpc.OutputTrackersSettingsResponse @@ -23,7 +24,7 @@ class OutputTrackersBehaviour( sendDerivedVelocity = config.sendDerivedVelocity, ), ) - } + }.launchIn(receiver.context.scope) // Receive config receiver.rpcDispatcher.on { req -> @@ -38,6 +39,6 @@ class OutputTrackersBehaviour( ) }, ) - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/provisioning.kt b/server/core/src/main/java/dev/slimevr/solarxr/provisioning.kt index 5d60ecc79b..25b331b476 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/provisioning.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/provisioning.kt @@ -23,11 +23,11 @@ class ProvisioningBehaviour( event.password, event.port, ) - } + }.launchIn(receiver.context.scope) receiver.rpcDispatcher.on { provisioningManager.stopProvisioning() - } + }.launchIn(receiver.context.scope) provisioningManager.context.state.drop(1).onEach { state -> receiver.sendRpc( diff --git a/server/core/src/main/java/dev/slimevr/solarxr/resets.kt b/server/core/src/main/java/dev/slimevr/solarxr/resets.kt index 81334e26b3..20db9c27f0 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/resets.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/resets.kt @@ -3,6 +3,7 @@ package dev.slimevr.solarxr import dev.slimevr.config.ResetsConfig import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions +import kotlinx.coroutines.flow.launchIn import solarxr_protocol.rpc.ArmsResetMode import solarxr_protocol.rpc.ChangeResetsSettingsRequest import solarxr_protocol.rpc.ResetsSettingsRequest @@ -24,7 +25,7 @@ class ResetsBehaviour( resetHmdPitch = config.resetHmdPitch, ), ) - } + }.launchIn(receiver.context.scope) // Receive config receiver.rpcDispatcher.on { req -> @@ -41,6 +42,6 @@ class ResetsBehaviour( ) }, ) - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt index dc3ae975bb..ff41b00186 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt @@ -1,7 +1,6 @@ package dev.slimevr.solarxr import dev.slimevr.serial.SerialConnection -import dev.slimevr.serial.SerialPortInfo import dev.slimevr.serial.SerialServer import dev.slimevr.util.safeLaunch import kotlinx.coroutines.Job @@ -12,7 +11,6 @@ import kotlinx.coroutines.flow.onEach import solarxr_protocol.rpc.CloseSerialRequest import solarxr_protocol.rpc.NewSerialDeviceResponse import solarxr_protocol.rpc.OpenSerialRequest -import solarxr_protocol.rpc.SerialDevice import solarxr_protocol.rpc.SerialDevicesRequest import solarxr_protocol.rpc.SerialDevicesResponse import solarxr_protocol.rpc.SerialTrackerCustomCommandRequest @@ -52,7 +50,7 @@ class SerialBehaviour(private val serialServer: SerialServer) : SolarXRBridgeBeh .map { it.toSerialDevice() }, ), ) - } + }.launchIn(scope) receiver.rpcDispatcher.on { req -> val portLocation = if (req.auto == true) { @@ -95,43 +93,43 @@ class SerialBehaviour(private val serialServer: SerialServer) : SolarXRBridgeBeh dev.slimevr.AppLogger.solarxr.error(e, "Error streaming serial log") } } - } + }.launchIn(scope) receiver.rpcDispatcher.on { logSubscription?.cancel() logSubscription = null activePortLocation = null - } + }.launchIn(scope) receiver.rpcDispatcher.on { val portLocation = activePortLocation ?: return@on val c = serialServer.context.state.value.connections[portLocation] if (c is SerialConnection.Console) c.handle.writeCommand("REBOOT") - } + }.launchIn(scope) receiver.rpcDispatcher.on { val portLocation = activePortLocation ?: return@on val c = serialServer.context.state.value.connections[portLocation] if (c is SerialConnection.Console) c.handle.writeCommand("GET INFO") - } + }.launchIn(scope) receiver.rpcDispatcher.on { val portLocation = activePortLocation ?: return@on val c = serialServer.context.state.value.connections[portLocation] if (c is SerialConnection.Console) c.handle.writeCommand("FRST") - } + }.launchIn(scope) receiver.rpcDispatcher.on { val portLocation = activePortLocation ?: return@on val c = serialServer.context.state.value.connections[portLocation] if (c is SerialConnection.Console) c.handle.writeCommand("GET WIFISCAN") - } + }.launchIn(scope) receiver.rpcDispatcher.on { req -> val portLocation = activePortLocation ?: return@on val command = req.command ?: return@on val c = serialServer.context.state.value.connections[portLocation] if (c is SerialConnection.Console) c.handle.writeCommand(command) - } + }.launchIn(scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/session-calibration.kt b/server/core/src/main/java/dev/slimevr/solarxr/session-calibration.kt index c6e22ed87e..b9ca709641 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/session-calibration.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/session-calibration.kt @@ -1,6 +1,7 @@ package dev.slimevr.solarxr import dev.slimevr.resets.ResetsManager +import kotlinx.coroutines.flow.launchIn import solarxr_protocol.rpc.ResetRequest import solarxr_protocol.rpc.ResetType @@ -10,6 +11,6 @@ class SessionCalibrationBehaviour( override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { req -> resetsManager.scheduleReset("SolarXRBridge", req.resetType ?: ResetType.YAW, req.delay ?: 0f, req.bodyParts) - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeleton-proportions.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeleton-proportions.kt index 9a8e52ef53..500d4d9cf3 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeleton-proportions.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeleton-proportions.kt @@ -8,6 +8,7 @@ import dev.slimevr.skeleton.computeDefaultProportionsByBone import dev.slimevr.skeleton.configToBoneValues import dev.slimevr.skeleton.height import dev.slimevr.skeleton.toBoneValues +import kotlinx.coroutines.flow.launchIn import solarxr_protocol.rpc.ChangeSkeletonProportionsRequest import solarxr_protocol.rpc.ChangeUserHeightRequest import solarxr_protocol.rpc.SkeletonPart @@ -38,7 +39,7 @@ class SkeletonProportionsBehaviour( override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { receiver.sendRpc(buildConfigResponse()) - } + }.launchIn(receiver.context.scope) receiver.rpcDispatcher.on { req -> val hmdHeight = req.hmdHeight ?: return@on @@ -51,7 +52,7 @@ class SkeletonProportionsBehaviour( }, ) } - } + }.launchIn(receiver.context.scope) receiver.rpcDispatcher.on { val height = userConfig.context.state.value.data.userHeight @@ -60,7 +61,7 @@ class SkeletonProportionsBehaviour( userConfig.context.dispatch(UserConfigActions.Update { copy(proportions = defaults) }) } receiver.sendRpc(buildConfigResponse()) - } + }.launchIn(receiver.context.scope) receiver.rpcDispatcher.on { req -> val bone = req.bone ?: return@on @@ -70,6 +71,6 @@ class SkeletonProportionsBehaviour( UserConfigActions.Update { copy(proportions = proportions + (bone.name to value)) }, ) receiver.sendRpc(buildConfigResponse()) - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/skeleton-settings.kt b/server/core/src/main/java/dev/slimevr/solarxr/skeleton-settings.kt index 047fb8e4e8..140668fd36 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/skeleton-settings.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/skeleton-settings.kt @@ -7,6 +7,7 @@ import dev.slimevr.config.SkeletonFilteringConfig import dev.slimevr.config.SkeletonRatiosConfig import dev.slimevr.config.SkeletonTogglesConfig import dev.slimevr.skeleton.SkeletonActions +import kotlinx.coroutines.flow.launchIn import solarxr_protocol.rpc.ChangeSkeletonSettingsRequest import solarxr_protocol.rpc.FilteringType import solarxr_protocol.rpc.SetPauseTrackingRequest @@ -51,7 +52,7 @@ class SkeletonSettingsBehaviour( ), ), ) - } + }.launchIn(receiver.context.scope) // Receive config receiver.rpcDispatcher.on { req -> @@ -91,7 +92,7 @@ class SkeletonSettingsBehaviour( ) }, ) - } + }.launchIn(receiver.context.scope) receiver.rpcDispatcher.on { receiver.appContext.skeleton.context.dispatch( @@ -105,7 +106,7 @@ class SkeletonSettingsBehaviour( trackingPaused = it.pauseTracking, ), ) - } + }.launchIn(receiver.context.scope) receiver.rpcDispatcher.on { receiver.sendRpc( @@ -113,6 +114,6 @@ class SkeletonSettingsBehaviour( trackingPaused = receiver.appContext.skeleton.context.state.value.paused, ), ) - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/tap-detection.kt b/server/core/src/main/java/dev/slimevr/solarxr/tap-detection.kt index 8f2db1b6b7..0fbe2a01dd 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/tap-detection.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/tap-detection.kt @@ -5,6 +5,7 @@ import dev.slimevr.config.SettingsActions import dev.slimevr.config.TapDetectionConfig import dev.slimevr.tapdetection.TapDetectionActions import dev.slimevr.tapdetection.TapDetectionManager +import kotlinx.coroutines.flow.launchIn import solarxr_protocol.rpc.ChangeTapDetectionSettingsRequest import solarxr_protocol.rpc.TapDetectionSettingsRequest import solarxr_protocol.rpc.TapDetectionSettingsResponse @@ -35,7 +36,7 @@ class TapDetectionBehaviour( numberTrackersOverThreshold = config.numberTrackersOverThreshold.toUByte(), ), ) - } + }.launchIn(receiver.context.scope) // Receive config receiver.rpcDispatcher.on { req -> @@ -60,11 +61,11 @@ class TapDetectionBehaviour( ) }, ) - } + }.launchIn(receiver.context.scope) // Setup Mode (tap to assign) receiver.rpcDispatcher.on { req -> tapDetectionManager.context.dispatch(TapDetectionActions.SetSetupMode(req.setupMode == true)) - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/tracker-assignment.kt b/server/core/src/main/java/dev/slimevr/solarxr/tracker-assignment.kt index 2dad01c508..ff2095ea0e 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/tracker-assignment.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/tracker-assignment.kt @@ -3,6 +3,7 @@ package dev.slimevr.solarxr import dev.slimevr.VRServer import dev.slimevr.tracker.TrackerActions import io.github.axisangles.ktmath.Quaternion +import kotlinx.coroutines.flow.launchIn import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.AssignTrackerRequest @@ -32,6 +33,6 @@ class AssignTrackerBehaviour( TrackerActions.SetMountingOrientation(mountingOrientation), ) } - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/tracking-checklist.kt b/server/core/src/main/java/dev/slimevr/solarxr/tracking-checklist.kt index 66e0b98848..de8f1307d5 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/tracking-checklist.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/tracking-checklist.kt @@ -39,7 +39,7 @@ class TrackingChecklistBehaviour( receiver.rpcDispatcher.on { receiver.sendRpc(buildResponse()) - } + }.launchIn(receiver.context.scope) receiver.rpcDispatcher.on { req -> val stepId = req.stepId ?: return@on @@ -49,6 +49,6 @@ class TrackingChecklistBehaviour( copy(mutedChecklistSteps = if (req.ignore == true) mutedChecklistSteps + name else mutedChecklistSteps - name) }, ) - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt index 059350bbfe..84e67deb1b 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vmc.kt @@ -2,7 +2,7 @@ package dev.slimevr.solarxr import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions -import dev.slimevr.config.VMCConfig +import kotlinx.coroutines.flow.launchIn import solarxr_protocol.rpc.ChangeVMCOSCSettingsRequest import solarxr_protocol.rpc.ChangeVRMSettingsRequest import solarxr_protocol.rpc.VMCOSCSettingsRequest @@ -27,7 +27,7 @@ class VmcBehaviour( mirrorTracking = config.mirrorTracking, ), ) - } + }.launchIn(receiver.context.scope) // Send VRM json receiver.rpcDispatcher.on { @@ -37,7 +37,7 @@ class VmcBehaviour( vrmJson = config.vrmJson, ), ) - } + }.launchIn(receiver.context.scope) // Receive VMC config receiver.rpcDispatcher.on { req -> @@ -55,7 +55,7 @@ class VmcBehaviour( ) }, ) - } + }.launchIn(receiver.context.scope) // Receive VRM json receiver.rpcDispatcher.on { req -> @@ -66,6 +66,6 @@ class VmcBehaviour( ) }, ) - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt index b50dc98b0f..89d165e353 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrchat.kt @@ -39,11 +39,11 @@ class VrcBehaviour( receiver.rpcDispatcher.on { receiver.sendRpc(buildCurrentResponse()) - } + }.launchIn(receiver.context.scope) receiver.rpcDispatcher.on { req -> val key = req.key ?: return@on vrcManager.context.dispatch(VRCConfigActions.ToggleMutedWarning(key)) - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt index 4c68fb31fc..833c696584 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/vrcosc.kt @@ -48,13 +48,13 @@ internal class VrcOscBehaviour( address = config.address, ), ) - } + }.launchIn(receiver.context.scope) receiver.rpcDispatcher.on { val state = vrcOscManager.context.state.value val config = settings.context.state.value.data.vrcOscConfig receiver.sendRpc(buildStatusResponse(state.status, config.enabled)) - } + }.launchIn(receiver.context.scope) receiver.rpcDispatcher.on { req -> settings.context.dispatch( @@ -70,7 +70,7 @@ internal class VrcOscBehaviour( ) }, ) - } + }.launchIn(receiver.context.scope) } private fun buildStatusResponse(status: VRCOSCStatus, enabled: Boolean) = VRCOSCStatusChangeResponse( diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 3f8c4d63bc..ec11eb6eaf 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -41,7 +41,7 @@ class PacketBehaviour : UDPConnectionBehaviour { } override fun observe(receiver: UDPConnection) { - receiver.packetEvents.onAny { packet -> + receiver.packetEvents.events.onEach { packet -> val state = receiver.context.state.value val now = System.currentTimeMillis() val num = packet.packetNumber @@ -49,10 +49,10 @@ class PacketBehaviour : UDPConnectionBehaviour { AppLogger.udp.info("[${state.address}] Reconnecting") } else if (num != null && num != 0L && num <= state.lastPacketNum) { AppLogger.udp.warn("[${state.address}] Received packet with wrong packet number") - return@onAny + return@onEach } receiver.context.dispatch(UDPConnectionActions.LastPacket(packetNum = num, time = now)) - } + }.launchIn(receiver.context.scope) } } @@ -63,8 +63,8 @@ class PacketLossBehaviour : UDPConnectionBehaviour { var lastPacketCounterReset = System.currentTimeMillis() var lastPacketNumber = 0L - receiver.packetEvents.onAny { packet -> - val num = packet.packetNumber ?: return@onAny + receiver.packetEvents.events.onEach { packet -> + val num = packet.packetNumber ?: return@onEach val now = System.currentTimeMillis() if (now - lastPacketCounterReset >= 10_000L) { @@ -83,7 +83,7 @@ class PacketLossBehaviour : UDPConnectionBehaviour { receiver.getDevice()?.context?.dispatch( DeviceActions.PacketStats(packetsReceived = totalPacketsReceived, packetsLost = totalPacketsReceived - acceptedPackets), ) - } + }.launchIn(receiver.context.scope) } } @@ -120,7 +120,7 @@ class PingBehaviour : UDPConnectionBehaviour { val ping = (System.currentTimeMillis() - state.lastPing.startTime) / 2 val device = receiver.appContext.server.getDevice(deviceId) ?: return@onPacket device.context.dispatch(DeviceActions.Update { copy(ping = ping) }) - } + }.launchIn(receiver.context.scope) } } @@ -202,7 +202,7 @@ class HandshakeBehaviour : UDPConnectionBehaviour { ) receiver.send(Handshake()) - } + }.launchIn(receiver.context.scope) } } @@ -276,12 +276,12 @@ class DeviceStatsBehaviour : UDPConnectionBehaviour { device.context.dispatch( DeviceActions.Update { copy(batteryLevel = batteryLevel, batteryVoltage = voltage) }, ) - } + }.launchIn(receiver.context.scope) receiver.packetEvents.onPacket { event -> val device = receiver.getDevice() ?: return@onPacket device.context.dispatch(DeviceActions.Update { copy(signalStrength = event.data.signal) }) - } + }.launchIn(receiver.context.scope) } } @@ -376,7 +376,7 @@ class SensorInfoBehaviour : UDPConnectionBehaviour { UDPConnectionActions.SetSensorConfig(sensorId = event.data.sensorId, flags = SensorConfigFlags(magStatus = desiredMagStatus)), ) } - } + }.launchIn(receiver.context.scope) } } @@ -385,22 +385,22 @@ class SensorRotationBehaviour : UDPConnectionBehaviour { receiver.packetEvents.onPacket { event -> val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket tracker.context.dispatch(TrackerActions.SetRotation(rotation = AXES_OFFSET * event.data.rotation)) - } + }.launchIn(receiver.context.scope) receiver.packetEvents.onPacket { event -> val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket tracker.context.dispatch(TrackerActions.SetRotation(rotation = AXES_OFFSET * event.data.rotation, acceleration = event.data.acceleration)) - } + }.launchIn(receiver.context.scope) receiver.packetEvents.onPacket { event -> val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket tracker.context.dispatch(TrackerActions.SetRotation(acceleration = event.data.acceleration)) - } + }.launchIn(receiver.context.scope) receiver.packetEvents.onPacket { event -> val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket tracker.context.dispatch(TrackerActions.SetRotation(rotation = AXES_OFFSET * event.data.rotation)) - } + }.launchIn(receiver.context.scope) } } @@ -413,7 +413,7 @@ class BundledPacketBehaviour : UDPConnectionBehaviour { // it should be done by the parent packet receiver.packetEvents.emit(PacketEvent(packet, packetNumber = null)) } - } + }.launchIn(receiver.context.scope) receiver.packetEvents.onPacket { event -> for (packet in event.data.packets) { @@ -422,7 +422,7 @@ class BundledPacketBehaviour : UDPConnectionBehaviour { // it should be done by the parent packet receiver.packetEvents.emit(PacketEvent(packet, packetNumber = null)) } - } + }.launchIn(receiver.context.scope) } } @@ -440,7 +440,7 @@ class FlagsBehaviour : UDPConnectionBehaviour { receiver.context.dispatch(UDPConnectionActions.FirmwareFeatures(event.data.firmwareFeatures)) // send back the server features receiver.send(FeatureFlags()) - } + }.launchIn(receiver.context.scope) } } @@ -449,7 +449,7 @@ class TemperatureBehaviour : UDPConnectionBehaviour { receiver.packetEvents.onPacket { event -> val tracker = receiver.getTracker(event.data.sensorId) ?: return@onPacket tracker.context.dispatch(TrackerActions.Update { copy(imuTemp = event.data.temp) }) - } + }.launchIn(receiver.context.scope) } } @@ -490,6 +490,6 @@ class AckConfigBehaviour : UDPConnectionBehaviour { if (configType == SensorConfigType.MAGNETOMETER) { tracker.context.dispatch(TrackerActions.SetMagStatus(flags.magStatus)) } - } + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 7925d2c58d..3aefe678b2 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -124,7 +124,7 @@ class UDPConnection( name = "UDPConnection[$address]", ) - val dispatcher = EventDispatcher> { it.data::class } + val dispatcher = EventDispatcher>() val packetChannel = Channel>(capacity = 256) val conn = UDPConnection( diff --git a/server/core/src/main/java/dev/slimevr/udp/packets.kt b/server/core/src/main/java/dev/slimevr/udp/packets.kt index 8f15246d0c..7a35dafc86 100644 --- a/server/core/src/main/java/dev/slimevr/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/udp/packets.kt @@ -4,6 +4,9 @@ import dev.slimevr.EventDispatcher import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import io.ktor.utils.io.core.remaining +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach import kotlinx.io.Buffer import kotlinx.io.Sink import kotlinx.io.Source @@ -537,7 +540,8 @@ data class PacketEvent( typealias UDPPacketDispatcher = EventDispatcher> -@Suppress("UNCHECKED_CAST") -inline fun UDPPacketDispatcher.onPacket(crossinline callback: suspend (PacketEvent) -> Unit) { - register(T::class) { callback(it as PacketEvent) } -} +inline fun UDPPacketDispatcher.onPacket(): Flow> = events + .mapNotNull { event -> (event.takeIf { it.data is T }) as PacketEvent? } + +inline fun UDPPacketDispatcher.onPacket(crossinline action: suspend (PacketEvent) -> Unit): Flow> = + onPacket().onEach { action(it) } diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt index 730387236b..e5d3abbc56 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt @@ -3,7 +3,6 @@ package dev.slimevr.vrcosc import dev.slimevr.AppLogger import dev.slimevr.config.VRCOSCConfig import dev.slimevr.osc.OscSender -import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.Skeleton import io.github.axisangles.ktmath.Quaternion @@ -80,7 +79,7 @@ class VRCOSCOutputBehaviour( private fun observeYawAlign(receiver: VRCOSCManager, runtime: OutputRuntime) { receiver.events.on { event -> sendYawAlign(receiver, runtime, event.headRotation) - } + }.launchIn(receiver.context.scope) } private suspend fun applyTarget( diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt index 4fe19ac811..79b772d437 100644 --- a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -10,6 +10,7 @@ import dev.slimevr.buildTestUserConfig import dev.slimevr.buildTestVrServer import dev.slimevr.context.Context import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import solarxr_protocol.MessageBundle @@ -46,7 +47,7 @@ private fun testConn(backgroundScope: kotlinx.coroutines.CoroutineScope, onSend: rpcDispatcher = EventDispatcher(), ) bridge.startObserving() - bridge.outbound.on { _ -> onSend(ByteArray(0)) } + bridge.outbound.on { onSend(ByteArray(0)) }.launchIn(backgroundScope) return bridge } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 871a99aafb..834dfcda40 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -19,6 +19,7 @@ import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -151,7 +152,8 @@ suspend fun handleDriverConnection( ), ), ) - } + }.launchIn(this) + bridge.outbound.on { event -> sendMsg( ProtobufMessage( @@ -167,7 +169,7 @@ suspend fun handleDriverConnection( ), ), ) - } + }.launchIn(this) bridge.outbound.on { event -> sendMsg( @@ -190,7 +192,7 @@ suspend fun handleDriverConnection( ), ), ) - } + }.launchIn(this) sendMsg(ProtobufMessage(version = Version(protocol_version = PROTOCOL_VERSION))) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt index b04b8329a3..f42b36705e 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt @@ -21,6 +21,7 @@ import io.ktor.util.moveToByteArray import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.withContext import solarxr_protocol.MessageBundle import solarxr_protocol.rpc.EnableSteamVRDriverRequest @@ -35,7 +36,7 @@ class EnableSteamVRDriverBehaviour : SolarXRBridgeBehaviour { unblockSteamVRDriver(client, "slimevr") client.close() } - } + }.launchIn(receiver.context.scope) } } @@ -50,7 +51,7 @@ class OpenKeybindSettingsBehaviour : SolarXRBridgeBehaviour { }.onFailure { AppLogger.keybind.error(it, "Failed to open global shortcuts settings") } } } - } + }.launchIn(receiver.context.scope) } } @@ -77,7 +78,7 @@ suspend fun handleSolarXRBridge( val fbb = FlatBufferBuilder(256) fbb.finish(bundle.encode(JvmFlatBufferWriter(fbb))) send(fbb.dataBuffer().moveToByteArray()) - } + }.launchIn(this) try { messages.collect { bytes -> diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt index 64f39b4b5a..ed5385c0a6 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt @@ -75,7 +75,7 @@ private suspend fun setupWindowsKeybinds(appContext: AppContextProvider, scope: } apply(appContext.config.settings.context.state.value.data.keybinds) - appContext.keybindManager.events.on { apply(it.keybinds) } + appContext.keybindManager.events.on { apply(it.keybinds) }.launchIn(scope) appContext.keybindManager.context.state .map { it.recording } From c850f184030cbb7e85fcfd88a7a3032bf38dc0e1 Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 23 Jul 2026 20:46:40 -0400 Subject: [PATCH 289/350] Rework rest orientation --- .../main/java/dev/slimevr/resets/module.kt | 16 +--- .../java/dev/slimevr/tracker/behaviours.kt | 96 ++++++++++++------- .../main/java/dev/slimevr/tracker/module.kt | 8 +- .../slimevr/tracker/session-calibration.kt | 9 +- 4 files changed, 74 insertions(+), 55 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index 01617ae744..9322467975 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -44,9 +44,7 @@ typealias ResetsBehaviour = Behaviour class ResetsManager(val context: ResetsContext, val server: VRServer, val settings: Settings) { fun startObserving() = context.observeAll(this) - - private val quarterRollLeft = EulerAngles(EulerOrder.YZX, 0f, 0f, -FastMath.HALF_PI).toQuaternion() - private val quarterRollRight = EulerAngles(EulerOrder.YZX, 0f, 0f, FastMath.HALF_PI).toQuaternion() + private var resetJob: Job = Job() /** @@ -121,23 +119,13 @@ class ResetsManager(val context: ResetsContext, val server: VRServer, val settin it.context.dispatch( when (resetType) { ResetType.YAW -> TrackerActions.YawReset(referenceRotation) - ResetType.FULL -> TrackerActions.FullReset(referenceRotation, getRestOrientation(it.context.state.value.bodyPart, config.armsResetMode)) + ResetType.FULL -> TrackerActions.FullReset(referenceRotation) ResetType.MOUNTING -> TrackerActions.MountingReset(referenceRotation, getYawOffset(it.context.state.value.bodyPart, config.armsResetMode)) }, ) } } - private fun getRestOrientation(bodyPart: BodyPart?, armsResetMode: ArmsResetMode) = if (armsResetMode == ArmsResetMode.T_POSE_DOWN) { - when (bodyPart) { - in LEFT_ARM_PARTS, in LEFT_FINGER_PARTS -> quarterRollLeft - in RIGHT_ARM_PARTS, in RIGHT_FINGER_PARTS -> quarterRollRight - else -> Quaternion.IDENTITY - } - } else { - Quaternion.IDENTITY - } - private fun getYawOffset(bodyPart: BodyPart?, armsResetMode: ArmsResetMode) = when (bodyPart) { // Going forward in UPPER_LEG_PARTS -> 0f diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 091a33c4cf..aa4541b46d 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -1,13 +1,24 @@ package dev.slimevr.tracker +import com.jme3.math.FastMath import dev.slimevr.AppLogger +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions +import dev.slimevr.resets.LEFT_ARM_PARTS +import dev.slimevr.resets.LEFT_FINGER_PARTS +import dev.slimevr.resets.RIGHT_ARM_PARTS +import dev.slimevr.resets.RIGHT_FINGER_PARTS import dev.slimevr.skeleton.SkeletonActions import dev.slimevr.util.safeLaunch +import io.github.axisangles.ktmath.EulerAngles +import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -15,6 +26,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.rpc.ArmsResetMode import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch @@ -54,6 +66,7 @@ class TrackerBasicBehaviour : TrackerBehaviour { rawRotation, cal.headingCorrection, cal.headingAlignment, + state.restOrientation ) cal != null -> state.acceleration @@ -74,6 +87,8 @@ class TrackerBasicBehaviour : TrackerBehaviour { is TrackerActions.SetMountingOrientation -> state.copy(mountingOrientation = action.mountingOrientation) + is TrackerActions.SetRestOrientation -> state.copy(restOrientation = action.restOrientation) + is TrackerActions.FullReset -> { val headingCorrection = estimateHeadingCorrect( state.rawRotation, @@ -92,19 +107,12 @@ class TrackerBasicBehaviour : TrackerBehaviour { attitudeAlignment = attitudeAlignment, ) - // Apply calibration on rawRotation - val rotation = applyCalibration( - state.rawRotation, - sessionCalibration.headingCorrection, - sessionCalibration.attitudeAlignment, - sessionCalibration.headingAlignment, - action.restOrientation - ) // don't track polarity on Full Reset + // Reset polarity tracking + val defaultPolarityRotation = state.rotation.twinNearest(Quaternion.IDENTITY) state.copy( sessionCalibration = sessionCalibration, - restOrientation = action.restOrientation, - rotation = rotation, + rotation = defaultPolarityRotation, ) } @@ -120,19 +128,7 @@ class TrackerBasicBehaviour : TrackerBehaviour { headingCorrection = headingCorrection, ) - // Apply calibration on rawRotation - val rotation = applyCalibration( - state.rawRotation, - sessionCalibration.headingCorrection, - sessionCalibration.attitudeAlignment, - sessionCalibration.headingAlignment, - state.restOrientation - ).twinNearest(state.rotation) - - state.copy( - sessionCalibration = sessionCalibration, - rotation = rotation, - ) + state.copy(sessionCalibration = sessionCalibration) } is TrackerActions.MountingReset -> { @@ -153,21 +149,18 @@ class TrackerBasicBehaviour : TrackerBehaviour { headingAlignment = headingAlignment, ) - // Apply calibration on rawRotation - val rotation = applyCalibration( - state.rawRotation, - sessionCalibration.headingCorrection, - sessionCalibration.attitudeAlignment, - sessionCalibration.headingAlignment, - state.restOrientation - ).twinNearest(state.rotation) - - state.copy( - sessionCalibration = sessionCalibration, - rotation = rotation, - ) + state.copy(sessionCalibration = sessionCalibration) } } + + override fun observe(receiver: Tracker) { + // Refresh the tracker's rotation whenever the sessionCalibration gets updated + receiver.context.state + .distinctUntilChangedBy { it.sessionCalibration to it.restOrientation } + .onEach { + receiver.context.dispatch(TrackerActions.SetRotation(it.rawRotation, it.rawAcceleration, it.rawMagnetometer)) + }.launchIn(receiver.context.scope) + } } class TrackerDefaultMountingOrientationBehaviour : TrackerBehaviour { @@ -208,6 +201,7 @@ class TrackerDefaultMountingOrientationBehaviour : TrackerBehaviour { receiver.context.state .map { it.bodyPart } .distinctUntilChanged() + .drop(1) .onEach { receiver.context.dispatch(TrackerActions.SetMountingOrientation(defaultMountingForBodyPart(it))) }.launchIn(receiver.context.scope) @@ -272,3 +266,33 @@ class TrackerToSkeletonBehaviour : TrackerBehaviour { .launchIn(receiver.context.scope) } } + +class TrackerRestOrientationBehaviour( + private val settings: Settings, +) : TrackerBehaviour { + override fun observe(receiver: Tracker) { + val armsResetModeFlow = settings.context.state.map { it.data.resetsConfig.armsResetMode } + val bodyPartFlow = receiver.context.state.map { it.bodyPart } + + combine(armsResetModeFlow, bodyPartFlow) { armsResetMode, bodyPart -> + getRestOrientation(bodyPart, armsResetMode) + } + .distinctUntilChanged() + .onEach { restOrientation -> + receiver.context.dispatch(TrackerActions.SetRestOrientation(restOrientation)) + } + .launchIn(receiver.context.scope) + } + + private val quarterRollLeft = EulerAngles(EulerOrder.YZX, 0f, 0f, -FastMath.HALF_PI).toQuaternion() + private val quarterRollRight = EulerAngles(EulerOrder.YZX, 0f, 0f, FastMath.HALF_PI).toQuaternion() + private fun getRestOrientation(bodyPart: BodyPart?, armsResetMode: ArmsResetMode) = if (armsResetMode == ArmsResetMode.T_POSE_DOWN) { + when (bodyPart) { + in LEFT_ARM_PARTS, in LEFT_FINGER_PARTS -> quarterRollLeft + in RIGHT_ARM_PARTS, in RIGHT_FINGER_PARTS -> quarterRollRight + else -> Quaternion.IDENTITY + } + } else { + Quaternion.IDENTITY + } +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index ab3fefb115..43eba9e2e0 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -25,7 +25,7 @@ data class TrackerState( val bodyPart: BodyPart?, val customName: String?, val mountingOrientation: HeadingAlignment?, - val restOrientation: Quaternion, + val restOrientation: RestOrientation, val rawRotation: RawRotation, val rotation: CalibratedRotation, val rawAcceleration: RawAcceleration, @@ -48,7 +48,8 @@ sealed interface TrackerActions { data class SetStatus(val status: TrackerStatus) : TrackerActions data class SetRotation(val rotation: Quaternion? = null, val acceleration: Vector3? = null, val magnetometer: Vector3? = null) : TrackerActions data class SetMountingOrientation(val mountingOrientation: HeadingAlignment?) : TrackerActions - data class FullReset(val referenceRotation: Quaternion, val restOrientation: Quaternion) : TrackerActions + data class SetRestOrientation(val restOrientation: Quaternion) : TrackerActions + data class FullReset(val referenceRotation: Quaternion) : TrackerActions data class YawReset(val referenceRotation: Quaternion) : TrackerActions data class MountingReset(val referenceRotation: Quaternion, val yawOffset: Float) : TrackerActions } @@ -81,6 +82,7 @@ class Tracker( id = id, hardwareId = hardwareId, name = name, + restOrientation = Quaternion.IDENTITY, rawRotation = Quaternion.IDENTITY, rotation = Quaternion.IDENTITY, rawAcceleration = Vector3.NULL, @@ -99,7 +101,6 @@ class Tracker( completedRestCalibration = false, magStatus = MagnetometerStatus.NOT_SUPPORTED, sessionCalibration = null, - restOrientation = Quaternion.IDENTITY, ) val trackerState = if (savedConfig != null) { restoreFromConfig(baseState, savedConfig, settings.context.state.value.data.resetsConfig.saveMountingReset) @@ -110,6 +111,7 @@ class Tracker( val behaviours = listOf( TrackerBasicBehaviour(), TrackerDefaultMountingOrientationBehaviour(), + TrackerRestOrientationBehaviour(settings), TrackerConfigBehaviour(settings, hardwareId), TrackerTPSBehaviour(), TrackerToSkeletonBehaviour(), diff --git a/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt b/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt index d5e202be46..a767a00947 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt @@ -11,6 +11,7 @@ typealias RawAcceleration = Vector3 typealias HeadingCorrection = Quaternion typealias AttitudeAlignment = Quaternion typealias HeadingAlignment = Quaternion +typealias RestOrientation = Quaternion // TODO verify math and make unit tests typealias AccelerationRotation = Quaternion @@ -21,14 +22,14 @@ data class SessionCalibration( val headingCorrection: HeadingCorrection = Quaternion.IDENTITY, val attitudeAlignment: AttitudeAlignment = Quaternion.IDENTITY, val headingAlignment: HeadingAlignment = Quaternion.IDENTITY, -) + ) fun applyCalibration( rawRotation: RawRotation, headingCorrect: HeadingCorrection = Quaternion.IDENTITY, attitudeAlign: AttitudeAlignment = Quaternion.IDENTITY, headingAlign: HeadingAlignment = Quaternion.IDENTITY, - restOrientation: Quaternion = Quaternion.IDENTITY, + restOrientation: RestOrientation = Quaternion.IDENTITY, ): CalibratedRotation = headingAlign.inv() * headingCorrect * rawRotation * attitudeAlign * headingAlign * restOrientation // We reverse the order of headingAlign and attitudeAlign here since our @@ -40,6 +41,7 @@ fun undoCalibration( headingCorrect: HeadingCorrection = Quaternion.IDENTITY, attitudeAlign: AttitudeAlignment = Quaternion.IDENTITY, headingAlign: HeadingAlignment = Quaternion.IDENTITY, + restOrientation: RestOrientation = Quaternion.IDENTITY, ): RawRotation = headingCorrect.inv() * headingAlign * calibratedRotation * headingAlign.inv() * attitudeAlign.inv() // Acceleration needs to be rotated by raw rotation with heading corrected @@ -47,6 +49,7 @@ private fun accelerationRotation( rawRotation: RawRotation, headingCorrect: HeadingCorrection = Quaternion.IDENTITY, headingAlign: HeadingAlignment = Quaternion.IDENTITY, + restOrientation: RestOrientation = Quaternion.IDENTITY, ): AccelerationRotation = headingAlign.inv() * headingCorrect * rawRotation fun applyCalibration( @@ -54,6 +57,7 @@ fun applyCalibration( rawRotation: RawRotation, headingCorrect: HeadingCorrection = Quaternion.IDENTITY, headingAlign: HeadingAlignment = Quaternion.IDENTITY, + restOrientation: RestOrientation = Quaternion.IDENTITY, ): CalibratedAcceleration = accelerationRotation(rawRotation, headingCorrect, headingAlign).sandwich( rawAcceleration, ) @@ -63,6 +67,7 @@ fun undoCalibration( rawRotation: RawRotation, headingCorrect: HeadingCorrection = Quaternion.IDENTITY, headingAlign: HeadingAlignment = Quaternion.IDENTITY, + restOrientation: RestOrientation = Quaternion.IDENTITY, ): RawAcceleration = accelerationRotation(rawRotation, headingCorrect, headingAlign).inv() .sandwich(calibratedAcceleration) From 4c0b187306ecbc922171f51f3ea3b34d2d603d4d Mon Sep 17 00:00:00 2001 From: lucas lelievre Date: Fri, 24 Jul 2026 22:14:06 +0200 Subject: [PATCH 290/350] Remove safeLaunch + Fix driver --- .idea/inspectionProfiles/Project_Default.xml | 24 ---- .../dev/slimevr/android/ForegroundService.kt | 16 ++- .../main/java/dev/slimevr/android/hid/hid.kt | 8 +- .../java/dev/slimevr/android/serial/serial.kt | 8 +- .../main/java/dev/slimevr/firmware/module.kt | 6 +- .../main/java/dev/slimevr/firmware/serial.kt | 4 +- .../dev/slimevr/heightcalibration/module.kt | 4 +- .../main/java/dev/slimevr/hid/behaviours.kt | 6 +- .../java/dev/slimevr/oscquery/discovery.kt | 5 +- .../java/dev/slimevr/provisioning/module.kt | 4 +- .../main/java/dev/slimevr/resets/module.kt | 4 +- .../main/java/dev/slimevr/serial/module.kt | 4 +- .../java/dev/slimevr/skeleton/behaviours.kt | 8 +- .../main/java/dev/slimevr/solarxr/datafeed.kt | 4 +- .../src/main/java/dev/slimevr/solarxr/mag.kt | 4 +- .../main/java/dev/slimevr/solarxr/serial.kt | 4 +- .../dev/slimevr/tapdetection/behaviours.kt | 4 +- .../java/dev/slimevr/tracker/behaviours.kt | 5 +- .../main/java/dev/slimevr/udp/behaviours.kt | 10 +- .../main/java/dev/slimevr/udp/connection.kt | 6 +- .../src/main/java/dev/slimevr/udp/server.kt | 4 +- .../java/dev/slimevr/util/error-reporting.kt | 43 +++++++ .../main/java/dev/slimevr/util/safe-launch.kt | 39 ------ .../main/java/dev/slimevr/vmc/behaviours.kt | 8 +- .../main/java/dev/slimevr/vrchat/module.kt | 4 +- .../src/main/java/dev/slimevr/vrcosc/input.kt | 4 +- .../main/java/dev/slimevr/vrcosc/module.kt | 5 +- .../context/ContextScopeIsolationTest.kt | 4 +- .../dev/slimevr/firmware/DoSerialFlashTest.kt | 20 +-- .../dev/slimevr/firmware/OtaUploadTest.kt | 6 +- .../HeightCalibrationTest.kt | 6 +- .../provisioning/ProvisioningManagerTest.kt | 20 +-- .../src/main/java/dev/slimevr/desktop/Main.kt | 19 ++- .../main/java/dev/slimevr/desktop/hid/hid.kt | 8 +- .../main/java/dev/slimevr/desktop/ipc/ipc.kt | 14 +- .../java/dev/slimevr/desktop/ipc/linux.kt | 28 +++- .../java/dev/slimevr/desktop/ipc/pipe-slot.kt | 120 ++++++++++++++++++ .../java/dev/slimevr/desktop/ipc/protocol.kt | 14 +- .../java/dev/slimevr/desktop/ipc/solarxr.kt | 8 +- .../java/dev/slimevr/desktop/ipc/windows.kt | 119 +++++++---------- .../dev/slimevr/desktop/keybind/keybinds.kt | 8 +- .../desktop/networkprofile/network-profile.kt | 4 +- .../java/dev/slimevr/desktop/serial/serial.kt | 8 +- .../desktop/trackingchecklist/behaviours.kt | 4 +- server/detekt.yml | 43 ++++++- 45 files changed, 423 insertions(+), 275 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/util/error-reporting.kt delete mode 100644 server/core/src/main/java/dev/slimevr/util/safe-launch.kt create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/ipc/pipe-slot.kt diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml index dd667131c1..6110e4b40f 100644 --- a/.idea/inspectionProfiles/Project_Default.xml +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -1,30 +1,6 @@ \ No newline at end of file diff --git a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt index 523483ab62..26f14f8a1b 100644 --- a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt +++ b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt @@ -37,15 +37,18 @@ import dev.slimevr.skeleton.Skeleton import dev.slimevr.tapdetection.TapDetectionManager import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer -import dev.slimevr.util.safeLaunch +import dev.slimevr.util.appCoroutineExceptionHandler +import dev.slimevr.util.installUncaughtExceptionReporting import dev.slimevr.vmc.VMCManager import dev.slimevr.vrcosc.VRCOSCManager import io.klogging.noCoLogger +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch private val logger = noCoLogger("ForegroundService") @@ -110,9 +113,12 @@ class ForegroundService : Service() { } if (serviceScope == null) { - val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + installUncaughtExceptionReporting() + val scope = CoroutineScope( + SupervisorJob() + Dispatchers.Default + appCoroutineExceptionHandler + CoroutineName("ForegroundService"), + ) serviceScope = scope - scope.safeLaunch { startServer(scope) } + scope.launch { startServer(scope) } } return START_NOT_STICKY @@ -191,8 +197,8 @@ class ForegroundService : Service() { try { appContext.startObserving() - scope.safeLaunch { createAndroidHIDManager(context = this@ForegroundService, appContext = appContext, scope = this) } - scope.safeLaunch { createAndroidSolarXRWebsocketServer(appContext) } + scope.launch { createAndroidHIDManager(context = this@ForegroundService, appContext = appContext, scope = this) } + scope.launch { createAndroidSolarXRWebsocketServer(appContext) } awaitCancellation() } finally { diff --git a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt index 60ae8c362d..949b7d3097 100644 --- a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt +++ b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt @@ -17,7 +17,6 @@ import dev.slimevr.device.DeviceActions import dev.slimevr.hid.HIDReceiver import dev.slimevr.hid.isCompatibleHidDevice import dev.slimevr.hid.parseHIDPackets -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -25,6 +24,7 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import solarxr_protocol.datatypes.TrackerStatus @@ -75,7 +75,7 @@ fun createAndroidHIDManager(context: Context, appContext: AppContextProvider, sc } ContextCompat.registerReceiver(context, usbReceiver, intentFilter, ContextCompat.RECEIVER_NOT_EXPORTED) - scope.safeLaunch { + scope.launch { while (isActive) { val found = withContext(Dispatchers.IO) { try { @@ -139,7 +139,7 @@ fun createAndroidHIDManager(context: Context, appContext: AppContextProvider, sc scope = deviceScope, ) - deviceScope.safeLaunch { + deviceScope.launch { try { val buffer = ByteArray(64) while (isActive) { @@ -151,7 +151,7 @@ fun createAndroidHIDManager(context: Context, appContext: AppContextProvider, sc } } when { - read < 0 -> return@safeLaunch + read < 0 -> return@launch read > 0 -> parseHIDPackets(buffer.copyOf(read)).forEach { packet -> receiver.packetEvents.emit(packet) } else -> delay(1) } diff --git a/server/android/src/main/java/dev/slimevr/android/serial/serial.kt b/server/android/src/main/java/dev/slimevr/android/serial/serial.kt index a1dd4032a3..dad4b181c4 100644 --- a/server/android/src/main/java/dev/slimevr/android/serial/serial.kt +++ b/server/android/src/main/java/dev/slimevr/android/serial/serial.kt @@ -16,10 +16,10 @@ import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialPortInfo import dev.slimevr.serial.SerialServer import dev.slimevr.serial.isKnownSerialBoard -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -69,14 +69,14 @@ private fun openAndroidPort( while (newlineIdx >= 0) { val line = readBuffer.substring(0, newlineIdx).trimEnd() readBuffer.delete(0, newlineIdx + 1) - scope.safeLaunch { onDataReceived(portLocation, line) } + scope.launch { onDataReceived(portLocation, line) } newlineIdx = readBuffer.indexOf("\n") } if (readBuffer.length >= 1024) readBuffer.clear() } override fun onRunError(e: Exception) { - scope.safeLaunch { onPortDisconnected(portLocation) } + scope.launch { onPortDisconnected(portLocation) } } }, ) @@ -182,7 +182,7 @@ fun createAndroidSerialServer(context: Context, scope: CoroutineScope): SerialSe scope = scope, ) - scope.safeLaunch { runAndroidSerialPoller(context, usbManager, server, wakeSignal) } + scope.launch { runAndroidSerialPoller(context, usbManager, server, wakeSignal) } return server } diff --git a/server/core/src/main/java/dev/slimevr/firmware/module.kt b/server/core/src/main/java/dev/slimevr/firmware/module.kt index 9cd1c04b3c..9cb65b6382 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/module.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/module.kt @@ -6,10 +6,10 @@ import dev.slimevr.config.Settings import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.serial.SerialServer -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch import solarxr_protocol.rpc.FirmwareDeviceIdTable import solarxr_protocol.rpc.FirmwarePart import solarxr_protocol.rpc.FirmwareUpdateDeviceId @@ -60,7 +60,7 @@ class FirmwareManager( server: VRServer, ) { runningJobs[portLocation]?.cancelAndJoin() - runningJobs[portLocation] = scope.safeLaunch { + runningJobs[portLocation] = scope.launch { doSerialFlash( portLocation = portLocation, parts = parts, @@ -96,7 +96,7 @@ class FirmwareManager( server: VRServer, ) { runningJobs[deviceIp]?.cancelAndJoin() - runningJobs[deviceIp] = scope.safeLaunch { + runningJobs[deviceIp] = scope.launch { doOtaFlash( deviceIp = deviceIp, deviceId = (firmwareDeviceId as? FirmwareDeviceIdTable)?.id ?: error("device id should exist"), diff --git a/server/core/src/main/java/dev/slimevr/firmware/serial.kt b/server/core/src/main/java/dev/slimevr/firmware/serial.kt index 151879a786..25121296db 100644 --- a/server/core/src/main/java/dev/slimevr/firmware/serial.kt +++ b/server/core/src/main/java/dev/slimevr/firmware/serial.kt @@ -7,13 +7,13 @@ import dev.slimevr.serial.FlashingHandler import dev.slimevr.serial.MAC_REGEX import dev.slimevr.serial.SerialConnection import dev.slimevr.serial.SerialServer -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import solarxr_protocol.rpc.FirmwarePart @@ -73,7 +73,7 @@ suspend fun doSerialFlash( handler = handler, parts = downloadedParts, onProgress = { progress -> - scope.safeLaunch { onStatus(FirmwareUpdateStatus.UPLOADING, progress) } + scope.launch { onStatus(FirmwareUpdateStatus.UPLOADING, progress) } }, ) } diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt index ac59af98e9..4632f7ba8d 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/module.kt @@ -7,7 +7,6 @@ import dev.slimevr.VRServer import dev.slimevr.config.UserConfig import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope @@ -17,6 +16,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.UserHeightCalibrationStatus @@ -89,7 +89,7 @@ class HeightCalibrationManager( fun start() { sessionJob?.cancel() - sessionJob = context.scope.safeLaunch { runCalibrationSession(context, userConfig, hmdUpdates, controllerUpdates) } + sessionJob = context.scope.launch { runCalibrationSession(context, userConfig, hmdUpdates, controllerUpdates) } } fun cancel() { diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 1ed74c4f53..e7e0f3feea 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -7,10 +7,10 @@ import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.launch import solarxr_protocol.datatypes.TrackerStatus class HIDRegistrationBehaviour : HIDReceiverBehaviour { @@ -186,7 +186,7 @@ class HIDSleepBehaviour : HIDReceiverBehaviour { sleepJobs.remove(hidId) return } - sleepJobs[hidId] = receiver.context.scope.safeLaunch { + sleepJobs[hidId] = receiver.context.scope.launch { delay(timeoutMs.toLong()) receiver.getTracker(hidId)?.context?.dispatch(TrackerActions.SetStatus(TrackerStatus.TIMED_OUT)) } @@ -194,7 +194,7 @@ class HIDSleepBehaviour : HIDReceiverBehaviour { fun armIdleTimeout(hidId: Int) { idleJobs[hidId]?.cancel() - idleJobs[hidId] = receiver.context.scope.safeLaunch { + idleJobs[hidId] = receiver.context.scope.launch { delay(HID_TIMEOUT_MS) receiver.getTracker(hidId)?.context?.dispatch(TrackerActions.SetStatus(TrackerStatus.TIMED_OUT)) } diff --git a/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt b/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt index f96ac9c974..e7ea60dad8 100644 --- a/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt +++ b/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt @@ -3,7 +3,6 @@ package dev.slimevr.oscquery import com.appstractive.dnssd.DiscoveryEvent import com.appstractive.dnssd.discoverServices import dev.slimevr.AppLogger -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin @@ -11,7 +10,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch private const val OSC_JSON_SERVICE_TYPE = "_oscjson._tcp" @@ -27,7 +26,7 @@ class OscQueryDiscovery( fun start(scope: CoroutineScope) { if (job != null) return - job = scope.safeLaunch { + job = scope.launch { discoverServicesFlow(serviceType).collect { event -> when (event) { is DiscoveryEvent.Discovered -> { diff --git a/server/core/src/main/java/dev/slimevr/provisioning/module.kt b/server/core/src/main/java/dev/slimevr/provisioning/module.kt index 4252996a26..961121f26d 100644 --- a/server/core/src/main/java/dev/slimevr/provisioning/module.kt +++ b/server/core/src/main/java/dev/slimevr/provisioning/module.kt @@ -7,13 +7,13 @@ import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.serial.SerialConnection import dev.slimevr.serial.SerialServer -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import solarxr_protocol.rpc.WifiProvisioningStatus data class ProvisioningManagerState( @@ -60,7 +60,7 @@ data class ProvisioningManager( currentJob.cancelAndJoin() context.dispatch(ProvisioningActions.Clear) } - provisioningJob = scope.safeLaunch { + provisioningJob = scope.launch { // TODO handle port field, currently ignored while (isActive) { if (!selectAndOpenPort(context, serialServer)) continue diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index 9322467975..7544da4b90 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -11,7 +11,6 @@ import dev.slimevr.config.SettingsActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.tracker.TrackerActions -import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.EulerAngles import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion @@ -19,6 +18,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.ArmsResetMode @@ -55,7 +55,7 @@ class ResetsManager(val context: ResetsContext, val server: VRServer, val settin */ suspend fun scheduleReset(resetSourceName: String, resetType: ResetType, delay: Float = 0f, bodyParts: List? = null) { resetJob.cancelAndJoin() - resetJob = context.scope.safeLaunch { + resetJob = context.scope.launch { val delayMs = (delay * 1000).toInt() val fullSeconds = delayMs / 1000 val remainder = delayMs % 1000 diff --git a/server/core/src/main/java/dev/slimevr/serial/module.kt b/server/core/src/main/java/dev/slimevr/serial/module.kt index aa9287992e..c20742dd94 100644 --- a/server/core/src/main/java/dev/slimevr/serial/module.kt +++ b/server/core/src/main/java/dev/slimevr/serial/module.kt @@ -4,8 +4,8 @@ import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.hid.isCompatibleHidReceiver import dev.slimevr.hid.isCompatibleHidTracker -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import solarxr_protocol.rpc.SerialDevice import solarxr_protocol.rpc.SerialDeviceType @@ -120,7 +120,7 @@ class SerialServer( return object : FlashingHandler by handler { override fun closeSerial() { handler.closeSerial() - context.scope.safeLaunch { context.dispatch(SerialServerActions.RemoveConnection(portLocation)) } + context.scope.launch { context.dispatch(SerialServerActions.RemoveConnection(portLocation)) } } } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 93f1aa9c94..7fd98c12b2 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -1,7 +1,6 @@ package dev.slimevr.skeleton import dev.slimevr.config.UserConfig -import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import io.ktor.utils.io.CancellationException @@ -10,6 +9,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart import kotlin.math.cos import kotlin.math.sin @@ -69,7 +69,7 @@ class ProportionsBehaviour(private val userConfig: UserConfig) : SkeletonBehavio class HeightLogBehaviour : SkeletonBehaviour { override fun observe(receiver: Skeleton) { - receiver.context.scope.safeLaunch { + receiver.context.scope.launch { receiver.context.state .map { state -> state.skeletonHeight } .distinctUntilChanged() @@ -80,7 +80,7 @@ class HeightLogBehaviour : SkeletonBehaviour { class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour { override fun observe(receiver: Skeleton) { - receiver.context.scope.safeLaunch { + receiver.context.scope.launch { val intervalMs = (1000f / inputHz).toLong() val startTime = System.currentTimeMillis() while (true) { @@ -129,7 +129,7 @@ class ComputedSkeletonBehaviour( ) : SkeletonBehaviour { override fun observe(receiver: Skeleton) { val intervalMs = (1000f / hz).toLong() - receiver.context.scope.safeLaunch { + receiver.context.scope.launch { while (true) { try { delay(intervalMs) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index dfb3b82b22..8dc8a70dce 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -8,11 +8,11 @@ import dev.slimevr.resets.ResetsManager import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.Skeleton import dev.slimevr.tracker.TrackerState -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import solarxr_protocol.data_feed.BoneMask import solarxr_protocol.data_feed.DataFeedConfig import solarxr_protocol.data_feed.DataFeedMessageHeader @@ -168,7 +168,7 @@ class DataFeedInitBehaviour(val server: VRServer, val skeleton: Skeleton) : Sola receiver.datafeedTimers.forEach { it.cancelAndJoin() } val timers = dataFeeds.mapIndexed { index, config -> - receiver.context.scope.safeLaunch { + receiver.context.scope.launch { val minTime = config.minimumTimeSinceLast.toLong() while (isActive) { try { diff --git a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt index 87b0cd6b3f..a4a7be8497 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt @@ -5,10 +5,10 @@ import dev.slimevr.config.SettingsActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.udp.SensorConfigFlags import dev.slimevr.udp.UDPConnectionActions -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.rpc.ChangeMagToggleRequest @@ -61,7 +61,7 @@ class MagBehaviour( when (trackerState.origin) { DeviceOrigin.UDP -> { if (!setUDPTrackerMag(trackerState.id, trackerState.deviceId, enable)) return@on - tracker.context.scope.safeLaunch { + tracker.context.scope.launch { try { withTimeout(10.seconds) { tracker.context.state diff --git a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt index ff41b00186..8798cfa7b2 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt @@ -2,12 +2,12 @@ package dev.slimevr.solarxr import dev.slimevr.serial.SerialConnection import dev.slimevr.serial.SerialServer -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.Job import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import solarxr_protocol.rpc.CloseSerialRequest import solarxr_protocol.rpc.NewSerialDeviceResponse import solarxr_protocol.rpc.OpenSerialRequest @@ -71,7 +71,7 @@ class SerialBehaviour(private val serialServer: SerialServer) : SolarXRBridgeBeh activePortLocation = portLocation var lastSentCount = 0 - logSubscription = scope.safeLaunch { + logSubscription = scope.launch { var disconnected = false try { connection.context.state.collect { connState -> diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt index 2daa811845..8e8833b36f 100644 --- a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt @@ -2,7 +2,6 @@ package dev.slimevr.tapdetection import dev.slimevr.config.TapDetectionConfig import dev.slimevr.tracker.Tracker -import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.combine @@ -13,6 +12,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.ResetType @@ -93,7 +93,7 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { ) if (tapTriggered) { - receiver.context.scope.safeLaunch { + receiver.context.scope.launch { // If it's in setup mode, tap to assign if (setupMode) { receiver.server.sendSolarxrRpc( diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index aa4541b46d..93ece4df57 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -3,13 +3,11 @@ package dev.slimevr.tracker import com.jme3.math.FastMath import dev.slimevr.AppLogger import dev.slimevr.config.Settings -import dev.slimevr.config.SettingsActions import dev.slimevr.resets.LEFT_ARM_PARTS import dev.slimevr.resets.LEFT_FINGER_PARTS import dev.slimevr.resets.RIGHT_ARM_PARTS import dev.slimevr.resets.RIGHT_FINGER_PARTS import dev.slimevr.skeleton.SkeletonActions -import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.EulerAngles import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion @@ -24,6 +22,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.ArmsResetMode @@ -217,7 +216,7 @@ class TrackerTPSBehaviour : TrackerBehaviour { count.incrementAndFetch() }.launchIn(receiver.context.scope) - receiver.context.scope.safeLaunch { + receiver.context.scope.launch { var mark = TimeSource.Monotonic.markNow() while (isActive) { try { diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index ec11eb6eaf..4f7034f78d 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -9,7 +9,6 @@ import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import dev.slimevr.tracker.TrackerSensorIds -import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -17,6 +16,7 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.UnknownDeviceHandshakeNotification @@ -95,7 +95,7 @@ class PingBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { // Send the ping every 1s - receiver.context.scope.safeLaunch { + receiver.context.scope.launch { while (isActive) { val state = receiver.context.state.value if (state.didHandshake) { @@ -167,7 +167,7 @@ class HandshakeBehaviour : UDPConnectionBehaviour { val settings = receiver.appContext.config.settings.context.state.value.data if (mac !in settings.allowedUdpDevices) { AppLogger.udp.info("[${state.address}] Unknown MAC $mac, notifying solarxr") - receiver.appContext.server.context.scope.safeLaunch { + receiver.appContext.server.context.scope.launch { receiver.appContext.server.sendSolarxrRpc( UnknownDeviceHandshakeNotification(macAddress = mac), ) @@ -208,7 +208,7 @@ class HandshakeBehaviour : UDPConnectionBehaviour { class TimeoutBehaviour : UDPConnectionBehaviour { override fun observe(receiver: UDPConnection) { - receiver.context.scope.safeLaunch { + receiver.context.scope.launch { while (isActive) { val state = receiver.context.state.value if (!state.didHandshake) { @@ -240,7 +240,7 @@ class DisconnectBehaviour : UDPConnectionBehaviour { .distinctUntilChangedBy { it.didHandshake } .onEach { state -> if (!state.didHandshake) { - removalJob = receiver.context.scope.safeLaunch { + removalJob = receiver.context.scope.launch { delay(CONNECTION_REMOVAL_MS) val currentState = receiver.context.state.value AppLogger.udp.info("[${currentState.address}] Connection removed after extended timeout") diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 3aefe678b2..226e1e1bbc 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -7,12 +7,12 @@ import dev.slimevr.context.Context import dev.slimevr.device.Device import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerSensorIds -import dev.slimevr.util.safeLaunch import io.ktor.network.sockets.BoundDatagramSocket import io.ktor.network.sockets.Datagram import io.ktor.network.sockets.InetSocketAddress import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch import kotlinx.io.Buffer import solarxr_protocol.datatypes.MagnetometerStatus @@ -63,7 +63,7 @@ class UDPConnection( private val scope: CoroutineScope, ) { fun send(packet: UDPPacket) { - scope.safeLaunch { + scope.launch { val buf = Buffer() writePacket(buf, packet) socket.send(Datagram(buf, remoteAddress)) @@ -139,7 +139,7 @@ class UDPConnection( conn.startObserving() // Dedicated coroutine per connection so the receive loop is never blocked by packet processing - scope.safeLaunch { + scope.launch { for (event in packetChannel) { // We skip any packet from the tracker that are not handshake packets // if we didn't do a handshake with the server diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index 7516df2c6d..a980dacfe0 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -4,7 +4,6 @@ import dev.slimevr.AppContextProvider import dev.slimevr.AppLogger import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.util.safeLaunch import io.ktor.network.selector.SelectorManager import io.ktor.network.sockets.BoundDatagramSocket import io.ktor.network.sockets.InetSocketAddress @@ -14,6 +13,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlin.time.measureTime data class UdpServerState( @@ -48,7 +48,7 @@ class UdpServer(val context: UdpServerContext, private val addressResolver: (Ine fun startReceiving(appContext: AppContextProvider, scope: CoroutineScope) { if (receiveJob != null) return - receiveJob = scope.safeLaunch { + receiveJob = scope.launch { val port = appContext.config.settings.context.state.value.data.trackersConfig.trackerPort val selectorManager = SelectorManager(Dispatchers.IO) val socket = aSocket(selectorManager).udp().bind(port = port) diff --git a/server/core/src/main/java/dev/slimevr/util/error-reporting.kt b/server/core/src/main/java/dev/slimevr/util/error-reporting.kt new file mode 100644 index 0000000000..0b908cc737 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/util/error-reporting.kt @@ -0,0 +1,43 @@ +package dev.slimevr.util + +import dev.slimevr.AppLogger +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineName +import kotlin.coroutines.CoroutineContext + +/** + * Reports every coroutine failure that nothing else handled. + * + * Install it in the context of the application's root scope. A [CoroutineExceptionHandler] is + * looked up in the context of the coroutine that *finishes* the exception, and coroutine context + * is inherited, so a single handler at the root is found by everything underneath it. + * + * It fires for coroutines that finish an exception themselves: a root coroutine, or a direct + * child of a `SupervisorJob` -- which is what every [dev.slimevr.context.Context] scope is, so + * every behaviour listener lands here. + * + * Cancellation is never reported: coroutines cancelled as a *consequence* of another failure + * carry a [kotlinx.coroutines.CancellationException] and are not passed to a handler at all. + */ +val appCoroutineExceptionHandler = CoroutineExceptionHandler { context, throwable -> + AppLogger.coroutines.error(throwable, "Unhandled exception in coroutine (scope: ${context.scopeName})") +} + +private val CoroutineContext.scopeName: String + get() = this[CoroutineName]?.name ?: "unnamed" + +/** + * Routes failures on threads that coroutines do not own -- JNA callbacks, JmDNS, serial reader + * threads -- into the same log, instead of the JVM dumping them on stderr where nothing collects + * them. + * + * Chains to any previously installed handler. The JVM installs none by default, so normally this + * replaces the stderr dump rather than adding to it. + */ +fun installUncaughtExceptionReporting() { + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + AppLogger.coroutines.error(throwable, "Unhandled exception on thread ${thread.name}") + previous?.uncaughtException(thread, throwable) + } +} diff --git a/server/core/src/main/java/dev/slimevr/util/safe-launch.kt b/server/core/src/main/java/dev/slimevr/util/safe-launch.kt deleted file mode 100644 index 9510fd6ab3..0000000000 --- a/server/core/src/main/java/dev/slimevr/util/safe-launch.kt +++ /dev/null @@ -1,39 +0,0 @@ -@file:Suppress("ForbiddenImport") - -package dev.slimevr.util - -import dev.slimevr.AppLogger -import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineName -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext - -/** - * A safe wrapper around [launch] that ensures unhandled exceptions - * are explicitly caught and routed to the AppLogger, preventing silent failures. - * - * If you need specific error recovery, use a try/catch block inside the coroutine, - * or provide your own [CoroutineExceptionHandler] in the [context]. - */ -@Suppress("ForbiddenImport") -fun CoroutineScope.safeLaunch( - context: CoroutineContext = EmptyCoroutineContext, - start: CoroutineStart = CoroutineStart.DEFAULT, - block: suspend CoroutineScope.() -> Unit, -): Job { - val handler = context[CoroutineExceptionHandler] - ?: this.coroutineContext[CoroutineExceptionHandler] - ?: CoroutineExceptionHandler { ctx, throwable -> - val name = ctx[CoroutineName]?.name ?: "UnknownScope" - AppLogger.coroutines.error( - throwable, - "Unhandled exception in safeLaunch (scope: $name)", - ) - } - //noinspection RAW_LAUNCH_USAGE - return this.launch(context + handler, start = start, block = block) -} diff --git a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt index d875474cb0..a74c20d71d 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt @@ -12,13 +12,13 @@ import dev.slimevr.osc.OscReceiver import dev.slimevr.osc.OscSender import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.Skeleton -import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart class VMCOutputBehaviour( @@ -54,7 +54,7 @@ class VMCOutputBehaviour( if (enabled) { sender = OscSender(addr, port) AppLogger.vmc.info("VMC output started: $addr:$port") - receiver.context.scope.safeLaunch { + receiver.context.scope.launch { try { sender?.send(OscMessage("/VMC/Ext/Req", emptyList())) } catch (e: Exception) { @@ -70,7 +70,7 @@ class VMCOutputBehaviour( val config = settings.context.state.value.data.vmcConfig val currentTime = System.currentTimeMillis() val vrm = vrmGeometry - receiver.context.scope.safeLaunch { + receiver.context.scope.launch { try { s.send(buildBundle(bones, config, currentTime, vrm)) } catch (e: Exception) { @@ -156,7 +156,7 @@ class VMCInputBehaviour(private val settings: Settings) : Behaviour for (content in bundle.contents) { diff --git a/server/core/src/main/java/dev/slimevr/vrchat/module.kt b/server/core/src/main/java/dev/slimevr/vrchat/module.kt index 2ca0925a13..c280b4b4e2 100644 --- a/server/core/src/main/java/dev/slimevr/vrchat/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrchat/module.kt @@ -4,9 +4,9 @@ import dev.slimevr.VRServer import dev.slimevr.config.AppConfig import dev.slimevr.context.Behaviour import dev.slimevr.context.Context -import dev.slimevr.util.safeLaunch 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 @@ -68,7 +68,7 @@ class VRCConfigManager( name = "VRCConfig", ) - scope.safeLaunch { + scope.launch { values.collect { context.dispatch(VRCConfigActions.UpdateValues(it)) } } diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt index 3b854dcac8..49a5ce0db9 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt @@ -13,11 +13,11 @@ import dev.slimevr.osc.OscMessage import dev.slimevr.osc.OscReceiver import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.VRCOSCInputState @@ -164,7 +164,7 @@ class VRCOSCInputBehaviour( ) AppLogger.vrc.info("VRChat OSC input listening on port $portIn") - receiver.context.scope.safeLaunch { + receiver.context.scope.launch { try { newReceiver.listenBundles { bundle -> handleBundle(bundle, registry, receiver, portIn) } } catch (e: Exception) { diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt index f1c812f2c7..bc14207529 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt @@ -4,13 +4,12 @@ import dev.slimevr.AppContextProvider import dev.slimevr.EventDispatcher import dev.slimevr.Phase1ContextProvider import dev.slimevr.config.Settings -import dev.slimevr.config.VRCOSCConfig import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager -import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import solarxr_protocol.rpc.VRCOSCInputState import solarxr_protocol.rpc.VRCOSCOscQueryState import solarxr_protocol.rpc.VRCOSCOutputState @@ -113,7 +112,7 @@ class VRCOSCManager( } fun yawAlign(headRotation: Quaternion) { - context.scope.safeLaunch { + context.scope.launch { events.emit(VRCOSCEvent.YawAlign(headRotation)) } } diff --git a/server/core/src/test/java/dev/slimevr/context/ContextScopeIsolationTest.kt b/server/core/src/test/java/dev/slimevr/context/ContextScopeIsolationTest.kt index 60f45035e2..67cb4f951e 100644 --- a/server/core/src/test/java/dev/slimevr/context/ContextScopeIsolationTest.kt +++ b/server/core/src/test/java/dev/slimevr/context/ContextScopeIsolationTest.kt @@ -1,10 +1,10 @@ package dev.slimevr.context -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -29,7 +29,7 @@ class ContextScopeIsolationTest { name = "IsolatedContext", ) - context.scope.safeLaunch { + context.scope.launch { error("boom") } diff --git a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt index f3e4513eaf..cde0d09c79 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/DoSerialFlashTest.kt @@ -12,10 +12,10 @@ import dev.slimevr.serial.FlashingHandler import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialPortInfo import dev.slimevr.serial.SerialServer -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import solarxr_protocol.datatypes.TrackerStatus @@ -167,7 +167,7 @@ class DoSerialFlashTest { server.openConnection("COM1") val statuses = mutableListOf() - val job = safeLaunch { + val job = launch { doSerialFlashPostFlash( portLocation = "COM1", needManualReboot = false, @@ -194,7 +194,7 @@ class DoSerialFlashTest { server.openConnection("COM1") val statuses = mutableListOf() - backgroundScope.safeLaunch { + backgroundScope.launch { doSerialFlashPostFlash( portLocation = "COM1", needManualReboot = false, @@ -207,7 +207,7 @@ class DoSerialFlashTest { ) } - backgroundScope.safeLaunch { + backgroundScope.launch { delay(100) server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") } @@ -225,7 +225,7 @@ class DoSerialFlashTest { server.openConnection("COM1") val statuses = mutableListOf() - val job = safeLaunch { + val job = launch { doSerialFlashPostFlash( portLocation = "COM1", needManualReboot = false, @@ -238,7 +238,7 @@ class DoSerialFlashTest { ) } - backgroundScope.safeLaunch { + backgroundScope.launch { delay(100) server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") } @@ -258,7 +258,7 @@ class DoSerialFlashTest { server.openConnection("COM1") val statuses = mutableListOf() - val job = safeLaunch { + val job = launch { doSerialFlashPostFlash( portLocation = "COM1", needManualReboot = false, @@ -271,7 +271,7 @@ class DoSerialFlashTest { ) } - backgroundScope.safeLaunch { + backgroundScope.launch { delay(100) server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") delay(200) @@ -294,7 +294,7 @@ class DoSerialFlashTest { val vrServer = buildTestVrServerStub(backgroundScope) val statuses = mutableListOf() - backgroundScope.safeLaunch { + backgroundScope.launch { doSerialFlashPostFlash( portLocation = "COM1", needManualReboot = false, @@ -307,7 +307,7 @@ class DoSerialFlashTest { ) } - backgroundScope.safeLaunch { + backgroundScope.launch { delay(100) server.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") delay(200) diff --git a/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt b/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt index b4219f0948..7e0aabd0df 100644 --- a/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt +++ b/server/core/src/test/java/dev/slimevr/firmware/OtaUploadTest.kt @@ -7,11 +7,11 @@ import dev.slimevr.buildTestVrServerStub import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin -import dev.slimevr.util.safeLaunch import io.ktor.utils.io.ByteChannel import io.ktor.utils.io.readRemaining import io.ktor.utils.io.writeFully import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.io.readByteArray @@ -76,7 +76,7 @@ class OtaUploadTest { val device = addUdpDevice(vrServer, backgroundScope, macAddress = "AA:BB:CC:DD:EE:FF") var result: Boolean? = null - val job = backgroundScope.safeLaunch { + val job = backgroundScope.launch { result = waitForConnected(vrServer, "AA:BB:CC:DD:EE:FF", timeoutMs = 1_000) } @@ -101,7 +101,7 @@ class OtaUploadTest { device.context.dispatch(DeviceActions.Update { copy(status = TrackerStatus.OK) }) var result: Boolean? = null - val job = backgroundScope.safeLaunch { + val job = backgroundScope.launch { result = waitForReconnected( vrServer, device.context.state.value.id.toUShort(), diff --git a/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt index 697d5fed19..1c36cce4d9 100644 --- a/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/heightcalibration/HeightCalibrationTest.kt @@ -3,11 +3,11 @@ package dev.slimevr.heightcalibration import dev.slimevr.buildTestUserConfig -import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.currentTime @@ -54,7 +54,7 @@ private fun TestScope.launchSession( ): Job { val scope = this val userConfig = buildTestUserConfig(backgroundScope) - return safeLaunch { + return launch { runCalibrationSession(context, userConfig, hmdFlow, controllerFlow, clock = { scope.currentTime * 1_000_000L }) } } @@ -293,7 +293,7 @@ class HeightCalibrationSessionTest { val context = testHeightCalibrationContext(backgroundScope) val controllerFlow = MutableSharedFlow(extraBufferCapacity = 1) val hmdFlow = MutableSharedFlow(extraBufferCapacity = 1) - val job = safeLaunch { runCalibrationSession(context, buildTestUserConfig(backgroundScope), hmdFlow, controllerFlow) } + val job = launch { runCalibrationSession(context, buildTestUserConfig(backgroundScope), hmdFlow, controllerFlow) } advanceTimeBy(TIMEOUT_MS + 1) diff --git a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt index 3b0ca3314d..8740ea6e98 100644 --- a/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt +++ b/server/core/src/test/java/dev/slimevr/provisioning/ProvisioningManagerTest.kt @@ -11,10 +11,10 @@ import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.serial.SerialPortInfo -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import solarxr_protocol.datatypes.TrackerStatus @@ -103,7 +103,7 @@ class ProvisioningManagerTest { advanceTimeBy(7_001) assertEquals(WifiProvisioningStatus.NO_SERIAL_LOGS_ERROR, manager.context.state.value.status) - safeLaunch { + launch { delay(100) serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") } @@ -121,7 +121,7 @@ class ProvisioningManagerTest { manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) - safeLaunch { + launch { delay(500) serialServer.onDataReceived("COM1", "some log line without a mac address") } @@ -140,7 +140,7 @@ class ProvisioningManagerTest { manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) - safeLaunch { + launch { delay(2_100) serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") // No credential acknowledgement sent @@ -160,7 +160,7 @@ class ProvisioningManagerTest { manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) - safeLaunch { + launch { delay(2_100) serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") delay(100) @@ -182,7 +182,7 @@ class ProvisioningManagerTest { manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) - safeLaunch { + launch { delay(2_100) serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") delay(100) @@ -209,7 +209,7 @@ class ProvisioningManagerTest { manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) - safeLaunch { + launch { delay(2_100) serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") delay(100) @@ -234,7 +234,7 @@ class ProvisioningManagerTest { manager.startProvisioning(vrServer, "wifi", "pass", null) serialServer.onPortDetected(fakePort()) - safeLaunch { + launch { delay(2_100) serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") delay(100) @@ -260,7 +260,7 @@ class ProvisioningManagerTest { manager.startProvisioning(vrServer, "wifi", "pass", null) serialServer.onPortDetected(fakePort()) - safeLaunch { + launch { delay(2_100) serialServer.onDataReceived("COM1", "mac: AA:BB:CC:DD:EE:FF") delay(100) @@ -290,7 +290,7 @@ class ProvisioningManagerTest { manager.startProvisioning(buildTestVrServer(backgroundScope), "wifi", "pass", null) serialServer.onPortDetected(fakePort()) - safeLaunch { + launch { delay(500) serialServer.onDataReceived("COM1", "some log without mac") } 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 49359dcfe0..9609014091 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -39,14 +39,19 @@ import dev.slimevr.skeleton.Skeleton import dev.slimevr.tapdetection.TapDetectionManager import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer -import dev.slimevr.util.safeLaunch +import dev.slimevr.util.appCoroutineExceptionHandler +import dev.slimevr.util.installUncaughtExceptionReporting import dev.slimevr.vmc.VMCManager import dev.slimevr.vrcosc.VRCOSCManager +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import solarxr_protocol.rpc.KeybindSupport -fun main(args: Array) = runBlocking { +fun main(args: Array) = runBlocking(appCoroutineExceptionHandler + CoroutineName("Main")) { + installUncaughtExceptionReporting() + contextDebugEnabled = System.getProperty("slimevr.debug.context") == "true" || System.getenv("SLIMEVR_DEBUG_CONTEXT") == "true" @@ -149,11 +154,11 @@ fun main(args: Array) = runBlocking { try { appContext.startObserving() - safeLaunch { createDesktopHIDManager(appContext, this) } - safeLaunch { createDesktopKeybindManager(appContext, this) } - safeLaunch { createSolarXRWebsocketServer(appContext) } - safeLaunch { createIpcServers(appContext) } - safeLaunch { setupDesktopNetworkProfileChecker(this, networkProfileManager) } + launch { createDesktopHIDManager(appContext, this) } + launch { createDesktopKeybindManager(appContext, this) } + launch { createSolarXRWebsocketServer(appContext) } + launch { createIpcServers(appContext) } + launch { setupDesktopNetworkProfileChecker(this, networkProfileManager) } awaitCancellation() } finally { diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index 0ec3d316fa..af00637ee5 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -6,7 +6,6 @@ import dev.slimevr.device.DeviceActions import dev.slimevr.hid.HIDReceiver import dev.slimevr.hid.isCompatibleHidDevice import dev.slimevr.hid.parseHIDPackets -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -14,6 +13,7 @@ import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.hid4java.HidDevice import org.hid4java.HidManager @@ -51,7 +51,7 @@ private data class ActiveReceiver(val job: Job, val receiver: HIDReceiver) fun createDesktopHIDManager(appContext: AppContextProvider, scope: CoroutineScope) { val active = mutableMapOf() - scope.safeLaunch { + scope.launch { while (isActive) { val found = withContext(Dispatchers.IO) { try { @@ -93,7 +93,7 @@ fun createDesktopHIDManager(appContext: AppContextProvider, scope: CoroutineScop scope = deviceScope, ) - deviceScope.safeLaunch { + deviceScope.launch { try { while (isActive) { val data = withContext(Dispatchers.IO) { @@ -104,7 +104,7 @@ fun createDesktopHIDManager(appContext: AppContextProvider, scope: CoroutineScop } } when { - data == null -> return@safeLaunch + data == null -> return@launch // read error, device gone data.isNotEmpty() -> parseHIDPackets(data).forEach { receiver.packetEvents.emit(it) } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt index a712ae5857..4bd23d7eec 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ipc.kt @@ -3,8 +3,8 @@ package dev.slimevr.desktop.ipc import dev.slimevr.AppContextProvider import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch const val DRIVER_SOCKET_NAME = "SlimeVRDriver" const val FEEDER_SOCKET_NAME = "SlimeVRInput" @@ -17,15 +17,15 @@ const val SOLARXR_PIPE = "\\\\.\\pipe\\SlimeVRRpc" suspend fun createIpcServers(appContext: AppContextProvider) = coroutineScope { when (CURRENT_PLATFORM) { Platform.LINUX, Platform.OSX -> { - safeLaunch { createUnixDriverSocket(appContext) } - safeLaunch { createUnixFeederSocket(appContext) } - safeLaunch { createUnixSolarXRSocket(appContext) } + launch { createUnixDriverSocket(appContext) } + launch { createUnixFeederSocket(appContext) } + launch { createUnixSolarXRSocket(appContext) } } Platform.WINDOWS -> { - safeLaunch { createWindowsDriverPipe(appContext) } - safeLaunch { createWindowsFeederPipe(appContext) } - safeLaunch { createWindowsSolarXRPipe(appContext) } + launch { createWindowsDriverPipe(appContext) } + launch { createWindowsFeederPipe(appContext) } + launch { createWindowsSolarXRPipe(appContext) } } else -> Unit diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index 888fb6e2c8..ce39e6c1f7 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -3,12 +3,15 @@ package dev.slimevr.desktop.ipc import dev.slimevr.AppContextProvider import dev.slimevr.AppLogger import dev.slimevr.getSocketDirectory -import dev.slimevr.util.safeLaunch +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext +import java.io.IOException import java.net.SocketException import java.net.StandardProtocolFamily import java.net.UnixDomainSocketAddress @@ -93,9 +96,26 @@ private suspend fun acceptUnixClients( ServerSocketChannel.open(StandardProtocolFamily.UNIX).use { server -> server.bind(UnixDomainSocketAddress.of(path)) - while (isActive) { - val client = server.accept() - safeLaunch { handle(client) } + supervisorScope { + while (isActive) { + val client = server.accept() + AppLogger.ipc.info("$name client connected") + launch { + try { + handle(client) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLogger.ipc.error(e, "Error while handling $name client, dropping connection") + } finally { + AppLogger.ipc.info("$name client disconnected") + try { + client.close() + } catch (ignored: IOException) { + } + } + } + } } } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/pipe-slot.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/pipe-slot.kt new file mode 100644 index 0000000000..5e8f6288ab --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/pipe-slot.kt @@ -0,0 +1,120 @@ +package dev.slimevr.desktop.ipc + +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.platform.win32.Kernel32 +import com.sun.jna.platform.win32.WinBase +import com.sun.jna.platform.win32.WinError +import com.sun.jna.platform.win32.WinNT +import com.sun.jna.ptr.IntByReference +import com.sun.jna.win32.StdCallLibrary +import dev.slimevr.AppLogger +import java.io.IOException + +private val k32 = Kernel32.INSTANCE +private val k32io = Kernel32IO.INSTANCE + +private const val HEADER_SIZE = 4 +private const val MAX_FRAME_SIZE = 256 * 1024 + +@Suppress("FunctionName") +private interface Kernel32IO : StdCallLibrary { + fun ReadFile(h: WinNT.HANDLE, buf: Pointer, len: Int, read: IntByReference?, ov: WinBase.OVERLAPPED): Boolean + + fun WriteFile(h: WinNT.HANDLE, buf: Pointer, len: Int, written: IntByReference?, ov: WinBase.OVERLAPPED): Boolean + + fun GetOverlappedResult(h: WinNT.HANDLE, ov: WinBase.OVERLAPPED, moved: IntByReference, wait: Boolean): Boolean + + companion object { + val INSTANCE: Kernel32IO = Native.load("kernel32", Kernel32IO::class.java) + } +} + +/** + * One slot of overlapped I/O on a Windows pipe handle: an event, an [WinBase.OVERLAPPED] and a pinned + * buffer. + */ +internal class PipeSlot(private val handle: WinNT.HANDLE) : AutoCloseable { + private val event = checkNotNull(k32.CreateEvent(null, true, false, null)) { + "CreateEvent failed: ${Native.getLastError()}" + } + + private val overlapped = WinBase.OVERLAPPED().apply { + hEvent = event + write() + setAutoSynch(false) + } + + private val buffer = Memory(MAX_FRAME_SIZE.toLong()) + + fun awaitClient(): Boolean { + // On an overlapped handle ConnectNamedPipe returns straight away, so we do the waiting + val connected = k32.ConnectNamedPipe(handle, overlapped) + val err = Native.getLastError() + return when { + // ERROR_PIPE_CONNECTED means the client got in before we asked + connected || err == WinError.ERROR_PIPE_CONNECTED -> true + err == WinError.ERROR_IO_PENDING -> awaitCompletion() >= 0 + else -> false + } + } + + suspend fun readFrame(): ByteArray? { + if (!readInto(0, HEADER_SIZE)) return null + val total = buffer.getInt(0) + if (total !in HEADER_SIZE..MAX_FRAME_SIZE) throw IOException("Frame length out of range: $total") + if (!readInto(HEADER_SIZE, total - HEADER_SIZE)) return null + return buffer.getByteArray(HEADER_SIZE.toLong(), total - HEADER_SIZE) + } + + suspend fun writeFrame(payload: ByteArray) { + val total = payload.size + HEADER_SIZE + if (total > MAX_FRAME_SIZE) throw IOException("Frame too large to send: $total bytes") + buffer.setInt(0, total) + buffer.write(HEADER_SIZE.toLong(), payload, 0, payload.size) + + val written = transfer { k32io.WriteFile(handle, buffer, total, null, overlapped) } + if (written != total) throw IOException("Pipe write incomplete: $written of $total bytes") + } + + override fun close() { + buffer.close() + k32.CloseHandle(event) + } + + private suspend fun readInto(at: Int, len: Int): Boolean { + var done = 0 + while (done < len) { + val remaining = len - done + val moved = transfer { + k32io.ReadFile(handle, buffer.share((at + done).toLong(), remaining.toLong()), remaining, null, overlapped) + } + if (moved <= 0) return false + done += moved + } + return true + } + + private suspend inline fun transfer(issue: () -> Boolean): Int { + if (!issue()) { + // Read the code as the very next thing: k32.GetLastError() is itself a JNA call, and the + // marshalling on the way into it can overwrite the value we wanted. Mistaking a pending + // operation for a failed one frees buffer while the kernel is still reading out of it + val err = Native.getLastError() + if (err != WinError.ERROR_IO_PENDING) { + // A broken pipe is just the client leaving, which the caller turns into a reconnect + if (err != WinError.ERROR_BROKEN_PIPE) AppLogger.ipc.warn("Pipe operation failed: $err") + return -1 + } + } + return awaitCompletion() + } + + private fun awaitCompletion(): Int { + k32.WaitForSingleObject(event, WinBase.INFINITE) + val moved = IntByReference() + if (!k32io.GetOverlappedResult(handle, overlapped, moved, true)) return -1 + return moved.value + } +} diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 834dfcda40..3da68c7c51 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -13,13 +13,14 @@ import dev.slimevr.desktop.platform.Version import dev.slimevr.driver.DriverBridge import dev.slimevr.driver.DriverBridgeInbound import dev.slimevr.driver.DriverBridgeOutbound -import dev.slimevr.util.safeLaunch import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext @@ -185,6 +186,11 @@ suspend fun handleDriverConnection( else -> TrackerStatus.Status.ERROR }, ), + ), + ) + + sendMsg( + ProtobufMessage( battery = Battery( tracker_id = event.trackerId, battery_level = event.battery ?: 0f, @@ -204,7 +210,7 @@ suspend fun handleDriverConnection( if (ver.protocol_version >= 2) { // FIXME: multiple launch could be created here bc nothing prevent protocol from changing or getting called again during runtime // causing a memory leak - safeLaunch { + this@coroutineScope.launch { startBindingProvider() } } @@ -253,5 +259,9 @@ suspend fun handleDriverConnection( } } finally { bridge.disconnect() + // The outbound listeners are launched into this scope and never complete on their own + // (they observe a SharedFlow), so coroutineScope would wait on them forever and the + // connection would never be released for the next client -- e.g. a SteamVR restart + coroutineContext.cancelChildren() } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt index f42b36705e..8f4313dfff 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt @@ -14,14 +14,15 @@ import dev.slimevr.fbscodegen.runtime.JvmFlatBufferWriter import dev.slimevr.solarxr.SolarXRBridge import dev.slimevr.solarxr.SolarXRBridgeBehaviour import dev.slimevr.solarxr.onSolarXRMessage -import dev.slimevr.util.safeLaunch import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import io.ktor.util.moveToByteArray import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import solarxr_protocol.MessageBundle import solarxr_protocol.rpc.EnableSteamVRDriverRequest @@ -31,7 +32,7 @@ import java.nio.ByteBuffer class EnableSteamVRDriverBehaviour : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { - receiver.context.scope.safeLaunch { + receiver.context.scope.launch { val client = HttpClient(CIO) unblockSteamVRDriver(client, "slimevr") client.close() @@ -44,7 +45,7 @@ class OpenKeybindSettingsBehaviour : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { receiver.rpcDispatcher.on { if (CURRENT_PLATFORM != Platform.LINUX) return@on - receiver.context.scope.safeLaunch { + receiver.context.scope.launch { withContext(Dispatchers.IO) { runCatching { PortalManager(SLIMEVR_IDENTIFIER).openGlobalShortcutsSettings() @@ -87,5 +88,6 @@ suspend fun handleSolarXRBridge( } } finally { bridge.disconnect() + coroutineContext.cancelChildren() } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt index decacb466a..204fd1082f 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt @@ -1,81 +1,48 @@ package dev.slimevr.desktop.ipc +import com.sun.jna.Native import com.sun.jna.platform.win32.Advapi32 import com.sun.jna.platform.win32.Kernel32 import com.sun.jna.platform.win32.WinBase -import com.sun.jna.platform.win32.WinError import com.sun.jna.platform.win32.WinNT -import com.sun.jna.ptr.IntByReference import dev.slimevr.AppContextProvider -import dev.slimevr.util.safeLaunch +import dev.slimevr.AppLogger +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.withContext -import java.nio.ByteBuffer -import java.nio.ByteOrder private val k32 = Kernel32.INSTANCE private val adv32 = Advapi32.INSTANCE -suspend fun createWindowsDriverPipe(appContext: AppContextProvider) = acceptWindowsClients(DRIVER_PIPE) { handle -> - handleDriverConnection( - appContext = appContext, - messages = readFramedMessages(handle), - send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, - ) -} +private const val PIPE_BUFFER_SIZE = 64 * 1024 -suspend fun createWindowsFeederPipe(appContext: AppContextProvider) = acceptWindowsClients(FEEDER_PIPE) { handle -> - handleDriverConnection( - appContext = appContext, - messages = readFramedMessages(handle), - send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, - ) +private typealias ConnectionHandler = suspend (messages: Flow, send: suspend (ByteArray) -> Unit) -> Unit + +suspend fun createWindowsDriverPipe(appContext: AppContextProvider) = acceptWindowsClients(DRIVER_PIPE) { messages, send -> + handleDriverConnection(appContext, messages, send) } -suspend fun createWindowsSolarXRPipe(appContext: AppContextProvider) = acceptWindowsClients(SOLARXR_PIPE) { handle -> - handleSolarXRBridge( - appContext = appContext, - messages = readFramedMessages(handle), - send = { bytes -> withContext(Dispatchers.IO) { writeFramedPipe(handle, bytes) } }, - ) +suspend fun createWindowsFeederPipe(appContext: AppContextProvider) = acceptWindowsClients(FEEDER_PIPE) { messages, send -> + handleDriverConnection(appContext, messages, send) } -// Length field is LE u32 and includes the 4-byte header itself -private fun readFramedMessages(handle: WinNT.HANDLE) = flow { - val lenBuf = ByteArray(4) - while (true) { - if (!readExact(handle, lenBuf, 4)) break - val totalLen = ByteBuffer.wrap(lenBuf).order(ByteOrder.LITTLE_ENDIAN).int +suspend fun createWindowsSolarXRPipe(appContext: AppContextProvider) = acceptWindowsClients(SOLARXR_PIPE) { messages, send -> + handleSolarXRBridge(appContext, messages, send) +} - val dataBuf = ByteArray(totalLen - 4) - if (!readExact(handle, dataBuf, totalLen - 4)) break - emit(dataBuf) +private fun readFrames(handle: WinNT.HANDLE) = flow { + PipeSlot(handle).use { reader -> + while (true) emit(reader.readFrame() ?: break) } }.flowOn(Dispatchers.IO) -private fun readExact(handle: WinNT.HANDLE, buf: ByteArray, len: Int): Boolean { - var offset = 0 - val bytesRead = IntByReference() - while (offset < len) { - val ok = k32.ReadFile(handle, buf, len - offset, bytesRead, null) - if (!ok || bytesRead.value == 0) return false - offset += bytesRead.value - } - return true -} - -private fun writeFramedPipe(handle: WinNT.HANDLE, bytes: ByteArray) { - val buf = ByteArray(bytes.size + 4) - ByteBuffer.wrap(buf).order(ByteOrder.LITTLE_ENDIAN).putInt(bytes.size + 4) - bytes.copyInto(buf, destinationOffset = 4) - k32.WriteFile(handle, buf, buf.size, IntByReference(), null) -} - private fun createSecurePipe(pipeName: String): WinNT.HANDLE { - // Null DACL allows any process (including SteamVR driver) to connect val descriptor = WinNT.SECURITY_DESCRIPTOR(64 * 1024) adv32.InitializeSecurityDescriptor(descriptor, WinNT.SECURITY_DESCRIPTOR_REVISION) adv32.SetSecurityDescriptorDacl(descriptor, true, null, false) @@ -86,40 +53,48 @@ private fun createSecurePipe(pipeName: String): WinNT.HANDLE { val pipe = k32.CreateNamedPipe( pipeName, - WinBase.PIPE_ACCESS_DUPLEX, + WinBase.PIPE_ACCESS_DUPLEX or WinNT.FILE_FLAG_OVERLAPPED, WinBase.PIPE_TYPE_BYTE or WinBase.PIPE_READMODE_BYTE or WinBase.PIPE_WAIT, WinBase.PIPE_UNLIMITED_INSTANCES, - 65536, - 65536, + PIPE_BUFFER_SIZE, + PIPE_BUFFER_SIZE, 0, attributes, ) check(pipe != WinNT.INVALID_HANDLE_VALUE) { - "CreateNamedPipe failed for $pipeName: ${k32.GetLastError()}" + "CreateNamedPipe failed for $pipeName: ${Native.getLastError()}" } return pipe } private suspend fun acceptWindowsClients( pipeName: String, - handle: suspend (WinNT.HANDLE) -> Unit, + handle: ConnectionHandler, ) = withContext(Dispatchers.IO) { - while (isActive) { - val pipe = createSecurePipe(pipeName) - - val ok = k32.ConnectNamedPipe(pipe, null) - val err = k32.GetLastError() - if (!ok && err != WinError.ERROR_PIPE_CONNECTED) { - k32.CloseHandle(pipe) - continue - } - - safeLaunch { - try { - handle(pipe) - } finally { - k32.DisconnectNamedPipe(pipe) + supervisorScope { + while (isActive) { + val pipe = createSecurePipe(pipeName) + val writer = PipeSlot(pipe) + if (!writer.awaitClient()) { + writer.close() k32.CloseHandle(pipe) + continue + } + AppLogger.ipc.info("$pipeName client connected") + + launch { + try { + handle(readFrames(pipe)) { bytes -> withContext(Dispatchers.IO) { writer.writeFrame(bytes) } } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLogger.ipc.error(e, "Error while handling $pipeName client, dropping connection") + } finally { + AppLogger.ipc.info("$pipeName client disconnected") + writer.close() + k32.DisconnectNamedPipe(pipe) + k32.CloseHandle(pipe) + } } } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt index ed5385c0a6..a993886b9f 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt @@ -13,7 +13,6 @@ import dev.slimevr.SLIMEVR_IDENTIFIER import dev.slimevr.config.KeybindConfig import dev.slimevr.config.SettingsActions import dev.slimevr.keybind.KeybindEvent -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.awaitCancellation @@ -26,6 +25,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import solarxr_protocol.rpc.KeybindId private const val BINDING_SETTLE_MS = 300L @@ -56,7 +56,7 @@ private suspend fun setupWindowsKeybinds(appContext: AppContextProvider, scope: instance.addHotKeyListener { identifier -> KeybindId.fromValue(identifier.toUByte())?.let { id -> - scope.safeLaunch { appContext.keybindManager.events.emit(KeybindEvent.Fired(id)) } + scope.launch { appContext.keybindManager.events.emit(KeybindEvent.Fired(id)) } } } @@ -114,7 +114,7 @@ private suspend fun setupLinuxKeybinds(appContext: AppContextProvider, scope: Co PortalManager(SLIMEVR_IDENTIFIER).globalShortcutsRequest(shortcuts).apply { onShortcutActivated = { shortcutId -> KeybindId.entries.firstOrNull { it.name == shortcutId }?.let { id -> - scope.safeLaunch { appContext.keybindManager.events.emit(KeybindEvent.Fired(id)) } + scope.launch { appContext.keybindManager.events.emit(KeybindEvent.Fired(id)) } } } } @@ -151,7 +151,7 @@ private suspend fun setupLinuxKeybinds(appContext: AppContextProvider, scope: Co } .launchIn(scope) - scope.safeLaunch { + scope.launch { try { awaitCancellation() } finally { diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt b/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt index 1ade92acf4..4d5a147a86 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt @@ -22,9 +22,9 @@ import dev.slimevr.networkprofile.NetworkCategory import dev.slimevr.networkprofile.NetworkInfo import dev.slimevr.networkprofile.NetworkProfileActions import dev.slimevr.networkprofile.NetworkProfileManager -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch @Suppress("ktlint:standard:function-naming") interface Iphlpapi : Library { @@ -173,7 +173,7 @@ private fun enumerateNetworks(): List = try { private class IpInterfaceChangeCallback(val scope: CoroutineScope, val manager: NetworkProfileManager) : Callback { @Suppress("UNUSED") fun callback(context: Pointer?, row: Pointer?, notificationType: Int) { - scope.safeLaunch { + scope.launch { val networks = enumerateNetworks().filter { it.connected == true && it.category == NetworkCategory.PUBLIC } manager.context.dispatch(NetworkProfileActions.UpdateNetworks(networks)) } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt b/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt index d74cb191e5..2ada3ae169 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt @@ -7,10 +7,10 @@ import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialPortInfo import dev.slimevr.serial.SerialServer import dev.slimevr.serial.isKnownSerialBoard -import dev.slimevr.util.safeLaunch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.OutputStreamWriter import com.fazecast.jSerialComm.SerialPort as JSerialPort @@ -38,11 +38,11 @@ private fun openPort( when (event.eventType) { JSerialPort.LISTENING_EVENT_DATA_RECEIVED -> { val line = event.receivedData.toString(Charsets.UTF_8).trimEnd() - scope.safeLaunch { onDataReceived(portLocation, line) } + scope.launch { onDataReceived(portLocation, line) } } JSerialPort.LISTENING_EVENT_PORT_DISCONNECTED -> - scope.safeLaunch { onPortDisconnected(portLocation) } + scope.launch { onPortDisconnected(portLocation) } } } }) @@ -98,6 +98,6 @@ fun createDesktopSerialServer(scope: CoroutineScope): SerialServer { openFlashingPort = { DesktopFlashingHandler() }, scope = scope, ) - scope.safeLaunch { runSerialPoller(server) } + scope.launch { runSerialPoller(server) } return server } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt b/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt index b07fdcda35..bef2b65ce4 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt @@ -14,7 +14,6 @@ import dev.slimevr.desktop.getSteamVRDriversList import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.trackingchecklist.TrackingChecklistActions import dev.slimevr.trackingchecklist.TrackingChecklistBehaviourType -import dev.slimevr.util.safeLaunch import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import kotlinx.coroutines.Dispatchers @@ -26,6 +25,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import solarxr_protocol.rpc.TrackingChecklistSteamVRDisconnected import solarxr_protocol.rpc.TrackingChecklistStep @@ -91,7 +91,7 @@ class SteamVRCheckBehaviour(private val server: VRServer) : TrackingChecklistBeh private val standableState = MutableStateFlow(false) override fun observe(receiver: TrackingChecklist) { - receiver.context.scope.safeLaunch { + receiver.context.scope.launch { val client = HttpClient(CIO) while (isActive) { val connected = server.context.state.value.drivers.isNotEmpty() diff --git a/server/detekt.yml b/server/detekt.yml index 7d1e151c2e..09577a58a8 100644 --- a/server/detekt.yml +++ b/server/detekt.yml @@ -1,11 +1,44 @@ -style: - ForbiddenImport: +# NOTE: the root build.gradle.kts sets `buildUponDefaultConfig = false`, so ONLY the rules +# listed here are active. Nothing is inherited from detekt's defaults. + +exceptions: + active: true + # Silencing an exception must be a decision that shows up in the diff, not something that + # happens by accident. A catch block has to *use* what it caught -- log it, wrap it, rethrow + # it. To deliberately drop one, say so by naming it: `catch (ignored: IOException)` or + # `catch (_: IOException)`. That is greppable; an empty `catch (e: Exception)` is not. + SwallowedException: active: true - imports: - - reason: 'Use dev.slimevr.util.safeLaunch instead of raw launch to ensure coroutine exceptions are handled properly.' - value: 'kotlinx.coroutines.launch' + ignoredExceptionTypes: + - 'InterruptedException' + - 'MalformedURLException' + - 'NumberFormatException' + - 'ParseException' + allowedExceptionNameRegex: '_|(ignore|expected).*' + PrintStackTrace: + active: true + # Deliberately NOT enabled: TooGenericExceptionCaught. `catch (e: Exception)` that logs is + # coarse, but it is not silent, and this codebase has ~47 of them at IO/JNA boundaries where + # a broad catch is the correct call. Enable it if you ever want to tighten catch *breadth*; + # it is a different concern from catch *silence*. + +empty-blocks: + active: true + EmptyCatchBlock: + active: true + allowedExceptionNameRegex: '_|(ignore|expected).*' coroutines: active: true GlobalCoroutineUsage: active: true + # Catching Exception/Throwable around a suspend call also catches CancellationException, + # which silently breaks structured concurrency: the coroutine reports success while its + # scope is being torn down. This is what hid the driver IPC failure. + # + # WARNING: this rule needs type resolution. Detekt is currently applied only to the root + # project with a manual `source.setFrom(...)`, so the `detekt` task runs WITHOUT type + # resolution and this rule never executes. Applying the plugin per-module gives you + # `detektMain`/`detektTest` tasks that do run it. Until then, treat this as aspirational. + SuspendFunSwallowedCancellation: + active: true From c9732f7e462f491048b24c16da94a7abfa55da81 Mon Sep 17 00:00:00 2001 From: lucas lelievre Date: Fri, 24 Jul 2026 23:03:55 +0200 Subject: [PATCH 291/350] Fix tests --- .../java/dev/slimevr/solarxr/DataFeedTest.kt | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt index 79b772d437..1de7c6f9b5 100644 --- a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -11,7 +11,9 @@ import dev.slimevr.buildTestVrServer import dev.slimevr.context.Context import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import solarxr_protocol.MessageBundle import solarxr_protocol.data_feed.DataFeedConfig @@ -20,7 +22,8 @@ import solarxr_protocol.data_feed.StartDataFeed import kotlin.test.Test import kotlin.test.assertEquals -private fun testConn(backgroundScope: kotlinx.coroutines.CoroutineScope, onSend: suspend (ByteArray) -> Unit): SolarXRBridge { +@OptIn(ExperimentalCoroutinesApi::class) +private fun TestScope.testConn(onSend: suspend (ByteArray) -> Unit): SolarXRBridge { val server = buildTestVrServer(backgroundScope) val skeleton = buildTestSkeleton(backgroundScope) val settings = buildTestSettings(backgroundScope) @@ -48,6 +51,11 @@ private fun testConn(backgroundScope: kotlinx.coroutines.CoroutineScope, onSend: ) bridge.startObserving() bridge.outbound.on { onSend(ByteArray(0)) }.launchIn(backgroundScope) + + // EventDispatcher is a replay-0 SharedFlow, so an emit with no subscriber yet is dropped, and + // launchIn above only subscribes once its coroutine is dispatched. Run those now, so the bridge + // this returns is actually listening. + runCurrent() return bridge } @@ -59,7 +67,7 @@ class DataFeedTest { @Test fun `StartDataFeed sends frames at the configured interval`() = runTest { var sendCount = 0 - val conn = testConn(backgroundScope) { sendCount++ } + val conn = testConn { sendCount++ } conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100)))) @@ -71,7 +79,7 @@ class DataFeedTest { @Test fun `StartDataFeed with multiple configs runs each at its own frequency`() = runTest { var sendCount = 0 - val conn = testConn(backgroundScope) { sendCount++ } + val conn = testConn { sendCount++ } conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100), config(200)))) @@ -84,7 +92,7 @@ class DataFeedTest { @Test fun `PollDataFeed sends exactly one frame without starting a repeating timer`() = runTest { var sendCount = 0 - val conn = testConn(backgroundScope) { sendCount++ } + val conn = testConn { sendCount++ } conn.dataFeedDispatcher.emit(PollDataFeed(config = config(100))) @@ -95,7 +103,7 @@ class DataFeedTest { @Test fun `StartDataFeed cancels old timers when called a second time`() = runTest { var sendCount = 0 - val conn = testConn(backgroundScope) { sendCount++ } + val conn = testConn { sendCount++ } conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100)))) advanceTimeBy(250) @@ -111,7 +119,7 @@ class DataFeedTest { @Test fun `StartDataFeed with empty list stops all existing timers`() = runTest { var sendCount = 0 - val conn = testConn(backgroundScope) { sendCount++ } + val conn = testConn { sendCount++ } conn.dataFeedDispatcher.emit(StartDataFeed(dataFeeds = listOf(config(100)))) advanceTimeBy(250) From dd7d7d0bd0025d6d9ed39941b65f41386be7cd46 Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 24 Jul 2026 17:10:37 -0400 Subject: [PATCH 292/350] Make manual mounting work + ClearMountingReset --- .../main/java/dev/slimevr/config/settings.kt | 3 +- .../main/java/dev/slimevr/resets/module.kt | 10 ++- .../main/java/dev/slimevr/solarxr/datafeed.kt | 2 +- .../slimevr/solarxr/session-calibration.kt | 7 ++ .../java/dev/slimevr/tracker/behaviours.kt | 73 ++++++++++--------- .../main/java/dev/slimevr/tracker/config.kt | 2 +- .../main/java/dev/slimevr/tracker/module.kt | 7 +- .../slimevr/tracker/session-calibration.kt | 5 +- 8 files changed, 64 insertions(+), 45 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index a915957a0e..2dc3b614d1 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -25,7 +25,8 @@ data class TrackerConfig( val bodyPart: BodyPart? = null, val customName: String? = null, @Serializable(with = QuaternionSerializer::class) - val mountingOrientation: Quaternion? = null, + val mountingOrientation: Quaternion = Quaternion.IDENTITY, + @Serializable(with = QuaternionSerializer::class) val mountingResetOrientation: Quaternion? = null, val magEnabled: Boolean? = null, ) diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index 7544da4b90..6627b82d9d 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -10,6 +10,7 @@ import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import io.github.axisangles.ktmath.EulerAngles import io.github.axisangles.ktmath.EulerOrder @@ -85,13 +86,20 @@ class ResetsManager(val context: ResetsContext, val server: VRServer, val settin AppLogger.resets.info("${resetType.name} Reset from $resetSourceName") - // Tell the GUI we ended a reset + // Tell the GUI we finished a reset server.sendSolarxrRpc( ResetResponse(resetType, ResetStatus.FINISHED, bodyParts, delayMs, delayMs), ) } } + suspend fun clearTrackersMountingReset(resetSourceName: String) { + val trackers = server.context.state.value.trackers.values + trackers.forEach { it.context.dispatch(TrackerActions.ClearMountingReset) } + + AppLogger.resets.info("Clear Mounting Reset from $resetSourceName") + } + private fun executeTrackerResets(resetType: ResetType, bodyParts: List? = null, config: ResetsConfig) { val allTrackers = server.context.state.value.trackers.values diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 8dc8a70dce..c319f66cf7 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -51,7 +51,7 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas bodyPart = tracker.bodyPart, displayName = tracker.name, customName = tracker.customName, - mountingOrientation = tracker.mountingOrientation?.let { Quat(it.x, it.y, it.z, it.w) }, + mountingOrientation = tracker.mountingOrientation.let { Quat(it.x, it.y, it.z, it.w) }, isImu = tracker.sensorType != null, magnetometer = tracker.magStatus, ) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/session-calibration.kt b/server/core/src/main/java/dev/slimevr/solarxr/session-calibration.kt index b9ca709641..c6a4a13df3 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/session-calibration.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/session-calibration.kt @@ -2,6 +2,7 @@ package dev.slimevr.solarxr import dev.slimevr.resets.ResetsManager import kotlinx.coroutines.flow.launchIn +import solarxr_protocol.rpc.ClearMountingResetRequest import solarxr_protocol.rpc.ResetRequest import solarxr_protocol.rpc.ResetType @@ -9,8 +10,14 @@ class SessionCalibrationBehaviour( private val resetsManager: ResetsManager, ) : SolarXRBridgeBehaviour { override fun observe(receiver: SolarXRBridge) { + // Reset request receiver.rpcDispatcher.on { req -> resetsManager.scheduleReset("SolarXRBridge", req.resetType ?: ResetType.YAW, req.delay ?: 0f, req.bodyParts) }.launchIn(receiver.context.scope) + + // Clear mounting reset request + receiver.rpcDispatcher.on { + resetsManager.clearTrackersMountingReset("SolarXRBridge") + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 93ece4df57..13c07a795d 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -48,8 +48,7 @@ class TrackerBasicBehaviour : TrackerBehaviour { rawRotation, cal.headingCorrection, cal.attitudeAlignment, - cal.headingAlignment, - state.restOrientation + cal.headingAlignment * state.mountingOrientation, ).twinNearest(state.rotation) cal != null -> state.rotation @@ -65,7 +64,6 @@ class TrackerBasicBehaviour : TrackerBehaviour { rawRotation, cal.headingCorrection, cal.headingAlignment, - state.restOrientation ) cal != null -> state.acceleration @@ -84,7 +82,12 @@ class TrackerBasicBehaviour : TrackerBehaviour { ) } - is TrackerActions.SetMountingOrientation -> state.copy(mountingOrientation = action.mountingOrientation) + is TrackerActions.SetMountingOrientation -> { + state.copy( + mountingOrientation = action.mountingOrientation, + sessionCalibration = state.sessionCalibration?.copy(headingAlignment = Quaternion.IDENTITY), + ) + } is TrackerActions.SetRestOrientation -> state.copy(restOrientation = action.restOrientation) @@ -138,7 +141,7 @@ class TrackerBasicBehaviour : TrackerBehaviour { action.referenceRotation, cal?.headingCorrection ?: Quaternion.IDENTITY, cal?.attitudeAlignment ?: Quaternion.IDENTITY, - state.mountingOrientation ?: Quaternion.IDENTITY, + state.mountingOrientation, action.yawOffset, ) @@ -150,12 +153,16 @@ class TrackerBasicBehaviour : TrackerBehaviour { state.copy(sessionCalibration = sessionCalibration) } + + is TrackerActions.ClearMountingReset -> { + state.copy(sessionCalibration = state.sessionCalibration?.copy(headingAlignment = Quaternion.IDENTITY)) + } } override fun observe(receiver: Tracker) { - // Refresh the tracker's rotation whenever the sessionCalibration gets updated + // Refresh the tracker's rotation whenever calibration gets updated receiver.context.state - .distinctUntilChangedBy { it.sessionCalibration to it.restOrientation } + .distinctUntilChangedBy { it.sessionCalibration to it.restOrientation to it.mountingOrientation } .onEach { receiver.context.dispatch(TrackerActions.SetRotation(it.rawRotation, it.rawAcceleration, it.rawMagnetometer)) }.launchIn(receiver.context.scope) @@ -167,33 +174,31 @@ class TrackerDefaultMountingOrientationBehaviour : TrackerBehaviour { * Returns the default mounting orientation for the body part */ private fun defaultMountingForBodyPart(bodyPart: BodyPart?): Quaternion = when (bodyPart) { -// BodyPart.LEFT_LOWER_ARM, BodyPart.LEFT_HAND, -// BodyPart.LEFT_INDEX_PROXIMAL, BodyPart.LEFT_INDEX_INTERMEDIATE, -// BodyPart.LEFT_INDEX_DISTAL, BodyPart.LEFT_MIDDLE_PROXIMAL, -// BodyPart.LEFT_MIDDLE_INTERMEDIATE, BodyPart.LEFT_MIDDLE_DISTAL, -// BodyPart.LEFT_RING_PROXIMAL, BodyPart.LEFT_RING_INTERMEDIATE, -// BodyPart.LEFT_RING_DISTAL, BodyPart.LEFT_LITTLE_PROXIMAL, -// BodyPart.LEFT_LITTLE_INTERMEDIATE, BodyPart.LEFT_LITTLE_DISTAL, -// BodyPart.LEFT_SHOULDER, -// -> Quaternion.SLIMEVR.LEFT -// -// BodyPart.RIGHT_LOWER_ARM, BodyPart.RIGHT_HAND, -// BodyPart.RIGHT_INDEX_PROXIMAL, BodyPart.RIGHT_INDEX_INTERMEDIATE, -// BodyPart.RIGHT_INDEX_DISTAL, BodyPart.RIGHT_MIDDLE_PROXIMAL, -// BodyPart.RIGHT_MIDDLE_INTERMEDIATE, BodyPart.RIGHT_MIDDLE_DISTAL, -// BodyPart.RIGHT_RING_PROXIMAL, BodyPart.RIGHT_RING_INTERMEDIATE, -// BodyPart.RIGHT_RING_DISTAL, BodyPart.RIGHT_LITTLE_PROXIMAL, -// BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_DISTAL, -// BodyPart.RIGHT_SHOULDER, -// -> Quaternion.SLIMEVR.RIGHT -// -// BodyPart.LEFT_UPPER_ARM, BodyPart.LEFT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_LEFT -// -// BodyPart.RIGHT_UPPER_ARM, BodyPart.RIGHT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_RIGHT -// -// else -> Quaternion.SLIMEVR.FRONT - // TODO fix mounting reset not mixing well with anything but BACK - else -> Quaternion.IDENTITY + BodyPart.LEFT_LOWER_ARM, BodyPart.LEFT_HAND, + BodyPart.LEFT_INDEX_PROXIMAL, BodyPart.LEFT_INDEX_INTERMEDIATE, + BodyPart.LEFT_INDEX_DISTAL, BodyPart.LEFT_MIDDLE_PROXIMAL, + BodyPart.LEFT_MIDDLE_INTERMEDIATE, BodyPart.LEFT_MIDDLE_DISTAL, + BodyPart.LEFT_RING_PROXIMAL, BodyPart.LEFT_RING_INTERMEDIATE, + BodyPart.LEFT_RING_DISTAL, BodyPart.LEFT_LITTLE_PROXIMAL, + BodyPart.LEFT_LITTLE_INTERMEDIATE, BodyPart.LEFT_LITTLE_DISTAL, + BodyPart.LEFT_SHOULDER, + -> Quaternion.SLIMEVR.LEFT + + BodyPart.RIGHT_LOWER_ARM, BodyPart.RIGHT_HAND, + BodyPart.RIGHT_INDEX_PROXIMAL, BodyPart.RIGHT_INDEX_INTERMEDIATE, + BodyPart.RIGHT_INDEX_DISTAL, BodyPart.RIGHT_MIDDLE_PROXIMAL, + BodyPart.RIGHT_MIDDLE_INTERMEDIATE, BodyPart.RIGHT_MIDDLE_DISTAL, + BodyPart.RIGHT_RING_PROXIMAL, BodyPart.RIGHT_RING_INTERMEDIATE, + BodyPart.RIGHT_RING_DISTAL, BodyPart.RIGHT_LITTLE_PROXIMAL, + BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_DISTAL, + BodyPart.RIGHT_SHOULDER, + -> Quaternion.SLIMEVR.RIGHT + + BodyPart.LEFT_UPPER_ARM, BodyPart.LEFT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_LEFT + + BodyPart.RIGHT_UPPER_ARM, BodyPart.RIGHT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_RIGHT + + else -> Quaternion.SLIMEVR.BACK } override fun observe(receiver: Tracker) { diff --git a/server/core/src/main/java/dev/slimevr/tracker/config.kt b/server/core/src/main/java/dev/slimevr/tracker/config.kt index b71acff977..ed1a9b8524 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/config.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/config.kt @@ -13,7 +13,7 @@ import solarxr_protocol.datatypes.MagnetometerStatus fun restoreFromConfig(state: TrackerState, config: TrackerConfig, saveMountingReset: Boolean): TrackerState = state.copy( bodyPart = config.bodyPart?.takeIf { it != BodyPart.NONE } ?: state.bodyPart, customName = config.customName ?: state.customName, - mountingOrientation = config.mountingOrientation ?: state.mountingOrientation, + mountingOrientation = config.mountingOrientation, sessionCalibration = if (saveMountingReset) { config.mountingResetOrientation?.let { SessionCalibration( diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 43eba9e2e0..d15d92b74b 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -24,7 +24,7 @@ data class TrackerState( val sensorType: ImuType?, val bodyPart: BodyPart?, val customName: String?, - val mountingOrientation: HeadingAlignment?, + val mountingOrientation: HeadingAlignment, val restOrientation: RestOrientation, val rawRotation: RawRotation, val rotation: CalibratedRotation, @@ -47,11 +47,12 @@ sealed interface TrackerActions { data class SetMagStatus(val status: MagnetometerStatus) : TrackerActions data class SetStatus(val status: TrackerStatus) : TrackerActions data class SetRotation(val rotation: Quaternion? = null, val acceleration: Vector3? = null, val magnetometer: Vector3? = null) : TrackerActions - data class SetMountingOrientation(val mountingOrientation: HeadingAlignment?) : TrackerActions + data class SetMountingOrientation(val mountingOrientation: HeadingAlignment) : TrackerActions data class SetRestOrientation(val restOrientation: Quaternion) : TrackerActions data class FullReset(val referenceRotation: Quaternion) : TrackerActions data class YawReset(val referenceRotation: Quaternion) : TrackerActions data class MountingReset(val referenceRotation: Quaternion, val yawOffset: Float) : TrackerActions + data object ClearMountingReset : TrackerActions } typealias TrackerContext = Context @@ -89,7 +90,7 @@ class Tracker( acceleration = Vector3.NULL, rawMagnetometer = Vector3.NULL, bodyPart = null, - mountingOrientation = null, + mountingOrientation = Quaternion.IDENTITY, origin = origin, deviceId = deviceId, customName = null, diff --git a/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt b/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt index a767a00947..6a73a984ca 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt @@ -11,7 +11,7 @@ typealias RawAcceleration = Vector3 typealias HeadingCorrection = Quaternion typealias AttitudeAlignment = Quaternion typealias HeadingAlignment = Quaternion -typealias RestOrientation = Quaternion // TODO verify math and make unit tests +typealias RestOrientation = Quaternion // TODO temporary workaround; need to figure out something else eventually. typealias AccelerationRotation = Quaternion @@ -41,7 +41,6 @@ fun undoCalibration( headingCorrect: HeadingCorrection = Quaternion.IDENTITY, attitudeAlign: AttitudeAlignment = Quaternion.IDENTITY, headingAlign: HeadingAlignment = Quaternion.IDENTITY, - restOrientation: RestOrientation = Quaternion.IDENTITY, ): RawRotation = headingCorrect.inv() * headingAlign * calibratedRotation * headingAlign.inv() * attitudeAlign.inv() // Acceleration needs to be rotated by raw rotation with heading corrected @@ -49,7 +48,6 @@ private fun accelerationRotation( rawRotation: RawRotation, headingCorrect: HeadingCorrection = Quaternion.IDENTITY, headingAlign: HeadingAlignment = Quaternion.IDENTITY, - restOrientation: RestOrientation = Quaternion.IDENTITY, ): AccelerationRotation = headingAlign.inv() * headingCorrect * rawRotation fun applyCalibration( @@ -57,7 +55,6 @@ fun applyCalibration( rawRotation: RawRotation, headingCorrect: HeadingCorrection = Quaternion.IDENTITY, headingAlign: HeadingAlignment = Quaternion.IDENTITY, - restOrientation: RestOrientation = Quaternion.IDENTITY, ): CalibratedAcceleration = accelerationRotation(rawRotation, headingCorrect, headingAlign).sandwich( rawAcceleration, ) From 540c291ccb216082ca0abad60558615d1b15105a Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 24 Jul 2026 18:20:43 -0400 Subject: [PATCH 293/350] Driver fixes including head data --- .../java/dev/slimevr/driver/behaviours.kt | 20 +++++++++++----- .../java/dev/slimevr/skeleton/behaviours.kt | 6 ++++- .../java/dev/slimevr/tracker/behaviours.kt | 24 +++++++++++++++---- 3 files changed, 39 insertions(+), 11 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index c62773d760..012683230b 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -6,7 +6,10 @@ import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.TrackerStatus @@ -30,8 +33,13 @@ class DriverOutgoingTrackersBehaviour : DriverBridgeBehaviour { // Should be safe: StateFlow never delivers two emissions concurrently to the same collector. val subscribedTrackers = mutableSetOf() - receiver.appContext.skeleton.computed.onEach { computedBones -> - val enabledBodyParts = receiver.appContext.outputTrackerToggle.context.state.value.trackers + combine( + receiver.appContext.skeleton.computed, + receiver.appContext.outputTrackerToggle.context.state.map { it.trackers }, + ::Pair + ) + .distinctUntilChanged() + .onEach { (computedBones, enabledBodyParts) -> val serverState = receiver.appContext.server.context.state.value computedBones.forEach { (part, state) -> @@ -65,7 +73,7 @@ class DriverOutgoingTrackersBehaviour : DriverBridgeBehaviour { receiver.outbound.emit( DriverBridgeOutbound.TrackerStatus( trackerId = part.value.toInt(), - battery = closestDevice?.batteryLevel, + battery = closestDevice?.batteryLevel ?: 1f, charging = closestDevice?.batteryVoltage != null && closestDevice.batteryVoltage >= 4.3f, status = closestTracker?.status ?: TrackerStatus.OK, ), @@ -74,7 +82,7 @@ class DriverOutgoingTrackersBehaviour : DriverBridgeBehaviour { receiver.outbound.emit( DriverBridgeOutbound.TrackerStatus( trackerId = part.value.toInt(), - battery = 0f, + battery = null, charging = false, status = TrackerStatus.DISCONNECTED, ), @@ -112,9 +120,9 @@ class DriverIncomingTrackersBehaviour : DriverBridgeBehaviour { receiver.inbound.on { event -> val trackerId = receiver.context.state.value.trackers[event.id] ?: return@on receiver.appContext.server.getTracker(trackerId)?.let { tracker -> - tracker.context.dispatch( + tracker.context.dispatch( // TODO should maybe use TrackerActions.SetRotation? TrackerActions.Update { - copy(rawRotation = event.rotation, position = event.position) + copy(rawRotation = event.rotation, rotation = event.rotation, position = event.position) }, ) } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 7fd98c12b2..0ab8b765fb 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -137,7 +137,11 @@ class ComputedSkeletonBehaviour( val processed = processors .fold(targetState) { state, processor -> processor.process(state) } // TODO: Add a constrain processor (maybe not needed) - val rootHead = Vector3(0f, targetState.skeletonHeight, 0f) // FIXME WRONG + // Get head position + val rootHead = processed.boneInputs[BodyPart.HEAD] + ?.let { Vector3(it.rawPosition.x, it.rawPosition.y, it.rawPosition.z) } + ?: Vector3(0f, targetState.skeletonHeight, 0f) + val fk = buildBones(processed, rootHead = rootHead) // val targetProcessors = [FloorClip, FloorSkating, ToePlant, FootPlant] diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 13c07a795d..8de1e102f1 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -17,9 +17,11 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -255,17 +257,31 @@ class TrackerToSkeletonBehaviour : TrackerBehaviour { } } .flatMapLatest { _ -> - receiver.context.state + // We only want trackers that are assigned to a BodyPart and are OK. + val activeState = receiver.context.state + .filter { it.bodyPart != null && it.status == TrackerStatus.OK } + + val rotationFlow = activeState .distinctUntilChangedBy { it.rotation } .onEach { trackerState -> - if (trackerState.bodyPart != null && trackerState.status == TrackerStatus.OK) { - // Send data to the skeleton + receiver.appContext.skeleton.context.dispatch( + SkeletonActions.SetBoneRotation(trackerState.bodyPart ?: BodyPart.NONE, trackerState.rotation), + ) + lastBodyPartSent = trackerState.bodyPart + } + + val positionFlow = activeState + .distinctUntilChangedBy { it.position } + .onEach { trackerState -> + trackerState.position?.let { receiver.appContext.skeleton.context.dispatch( - SkeletonActions.SetBoneRotation(trackerState.bodyPart, trackerState.rotation), + SkeletonActions.SetBonePosition(trackerState.bodyPart ?: BodyPart.NONE, it), ) lastBodyPartSent = trackerState.bodyPart } } + + merge(rotationFlow, positionFlow) } .launchIn(receiver.context.scope) } From 4e1e53ee0aaee601eb5b3415d4bcf6dcf4eb8dca Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 24 Jul 2026 19:16:02 -0400 Subject: [PATCH 294/350] Move around reset settings in GUI and add titles --- gui/app/public/i18n/en/translation.ftl | 10 +- .../pages/components/ResetsSettings.tsx | 158 ++++++++++-------- .../pages/components/TapDetectionSettings.tsx | 8 +- 3 files changed, 101 insertions(+), 75 deletions(-) diff --git a/gui/app/public/i18n/en/translation.ftl b/gui/app/public/i18n/en/translation.ftl index c1af189355..25ee5c2308 100644 --- a/gui/app/public/i18n/en/translation.ftl +++ b/gui/app/public/i18n/en/translation.ftl @@ -523,7 +523,8 @@ settings-general-tracker_mechanics-filtering-type-smoothing-description = Smooth settings-general-tracker_mechanics-filtering-type-prediction = Prediction settings-general-tracker_mechanics-filtering-type-prediction-description = Reduces latency and makes movements more snappy, but may increase jitter. settings-general-tracker_mechanics-filtering-amount = Amount -settings-general-tracker_mechanics-yaw-reset-smooth-time = Yaw reset smooth time (0s disables smoothing) +settings-general-tracker_mechanics-yaw-reset-smooth-time = Yaw reset smooth time +settings-general-tracker_mechanics-yaw-reset-smooth-time-description = Smooths the trackers' rotations upon doing a yaw reset. 0s disables smoothing. settings-general-tracker_mechanics-drift_compensation_warning-cancel = Cancel settings-general-tracker_mechanics-drift_compensation_warning-done = I understand settings-general-tracker_mechanics-drift_compensation-amount-label = Compensation amount @@ -619,8 +620,8 @@ settings-general-fk_settings-leg_tweak-floor_clip-description = Floor-clip can r settings-general-fk_settings-leg_tweak-toe_snap-description = Toe-snap attempts to guess the rotation of your feet if foot trackers are not in use. settings-general-fk_settings-leg_tweak-foot_plant-description = Foot-plant rotates your feet to be parallel to the ground when in contact. settings-general-fk_settings-leg_fk = Leg tracking -settings-general-fk_settings-leg_fk-reset_mounting_feet-description-v1 = Force feet mounting calibration during body mounting calibration. settings-general-fk_settings-leg_fk-reset_mounting_feet-v1 = Force feet mounting calibration +settings-general-fk_settings-leg_fk-reset_mounting_feet-description-v1 = Force feet mounting calibration during body mounting calibration. settings-general-fk_settings-enforce_joint_constraints = Skeletal Limits settings-general-fk_settings-enforce_joint_constraints-enforce_constraints = Enforce constraints settings-general-fk_settings-enforce_joint_constraints-enforce_constraints-description = Prevents joints from rotating past their limit @@ -636,9 +637,10 @@ settings-general-fk_settings-arm_fk = Arm tracking settings-general-fk_settings-arm_fk-description = Force arms to be tracked from the headset (HMD) even if positional hand data is available. settings-general-fk_settings-arm_fk-force_arms = Force arms from HMD settings-general-fk_settings-resets_settings = Resets Settings -settings-general-fk_settings-reset_settings-reset_hmd_pitch-description = Reset the HMD's pitch (vertical rotation) upon doing a full reset. Useful if wearing an HMD on the forehead for VTubing or mocap. Do not enable for VR. settings-general-fk_settings-reset_settings-reset_hmd_pitch = Reset HMD pitch -settings-general-fk_settings-arm_fk-reset_mode-description = Change which arm pose is expected for mounting calibration. +settings-general-fk_settings-reset_settings-reset_hmd_pitch-description = Reset the HMD's vertical rotation upon doing a full reset. Useful if wearing an HMD on the forehead for VTubing or mocap. Do not enable for VR. +settings-general-fk_settings-arm_fk-reset_mode = Arms reset mode +settings-general-fk_settings-arm_fk-reset_mode-description = Change which arm pose is expected for resets. settings-general-fk_settings-arm_fk-back = Back settings-general-fk_settings-arm_fk-back-description = The default mode, with the upper arms going back and lower arms going forward. settings-general-fk_settings-arm_fk-tpose_up = T-pose (up) diff --git a/gui/app/src/components/settings/pages/components/ResetsSettings.tsx b/gui/app/src/components/settings/pages/components/ResetsSettings.tsx index bc50ebf3a9..66771fbbd3 100644 --- a/gui/app/src/components/settings/pages/components/ResetsSettings.tsx +++ b/gui/app/src/components/settings/pages/components/ResetsSettings.tsx @@ -98,90 +98,65 @@ export function ResetsSettings() { return ( <> -

+
{l10n.getString( - 'settings-general-tracker_mechanics-yaw-reset-smooth-time' + 'settings-general-fk_settings-leg_fk-reset_mounting_feet-v1' )} -
- secondsFormat.format(value)} - min={0.0} - max={0.5} - step={0.05} - /> + + {l10n.getString( + 'settings-general-fk_settings-leg_fk-reset_mounting_feet-description-v1' + )} + -
+ +
+ +
{l10n.getString( - 'settings-general-tracker_mechanics-save_mounting_reset' + 'settings-general-tracker_mechanics-yaw-reset-smooth-time' )} - }} - > - - -
- - -
-
-
- - {l10n.getString( - 'settings-general-fk_settings-reset_settings-reset_hmd_pitch-description' - )} - - -
-
- - {l10n.getString( - 'settings-general-fk_settings-leg_fk-reset_mounting_feet-description-v1' - )} - - -
-
+ + + {l10n.getString( + 'settings-general-tracker_mechanics-yaw-reset-smooth-time-description' + )} + + + secondsFormat.format(value)} + min={0.0} + max={0.5} + step={0.05} + />
-
+
+ + {l10n.getString('settings-general-fk_settings-arm_fk-reset_mode')} + + {l10n.getString( 'settings-general-fk_settings-arm_fk-reset_mode-description' )} -
+ +
+ +
+ + {l10n.getString( + 'settings-general-fk_settings-reset_settings-reset_hmd_pitch' + )} + + + + {l10n.getString( + 'settings-general-fk_settings-reset_settings-reset_hmd_pitch-description' + )} + + + +
+ +
+ + {l10n.getString( + 'settings-general-tracker_mechanics-save_mounting_reset' + )} + + + }} + > + + + + +
); } diff --git a/gui/app/src/components/settings/pages/components/TapDetectionSettings.tsx b/gui/app/src/components/settings/pages/components/TapDetectionSettings.tsx index 65da4fde09..dd7fa02de8 100644 --- a/gui/app/src/components/settings/pages/components/TapDetectionSettings.tsx +++ b/gui/app/src/components/settings/pages/components/TapDetectionSettings.tsx @@ -218,28 +218,28 @@ export function TapDetectionSettings() {
{l10n.getString( - 'settings-general-gesture_control-mountingResetTracker' + 'settings-general-gesture_control-fullResetTracker' )}
{l10n.getString( - 'settings-general-gesture_control-fullResetTracker' + 'settings-general-gesture_control-mountingResetTracker' )}
From 6b54329dd222bea3a4195bc045b20831e2c48578 Mon Sep 17 00:00:00 2001 From: lucas lelievre Date: Sat, 25 Jul 2026 07:22:20 +0200 Subject: [PATCH 295/350] Better logs / Logs to file and log rotation --- .../dev/slimevr/android/ForegroundService.kt | 4 +- .../main/java/dev/slimevr/android/hid/hid.kt | 2 +- .../java/dev/slimevr/android/ipc/ws-server.kt | 2 +- .../main/java/dev/slimevr/android/logging.kt | 37 ++++-- .../java/dev/slimevr/android/serial/serial.kt | 2 +- .../main/java/dev/slimevr/bvh/behaviours.kt | 2 +- .../src/main/java/dev/slimevr/config/io.kt | 10 +- .../main/java/dev/slimevr/hid/behaviours.kt | 2 +- .../core/src/main/java/dev/slimevr/logger.kt | 40 ------- .../java/dev/slimevr/logging/log-files.kt | 75 ++++++++++++ .../main/java/dev/slimevr/logging/logger.kt | 69 +++++++++++ .../dev/slimevr/logging/standard-streams.kt | 38 +++++++ .../java/dev/slimevr/oscquery/discovery.kt | 2 +- .../main/java/dev/slimevr/resets/module.kt | 2 +- .../java/dev/slimevr/skeleton/behaviours.kt | 5 +- .../main/java/dev/slimevr/solarxr/datafeed.kt | 2 +- .../src/main/java/dev/slimevr/solarxr/mag.kt | 3 +- .../main/java/dev/slimevr/solarxr/serial.kt | 2 +- .../java/dev/slimevr/tracker/behaviours.kt | 56 ++++----- .../main/java/dev/slimevr/udp/behaviours.kt | 2 +- .../src/main/java/dev/slimevr/udp/server.kt | 2 +- .../java/dev/slimevr/util/error-reporting.kt | 2 +- .../main/java/dev/slimevr/vmc/behaviours.kt | 2 +- .../src/main/java/dev/slimevr/vrcosc/input.kt | 3 +- .../main/java/dev/slimevr/vrcosc/oscquery.kt | 2 +- .../main/java/dev/slimevr/vrcosc/output.kt | 2 +- .../dev/slimevr/logging/LogFileSenderTest.kt | 107 ++++++++++++++++++ server/desktop/.gitignore | 5 +- server/desktop/build.gradle.kts | 2 + server/desktop/config/.gitkeep | 0 server/desktop/config/README.md | 2 - .../src/main/java/dev/slimevr/desktop/Main.kt | 1 + .../main/java/dev/slimevr/desktop/hid/hid.kt | 2 +- .../dev/slimevr/desktop/install/install.kt | 3 +- .../java/dev/slimevr/desktop/install/linux.kt | 3 +- .../dev/slimevr/desktop/install/windows.kt | 2 +- .../java/dev/slimevr/desktop/ipc/linux.kt | 2 +- .../java/dev/slimevr/desktop/ipc/pipe-slot.kt | 4 +- .../java/dev/slimevr/desktop/ipc/protocol.kt | 2 +- .../java/dev/slimevr/desktop/ipc/solarxr.kt | 2 +- .../java/dev/slimevr/desktop/ipc/windows.kt | 2 +- .../java/dev/slimevr/desktop/ipc/ws-server.kt | 2 +- .../java/dev/slimevr/desktop/keybind/gnome.kt | 2 +- .../dev/slimevr/desktop/keybind/keybinds.kt | 2 +- .../main/java/dev/slimevr/desktop/logging.kt | 16 +++ .../desktop/networkprofile/network-profile.kt | 5 +- .../java/dev/slimevr/desktop/serial/serial.kt | 2 +- .../main/java/dev/slimevr/desktop/steamvr.kt | 2 +- .../desktop/trackingchecklist/behaviours.kt | 2 +- .../java/dev/slimevr/desktop/vrchat/linux.kt | 2 +- .../dev/slimevr/desktop/vrchat/windows.kt | 2 +- .../src/main/java/dev/slimevr/platform.kt | 4 + 52 files changed, 423 insertions(+), 127 deletions(-) delete mode 100644 server/core/src/main/java/dev/slimevr/logger.kt create mode 100644 server/core/src/main/java/dev/slimevr/logging/log-files.kt create mode 100644 server/core/src/main/java/dev/slimevr/logging/logger.kt create mode 100644 server/core/src/main/java/dev/slimevr/logging/standard-streams.kt create mode 100644 server/core/src/test/java/dev/slimevr/logging/LogFileSenderTest.kt create mode 100644 server/desktop/config/.gitkeep delete mode 100644 server/desktop/config/README.md create mode 100644 server/desktop/src/main/java/dev/slimevr/desktop/logging.kt diff --git a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt index 26f14f8a1b..ce4e2b7385 100644 --- a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt +++ b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt @@ -49,6 +49,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancel import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking private val logger = noCoLogger("ForegroundService") @@ -78,7 +79,8 @@ class ForegroundService : Service() { override fun onCreate() { super.onCreate() - setupAndroidLogging() + // Blocking so logging is live before onStartCommand, which already reports errors. + runBlocking { setupAndroidLogging(filesDir) } createNotificationChannel() } diff --git a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt index 949b7d3097..4f560b0965 100644 --- a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt +++ b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt @@ -12,11 +12,11 @@ import android.hardware.usb.UsbInterface import android.hardware.usb.UsbManager import androidx.core.content.ContextCompat import dev.slimevr.AppContextProvider -import dev.slimevr.AppLogger import dev.slimevr.device.DeviceActions import dev.slimevr.hid.HIDReceiver import dev.slimevr.hid.isCompatibleHidDevice import dev.slimevr.hid.parseHIDPackets +import dev.slimevr.logging.AppLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job diff --git a/server/android/src/main/java/dev/slimevr/android/ipc/ws-server.kt b/server/android/src/main/java/dev/slimevr/android/ipc/ws-server.kt index 72b1768e27..11c5e8a70f 100644 --- a/server/android/src/main/java/dev/slimevr/android/ipc/ws-server.kt +++ b/server/android/src/main/java/dev/slimevr/android/ipc/ws-server.kt @@ -2,10 +2,10 @@ package dev.slimevr.android.ipc import com.google.flatbuffers.FlatBufferBuilder import dev.slimevr.AppContextProvider -import dev.slimevr.AppLogger import dev.slimevr.VRServerActions import dev.slimevr.fbscodegen.runtime.JvmFlatBufferReader import dev.slimevr.fbscodegen.runtime.JvmFlatBufferWriter +import dev.slimevr.logging.AppLogger import dev.slimevr.solarxr.SolarXRBridge import dev.slimevr.solarxr.onSolarXRMessage import io.ktor.server.application.install diff --git a/server/android/src/main/java/dev/slimevr/android/logging.kt b/server/android/src/main/java/dev/slimevr/android/logging.kt index 9bb9d1c04d..ecfd358d3f 100644 --- a/server/android/src/main/java/dev/slimevr/android/logging.kt +++ b/server/android/src/main/java/dev/slimevr/android/logging.kt @@ -1,16 +1,35 @@ package dev.slimevr.android +import android.util.Log +import dev.slimevr.logging.configureLogging import io.klogging.Level -import io.klogging.config.loggingConfiguration -import io.klogging.sending.ANDROID +import io.klogging.config.SinkConfiguration +import io.klogging.rendering.RenderString +import io.klogging.rendering.evalTemplate +import io.klogging.sending.EventSender +import java.io.File -fun setupAndroidLogging() { - loggingConfiguration { - sink("android", ANDROID) - logging { - fromMinLevel(Level.DEBUG) { - toSink("android") - } +private val RENDER_LOGCAT = RenderString { event -> + val items = if (event.items.isNotEmpty()) " : ${event.items}" else "" + val stackTrace = event.stackTrace?.let { "\n$it" } ?: "" + event.evalTemplate() + items + stackTrace +} + +private val LOGCAT = EventSender { batch -> + for (event in batch) { + val priority = when (event.level) { + Level.TRACE -> Log.VERBOSE + Level.DEBUG -> Log.DEBUG + Level.INFO, Level.NONE -> Log.INFO + Level.WARN -> Log.WARN + Level.ERROR, Level.FATAL -> Log.ERROR } + Log.println(priority, event.logger, RENDER_LOGCAT(event)) } } + +suspend fun setupAndroidLogging(filesDir: File) = configureLogging( + consoleSink = SinkConfiguration(eventSender = LOGCAT), + logDirectory = filesDir.toPath(), + minLevel = Level.DEBUG, +) diff --git a/server/android/src/main/java/dev/slimevr/android/serial/serial.kt b/server/android/src/main/java/dev/slimevr/android/serial/serial.kt index dad4b181c4..16a89dfb62 100644 --- a/server/android/src/main/java/dev/slimevr/android/serial/serial.kt +++ b/server/android/src/main/java/dev/slimevr/android/serial/serial.kt @@ -11,7 +11,7 @@ import androidx.core.content.ContextCompat import com.hoho.android.usbserial.driver.UsbSerialPort import com.hoho.android.usbserial.driver.UsbSerialProber import com.hoho.android.usbserial.util.SerialInputOutputManager -import dev.slimevr.AppLogger +import dev.slimevr.logging.AppLogger import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialPortInfo import dev.slimevr.serial.SerialServer diff --git a/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt b/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt index 17e4fdd34f..8bb265989f 100644 --- a/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt @@ -1,7 +1,7 @@ package dev.slimevr.bvh -import dev.slimevr.AppLogger import dev.slimevr.config.ConfigStorage +import dev.slimevr.logging.AppLogger import dev.slimevr.skeleton.Skeleton import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn diff --git a/server/core/src/main/java/dev/slimevr/config/io.kt b/server/core/src/main/java/dev/slimevr/config/io.kt index 510183d9b2..162d40827c 100644 --- a/server/core/src/main/java/dev/slimevr/config/io.kt +++ b/server/core/src/main/java/dev/slimevr/config/io.kt @@ -1,5 +1,6 @@ package dev.slimevr.config +import dev.slimevr.logging.AppLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -56,12 +57,11 @@ suspend inline fun loadFileWithBackup( try { deserialize(raw) } catch (e: Exception) { - e.printStackTrace() - System.err.println("Failed to load ${storage.displayPath(path)}: ${e.message}") + AppLogger.config.error(e, "Failed to load ${storage.displayPath(path)}") try { storage.backup(path) } catch (e2: Exception) { - System.err.println("Failed to back up corrupted file: ${e2.message}") + AppLogger.config.error(e2, "Failed to back up corrupted file") } default } @@ -90,9 +90,9 @@ fun launchAutosave( val path = toPath(s) storage.write(path, serialize(s)) lastSaved = s - println("Saved ${storage.displayPath(path)}") + AppLogger.config.info("Saved ${storage.displayPath(path)}") } catch (e: Exception) { - System.err.println("Failed to save: ${e.message}") + AppLogger.config.error(e, "Failed to save") } } .launchIn(scope) diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index e7e0f3feea..55b7a3dc51 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -1,10 +1,10 @@ package dev.slimevr.hid -import dev.slimevr.AppLogger import dev.slimevr.VRServerActions import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin +import dev.slimevr.logging.AppLogger import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import kotlinx.coroutines.Job diff --git a/server/core/src/main/java/dev/slimevr/logger.kt b/server/core/src/main/java/dev/slimevr/logger.kt deleted file mode 100644 index b03bd11b0c..0000000000 --- a/server/core/src/main/java/dev/slimevr/logger.kt +++ /dev/null @@ -1,40 +0,0 @@ -package dev.slimevr - -import io.klogging.Level -import io.klogging.config.loggingConfiguration -import io.klogging.logger -import io.klogging.noCoLogger -import io.klogging.rendering.RENDER_SIMPLE -import io.klogging.sending.STDOUT - -object AppLogger { - val tracker = logger("Tracker") - val device = logger("Device") - val udp = logger("UDPConnection") - val ipc = logger("IPC") - val solarxr = logger("SolarXR") - val checklist = logger("Checklist") - val steamvr = logger("SteamVR") - val hid = logger("HID") - val serial = logger("Serial") - val firmware = logger("Firmware") - val vrc = logger("VRChat") - val bvh = logger("BVH") - val vmc = logger("VMC") - val oscQuery = logger("OSCQuery") - val install = logger("Install") - val resets = logger("Resets") - val keybind = logger("Keybind") - val coroutines = noCoLogger("Coroutines") - - init { - loggingConfiguration { - sink("stdout", RENDER_SIMPLE, STDOUT) - logging { - fromMinLevel(Level.INFO) { - toSink("stdout") - } - } - } - } -} diff --git a/server/core/src/main/java/dev/slimevr/logging/log-files.kt b/server/core/src/main/java/dev/slimevr/logging/log-files.kt new file mode 100644 index 0000000000..9d248fdd19 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/logging/log-files.kt @@ -0,0 +1,75 @@ +package dev.slimevr.logging + +import io.klogging.sending.SendString +import java.io.BufferedOutputStream +import java.io.Closeable +import java.io.DataOutputStream +import java.io.FileOutputStream +import java.nio.file.Path +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import kotlin.io.path.createDirectories + +/** Prefix of every log file name, also used to recognise logs left by previous runs. */ +private const val LOG_TAG = "slimevr-server" + +private const val LOG_SUFFIX = ".log" + +/** Separates the tag, the timestamp and the index in a log file name. */ +private const val SEPARATOR = '_' + +private val FILE_NAME_DATE_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss") + +/** Size at which the current file is closed and a new one started, in bytes. */ +private const val FILE_SIZE_LIMIT = 12 * 1000 * 1000 + +/** How many log files are kept, across all runs. Times [FILE_SIZE_LIMIT], this caps the folder. */ +private const val MAX_FILE_COUNT = 8 + +/** + * Writes rendered log events to `slimevr-server__.log` files in [directory], starting + * a new one once the current file passes [fileSizeLimit] and keeping only the [maxFileCount] most + * recent, logs left by previous runs included. + */ +class LogFileSender( + private val directory: Path, + private val fileSizeLimit: Int = FILE_SIZE_LIMIT, + private val maxFileCount: Int = MAX_FILE_COUNT, +) : SendString, + Closeable { + + private val namePrefix = "$LOG_TAG$SEPARATOR${LocalDateTime.now().format(FILE_NAME_DATE_FORMAT)}$SEPARATOR" + + private var index = 0 + private var stream = openNextFile() + + @Synchronized + override fun invoke(eventString: String) { + if (stream.size() >= fileSizeLimit) { + stream.close() + stream = openNextFile() + } + + stream.write("$eventString\n".toByteArray()) + stream.flush() + } + + @Synchronized + override fun close() = stream.close() + + private fun openNextFile(): DataOutputStream { + directory.createDirectories() + deleteLogsBeyond(directory, keep = maxFileCount - 1) + + val file = directory.resolve("$namePrefix${index++}$LOG_SUFFIX").toFile() + return DataOutputStream(BufferedOutputStream(FileOutputStream(file))) + } +} + +private fun deleteLogsBeyond(directory: Path, keep: Int) = directory.toFile() + .listFiles().orEmpty() + .filter { it.name.startsWith(LOG_TAG) && it.name.endsWith(LOG_SUFFIX) } + .sortedByDescending { it.lastModified() } + .drop(keep) + // A file another process still holds open cannot be deleted on Windows; retry on exit. + .forEach { if (!it.delete()) it.deleteOnExit() } diff --git a/server/core/src/main/java/dev/slimevr/logging/logger.kt b/server/core/src/main/java/dev/slimevr/logging/logger.kt new file mode 100644 index 0000000000..342a850577 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/logging/logger.kt @@ -0,0 +1,69 @@ +package dev.slimevr.logging + +import io.klogging.Level +import io.klogging.config.SinkConfiguration +import io.klogging.config.loggingConfiguration +import io.klogging.logger +import io.klogging.noCoLogger +import io.klogging.rendering.RENDER_SIMPLE +import java.nio.file.Path + +object AppLogger { + val tracker = logger("Tracker") + val device = logger("Device") + val udp = logger("UDPConnection") + val ipc = logger("IPC") + val solarxr = logger("SolarXR") + val checklist = logger("Checklist") + val steamvr = logger("SteamVR") + val hid = logger("HID") + val serial = logger("Serial") + val firmware = logger("Firmware") + val vrc = logger("VRChat") + val bvh = logger("BVH") + val vmc = logger("VMC") + val oscQuery = logger("OSCQuery") + val install = logger("Install") + val resets = logger("Resets") + val keybind = logger("Keybind") + val config = logger("Config") + val skeleton = logger("Skeleton") + val networkProfile = logger("NetworkProfile") + val logging = logger("Logging") + + val coroutines = noCoLogger("Coroutines") + val console = noCoLogger("Console") +} + +private const val CONSOLE_SINK = "console" +private const val FILE_SINK = "file" + +suspend fun configureLogging(consoleSink: SinkConfiguration, logDirectory: Path?, minLevel: Level = Level.INFO) { + val fileSender = logDirectory?.let { runCatching { LogFileSender(it) } } + + loggingConfiguration { + sink(CONSOLE_SINK, consoleSink) + fileSender?.getOrNull()?.let { sink(FILE_SINK, RENDER_SIMPLE, it) } + logging { + fromMinLevel(minLevel) { + toSink(CONSOLE_SINK) + if (fileSender?.isSuccess == true) toSink(FILE_SINK) + } + } + } + + captureStandardStreams() + + val failure = fileSender?.exceptionOrNull() + when { + fileSender == null -> + AppLogger.logging + .warn("No log folder for this platform, logging to the console only") + + failure != null -> + AppLogger.logging + .error(failure, "Cannot write logs in $logDirectory, logging to the console only") + + else -> AppLogger.logging.info("Writing logs to $logDirectory") + } +} diff --git a/server/core/src/main/java/dev/slimevr/logging/standard-streams.kt b/server/core/src/main/java/dev/slimevr/logging/standard-streams.kt new file mode 100644 index 0000000000..3919899922 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/logging/standard-streams.kt @@ -0,0 +1,38 @@ +package dev.slimevr.logging + +import io.klogging.Level +import io.klogging.sending.SendString +import java.io.ByteArrayOutputStream +import java.io.OutputStream +import java.io.PrintStream + +private val realOut: PrintStream = System.out + +/** + * Console sink target, bound to the stream the process started with. Klogging's `STDOUT` writes + * through [System.out], which [captureStandardStreams] replaces with a stream that logs whatever + * is written to it, so a sink using it would feed on its own output forever. + */ +val CONSOLE: SendString = SendString { realOut.println(it) } + +/** Routes [System.out] and [System.err] into the log, so `println` and stack traces reach the files. */ +fun captureStandardStreams() { + System.setOut(PrintStream(LineLogger(Level.INFO), true)) + System.setErr(PrintStream(LineLogger(Level.ERROR), true)) +} + +private class LineLogger(private val level: Level) : OutputStream() { + private val line = ByteArrayOutputStream() + + @Synchronized + override fun write(b: Int) { + if (b != '\n'.code) { + line.write(b) + return + } + + val text = line.toString(Charsets.UTF_8).trimEnd('\r') + line.reset() + if (text.isNotEmpty()) AppLogger.console.log(level, text) + } +} diff --git a/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt b/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt index e7ea60dad8..101c777db2 100644 --- a/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt +++ b/server/core/src/main/java/dev/slimevr/oscquery/discovery.kt @@ -2,7 +2,7 @@ package dev.slimevr.oscquery import com.appstractive.dnssd.DiscoveryEvent import com.appstractive.dnssd.discoverServices -import dev.slimevr.AppLogger +import dev.slimevr.logging.AppLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index 6627b82d9d..95168c21de 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -1,7 +1,6 @@ package dev.slimevr.resets import com.jme3.math.FastMath -import dev.slimevr.AppLogger import dev.slimevr.Phase1ContextProvider import dev.slimevr.VRServer import dev.slimevr.config.MountingMethods @@ -10,6 +9,7 @@ import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.logging.AppLogger import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import io.github.axisangles.ktmath.EulerAngles diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 0ab8b765fb..ba488e96e9 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -1,6 +1,7 @@ package dev.slimevr.skeleton import dev.slimevr.config.UserConfig +import dev.slimevr.logging.AppLogger import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import io.ktor.utils.io.CancellationException @@ -73,7 +74,7 @@ class HeightLogBehaviour : SkeletonBehaviour { receiver.context.state .map { state -> state.skeletonHeight } .distinctUntilChanged() - .collect { height -> println("User height changed: ${"%.2f".format(height)}m") } + .collect { height -> AppLogger.skeleton.info("User height changed: ${"%.2f".format(height)}m") } } } } @@ -160,7 +161,7 @@ class ComputedSkeletonBehaviour( } catch (e: CancellationException) { throw e } catch (e: Exception) { - dev.slimevr.AppLogger.coroutines.error(e, "Error in ComputedSkeletonBehaviour") + dev.slimevr.logging.AppLogger.coroutines.error(e, "Error in ComputedSkeletonBehaviour") } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index c319f66cf7..cb4b761934 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -1,9 +1,9 @@ package dev.slimevr.solarxr -import dev.slimevr.AppLogger import dev.slimevr.VRServer import dev.slimevr.device.DeviceState import dev.slimevr.heightcalibration.HeightCalibrationManager +import dev.slimevr.logging.AppLogger import dev.slimevr.resets.ResetsManager import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.Skeleton diff --git a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt index a4a7be8497..58f811e17c 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/mag.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/mag.kt @@ -3,6 +3,7 @@ package dev.slimevr.solarxr import dev.slimevr.AppContextProvider import dev.slimevr.config.SettingsActions import dev.slimevr.device.DeviceOrigin +import dev.slimevr.logging.AppLogger import dev.slimevr.udp.SensorConfigFlags import dev.slimevr.udp.UDPConnectionActions import kotlinx.coroutines.flow.distinctUntilChangedBy @@ -70,7 +71,7 @@ class MagBehaviour( receiver.sendRpc(MagToggleResponse(trackerId = trackerId, enable = enable)) } } catch (e: kotlinx.coroutines.TimeoutCancellationException) { - dev.slimevr.AppLogger.solarxr.warn("Timeout waiting for mag toggle response from tracker") + AppLogger.solarxr.warn("Timeout waiting for mag toggle response from tracker") } } } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt index 8798cfa7b2..cf8b011006 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/serial.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/serial.kt @@ -90,7 +90,7 @@ class SerialBehaviour(private val serialServer: SerialServer) : SolarXRBridgeBeh } } } catch (e: Exception) { - dev.slimevr.AppLogger.solarxr.error(e, "Error streaming serial log") + dev.slimevr.logging.AppLogger.solarxr.error(e, "Error streaming serial log") } } }.launchIn(scope) diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 8de1e102f1..28837a8308 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -1,8 +1,8 @@ package dev.slimevr.tracker import com.jme3.math.FastMath -import dev.slimevr.AppLogger import dev.slimevr.config.Settings +import dev.slimevr.logging.AppLogger import dev.slimevr.resets.LEFT_ARM_PARTS import dev.slimevr.resets.LEFT_FINGER_PARTS import dev.slimevr.resets.RIGHT_ARM_PARTS @@ -166,8 +166,8 @@ class TrackerBasicBehaviour : TrackerBehaviour { receiver.context.state .distinctUntilChangedBy { it.sessionCalibration to it.restOrientation to it.mountingOrientation } .onEach { - receiver.context.dispatch(TrackerActions.SetRotation(it.rawRotation, it.rawAcceleration, it.rawMagnetometer)) - }.launchIn(receiver.context.scope) + receiver.context.dispatch(TrackerActions.SetRotation(it.rawRotation, it.rawAcceleration, it.rawMagnetometer)) + }.launchIn(receiver.context.scope) } } @@ -176,31 +176,31 @@ class TrackerDefaultMountingOrientationBehaviour : TrackerBehaviour { * Returns the default mounting orientation for the body part */ private fun defaultMountingForBodyPart(bodyPart: BodyPart?): Quaternion = when (bodyPart) { - BodyPart.LEFT_LOWER_ARM, BodyPart.LEFT_HAND, - BodyPart.LEFT_INDEX_PROXIMAL, BodyPart.LEFT_INDEX_INTERMEDIATE, - BodyPart.LEFT_INDEX_DISTAL, BodyPart.LEFT_MIDDLE_PROXIMAL, - BodyPart.LEFT_MIDDLE_INTERMEDIATE, BodyPart.LEFT_MIDDLE_DISTAL, - BodyPart.LEFT_RING_PROXIMAL, BodyPart.LEFT_RING_INTERMEDIATE, - BodyPart.LEFT_RING_DISTAL, BodyPart.LEFT_LITTLE_PROXIMAL, - BodyPart.LEFT_LITTLE_INTERMEDIATE, BodyPart.LEFT_LITTLE_DISTAL, - BodyPart.LEFT_SHOULDER, - -> Quaternion.SLIMEVR.LEFT - - BodyPart.RIGHT_LOWER_ARM, BodyPart.RIGHT_HAND, - BodyPart.RIGHT_INDEX_PROXIMAL, BodyPart.RIGHT_INDEX_INTERMEDIATE, - BodyPart.RIGHT_INDEX_DISTAL, BodyPart.RIGHT_MIDDLE_PROXIMAL, - BodyPart.RIGHT_MIDDLE_INTERMEDIATE, BodyPart.RIGHT_MIDDLE_DISTAL, - BodyPart.RIGHT_RING_PROXIMAL, BodyPart.RIGHT_RING_INTERMEDIATE, - BodyPart.RIGHT_RING_DISTAL, BodyPart.RIGHT_LITTLE_PROXIMAL, - BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_DISTAL, - BodyPart.RIGHT_SHOULDER, - -> Quaternion.SLIMEVR.RIGHT - - BodyPart.LEFT_UPPER_ARM, BodyPart.LEFT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_LEFT - - BodyPart.RIGHT_UPPER_ARM, BodyPart.RIGHT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_RIGHT - - else -> Quaternion.SLIMEVR.BACK + BodyPart.LEFT_LOWER_ARM, BodyPart.LEFT_HAND, + BodyPart.LEFT_INDEX_PROXIMAL, BodyPart.LEFT_INDEX_INTERMEDIATE, + BodyPart.LEFT_INDEX_DISTAL, BodyPart.LEFT_MIDDLE_PROXIMAL, + BodyPart.LEFT_MIDDLE_INTERMEDIATE, BodyPart.LEFT_MIDDLE_DISTAL, + BodyPart.LEFT_RING_PROXIMAL, BodyPart.LEFT_RING_INTERMEDIATE, + BodyPart.LEFT_RING_DISTAL, BodyPart.LEFT_LITTLE_PROXIMAL, + BodyPart.LEFT_LITTLE_INTERMEDIATE, BodyPart.LEFT_LITTLE_DISTAL, + BodyPart.LEFT_SHOULDER, + -> Quaternion.SLIMEVR.LEFT + + BodyPart.RIGHT_LOWER_ARM, BodyPart.RIGHT_HAND, + BodyPart.RIGHT_INDEX_PROXIMAL, BodyPart.RIGHT_INDEX_INTERMEDIATE, + BodyPart.RIGHT_INDEX_DISTAL, BodyPart.RIGHT_MIDDLE_PROXIMAL, + BodyPart.RIGHT_MIDDLE_INTERMEDIATE, BodyPart.RIGHT_MIDDLE_DISTAL, + BodyPart.RIGHT_RING_PROXIMAL, BodyPart.RIGHT_RING_INTERMEDIATE, + BodyPart.RIGHT_RING_DISTAL, BodyPart.RIGHT_LITTLE_PROXIMAL, + BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_DISTAL, + BodyPart.RIGHT_SHOULDER, + -> Quaternion.SLIMEVR.RIGHT + + BodyPart.LEFT_UPPER_ARM, BodyPart.LEFT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_LEFT + + BodyPart.RIGHT_UPPER_ARM, BodyPart.RIGHT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_RIGHT + + else -> Quaternion.SLIMEVR.BACK } override fun observe(receiver: Tracker) { diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 4f7034f78d..334fbfe264 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -1,11 +1,11 @@ package dev.slimevr.udp import com.jme3.math.FastMath -import dev.slimevr.AppLogger import dev.slimevr.VRServerActions import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin +import dev.slimevr.logging.AppLogger import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import dev.slimevr.tracker.TrackerSensorIds diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index a980dacfe0..cd63f1fe80 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -1,9 +1,9 @@ package dev.slimevr.udp import dev.slimevr.AppContextProvider -import dev.slimevr.AppLogger import dev.slimevr.context.Behaviour import dev.slimevr.context.Context +import dev.slimevr.logging.AppLogger import io.ktor.network.selector.SelectorManager import io.ktor.network.sockets.BoundDatagramSocket import io.ktor.network.sockets.InetSocketAddress diff --git a/server/core/src/main/java/dev/slimevr/util/error-reporting.kt b/server/core/src/main/java/dev/slimevr/util/error-reporting.kt index 0b908cc737..5e6dfef55b 100644 --- a/server/core/src/main/java/dev/slimevr/util/error-reporting.kt +++ b/server/core/src/main/java/dev/slimevr/util/error-reporting.kt @@ -1,6 +1,6 @@ package dev.slimevr.util -import dev.slimevr.AppLogger +import dev.slimevr.logging.AppLogger import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineName import kotlin.coroutines.CoroutineContext diff --git a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt index a74c20d71d..39e18f0e1b 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt @@ -1,9 +1,9 @@ package dev.slimevr.vmc -import dev.slimevr.AppLogger import dev.slimevr.config.Settings import dev.slimevr.config.VMCConfig import dev.slimevr.context.Behaviour +import dev.slimevr.logging.AppLogger import dev.slimevr.osc.OscArg import dev.slimevr.osc.OscBundle import dev.slimevr.osc.OscContent diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt index 49a5ce0db9..d77e0c070c 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt @@ -1,12 +1,11 @@ package dev.slimevr.vrcosc import dev.slimevr.AppContextProvider -import dev.slimevr.AppLogger -import dev.slimevr.Phase1ContextProvider import dev.slimevr.VRServerActions import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin +import dev.slimevr.logging.AppLogger import dev.slimevr.osc.OscBundle import dev.slimevr.osc.OscContent import dev.slimevr.osc.OscMessage diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt b/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt index b741e0eb0e..06e29aaf2e 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/oscquery.kt @@ -1,7 +1,7 @@ package dev.slimevr.vrcosc -import dev.slimevr.AppLogger import dev.slimevr.config.DEFAULT_VRC_OSC_PORT_OUT +import dev.slimevr.logging.AppLogger import dev.slimevr.oscquery.OscQueryAccess import dev.slimevr.oscquery.OscQueryDiscovery import dev.slimevr.oscquery.OscQueryNode diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt index e5d3abbc56..2d6fedd364 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt @@ -1,7 +1,7 @@ package dev.slimevr.vrcosc -import dev.slimevr.AppLogger import dev.slimevr.config.VRCOSCConfig +import dev.slimevr.logging.AppLogger import dev.slimevr.osc.OscSender import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.Skeleton diff --git a/server/core/src/test/java/dev/slimevr/logging/LogFileSenderTest.kt b/server/core/src/test/java/dev/slimevr/logging/LogFileSenderTest.kt new file mode 100644 index 0000000000..8d72623146 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/logging/LogFileSenderTest.kt @@ -0,0 +1,107 @@ +package dev.slimevr.logging + +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.FileTime +import java.time.Instant +import java.time.temporal.ChronoUnit +import kotlin.io.path.deleteRecursively +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.name +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LogFileSenderTest { + + @OptIn(kotlin.io.path.ExperimentalPathApi::class) + private fun withTempDir(block: (Path) -> Unit) { + val dir = Files.createTempDirectory("slimevr-logs") + try { + block(dir) + } finally { + dir.deleteRecursively() + } + } + + private fun Path.logNames(): List = listDirectoryEntries("*.log").map { it.name }.sorted() + + /** Writes a log file and backdates it, so ordering by modification time has something to see. */ + private fun Path.staleLog(daysAgo: Long): Path = apply { + Files.writeString(this, "old run\n") + Files.setLastModifiedTime(this, FileTime.from(Instant.now().minus(daysAgo, ChronoUnit.DAYS))) + } + + @Test + fun `writes every event as its own line`() = withTempDir { dir -> + val sender = LogFileSender(dir) + sender("first") + sender("second") + sender.close() + + val logs = dir.logNames() + assertEquals(1, logs.size) + assertEquals(listOf("first", "second"), dir.resolve(logs.single()).readText().trim().lines()) + } + + @Test + fun `creates the folder when it does not exist yet`() = withTempDir { dir -> + val nested = dir.resolve("logs") + val sender = LogFileSender(nested) + sender("hello") + sender.close() + + assertEquals(1, nested.logNames().size) + } + + @Test + fun `rolls over to a new file past the size limit`() = withTempDir { dir -> + val sender = LogFileSender(dir, fileSizeLimit = 16) + repeat(3) { sender("0123456789") } + sender.close() + + // Each event writes 11 bytes, so the second one takes the first file past the 16 byte limit. + val logs = dir.logNames() + assertEquals(2, logs.size) + assertEquals(2, dir.resolve(logs[0]).readText().trim().lines().size) + assertEquals(1, dir.resolve(logs[1]).readText().trim().lines().size) + } + + @Test + fun `keeps at most the configured number of files`() = withTempDir { dir -> + val sender = LogFileSender(dir, fileSizeLimit = 1, maxFileCount = 3) + repeat(10) { sender("event $it") } + sender.close() + + assertEquals(3, dir.logNames().size) + } + + @Test + fun `keeps writing to the newest file when only one is allowed`() = withTempDir { dir -> + val sender = LogFileSender(dir, fileSizeLimit = 1, maxFileCount = 1) + repeat(3) { sender("event $it") } + sender.close() + + val logs = dir.logNames() + assertEquals(1, logs.size) + assertEquals("event 2", dir.resolve(logs.single()).readText().trim()) + } + + @Test + fun `deletes logs left by previous runs, oldest first`() = withTempDir { dir -> + val older = dir.resolve("slimevr-server_2020-01-01_00-00-00_0.log").staleLog(daysAgo = 30) + val newer = dir.resolve("slimevr-server_2020-01-01_00-00-00_1.log").staleLog(daysAgo = 1) + val unrelated = dir.resolve("notes.txt") + Files.writeString(unrelated, "keep me\n") + + // Room for one file from a previous run next to the one we are about to open. + val sender = LogFileSender(dir, maxFileCount = 2) + sender("new run") + sender.close() + + assertTrue(Files.notExists(older), "the least recently written log should have gone first") + assertTrue(Files.exists(newer), "the more recent log should have been kept") + assertTrue(Files.exists(unrelated), "files that are not logs must be left alone") + } +} diff --git a/server/desktop/.gitignore b/server/desktop/.gitignore index d18cf6eed8..bd47edbce3 100644 --- a/server/desktop/.gitignore +++ b/server/desktop/.gitignore @@ -1,3 +1,6 @@ /build config/* -!config/README.md +!config/.gitkeep + +logs/* +!logs/.gitkeep diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index ceafe6146f..17c7a2508a 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -87,6 +87,8 @@ dependencies { } implementation("org.hid4java:hid4java:0.8.0") implementation("io.klogging:klogging:0.11.7") + // SLF4J provider, so what ktor and the discovery libraries log reaches our sinks + runtimeOnly("io.klogging:slf4j-klogging:0.11.7") // Global keybinds: JIntellitype on Windows, dbus xdg-desktop-portal on Linux implementation("com.melloware:jintellitype:1.+") diff --git a/server/desktop/config/.gitkeep b/server/desktop/config/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/server/desktop/config/README.md b/server/desktop/config/README.md deleted file mode 100644 index 1106efb737..0000000000 --- a/server/desktop/config/README.md +++ /dev/null @@ -1,2 +0,0 @@ -This file is here so server does portable configuration mode! -What that means, is that it will save the config next to it, instead of in the user's config folder 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 9609014091..2f474de28b 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -50,6 +50,7 @@ import kotlinx.coroutines.runBlocking import solarxr_protocol.rpc.KeybindSupport fun main(args: Array) = runBlocking(appCoroutineExceptionHandler + CoroutineName("Main")) { + setupDesktopLogging() installUncaughtExceptionReporting() contextDebugEnabled = System.getProperty("slimevr.debug.context") == "true" || diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index af00637ee5..8470bd9d0a 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -1,11 +1,11 @@ package dev.slimevr.desktop.hid import dev.slimevr.AppContextProvider -import dev.slimevr.AppLogger import dev.slimevr.device.DeviceActions import dev.slimevr.hid.HIDReceiver import dev.slimevr.hid.isCompatibleHidDevice import dev.slimevr.hid.parseHIDPackets +import dev.slimevr.logging.AppLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/install/install.kt b/server/desktop/src/main/java/dev/slimevr/desktop/install/install.kt index ce55682b33..05d7477e62 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/install/install.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/install/install.kt @@ -1,9 +1,8 @@ package dev.slimevr.desktop.install -import dev.slimevr.AppLogger import dev.slimevr.CURRENT_PLATFORM -import dev.slimevr.FeatureFlags import dev.slimevr.Platform +import dev.slimevr.logging.AppLogger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.IOException diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/install/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/install/linux.kt index 7c0c665023..8be413c1cc 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/install/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/install/linux.kt @@ -1,8 +1,7 @@ package dev.slimevr.desktop.install -import dev.slimevr.AppLogger -import dev.slimevr.FeatureFlags import dev.slimevr.desktop.linux.findAppLibraryLocation +import dev.slimevr.logging.AppLogger private const val STEAMVR_APPID = 250820 private const val LINUX_STEAM_DRIVER_DIRECTORY = "slimevr-openvr-driver-x64-linux" diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/install/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/install/windows.kt index e78e3f7c64..37b9c9afc7 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/install/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/install/windows.kt @@ -2,7 +2,7 @@ package dev.slimevr.desktop.install import com.sun.jna.platform.win32.Advapi32Util import com.sun.jna.platform.win32.WinReg -import dev.slimevr.AppLogger +import dev.slimevr.logging.AppLogger import kotlin.io.path.Path import kotlin.io.path.exists diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index ce39e6c1f7..6e8eb642c9 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -1,8 +1,8 @@ package dev.slimevr.desktop.ipc import dev.slimevr.AppContextProvider -import dev.slimevr.AppLogger import dev.slimevr.getSocketDirectory +import dev.slimevr.logging.AppLogger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/pipe-slot.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/pipe-slot.kt index 5e8f6288ab..db05fc067f 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/pipe-slot.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/pipe-slot.kt @@ -9,7 +9,7 @@ import com.sun.jna.platform.win32.WinError import com.sun.jna.platform.win32.WinNT import com.sun.jna.ptr.IntByReference import com.sun.jna.win32.StdCallLibrary -import dev.slimevr.AppLogger +import dev.slimevr.logging.AppLogger import java.io.IOException private val k32 = Kernel32.INSTANCE @@ -55,7 +55,9 @@ internal class PipeSlot(private val handle: WinNT.HANDLE) : AutoCloseable { return when { // ERROR_PIPE_CONNECTED means the client got in before we asked connected || err == WinError.ERROR_PIPE_CONNECTED -> true + err == WinError.ERROR_IO_PENDING -> awaitCompletion() >= 0 + else -> false } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 3da68c7c51..94565ff874 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -1,7 +1,6 @@ package dev.slimevr.desktop.ipc import dev.slimevr.AppContextProvider -import dev.slimevr.AppLogger import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform import dev.slimevr.desktop.platform.Battery @@ -13,6 +12,7 @@ import dev.slimevr.desktop.platform.Version import dev.slimevr.driver.DriverBridge import dev.slimevr.driver.DriverBridgeInbound import dev.slimevr.driver.DriverBridgeOutbound +import dev.slimevr.logging.AppLogger import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.Dispatchers diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt index 8f4313dfff..5b8569aa03 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt @@ -3,7 +3,6 @@ package dev.slimevr.desktop.ipc import com.google.flatbuffers.FlatBufferBuilder import dev.hannah.portals.PortalManager import dev.slimevr.AppContextProvider -import dev.slimevr.AppLogger import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform import dev.slimevr.SLIMEVR_IDENTIFIER @@ -11,6 +10,7 @@ import dev.slimevr.VRServerActions import dev.slimevr.desktop.unblockSteamVRDriver import dev.slimevr.fbscodegen.runtime.JvmFlatBufferReader import dev.slimevr.fbscodegen.runtime.JvmFlatBufferWriter +import dev.slimevr.logging.AppLogger import dev.slimevr.solarxr.SolarXRBridge import dev.slimevr.solarxr.SolarXRBridgeBehaviour import dev.slimevr.solarxr.onSolarXRMessage diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt index 204fd1082f..0ebdf00e38 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt @@ -6,7 +6,7 @@ import com.sun.jna.platform.win32.Kernel32 import com.sun.jna.platform.win32.WinBase import com.sun.jna.platform.win32.WinNT import dev.slimevr.AppContextProvider -import dev.slimevr.AppLogger +import dev.slimevr.logging.AppLogger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ws-server.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ws-server.kt index 90f2f6a4d4..9402f4c94f 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ws-server.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/ws-server.kt @@ -1,7 +1,7 @@ package dev.slimevr.desktop.ipc import dev.slimevr.AppContextProvider -import dev.slimevr.AppLogger +import dev.slimevr.logging.AppLogger import io.ktor.server.application.install import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/keybind/gnome.kt b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/gnome.kt index 2035bf677e..89a55c49bc 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/keybind/gnome.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/gnome.kt @@ -1,8 +1,8 @@ package dev.slimevr.desktop.keybind -import dev.slimevr.AppLogger import dev.slimevr.config.KeybindConfig import dev.slimevr.desktop.install.executeShellCommand +import dev.slimevr.logging.AppLogger import dev.slimevr.solarxr.canonicalKeybind import solarxr_protocol.rpc.KeybindId import java.io.File diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt index a993886b9f..400c4e42e9 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/keybind/keybinds.kt @@ -6,13 +6,13 @@ import dev.hannah.portals.globalShortcuts.GlobalShortcutsHandler import dev.hannah.portals.globalShortcuts.Shortcut import dev.hannah.portals.globalShortcuts.ShortcutTuple import dev.slimevr.AppContextProvider -import dev.slimevr.AppLogger import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform import dev.slimevr.SLIMEVR_IDENTIFIER import dev.slimevr.config.KeybindConfig import dev.slimevr.config.SettingsActions import dev.slimevr.keybind.KeybindEvent +import dev.slimevr.logging.AppLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.awaitCancellation diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/logging.kt b/server/desktop/src/main/java/dev/slimevr/desktop/logging.kt new file mode 100644 index 0000000000..4377c282e5 --- /dev/null +++ b/server/desktop/src/main/java/dev/slimevr/desktop/logging.kt @@ -0,0 +1,16 @@ +package dev.slimevr.desktop + +import dev.slimevr.logging.CONSOLE +import dev.slimevr.logging.configureLogging +import dev.slimevr.resolveLogDirectory +import io.klogging.config.SinkConfiguration +import io.klogging.rendering.RENDER_SIMPLE + +/** + * Logs to standard output and to the per-user log folder the launcher and the bug report tooling + * already look in: `%AppData%`, `~/Library/Logs` or `$XDG_DATA_HOME` depending on the platform. + */ +suspend fun setupDesktopLogging() = configureLogging( + consoleSink = SinkConfiguration(RENDER_SIMPLE, CONSOLE), + logDirectory = resolveLogDirectory(), +) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt b/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt index 4d5a147a86..578b9f828f 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/networkprofile/network-profile.kt @@ -17,6 +17,7 @@ import com.sun.jna.ptr.IntByReference import com.sun.jna.ptr.PointerByReference import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform +import dev.slimevr.logging.AppLogger import dev.slimevr.networkprofile.ConnectivityFlags import dev.slimevr.networkprofile.NetworkCategory import dev.slimevr.networkprofile.NetworkInfo @@ -139,7 +140,7 @@ private class INetwork(instance: Pointer?) : } } -private fun enumerateNetworks(): List = try { +private suspend fun enumerateNetworks(): List = try { Ole32.INSTANCE.CoInitializeEx(null, 0) val clsid = CLSID("dcb00c01-570f-4a9b-8d69-199fdba5723b") val iid = IID("dcb00000-570f-4a9b-8d69-199fdba5723b") @@ -166,7 +167,7 @@ private fun enumerateNetworks(): List = try { Ole32.INSTANCE.CoUninitialize() result } catch (e: Exception) { - println(e.stackTraceToString()) + AppLogger.networkProfile.error(e, "Failed to enumerate networks") emptyList() } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt b/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt index 2ada3ae169..6662ba0304 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/serial/serial.kt @@ -2,7 +2,7 @@ package dev.slimevr.desktop.serial import com.fazecast.jSerialComm.SerialPortEvent import com.fazecast.jSerialComm.SerialPortMessageListener -import dev.slimevr.AppLogger +import dev.slimevr.logging.AppLogger import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialPortInfo import dev.slimevr.serial.SerialServer diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/steamvr.kt b/server/desktop/src/main/java/dev/slimevr/desktop/steamvr.kt index ecf634b7e8..1a2e00860e 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/steamvr.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/steamvr.kt @@ -1,6 +1,6 @@ package dev.slimevr.desktop -import dev.slimevr.AppLogger +import dev.slimevr.logging.AppLogger import dev.slimevr.tryOpenUri import io.ktor.client.HttpClient import io.ktor.client.call.body diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt b/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt index bef2b65ce4..145f76748d 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt @@ -6,11 +6,11 @@ import com.sun.jna.platform.win32.Kernel32Util import com.sun.jna.platform.win32.Tlhelp32 import com.sun.jna.platform.win32.WinBase import com.sun.jna.platform.win32.WinDef -import dev.slimevr.AppLogger import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform import dev.slimevr.VRServer import dev.slimevr.desktop.getSteamVRDriversList +import dev.slimevr.logging.AppLogger import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.trackingchecklist.TrackingChecklistActions import dev.slimevr.trackingchecklist.TrackingChecklistBehaviourType diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt index 3e23fb8b77..18dd0bd962 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/linux.kt @@ -1,7 +1,7 @@ package dev.slimevr.desktop.vrchat -import dev.slimevr.AppLogger import dev.slimevr.desktop.linux.findAppLibraryLocation +import dev.slimevr.logging.AppLogger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/windows.kt index dd17b0869d..d622558341 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/vrchat/windows.kt @@ -10,7 +10,7 @@ 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 dev.slimevr.logging.AppLogger import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow diff --git a/server/desktop/src/main/java/dev/slimevr/platform.kt b/server/desktop/src/main/java/dev/slimevr/platform.kt index 94e25aee57..c7eb04c75c 100644 --- a/server/desktop/src/main/java/dev/slimevr/platform.kt +++ b/server/desktop/src/main/java/dev/slimevr/platform.kt @@ -126,6 +126,10 @@ fun resolveConfigDirectory(): Path? { } fun resolveLogDirectory(): Path? { + if (Path("logs/").exists()) { + return Path("logs/") + } + val home = System.getenv("HOME") val appData = System.getenv("AppData") From b0232ae783945057a578136123c6696fe2fc51d2 Mon Sep 17 00:00:00 2001 From: lucas lelievre Date: Sat, 25 Jul 2026 08:25:49 +0200 Subject: [PATCH 296/350] Optimise HID loop --- .../main/java/dev/slimevr/android/hid/hid.kt | 12 ++++----- .../main/java/dev/slimevr/desktop/hid/hid.kt | 27 +++++++++++-------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt index 4f560b0965..3fe64cc133 100644 --- a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt +++ b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt @@ -139,16 +139,14 @@ fun createAndroidHIDManager(context: Context, appContext: AppContextProvider, sc scope = deviceScope, ) - deviceScope.launch { + deviceScope.launch(Dispatchers.IO) { try { val buffer = ByteArray(64) while (isActive) { - val read = withContext(Dispatchers.IO) { - try { - connection.bulkTransfer(endpoint, buffer, buffer.size, 0) - } catch (_: Exception) { - -1 - } + val read = try { + connection.bulkTransfer(endpoint, buffer, buffer.size, 0) + } catch (_: Exception) { + -1 } when { read < 0 -> return@launch diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index 8470bd9d0a..a53e9a29f1 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -24,6 +24,12 @@ import solarxr_protocol.datatypes.TrackerStatus private const val HID_POLL_INTERVAL_MS = 3000L +// A HID report is 64 bytes, which parseHIDPackets splits into four 16-byte packets +private const val HID_READ_BUFFER_SIZE = 64 + +// Bounds how long cancellation waits on the uninterruptible native read when a device is idle +private const val HID_READ_TIMEOUT_MS = 100 + private val hidSpec = HidServicesSpecification().apply { isAutoStart = false } // Initialize the native HID library. Must be called before enumerateDevices. @@ -93,23 +99,22 @@ fun createDesktopHIDManager(appContext: AppContextProvider, scope: CoroutineScop scope = deviceScope, ) - deviceScope.launch { + deviceScope.launch(Dispatchers.IO) { try { while (isActive) { - val data = withContext(Dispatchers.IO) { - try { - hidDevice.readAll(0) - } catch (_: Exception) { - null - } + val buffer = ByteArray(HID_READ_BUFFER_SIZE) + val read = try { + hidDevice.read(buffer, HID_READ_TIMEOUT_MS) + } catch (_: Exception) { + -1 } when { - data == null -> return@launch - // read error, device gone - data.isNotEmpty() -> parseHIDPackets(data).forEach { receiver.packetEvents.emit(it) } + read < 0 -> return@launch + + read > 0 -> parseHIDPackets(buffer).forEach { receiver.packetEvents.emit(it) } - else -> delay(1) // no data yet, yield without busy-spinning + // 0 is a timeout with no data: the read already blocked, so just go again } } } finally { From e207abbc31e79e4ef615d954bd3c77366d8c66fe Mon Sep 17 00:00:00 2001 From: lucas lelievre Date: Sat, 25 Jul 2026 17:17:08 +0200 Subject: [PATCH 297/350] Speeeeeeeeed --- .../main/java/dev/slimevr/android/hid/hid.kt | 2 +- .../context/debug/logging-middleware.kt | 2 +- .../main/java/dev/slimevr/hid/behaviours.kt | 12 +- .../src/main/java/dev/slimevr/hid/packets.kt | 6 +- .../src/main/java/dev/slimevr/osc/codec.kt | 177 ++++++++---------- .../main/java/dev/slimevr/osc/transport.kt | 21 ++- .../test/java/dev/slimevr/osc/CodecTest.kt | 149 +++++++++++---- .../main/java/dev/slimevr/desktop/hid/hid.kt | 5 +- .../java/dev/slimevr/desktop/ipc/linux.kt | 6 +- .../java/dev/slimevr/desktop/ipc/pipe-slot.kt | 38 ++-- .../java/dev/slimevr/desktop/ipc/protocol.kt | 8 +- .../java/dev/slimevr/desktop/ipc/solarxr.kt | 7 +- .../java/dev/slimevr/desktop/ipc/windows.kt | 2 +- .../desktop/trackingchecklist/behaviours.kt | 72 ++++--- 14 files changed, 306 insertions(+), 201 deletions(-) diff --git a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt index 3fe64cc133..dd88a659d0 100644 --- a/server/android/src/main/java/dev/slimevr/android/hid/hid.kt +++ b/server/android/src/main/java/dev/slimevr/android/hid/hid.kt @@ -150,7 +150,7 @@ fun createAndroidHIDManager(context: Context, appContext: AppContextProvider, sc } when { read < 0 -> return@launch - read > 0 -> parseHIDPackets(buffer.copyOf(read)).forEach { packet -> receiver.packetEvents.emit(packet) } + read > 0 -> parseHIDPackets(buffer, read).forEach { packet -> receiver.packetEvents.emit(packet) } else -> delay(1) } } diff --git a/server/core/src/main/java/dev/slimevr/context/debug/logging-middleware.kt b/server/core/src/main/java/dev/slimevr/context/debug/logging-middleware.kt index 109952a90a..35a6195c42 100644 --- a/server/core/src/main/java/dev/slimevr/context/debug/logging-middleware.kt +++ b/server/core/src/main/java/dev/slimevr/context/debug/logging-middleware.kt @@ -30,7 +30,7 @@ class LoggingMiddleware( contextName = context.scope.coroutineContext[CoroutineName]?.name ?: "Unknown" } - private fun isAllowed(action: A): Boolean { + override fun isAllowed(action: A): Boolean { val klass = action::class if (klass in block) return false if (allow != null && klass !in allow) return false diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 55b7a3dc51..b6a2459c79 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.launch import solarxr_protocol.datatypes.TrackerStatus +import kotlin.time.TimeSource class HIDRegistrationBehaviour : HIDReceiverBehaviour { override fun reduce(state: HIDReceiverState, action: HIDReceiverActions) = when (action) { @@ -176,8 +177,10 @@ private const val HID_TIMEOUT_MS = 2_000L class HIDSleepBehaviour : HIDReceiverBehaviour { override fun observe(receiver: HIDReceiver) { + val startedAt = TimeSource.Monotonic.markNow() val sleepJobs = mutableMapOf() val idleJobs = mutableMapOf() + val lastSeen = mutableMapOf() fun scheduleSleep(hidId: Int, timeoutMs: Int) { if (timeoutMs == 0) return @@ -193,9 +196,14 @@ class HIDSleepBehaviour : HIDReceiverBehaviour { } fun armIdleTimeout(hidId: Int) { - idleJobs[hidId]?.cancel() + lastSeen[hidId] = startedAt.elapsedNow().inWholeMilliseconds + if (idleJobs[hidId]?.isActive == true) return idleJobs[hidId] = receiver.context.scope.launch { - delay(HID_TIMEOUT_MS) + var remaining = HID_TIMEOUT_MS + while (remaining > 0) { + delay(remaining) + remaining = (lastSeen[hidId] ?: 0L) + HID_TIMEOUT_MS - startedAt.elapsedNow().inWholeMilliseconds + } receiver.getTracker(hidId)?.context?.dispatch(TrackerActions.SetStatus(TrackerStatus.TIMED_OUT)) } } diff --git a/server/core/src/main/java/dev/slimevr/hid/packets.kt b/server/core/src/main/java/dev/slimevr/hid/packets.kt index f700a1d800..bf9a83fae9 100644 --- a/server/core/src/main/java/dev/slimevr/hid/packets.kt +++ b/server/core/src/main/java/dev/slimevr/hid/packets.kt @@ -247,7 +247,7 @@ private fun parseSingleHIDPacket(data: ByteArray, i: Int): HIDPacket? { } } -fun parseHIDPackets(data: ByteArray): List { - if (data.size % HID_PACKET_SIZE != 0) return emptyList() - return (0 until data.size / HID_PACKET_SIZE).mapNotNull { parseSingleHIDPacket(data, it * HID_PACKET_SIZE) } +fun parseHIDPackets(data: ByteArray, length: Int = data.size): List { + if (length % HID_PACKET_SIZE != 0) return emptyList() + return (0 until length / HID_PACKET_SIZE).mapNotNull { parseSingleHIDPacket(data, it * HID_PACKET_SIZE) } } diff --git a/server/core/src/main/java/dev/slimevr/osc/codec.kt b/server/core/src/main/java/dev/slimevr/osc/codec.kt index ccd9f542b1..d1c4d195d1 100644 --- a/server/core/src/main/java/dev/slimevr/osc/codec.kt +++ b/server/core/src/main/java/dev/slimevr/osc/codec.kt @@ -1,125 +1,109 @@ package dev.slimevr.osc +import kotlinx.io.Buffer +import kotlinx.io.Sink +import kotlinx.io.Source +import kotlinx.io.indexOf +import kotlinx.io.readByteArray +import kotlinx.io.readDouble +import kotlinx.io.readFloat +import kotlinx.io.readString +import kotlinx.io.write +import kotlinx.io.writeDouble +import kotlinx.io.writeFloat + private val BUNDLE_PREFIX = "#bundle".encodeToByteArray() + byteArrayOf(0) private fun pad4(n: Int) = (n + 3) and 3.inv() -private class Writer(capacity: Int = 65536) { - private val buf = ByteArray(capacity) - var pos = 0 +/** OSC aligns strings and blobs to a four-byte boundary with trailing nulls. */ +private fun Sink.writePadding(unpadded: Int) = repeat(pad4(unpadded) - unpadded) { writeByte(0) } - fun byte(v: Byte) { - buf[pos++] = v - } - fun bytes(v: ByteArray) { - v.copyInto(buf, pos) - pos += v.size - } - fun int(v: Int) { - byte((v ushr 24).toByte()) - byte((v ushr 16).toByte()) - byte((v ushr 8).toByte()) - byte(v.toByte()) - } - fun long(v: Long) { - int((v ushr 32).toInt()) - int(v.toInt()) - } - fun float(v: Float) = int(v.toBits()) - fun double(v: Double) = long(v.toBits()) - fun string(s: String) { - val b = s.encodeToByteArray() - bytes(b) - byte(0) - repeat(pad4(b.size + 1) - b.size - 1) { byte(0) } - } - fun result() = buf.copyOfRange(0, pos) +private fun Sink.writeOscString(value: String) { + val bytes = value.encodeToByteArray() + write(bytes) + writeByte(0) + writePadding(bytes.size + 1) } -private class Reader(private val bytes: ByteArray, var pos: Int = 0) { - private fun byte() = bytes[pos++].toInt() - fun int() = (byte() and 0xFF shl 24) or (byte() and 0xFF shl 16) or (byte() and 0xFF shl 8) or (byte() and 0xFF) - fun long() = (int().toLong() and 0xFFFFFFFFL shl 32) or (int().toLong() and 0xFFFFFFFFL) - fun float() = Float.fromBits(int()) - fun double() = Double.fromBits(long()) - fun string(): String { - val start = pos - while (pos < bytes.size && bytes[pos] != 0.toByte()) pos++ - val s = bytes.decodeToString(start, pos) - pos++ // skip null - pos = pad4(pos) - return s - } - fun bytes(n: Int) = bytes.copyOfRange(pos, pos + n).also { pos += n } - fun hasPrefix(prefix: ByteArray) = pos + prefix.size <= bytes.size && - bytes.copyOfRange(pos, pos + prefix.size).contentEquals(prefix) +private fun Source.readOscString(): String { + val length = indexOf(0) + require(length >= 0) { "Unterminated OSC string" } + val value = readString(length) + // The null terminator counts towards the alignment it pads out to + skip(pad4(length.toInt() + 1) - length) + return value +} + +private fun Source.startsWithBundlePrefix(): Boolean { + val peeked = peek() + return peeked.request(BUNDLE_PREFIX.size.toLong()) && + peeked.readByteArray(BUNDLE_PREFIX.size).contentEquals(BUNDLE_PREFIX) } -fun encodeMessage(msg: OscMessage): ByteArray { - val w = Writer() - w.string(msg.address) - w.string("," + msg.args.joinToString("") { it.typeTag.toString() }) +fun writeMessage(dst: Sink, msg: OscMessage) { + dst.writeOscString(msg.address) + dst.writeOscString("," + msg.args.joinToString("") { it.typeTag.toString() }) for (arg in msg.args) { when (arg) { - is OscArg.Int -> w.int(arg.value) + is OscArg.Int -> dst.writeInt(arg.value) - is OscArg.Long -> w.long(arg.value) + is OscArg.Long -> dst.writeLong(arg.value) - is OscArg.Float -> w.float(arg.value) + is OscArg.Float -> dst.writeFloat(arg.value) - is OscArg.Double -> w.double(arg.value) + is OscArg.Double -> dst.writeDouble(arg.value) - is OscArg.String -> w.string(arg.value) + is OscArg.String -> dst.writeOscString(arg.value) is OscArg.Blob -> { - w.int(arg.value.size) - w.bytes(arg.value) - repeat(pad4(arg.value.size) - arg.value.size) { w.byte(0) } + dst.writeInt(arg.value.size) + dst.write(arg.value) + dst.writePadding(arg.value.size) } else -> {} } } - return w.result() } -fun encodeBundle(bundle: OscBundle): ByteArray { - val w = Writer() - w.bytes(BUNDLE_PREFIX) - w.long(bundle.timetag) +fun writeBundle(dst: Sink, bundle: OscBundle) { + dst.write(BUNDLE_PREFIX) + dst.writeLong(bundle.timetag) + // Each element is length-prefixed, so it has to be built before its size is known. Handing it over + // between two Buffers moves segments rather than copying the bytes, and drains it for the next one. + val element = Buffer() for (content in bundle.contents) { - val encoded = when (content) { - is OscContent.Message -> encodeMessage(content.msg) - is OscContent.Bundle -> encodeBundle(content.bundle) + when (content) { + is OscContent.Message -> writeMessage(element, content.msg) + is OscContent.Bundle -> writeBundle(element, content.bundle) } - w.int(encoded.size) - w.bytes(encoded) + dst.writeInt(element.size.toInt()) + dst.transferFrom(element) } - return w.result() } -fun decodeMessage(bytes: ByteArray, offset: Int = 0): Pair { - val r = Reader(bytes, offset) - val address = r.string() - val typeTag = r.string() +fun readMessage(src: Source): OscMessage { + val address = src.readOscString() + val typeTag = src.readOscString() val args = mutableListOf() if (typeTag.startsWith(",")) { for (c in typeTag.drop(1)) { when (c) { - 'i' -> args += OscArg.Int(r.int()) + 'i' -> args += OscArg.Int(src.readInt()) - 'h' -> args += OscArg.Long(r.long()) + 'h' -> args += OscArg.Long(src.readLong()) - 'f' -> args += OscArg.Float(r.float()) + 'f' -> args += OscArg.Float(src.readFloat()) - 'd' -> args += OscArg.Double(r.double()) + 'd' -> args += OscArg.Double(src.readDouble()) - 's' -> args += OscArg.String(r.string()) + 's' -> args += OscArg.String(src.readOscString()) 'b' -> { - val size = r.int() - args += OscArg.Blob(r.bytes(size)) - r.pos += pad4(size) - size + val size = src.readInt() + args += OscArg.Blob(src.readByteArray(size)) + src.skip((pad4(size) - size).toLong()) } 'I' -> args += OscArg.Impulse @@ -132,29 +116,28 @@ fun decodeMessage(bytes: ByteArray, offset: Int = 0): Pair { } } } - return OscMessage(address, args) to r.pos + return OscMessage(address, args) } -fun decodeBundle(bytes: ByteArray, offset: Int = 0): Pair { - val r = Reader(bytes, offset) - if (!r.hasPrefix(BUNDLE_PREFIX)) throw IllegalArgumentException("Invalid bundle prefix") - r.pos += 8 - val timetag = r.long() +fun readBundle(src: Source): OscBundle { + require(src.startsWithBundlePrefix()) { "Invalid bundle prefix" } + src.skip(BUNDLE_PREFIX.size.toLong()) + val timetag = src.readLong() val contents = mutableListOf() - while (r.pos + 4 <= bytes.size) { - val elementSize = r.int() - if (elementSize <= 0 || r.pos + elementSize > bytes.size) break - val elemStart = r.pos - try { - contents += if (Reader(bytes, elemStart).hasPrefix(BUNDLE_PREFIX)) { - OscContent.Bundle(decodeBundle(bytes, elemStart).first) + while (src.request(4)) { + val size = src.readInt() + if (size <= 0 || !src.request(size.toLong())) break + val element = Buffer() + src.readTo(element, size.toLong()) + contents += try { + if (element.startsWithBundlePrefix()) { + OscContent.Bundle(readBundle(element)) } else { - OscContent.Message(decodeMessage(bytes, elemStart).first) + OscContent.Message(readMessage(element)) } } catch (_: Exception) { break } - r.pos = elemStart + elementSize } - return OscBundle(timetag, contents) to r.pos + return OscBundle(timetag, contents) } diff --git a/server/core/src/main/java/dev/slimevr/osc/transport.kt b/server/core/src/main/java/dev/slimevr/osc/transport.kt index b785550de6..36a52da5ee 100644 --- a/server/core/src/main/java/dev/slimevr/osc/transport.kt +++ b/server/core/src/main/java/dev/slimevr/osc/transport.kt @@ -12,8 +12,6 @@ import kotlinx.io.readByteArray private val selectorManager = SelectorManager(Dispatchers.IO) -private fun ByteArray.toBuffer() = Buffer().apply { write(this@toBuffer) } - class OscSender(private val address: String, private val port: Int) { private var socket: ConnectedDatagramSocket? = null @@ -22,8 +20,17 @@ class OscSender(private val address: String, private val port: Int) { return s } - suspend fun send(message: OscMessage) = socket().send(Datagram(encodeMessage(message).toBuffer(), InetSocketAddress(address, port))) - suspend fun send(bundle: OscBundle) = socket().send(Datagram(encodeBundle(bundle).toBuffer(), InetSocketAddress(address, port))) + suspend fun send(message: OscMessage) { + val buf = Buffer() + writeMessage(buf, message) + socket().send(Datagram(buf, InetSocketAddress(address, port))) + } + + suspend fun send(bundle: OscBundle) { + val buf = Buffer() + writeBundle(buf, bundle) + socket().send(Datagram(buf, InetSocketAddress(address, port))) + } fun close() { socket?.close() @@ -45,11 +52,11 @@ class OscReceiver(private val port: Int) { running = true while (running) { try { - val bytes = s.receive().packet.readByteArray() + val packet = s.receive().packet val bundle = try { - decodeBundle(bytes).first + readBundle(packet) } catch (_: IllegalArgumentException) { - OscBundle(1, listOf(OscContent.Message(decodeMessage(bytes).first))) + OscBundle(1, listOf(OscContent.Message(readMessage(packet)))) } onBundle(bundle) } catch (_: Exception) { diff --git a/server/core/src/test/java/dev/slimevr/osc/CodecTest.kt b/server/core/src/test/java/dev/slimevr/osc/CodecTest.kt index 99135d714a..b7c94df020 100644 --- a/server/core/src/test/java/dev/slimevr/osc/CodecTest.kt +++ b/server/core/src/test/java/dev/slimevr/osc/CodecTest.kt @@ -1,15 +1,22 @@ package dev.slimevr.osc +import kotlinx.io.Buffer import org.junit.jupiter.api.Test +import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue +private fun encode(msg: OscMessage) = Buffer().also { writeMessage(it, msg) } + +private fun encode(bundle: OscBundle) = Buffer().also { writeBundle(it, bundle) } + class CodecTest { @Test fun testEncodeDecodeIntMessage() { val msg = OscMessage("/test", listOf(OscArg.Int(42))) - val encoded = encodeMessage(msg) - val (decoded, _) = decodeMessage(encoded) + val encoded = encode(msg) + val decoded = readMessage(encoded) assertEquals("/test", decoded.address) assertEquals(1, decoded.args.size) @@ -19,8 +26,8 @@ class CodecTest { @Test fun testEncodeDecodeFloatMessage() { val msg = OscMessage("/test/float", listOf(OscArg.Float(3.14f))) - val encoded = encodeMessage(msg) - val (decoded, _) = decodeMessage(encoded) + val encoded = encode(msg) + val decoded = readMessage(encoded) assertEquals("/test/float", decoded.address) assertEquals(1, decoded.args.size) @@ -31,8 +38,8 @@ class CodecTest { @Test fun testEncodeDecodeStringMessage() { val msg = OscMessage("/address", listOf(OscArg.String("hello"))) - val encoded = encodeMessage(msg) - val (decoded, _) = decodeMessage(encoded) + val encoded = encode(msg) + val decoded = readMessage(encoded) assertEquals("/address", decoded.address) assertEquals(1, decoded.args.size) @@ -43,8 +50,8 @@ class CodecTest { fun testEncodeDecodeBlobMessage() { val blobData = byteArrayOf(1, 2, 3, 4, 5) val msg = OscMessage("/blob", listOf(OscArg.Blob(blobData))) - val encoded = encodeMessage(msg) - val (decoded, _) = decodeMessage(encoded) + val encoded = encode(msg) + val decoded = readMessage(encoded) assertEquals("/blob", decoded.address) assertEquals(1, decoded.args.size) @@ -55,8 +62,8 @@ class CodecTest { @Test fun testEncodeDecodeLongMessage() { val msg = OscMessage("/long", listOf(OscArg.Long(9876543210L))) - val encoded = encodeMessage(msg) - val (decoded, _) = decodeMessage(encoded) + val encoded = encode(msg) + val decoded = readMessage(encoded) assertEquals("/long", decoded.address) assertEquals(1, decoded.args.size) @@ -66,8 +73,8 @@ class CodecTest { @Test fun testEncodeDecodeDoubleMessage() { val msg = OscMessage("/double", listOf(OscArg.Double(2.718281828))) - val encoded = encodeMessage(msg) - val (decoded, _) = decodeMessage(encoded) + val encoded = encode(msg) + val decoded = readMessage(encoded) assertEquals("/double", decoded.address) assertEquals(1, decoded.args.size) @@ -90,8 +97,8 @@ class CodecTest { OscArg.Float(0.707f), ), ) - val encoded = encodeMessage(msg) - val (decoded, _) = decodeMessage(encoded) + val encoded = encode(msg) + val decoded = readMessage(encoded) assertEquals("/VMC/Ext/Bone/Pos", decoded.address) assertEquals(8, decoded.args.size) @@ -110,8 +117,8 @@ class CodecTest { OscArg.Long(4L), ), ) - val encoded = encodeMessage(msg) - val (decoded, _) = decodeMessage(encoded) + val encoded = encode(msg) + val decoded = readMessage(encoded) assertEquals(4, decoded.args.size) assertEquals(1, (decoded.args[0] as OscArg.Int).value) @@ -124,8 +131,8 @@ class CodecTest { fun testSimpleBundle() { // Test with just one message to isolate the issue val msg = OscMessage("/test", listOf(OscArg.Int(42))) - val encoded = encodeMessage(msg) - val (decoded, _) = decodeMessage(encoded) + val encoded = encode(msg) + val decoded = readMessage(encoded) assertEquals(42, (decoded.args[0] as OscArg.Int).value) } @@ -133,9 +140,9 @@ class CodecTest { fun testBundleWithOneMessage() { val msg = OscMessage("/single", listOf(OscArg.Int(99))) val bundle = OscBundle(5, listOf(OscContent.Message(msg))) - val encoded = encodeBundle(bundle) + val encoded = encode(bundle) - val (decoded, _) = decodeBundle(encoded) + val decoded = readBundle(encoded) assertEquals(5, decoded.timetag) assertEquals(1, decoded.contents.size) @@ -157,8 +164,8 @@ class CodecTest { OscContent.Message(OscMessage("/msg2", listOf(OscArg.String("hello")))), ), ) - val encoded = encodeBundle(bundle) - val (decoded, _) = decodeBundle(encoded) + val encoded = encode(bundle) + val decoded = readBundle(encoded) assertEquals(1, decoded.timetag) assertEquals(2, decoded.contents.size) @@ -186,8 +193,8 @@ class CodecTest { ), ) - val encoded = encodeBundle(outerBundle) - val (decoded, _) = decodeBundle(encoded) + val encoded = encode(outerBundle) + val decoded = readBundle(encoded) assertEquals(1, decoded.timetag) assertEquals(2, decoded.contents.size) @@ -205,8 +212,8 @@ class CodecTest { @Test fun testEmptyMessage() { val msg = OscMessage("/empty") - val encoded = encodeMessage(msg) - val (decoded, _) = decodeMessage(encoded) + val encoded = encode(msg) + val decoded = readMessage(encoded) assertEquals("/empty", decoded.address) assertEquals(0, decoded.args.size) @@ -218,8 +225,8 @@ class CodecTest { "/special", listOf(OscArg.True, OscArg.False, OscArg.Null, OscArg.Impulse), ) - val encoded = encodeMessage(msg) - val (decoded, _) = decodeMessage(encoded) + val encoded = encode(msg) + val decoded = readMessage(encoded) assertEquals(4, decoded.args.size) assertEquals(OscArg.True, decoded.args[0]) @@ -234,12 +241,12 @@ class CodecTest { val short = OscMessage("/a", listOf(OscArg.Int(1))) val long = OscMessage("/this/is/a/very/long/address", listOf(OscArg.Int(1))) - val encodedShort = encodeMessage(short) - val encodedLong = encodeMessage(long) + val encodedShort = encode(short) + val encodedLong = encode(long) // Both should encode/decode correctly regardless of alignment - val (decodedShort, _) = decodeMessage(encodedShort) - val (decodedLong, _) = decodeMessage(encodedLong) + val decodedShort = readMessage(encodedShort) + val decodedLong = readMessage(encodedLong) assertEquals("/a", decodedShort.address) assertEquals("/this/is/a/very/long/address", decodedLong.address) @@ -250,16 +257,76 @@ class CodecTest { val msg1 = OscMessage("/first", listOf(OscArg.Int(1))) val msg2 = OscMessage("/second", listOf(OscArg.Int(2))) - val encoded1 = encodeMessage(msg1) - val encoded2 = encodeMessage(msg2) - val combined = encoded1 + encoded2 + // Both written back to back into one source: reading the first has to leave the second aligned + val combined = Buffer() + writeMessage(combined, msg1) + writeMessage(combined, msg2) + + assertEquals("/first", readMessage(combined).address) + assertEquals("/second", readMessage(combined).address) + assertTrue(combined.exhausted(), "Reading both messages should consume the whole source") + } + + @Test + fun testStringAndBlobPaddingBoundaries() { + // Every length either side of a four-byte boundary, where the alignment maths is easiest to get wrong + for (n in 0..9) { + val text = "z".repeat(n) + val decoded = readMessage(encode(OscMessage("/s", listOf(OscArg.String(text))))) + assertEquals(text, (decoded.args[0] as OscArg.String).value, "string of length $n") + + val blob = ByteArray(n) { it.toByte() } + val decodedBlob = readMessage(encode(OscMessage("/b", listOf(OscArg.Blob(blob))))) + assertContentEquals(blob, (decodedBlob.args[0] as OscArg.Blob).value, "blob of length $n") + } + } + + @Test + fun testAddressLengthsAcrossBoundaries() { + for (n in 1..12) { + val address = "/" + "a".repeat(n) + val decoded = readMessage(encode(OscMessage(address, listOf(OscArg.Int(7))))) + assertEquals(address, decoded.address, "address of length ${address.length}") + assertEquals(7, (decoded.args[0] as OscArg.Int).value) + } + } + + @Test + fun testLargeBundleRoundTrips() { + // A VMC-shaped frame: many small messages, far past any single-segment buffer + val contents = (0 until 55).map { i -> + OscContent.Message( + OscMessage( + "/VMC/Ext/Bone/Pos", + listOf(OscArg.String("BoneNameNumber$i")) + (0 until 7).map { OscArg.Float(i * 0.125f + it) }, + ), + ) + } + val decoded = readBundle(encode(OscBundle(1, contents))) + + assertEquals(contents.size, decoded.contents.size) + assertEquals(contents, decoded.contents) + } - // Parse first message from combined - val (decoded1, offset) = decodeMessage(combined, 0) - assertEquals("/first", decoded1.address) + @Test + fun testOversizedBlobRoundTrips() { + val blob = ByteArray(9000) { (it * 31).toByte() } + val msg = OscMessage("/big", listOf(OscArg.Blob(blob), OscArg.String("tail"), OscArg.Int(42))) + val decoded = readMessage(encode(msg)) + + assertContentEquals(blob, (decoded.args[0] as OscArg.Blob).value) + assertEquals("tail", (decoded.args[1] as OscArg.String).value) + assertEquals(42, (decoded.args[2] as OscArg.Int).value) + } - // Parse second message from offset - val (decoded2, _) = decodeMessage(combined, offset) - assertEquals("/second", decoded2.address) + @Test + fun testNonBundleIsRejectedWithoutConsuming() { + // listenBundles relies on this to fall back to reading the same source as a plain message + val source = encode(OscMessage("/plain", listOf(OscArg.Int(1)))) + val before = source.size + + assertFailsWith { readBundle(source) } + assertEquals(before, source.size, "A rejected bundle must not consume any bytes") + assertEquals("/plain", readMessage(source).address) } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt index a53e9a29f1..a2968c4a8e 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/hid/hid.kt @@ -101,8 +101,9 @@ fun createDesktopHIDManager(appContext: AppContextProvider, scope: CoroutineScop deviceScope.launch(Dispatchers.IO) { try { + // Reused across reads: parseHIDPackets is told how much of it is live + val buffer = ByteArray(HID_READ_BUFFER_SIZE) while (isActive) { - val buffer = ByteArray(HID_READ_BUFFER_SIZE) val read = try { hidDevice.read(buffer, HID_READ_TIMEOUT_MS) } catch (_: Exception) { @@ -112,7 +113,7 @@ fun createDesktopHIDManager(appContext: AppContextProvider, scope: CoroutineScop // read error, device gone read < 0 -> return@launch - read > 0 -> parseHIDPackets(buffer).forEach { receiver.packetEvents.emit(it) } + read > 0 -> parseHIDPackets(buffer, read).forEach { receiver.packetEvents.emit(it) } // 0 is a timeout with no data: the read already blocked, so just go again } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt index 6e8eb642c9..1f0f9808cb 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/linux.kt @@ -25,7 +25,7 @@ suspend fun createUnixDriverSocket(appContext: AppContextProvider) = acceptUnixC handleDriverConnection( appContext = appContext, messages = readFramedMessages(channel), - send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, + send = { bytes -> writeFramed(channel, bytes) }, ) } @@ -33,7 +33,7 @@ suspend fun createUnixFeederSocket(appContext: AppContextProvider) = acceptUnixC handleDriverConnection( appContext = appContext, messages = readFramedMessages(channel), - send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, + send = { bytes -> writeFramed(channel, bytes) }, ) } @@ -41,7 +41,7 @@ suspend fun createUnixSolarXRSocket(appContext: AppContextProvider) = acceptUnix handleSolarXRBridge( appContext = appContext, messages = readFramedMessages(channel), - send = { bytes -> withContext(Dispatchers.IO) { writeFramed(channel, bytes) } }, + send = { bytes -> writeFramed(channel, bytes) }, ) } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/pipe-slot.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/pipe-slot.kt index db05fc067f..1ed252a76d 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/pipe-slot.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/pipe-slot.kt @@ -7,28 +7,28 @@ import com.sun.jna.platform.win32.Kernel32 import com.sun.jna.platform.win32.WinBase import com.sun.jna.platform.win32.WinError import com.sun.jna.platform.win32.WinNT -import com.sun.jna.ptr.IntByReference import com.sun.jna.win32.StdCallLibrary import dev.slimevr.logging.AppLogger import java.io.IOException private val k32 = Kernel32.INSTANCE -private val k32io = Kernel32IO.INSTANCE private const val HEADER_SIZE = 4 private const val MAX_FRAME_SIZE = 256 * 1024 @Suppress("FunctionName") -private interface Kernel32IO : StdCallLibrary { - fun ReadFile(h: WinNT.HANDLE, buf: Pointer, len: Int, read: IntByReference?, ov: WinBase.OVERLAPPED): Boolean +private object Kernel32IO : StdCallLibrary { + init { + Native.register(Kernel32IO::class.java, "kernel32") + } - fun WriteFile(h: WinNT.HANDLE, buf: Pointer, len: Int, written: IntByReference?, ov: WinBase.OVERLAPPED): Boolean + external fun ReadFile(handle: Pointer, buffer: Pointer, len: Int, read: Pointer?, overlapped: Pointer): Boolean - fun GetOverlappedResult(h: WinNT.HANDLE, ov: WinBase.OVERLAPPED, moved: IntByReference, wait: Boolean): Boolean + external fun WriteFile(handle: Pointer, buffer: Pointer, len: Int, written: Pointer?, overlapped: Pointer): Boolean - companion object { - val INSTANCE: Kernel32IO = Native.load("kernel32", Kernel32IO::class.java) - } + external fun GetOverlappedResult(handle: Pointer, overlapped: Pointer, moved: Pointer, wait: Boolean): Boolean + + external fun WaitForSingleObject(handle: Pointer, millis: Int): Int } /** @@ -48,6 +48,14 @@ internal class PipeSlot(private val handle: WinNT.HANDLE) : AutoCloseable { private val buffer = Memory(MAX_FRAME_SIZE.toLong()) + // Resolved once. The wrappers have to stay referenced to keep the native memory alive anyway, so + // unwrapping them per call would only re-do work. `transferred` replaces an IntByReference that + // was allocated on every completion. + private val handlePointer = handle.pointer + private val eventPointer = event.pointer + private val overlappedPointer = overlapped.pointer + private val transferred = Memory(4) + fun awaitClient(): Boolean { // On an overlapped handle ConnectNamedPipe returns straight away, so we do the waiting val connected = k32.ConnectNamedPipe(handle, overlapped) @@ -76,12 +84,13 @@ internal class PipeSlot(private val handle: WinNT.HANDLE) : AutoCloseable { buffer.setInt(0, total) buffer.write(HEADER_SIZE.toLong(), payload, 0, payload.size) - val written = transfer { k32io.WriteFile(handle, buffer, total, null, overlapped) } + val written = transfer { Kernel32IO.WriteFile(handlePointer, buffer, total, null, overlappedPointer) } if (written != total) throw IOException("Pipe write incomplete: $written of $total bytes") } override fun close() { buffer.close() + transferred.close() k32.CloseHandle(event) } @@ -90,7 +99,7 @@ internal class PipeSlot(private val handle: WinNT.HANDLE) : AutoCloseable { while (done < len) { val remaining = len - done val moved = transfer { - k32io.ReadFile(handle, buffer.share((at + done).toLong(), remaining.toLong()), remaining, null, overlapped) + Kernel32IO.ReadFile(handlePointer, buffer.share((at + done).toLong(), remaining.toLong()), remaining, null, overlappedPointer) } if (moved <= 0) return false done += moved @@ -114,9 +123,8 @@ internal class PipeSlot(private val handle: WinNT.HANDLE) : AutoCloseable { } private fun awaitCompletion(): Int { - k32.WaitForSingleObject(event, WinBase.INFINITE) - val moved = IntByReference() - if (!k32io.GetOverlappedResult(handle, overlapped, moved, true)) return -1 - return moved.value + Kernel32IO.WaitForSingleObject(eventPointer, WinBase.INFINITE) + if (!Kernel32IO.GetOverlappedResult(handlePointer, overlappedPointer, transferred, true)) return -1 + return transferred.getInt(0) } } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 94565ff874..6373727b8d 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -1,5 +1,6 @@ package dev.slimevr.desktop.ipc +import com.squareup.wire.ProtoWriter import dev.slimevr.AppContextProvider import dev.slimevr.CURRENT_PLATFORM import dev.slimevr.Platform @@ -24,6 +25,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import okio.Buffer import solarxr_protocol.datatypes.BodyPart import java.io.File import java.nio.file.Path @@ -130,8 +132,12 @@ suspend fun handleDriverConnection( ) = coroutineScope { val sendMutex = Mutex() + val encodeBuffer = Buffer() + val encodeWriter = ProtoWriter(encodeBuffer) + suspend fun sendMsg(msg: ProtobufMessage) = sendMutex.withLock { - send(ProtobufMessage.ADAPTER.encode(msg)) + ProtobufMessage.ADAPTER.encode(encodeWriter, msg) + send(encodeBuffer.readByteArray()) } val bridge = DriverBridge.create( diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt index 5b8569aa03..a519fcbef9 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/solarxr.kt @@ -75,8 +75,13 @@ suspend fun handleSolarXRBridge( appContext.server.context.dispatch(VRServerActions.SolarXRConnected(bridge)) + // One builder for the life of the connection. clear() keeps the buffer it has already grown into, + // so a datafeed frame stops re-growing from 256 bytes every time. Safe to share: this collector + // handles one bundle at a time, and moveToByteArray copies the bytes out before send suspends. + val fbb = FlatBufferBuilder(256) + bridge.outbound.on { bundle -> - val fbb = FlatBufferBuilder(256) + fbb.clear() fbb.finish(bundle.encode(JvmFlatBufferWriter(fbb))) send(fbb.dataBuffer().moveToByteArray()) }.launchIn(this) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt index 0ebdf00e38..d07db99420 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/windows.kt @@ -84,7 +84,7 @@ private suspend fun acceptWindowsClients( launch { try { - handle(readFrames(pipe)) { bytes -> withContext(Dispatchers.IO) { writer.writeFrame(bytes) } } + handle(readFrames(pipe)) { bytes -> writer.writeFrame(bytes) } } catch (e: CancellationException) { throw e } catch (e: Exception) { diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt b/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt index 145f76748d..1e1fb78f66 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt @@ -32,6 +32,10 @@ import solarxr_protocol.rpc.TrackingChecklistStep import solarxr_protocol.rpc.TrackingChecklistStepId import solarxr_protocol.rpc.TrackingChecklistStepVisibility import java.io.IOException +import java.net.ConnectException +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource data class Process(val pid: ULong, val name: String) @@ -80,6 +84,12 @@ private suspend fun getRunningProcesses(): List = when (CURRENT_PLATFOR else -> emptyList() } +// Enumerating every process is orders of magnitude costlier than the loopback request it guards, so it +// only runs to disambiguate a refused connection, and only this often. +private val PROCESS_SCAN_INTERVAL = 10.seconds + +private fun Throwable.isConnectionRefused(): Boolean = generateSequence(this, Throwable::cause).any { it is ConnectException } + data class SteamVRDriverState(val connected: Boolean = false, val installed: Boolean = true, val blocked: Boolean = false, val enabled: Boolean = true) class SteamVRCheckBehaviour(private val server: VRServer) : TrackingChecklistBehaviourType { @@ -92,41 +102,51 @@ class SteamVRCheckBehaviour(private val server: VRServer) : TrackingChecklistBeh override fun observe(receiver: TrackingChecklist) { receiver.context.scope.launch { - val client = HttpClient(CIO) - while (isActive) { - val connected = server.context.state.value.drivers.isNotEmpty() - val running = connected || - getRunningProcesses().any { proc -> - proc.name == steamVRProcName - } + HttpClient(CIO).use { client -> + var scannedRunning = false + var lastScan: TimeMark? = null + + while (isActive) { + val connected = server.context.state.value.drivers.isNotEmpty() - val drivers = if (running) { - try { + val drivers = try { getSteamVRDriversList(client) } catch (e: Exception) { - AppLogger.checklist.warn(e, "Failed to get SteamVR drivers list") + if (!e.isConnectionRefused()) { + AppLogger.checklist.warn(e, "Failed to get SteamVR drivers list") + } null } - } else { - null - } - val driver = drivers?.firstOrNull { - it.manifest.name == "slimevr" - } - val standableDriver = drivers?.firstOrNull { - it.manifest.name == "standable" - } + val scan = lastScan + if (drivers != null) { + scannedRunning = false + lastScan = null + } else if (scan == null || scan.elapsedNow() >= PROCESS_SCAN_INTERVAL) { + scannedRunning = getRunningProcesses().any { proc -> + proc.name == steamVRProcName + } + lastScan = TimeSource.Monotonic.markNow() + } - driverState.update { - SteamVRDriverState(connected = connected, installed = !running || driver != null, driver?.blockedBySafeMode ?: false, enabled = driver?.enabled ?: true) - } - standableState.update { - standableDriver != null + val running = connected || drivers != null || scannedRunning + + val driver = drivers?.firstOrNull { + it.manifest.name == "slimevr" + } + val standableDriver = drivers?.firstOrNull { + it.manifest.name == "standable" + } + + driverState.update { + SteamVRDriverState(connected = connected, installed = !running || driver != null, driver?.blockedBySafeMode ?: false, enabled = driver?.enabled ?: true) + } + standableState.update { + standableDriver != null + } + delay(3000) } - delay(3000) } - client.close() } driverState From 51342dd33824938c9e52e8b7c0519371e99ccef9 Mon Sep 17 00:00:00 2001 From: lucas lelievre Date: Sat, 25 Jul 2026 17:20:45 +0200 Subject: [PATCH 298/350] Speed part 2 --- .../dev/slimevr/context/debug/logging-middleware.kt | 2 +- .../src/main/java/dev/slimevr/tracker/behaviours.kt | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/context/debug/logging-middleware.kt b/server/core/src/main/java/dev/slimevr/context/debug/logging-middleware.kt index 35a6195c42..109952a90a 100644 --- a/server/core/src/main/java/dev/slimevr/context/debug/logging-middleware.kt +++ b/server/core/src/main/java/dev/slimevr/context/debug/logging-middleware.kt @@ -30,7 +30,7 @@ class LoggingMiddleware( contextName = context.scope.coroutineContext[CoroutineName]?.name ?: "Unknown" } - override fun isAllowed(action: A): Boolean { + private fun isAllowed(action: A): Boolean { val klass = action::class if (klass in block) return false if (allow != null && klass !in allow) return false diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 28837a8308..f534f9b531 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -164,7 +164,11 @@ class TrackerBasicBehaviour : TrackerBehaviour { override fun observe(receiver: Tracker) { // Refresh the tracker's rotation whenever calibration gets updated receiver.context.state - .distinctUntilChangedBy { it.sessionCalibration to it.restOrientation to it.mountingOrientation } + .distinctUntilChanged { old, new -> + old.sessionCalibration == new.sessionCalibration && + old.restOrientation == new.restOrientation && + old.mountingOrientation == new.mountingOrientation + } .onEach { receiver.context.dispatch(TrackerActions.SetRotation(it.rawRotation, it.rawAcceleration, it.rawMagnetometer)) }.launchIn(receiver.context.scope) @@ -246,7 +250,7 @@ class TrackerToSkeletonBehaviour : TrackerBehaviour { @OptIn(ExperimentalCoroutinesApi::class) override fun observe(receiver: Tracker) { receiver.context.state - .distinctUntilChangedBy { it.status to it.bodyPart } + .distinctUntilChanged { old, new -> old.status == new.status && old.bodyPart == new.bodyPart } .onEach { _ -> // Tell the skeleton the tracker has stopped sending data to the last bone it was sending data to. lastBodyPartSent?.let { @@ -292,7 +296,7 @@ class TrackerRestOrientationBehaviour( ) : TrackerBehaviour { override fun observe(receiver: Tracker) { val armsResetModeFlow = settings.context.state.map { it.data.resetsConfig.armsResetMode } - val bodyPartFlow = receiver.context.state.map { it.bodyPart } + val bodyPartFlow = receiver.context.state.map { it.bodyPart }.distinctUntilChanged() combine(armsResetModeFlow, bodyPartFlow) { armsResetMode, bodyPart -> getRestOrientation(bodyPart, armsResetMode) From a238211c93a06054f962a5c55543841f8ba1d6ea Mon Sep 17 00:00:00 2001 From: lucas lelievre Date: Sat, 25 Jul 2026 21:23:25 +0200 Subject: [PATCH 299/350] Speed part 3 --- .../java/dev/slimevr/driver/behaviours.kt | 80 +++--- .../main/java/dev/slimevr/driver/module.kt | 4 +- .../main/java/dev/slimevr/event-dispatcher.kt | 171 +++++++++++- .../slimevr/heightcalibration/behaviours.kt | 12 +- .../src/main/java/dev/slimevr/hid/module.kt | 10 +- .../main/java/dev/slimevr/keybind/module.kt | 2 +- .../main/java/dev/slimevr/logging/logger.kt | 1 + .../slimevr/outputtrackertoggle/behaviours.kt | 8 +- .../main/java/dev/slimevr/solarxr/module.kt | 6 +- .../dev/slimevr/tapdetection/behaviours.kt | 8 +- .../main/java/dev/slimevr/udp/behaviours.kt | 8 +- .../main/java/dev/slimevr/udp/connection.kt | 9 +- .../src/main/java/dev/slimevr/udp/packets.kt | 18 +- .../main/java/dev/slimevr/vrcosc/module.kt | 2 +- .../java/dev/slimevr/EventDispatcherTest.kt | 247 ++++++++++++++++++ .../java/dev/slimevr/solarxr/DataFeedTest.kt | 9 +- 16 files changed, 515 insertions(+), 80 deletions(-) create mode 100644 server/core/src/test/java/dev/slimevr/EventDispatcherTest.kt diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 012683230b..b9458ee079 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -6,6 +6,7 @@ import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions +import dev.slimevr.tracker.TrackerState import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn @@ -33,63 +34,72 @@ class DriverOutgoingTrackersBehaviour : DriverBridgeBehaviour { // Should be safe: StateFlow never delivers two emissions concurrently to the same collector. val subscribedTrackers = mutableSetOf() + // Status and battery are rebuilt every frame but only change every few seconds, so the driver + // was being sent the same values at skeleton rate. Keep the last one per body part and only + // send on a real change, the same way subscribedTrackers already gates TrackerAdded. + val lastStatus = mutableMapOf() + combine( receiver.appContext.skeleton.computed, receiver.appContext.outputTrackerToggle.context.state.map { it.trackers }, - ::Pair + ::Pair, ) .distinctUntilChanged() .onEach { (computedBones, enabledBodyParts) -> - val serverState = receiver.appContext.server.context.state.value - - computedBones.forEach { (part, state) -> - if (enabledBodyParts.contains(part)) { - val closestTracker = bodyPartToNearest[part].orEmpty() - .firstNotNullOfOrNull { fallbackPart -> - serverState.trackers.values - .find { it.context.state.value.bodyPart == fallbackPart && it.context.state.value.origin != DeviceOrigin.DRIVER } - ?.context?.state?.value + val serverState = receiver.appContext.server.context.state.value + + // One pass over the trackers instead of a scan per fallback part per bone. + // putIfAbsent keeps the first match, which is what the find { } did. + val trackerStateByBodyPart = mutableMapOf() + for (tracker in serverState.trackers.values) { + val trackerState = tracker.context.state.value + if (trackerState.origin == DeviceOrigin.DRIVER) continue + val bodyPart = trackerState.bodyPart ?: continue + trackerStateByBodyPart.putIfAbsent(bodyPart, trackerState) + } + + computedBones.forEach { (part, state) -> + if (enabledBodyParts.contains(part)) { + val closestTracker = bodyPartToNearest[part].orEmpty() + .firstNotNullOfOrNull { fallbackPart -> trackerStateByBodyPart[fallbackPart] } + val closestDevice = serverState.devices[closestTracker?.deviceId]?.context?.state?.value + + val newTracker = subscribedTrackers.add(part.value) + if (newTracker) { + receiver.outbound.emit( + DriverBridgeOutbound.TrackerAdded( + trackerId = part.value.toInt(), + part = part, + ), + ) } - val closestDevice = serverState.devices[closestTracker?.deviceId]?.context?.state?.value - val newTracker = subscribedTrackers.add(part.value) - if (newTracker) { receiver.outbound.emit( - DriverBridgeOutbound.TrackerAdded( + DriverBridgeOutbound.TrackerPosition( trackerId = part.value.toInt(), - part = part, + rotation = state.rotation, + position = state.tailPosition, ), ) - } - - receiver.outbound.emit( - DriverBridgeOutbound.TrackerPosition( - trackerId = part.value.toInt(), - rotation = state.rotation, - position = state.tailPosition, - ), - ) - receiver.outbound.emit( - DriverBridgeOutbound.TrackerStatus( + val status = DriverBridgeOutbound.TrackerStatus( trackerId = part.value.toInt(), battery = closestDevice?.batteryLevel ?: 1f, charging = closestDevice?.batteryVoltage != null && closestDevice.batteryVoltage >= 4.3f, status = closestTracker?.status ?: TrackerStatus.OK, - ), - ) - } else { - receiver.outbound.emit( - DriverBridgeOutbound.TrackerStatus( + ) + if (lastStatus.put(part.value, status) != status) receiver.outbound.emit(status) + } else { + val status = DriverBridgeOutbound.TrackerStatus( trackerId = part.value.toInt(), battery = null, charging = false, status = TrackerStatus.DISCONNECTED, - ), - ) + ) + if (lastStatus.put(part.value, status) != status) receiver.outbound.emit(status) + } } - } - }.launchIn(receiver.context.scope) + }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt index b4f7938729..6d4f8fb561 100644 --- a/server/core/src/main/java/dev/slimevr/driver/module.kt +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -44,8 +44,8 @@ class DriverBridge( val id: Int, val context: DriverBridgeContext, val appContext: AppContextProvider, - val inbound: EventDispatcher = EventDispatcher(), - val outbound: EventDispatcher = EventDispatcher(), + val inbound: EventDispatcher = EventDispatcher("Driver.inbound", context.scope, capacity = 128), + val outbound: EventDispatcher = EventDispatcher("Driver.outbound", context.scope, capacity = 512), private val managedContext: ManagedContext? = null, ) { fun dispose() = managedContext?.dispose() diff --git a/server/core/src/main/java/dev/slimevr/event-dispatcher.kt b/server/core/src/main/java/dev/slimevr/event-dispatcher.kt index 4e378721a9..7b9ee61cf2 100644 --- a/server/core/src/main/java/dev/slimevr/event-dispatcher.kt +++ b/server/core/src/main/java/dev/slimevr/event-dispatcher.kt @@ -1,23 +1,166 @@ package dev.slimevr +import dev.slimevr.logging.AppLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.DisposableHandle +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.onEach - -// For now, it blocks all the time, no queue. Maybe it is a bad idea IDK. Future us problem +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedSendChannelException +import kotlinx.coroutines.delay +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import kotlin.reflect.KClass +import kotlin.time.Duration.Companion.seconds + +private val SATURATION_REPORT_INTERVAL = 5.seconds + +/** + * A handler that has been described but not yet attached. [launchIn] attaches it and ties it to the + * lifetime of the given scope. + * + * FIXME: Keep this until we are sure the current event dispatcher is good. Instead of redoing all [EventDispatcher.on] calls in all the behaviours + */ +class Subscription @PublishedApi internal constructor( + private val dispatcher: EventDispatcher, + private val type: KClass

, + private val action: suspend (P) -> Unit, +) { + fun launchIn(scope: CoroutineScope): DisposableHandle = dispatcher.subscribe(type, scope, action) +} + +/** + * Single-consumer event bus. + * + * One coroutine drains the mailbox and runs every handler for an event before taking the next one, + * so handlers still observe events in emission order. Several behaviours depend on that: a + * `HIDDeviceRegister` has to be handled before the `HIDDeviceInfo` that follows it, and the driver + * needs `TrackerAdded` before `TrackerPosition`. + * + * Producers only wait for mailbox space, never for handlers, so a slow handler cannot stall the + * thread reading from a device. + * + * [onBufferOverflow] is a decision per dispatcher, not a default to accept: + * - [BufferOverflow.SUSPEND] for anything that must not be lost (registrations, RPC). + * - [BufferOverflow.DROP_OLDEST] for state-like samples where a stale value is worthless anyway. + * + * Saturation is reported on three signals: how deep the mailbox got, events dropped under + * DROP_OLDEST, and producers forced to wait under SUSPEND. Depth is the one that fires first, at a + * quarter of [capacity], so a dispatcher that is starting to fall behind says so while it is still + * keeping up. Capacity stays large enough to absorb a GC pause without dropping; the warning + * threshold is what makes it early, not a small buffer. + */ class EventDispatcher( - extraBufferCapacity: Int = 0, + private val name: String, + scope: CoroutineScope, + private val capacity: Int = 64, onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND, ) { - private val flow = MutableSharedFlow(extraBufferCapacity = extraBufferCapacity, onBufferOverflow = onBufferOverflow) - val events: SharedFlow = flow.asSharedFlow() + private class Sub(val type: KClass<*>, val action: suspend (T) -> Unit) + + private val subs = CopyOnWriteArrayList>() + + // Which handlers a concrete event class resolves to. Cleared whenever the subscriber list moves, + // which only happens at wiring time, so the per-event cost is one map lookup. + private val resolved = ConcurrentHashMap, List>>() + + private val dropped = AtomicLong() + private val stalled = AtomicLong() + private val depth = AtomicInteger() + private val peakDepth = AtomicInteger() + + // Warn on how full the mailbox gets, not on it overflowing. Overflow is the failure; depth is the + // leading indicator, and separating the two means the buffer can stay big enough to ride out a GC + // pause without that also delaying the warning. + private val warnDepth = (capacity / 4).coerceAtLeast(1) + + private val mailbox = Channel(capacity, onBufferOverflow) { + dropped.incrementAndGet() + depth.decrementAndGet() + } + + init { + scope.launch { + for (event in mailbox) { + depth.decrementAndGet() + for (sub in handlersFor(event::class)) sub.action(event) + } + } + + scope.launch { + while (true) { + delay(SATURATION_REPORT_INTERVAL) + reportSaturation() + } + } + + // Once the owner is gone nothing drains the mailbox, so it has to stop accepting. Without this + // a SUSPEND producer would fill the buffer and then block forever on a queue nobody reads. + scope.coroutineContext.job.invokeOnCompletion { mailbox.close() } + } + + suspend fun emit(event: T) { + val result = mailbox.trySend(event) + if (result.isSuccess) return recordDepth() + if (result.isClosed) return + + stalled.incrementAndGet() + try { + mailbox.send(event) + recordDepth() + } catch (_: ClosedSendChannelException) { + // The owner can go away between the trySend above and here. + } + } + + private fun recordDepth() { + val current = depth.incrementAndGet() + peakDepth.updateAndGet { seen -> if (current > seen) current else seen } + } + + inline fun on(noinline action: suspend (P) -> Unit): Subscription = Subscription(this, P::class, action) + + fun

subscribe(type: KClass

, scope: CoroutineScope, action: suspend (P) -> Unit): DisposableHandle { + @Suppress("UNCHECKED_CAST") + val sub = Sub(type) { event -> action(event as P) } + subs += sub + resolved.clear() + + // A job in the listener's scope, so a cancelled scope and an explicit dispose both unsubscribe + // through the same path. ATOMIC because a scope that is already dead would otherwise skip the + // body entirely and leave the handler registered. + val job = scope.launch(start = CoroutineStart.ATOMIC) { + try { + awaitCancellation() + } finally { + subs -= sub + resolved.clear() + } + } + return DisposableHandle { job.cancel() } + } + + private fun handlersFor(cls: KClass<*>): List> = resolved.getOrPut(cls) { subs.filter { sub -> sub.type.java.isAssignableFrom(cls.java) } } + + private suspend fun reportSaturation() = saturationReport()?.let { AppLogger.events.warn(it) } - suspend fun emit(event: T) = flow.emit(event) + internal fun saturationReport(): String? { + val peak = peakDepth.getAndSet(depth.get().coerceAtLeast(0)) + val drops = dropped.getAndSet(0) + val stalls = stalled.getAndSet(0) + if (drops == 0L && stalls == 0L && peak < warnDepth) return null - inline fun on(crossinline action: suspend (P) -> Unit): Flow

= - events.filterIsInstance

().onEach { action(it) } + val outcome = when { + drops > 0L -> "$drops events dropped" + stalls > 0L -> "$stalls producer stalls" + else -> "nothing lost yet" + } + return "$name mailbox peaked at $peak of $capacity in the last $SATURATION_REPORT_INTERVAL " + + "($outcome). A handler is not keeping up, which usually points at something else being wrong." + } } diff --git a/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt b/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt index 913f12d8ec..b2bd95ff19 100644 --- a/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/heightcalibration/behaviours.kt @@ -87,8 +87,16 @@ class BaseCalibrationBehaviour : HeightCalibrationBehaviourType { .flatMapLatest { state -> val trackers = state.trackers.values.toList() if (trackers.isEmpty()) return@flatMapLatest flowOf(false) - // React to per-tracker position/bodyPart changes, not just tracker add/remove. - combine(trackers.map { it.context.state }) { states -> canCalibrate(states.toList()) } + // React to per-tracker position/bodyPart changes, not just tracker add/remove. canCalibrate + // only looks at bodyPart and whether position is set, so dedup on that per tracker rather + // than letting every rotation packet resume the combine. + combine( + trackers.map { tracker -> + tracker.context.state.distinctUntilChanged { a, b -> + a.bodyPart == b.bodyPart && (a.position == null) == (b.position == null) + } + }, + ) { states -> canCalibrate(states.toList()) } } .distinctUntilChanged() .onEach { receiver.context.dispatch(HeightCalibrationActions.SetCanCalibrate(it)) } diff --git a/server/core/src/main/java/dev/slimevr/hid/module.kt b/server/core/src/main/java/dev/slimevr/hid/module.kt index 33a8ff28b5..14a6eaa3d7 100644 --- a/server/core/src/main/java/dev/slimevr/hid/module.kt +++ b/server/core/src/main/java/dev/slimevr/hid/module.kt @@ -7,6 +7,7 @@ import dev.slimevr.context.Context import dev.slimevr.device.Device import dev.slimevr.tracker.Tracker import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow data class HIDTrackerRecord( val hidId: Int, @@ -70,7 +71,14 @@ class HIDReceiver( name = "HIDReceiver[$serialNumber]", ) - val dispatcher = HIDPacketDispatcher() + // Every packet here is a state sample, so a backlog is worth less than the newest reading. + // Measured peak depth is ~55 with six trackers, so the depth warning sits clear of that. + val dispatcher = HIDPacketDispatcher( + name = "HID[$serialNumber]", + scope = context.scope, + capacity = 256, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) val receiver = HIDReceiver( context = context, diff --git a/server/core/src/main/java/dev/slimevr/keybind/module.kt b/server/core/src/main/java/dev/slimevr/keybind/module.kt index 387329f600..f90d3dc88a 100644 --- a/server/core/src/main/java/dev/slimevr/keybind/module.kt +++ b/server/core/src/main/java/dev/slimevr/keybind/module.kt @@ -96,7 +96,7 @@ class KeybindTriggerBehaviour( class KeybindManager(val context: KeybindContext) { val recording: Boolean get() = context.state.value.recording - val events = EventDispatcher() + val events = EventDispatcher("Keybind", context.scope, capacity = 16) fun startObserving(appContext: AppContextProvider) { context.behaviours.addAll( diff --git a/server/core/src/main/java/dev/slimevr/logging/logger.kt b/server/core/src/main/java/dev/slimevr/logging/logger.kt index 342a850577..081bb2c47b 100644 --- a/server/core/src/main/java/dev/slimevr/logging/logger.kt +++ b/server/core/src/main/java/dev/slimevr/logging/logger.kt @@ -30,6 +30,7 @@ object AppLogger { val skeleton = logger("Skeleton") val networkProfile = logger("NetworkProfile") val logging = logger("Logging") + val events = logger("Events") val coroutines = noCoLogger("Coroutines") val console = noCoLogger("Console") diff --git a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt index 0bf9790360..ac236f2a65 100644 --- a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt @@ -57,7 +57,13 @@ class OutputTrackerToggleBasicBehaviour : OutputTrackerToggleBehaviour { receiver.server.context.state .map { it.trackers.values } .flatMapLatest { trackers -> - combine(trackers.map { it.context.state }) { states -> + // Tracker state emits on every rotation packet, but only bodyPart/status matter here. + // Dedup per tracker first, or combine gets resumed once per packet per tracker. + combine( + trackers.map { tracker -> + tracker.context.state.distinctUntilChanged { a, b -> a.bodyPart == b.bodyPart && a.status == b.status } + }, + ) { states -> states.map { it.bodyPart to it.status } } .distinctUntilChanged() diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 748f2a15ef..99725704b6 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -44,7 +44,7 @@ class SolarXRBridge( val appContext: AppContextProvider, val dataFeedDispatcher: EventDispatcher, val rpcDispatcher: EventDispatcher, - val outbound: EventDispatcher = EventDispatcher(), + val outbound: EventDispatcher = EventDispatcher("SolarXR[$id].outbound", context.scope, capacity = 64), private val managedContext: ManagedContext? = null, ) { // Jobs are mutable handles with no meaningful equality; storing them in state @@ -107,8 +107,8 @@ class SolarXRBridge( id = id, context = managedContext.context, appContext = appContext, - dataFeedDispatcher = EventDispatcher(), - rpcDispatcher = EventDispatcher(), + dataFeedDispatcher = EventDispatcher("SolarXR[$id].datafeed", managedContext.context.scope, capacity = 32), + rpcDispatcher = EventDispatcher("SolarXR[$id].rpc", managedContext.context.scope, capacity = 64), managedContext = managedContext, ) bridge.startObserving() diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt index 8e8833b36f..8f68cf7f12 100644 --- a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt @@ -42,7 +42,13 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { val okTrackersFlow = receiver.server.context.state .map { it.trackers.values } .flatMapLatest { trackers -> - combine(trackers.map { it.context.state }) { states -> + // Tracker state emits on every rotation packet, but only bodyPart/status matter here. + // Dedup per tracker first, or combine gets resumed once per packet per tracker. + combine( + trackers.map { tracker -> + tracker.context.state.distinctUntilChanged { a, b -> a.bodyPart == b.bodyPart && a.status == b.status } + }, + ) { states -> states.map { it.bodyPart to it.status } } .distinctUntilChanged() diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 334fbfe264..4954db13a8 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -41,7 +41,7 @@ class PacketBehaviour : UDPConnectionBehaviour { } override fun observe(receiver: UDPConnection) { - receiver.packetEvents.events.onEach { packet -> + receiver.packetEvents.on> { packet -> val state = receiver.context.state.value val now = System.currentTimeMillis() val num = packet.packetNumber @@ -49,7 +49,7 @@ class PacketBehaviour : UDPConnectionBehaviour { AppLogger.udp.info("[${state.address}] Reconnecting") } else if (num != null && num != 0L && num <= state.lastPacketNum) { AppLogger.udp.warn("[${state.address}] Received packet with wrong packet number") - return@onEach + return@on } receiver.context.dispatch(UDPConnectionActions.LastPacket(packetNum = num, time = now)) }.launchIn(receiver.context.scope) @@ -63,8 +63,8 @@ class PacketLossBehaviour : UDPConnectionBehaviour { var lastPacketCounterReset = System.currentTimeMillis() var lastPacketNumber = 0L - receiver.packetEvents.events.onEach { packet -> - val num = packet.packetNumber ?: return@onEach + receiver.packetEvents.on> { packet -> + val num = packet.packetNumber ?: return@on val now = System.currentTimeMillis() if (now - lastPacketCounterReset >= 10_000L) { diff --git a/server/core/src/main/java/dev/slimevr/udp/connection.kt b/server/core/src/main/java/dev/slimevr/udp/connection.kt index 226e1e1bbc..b15759ffbe 100644 --- a/server/core/src/main/java/dev/slimevr/udp/connection.kt +++ b/server/core/src/main/java/dev/slimevr/udp/connection.kt @@ -11,6 +11,7 @@ import io.ktor.network.sockets.BoundDatagramSocket import io.ktor.network.sockets.Datagram import io.ktor.network.sockets.InetSocketAddress import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.launch import kotlinx.io.Buffer @@ -124,7 +125,13 @@ class UDPConnection( name = "UDPConnection[$address]", ) - val dispatcher = EventDispatcher>() + // Same reasoning as the HID dispatcher: these are state samples, the newest one wins. + val dispatcher = EventDispatcher>( + name = "UDP[$address]", + scope = context.scope, + capacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) val packetChannel = Channel>(capacity = 256) val conn = UDPConnection( diff --git a/server/core/src/main/java/dev/slimevr/udp/packets.kt b/server/core/src/main/java/dev/slimevr/udp/packets.kt index 7a35dafc86..88c72f4105 100644 --- a/server/core/src/main/java/dev/slimevr/udp/packets.kt +++ b/server/core/src/main/java/dev/slimevr/udp/packets.kt @@ -1,12 +1,10 @@ package dev.slimevr.udp import dev.slimevr.EventDispatcher +import dev.slimevr.Subscription import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import io.ktor.utils.io.core.remaining -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onEach import kotlinx.io.Buffer import kotlinx.io.Sink import kotlinx.io.Source @@ -15,7 +13,6 @@ import kotlinx.io.readFloat import kotlinx.io.readString import kotlinx.io.readUByte import kotlinx.io.readUShort -import kotlinx.io.write import kotlinx.io.writeUByte import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.BoardType @@ -540,8 +537,11 @@ data class PacketEvent( typealias UDPPacketDispatcher = EventDispatcher> -inline fun UDPPacketDispatcher.onPacket(): Flow> = events - .mapNotNull { event -> (event.takeIf { it.data is T }) as PacketEvent? } - -inline fun UDPPacketDispatcher.onPacket(crossinline action: suspend (PacketEvent) -> Unit): Flow> = - onPacket().onEach { action(it) } +// The discriminator is the payload type, not the event type, so this filters inside the handler +// rather than relying on the dispatcher's per-class routing. +inline fun UDPPacketDispatcher.onPacket( + crossinline action: suspend (PacketEvent) -> Unit, +): Subscription, PacketEvent> = on { event -> + @Suppress("UNCHECKED_CAST") + if (event.data is T) action(event as PacketEvent) +} diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt index bc14207529..def09bc6c8 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/module.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/module.kt @@ -98,7 +98,7 @@ class VRCOSCManager( val settings: Settings, val outputTrackerToggle: OutputTrackerToggleManager, ) { - val events: EventDispatcher = EventDispatcher() + val events: EventDispatcher = EventDispatcher("VRCOSC", context.scope, capacity = 32) fun startObserving(appContext: AppContextProvider) { val behaviours = listOf( diff --git a/server/core/src/test/java/dev/slimevr/EventDispatcherTest.kt b/server/core/src/test/java/dev/slimevr/EventDispatcherTest.kt new file mode 100644 index 0000000000..73e510dc1e --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/EventDispatcherTest.kt @@ -0,0 +1,247 @@ +package dev.slimevr + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +private sealed interface TestEvent { + data class A(val n: Int) : TestEvent + data class B(val n: Int) : TestEvent +} + +class EventDispatcherTest { + + @Test + fun `every handler for an event runs before the next event is taken`() = runTest { + val seen = mutableListOf() + val dispatcher = EventDispatcher("test", backgroundScope) + + dispatcher.on { seen += "first-${it.n}" }.launchIn(backgroundScope) + dispatcher.on { seen += "second-${it.n}" }.launchIn(backgroundScope) + + dispatcher.emit(TestEvent.A(1)) + dispatcher.emit(TestEvent.A(2)) + runCurrent() + + // Not first-1, first-2, second-1, second-2: the barrier several behaviours rely on. + assertContentEquals(listOf("first-1", "second-1", "first-2", "second-2"), seen) + } + + @Test + fun `a handler only receives the type it subscribed to`() = runTest { + val a = mutableListOf() + val b = mutableListOf() + val dispatcher = EventDispatcher("test", backgroundScope) + + dispatcher.on { a += it.n }.launchIn(backgroundScope) + dispatcher.on { b += it.n }.launchIn(backgroundScope) + + dispatcher.emit(TestEvent.A(1)) + dispatcher.emit(TestEvent.B(2)) + dispatcher.emit(TestEvent.A(3)) + runCurrent() + + assertContentEquals(listOf(1, 3), a) + assertContentEquals(listOf(2), b) + } + + @Test + fun `a supertype handler receives every subtype`() = runTest { + val seen = mutableListOf() + val dispatcher = EventDispatcher("test", backgroundScope) + + dispatcher.on { seen += it }.launchIn(backgroundScope) + + dispatcher.emit(TestEvent.A(1)) + dispatcher.emit(TestEvent.B(2)) + runCurrent() + + assertContentEquals(listOf(TestEvent.A(1), TestEvent.B(2)), seen) + } + + @Test + fun `a stuck handler does not block the producer`() = runTest { + val gate = CompletableDeferred() + val dispatcher = EventDispatcher("test", backgroundScope, capacity = 8) + + dispatcher.on { gate.await() }.launchIn(backgroundScope) + + dispatcher.emit(TestEvent.A(1)) + runCurrent() + + // The handler is now parked forever. Emitting must still return, which is the whole point: + // under the old rendezvous these would have blocked behind the handler. + var emitted = 0 + repeat(5) { + dispatcher.emit(TestEvent.A(it)) + emitted++ + } + assertEquals(5, emitted) + assertTrue(gate.isActive, "the handler must still be parked, or this proves nothing") + } + + @Test + fun `drop oldest keeps the newest events`() = runTest { + val gate = CompletableDeferred() + val seen = mutableListOf() + val dispatcher = EventDispatcher( + name = "test", + scope = backgroundScope, + capacity = 2, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + dispatcher.on { + if (it.n == 1) gate.await() + seen += it.n + }.launchIn(backgroundScope) + + dispatcher.emit(TestEvent.A(1)) + runCurrent() // event 1 is taken and the handler parks on the gate + + for (n in 2..6) dispatcher.emit(TestEvent.A(n)) + gate.complete(Unit) + runCurrent() + + // A two-slot mailbox keeps the last two of 2..6; the rest are dropped rather than queued. + assertContentEquals(listOf(1, 5, 6), seen) + } + + @Test + fun `cancelling the listener scope unsubscribes the handler`() = runTest { + val listenerScope = CoroutineScope(backgroundScope.coroutineContext + Job()) + val seen = mutableListOf() + val dispatcher = EventDispatcher("test", backgroundScope) + + dispatcher.on { seen += it.n }.launchIn(listenerScope) + + dispatcher.emit(TestEvent.A(1)) + runCurrent() + + listenerScope.cancel() + runCurrent() + + dispatcher.emit(TestEvent.A(2)) + runCurrent() + + assertContentEquals(listOf(1), seen) + } + + @Test + fun `subscribing with an already cancelled scope registers nothing`() = runTest { + val deadScope = CoroutineScope(backgroundScope.coroutineContext + Job()) + deadScope.cancel() + val seen = mutableListOf() + val dispatcher = EventDispatcher("test", backgroundScope) + + dispatcher.on { seen += it.n }.launchIn(deadScope) + runCurrent() + + dispatcher.emit(TestEvent.A(1)) + runCurrent() + + assertTrue(seen.isEmpty(), "a dead scope must not leave a handler registered") + } + + @Test + fun `emitting after the owner is gone does nothing instead of blocking`() = runTest { + val ownerScope = CoroutineScope(backgroundScope.coroutineContext + Job()) + val seen = mutableListOf() + val dispatcher = EventDispatcher("test", ownerScope, capacity = 2) + + dispatcher.on { seen += it.n }.launchIn(backgroundScope) + + dispatcher.emit(TestEvent.A(1)) + runCurrent() + assertContentEquals(listOf(1), seen) + + ownerScope.cancel() + runCurrent() + + // Well past the capacity of 2. With the mailbox left open these would fill it and then block + // forever on a queue whose drain loop is already cancelled. + repeat(10) { dispatcher.emit(TestEvent.A(it)) } + runCurrent() + + assertContentEquals(listOf(1), seen) + } + + @Test + fun `a mailbox that keeps up reports nothing`() = runTest { + val dispatcher = EventDispatcher("test", backgroundScope, capacity = 8) + dispatcher.on { }.launchIn(backgroundScope) + + // Drained between emits, so the mailbox never holds more than one event. + repeat(20) { + dispatcher.emit(TestEvent.A(it)) + runCurrent() + } + + assertNull(dispatcher.saturationReport(), "a dispatcher keeping up must stay quiet") + } + + @Test + fun `backing up warns before anything is lost`() = runTest { + val gate = CompletableDeferred() + // capacity 8 puts the depth warning at 2 + val dispatcher = EventDispatcher("test", backgroundScope, capacity = 8) + dispatcher.on { gate.await() }.launchIn(backgroundScope) + + dispatcher.emit(TestEvent.A(0)) + runCurrent() // taken by the handler, which parks + + dispatcher.emit(TestEvent.A(1)) + dispatcher.emit(TestEvent.A(2)) + + val report = dispatcher.saturationReport() + assertNotNull(report, "depth reached the warning threshold, so this must report") + assertTrue(report.contains("nothing lost yet"), "still within capacity: $report") + } + + @Test + fun `drops are reported as lost`() = runTest { + val gate = CompletableDeferred() + val dispatcher = EventDispatcher( + name = "test", + scope = backgroundScope, + capacity = 2, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + dispatcher.on { gate.await() }.launchIn(backgroundScope) + + dispatcher.emit(TestEvent.A(0)) + runCurrent() + repeat(6) { dispatcher.emit(TestEvent.A(it)) } + + val report = dispatcher.saturationReport() + assertNotNull(report) + assertTrue(report.contains("events dropped"), "should name the loss: $report") + } + + @Test + fun `disposing the handle unsubscribes the handler`() = runTest { + val seen = mutableListOf() + val dispatcher = EventDispatcher("test", backgroundScope) + + val handle = dispatcher.on { seen += it.n }.launchIn(backgroundScope) + + dispatcher.emit(TestEvent.A(1)) + runCurrent() + + handle.dispose() + dispatcher.emit(TestEvent.A(2)) + runCurrent() + + assertContentEquals(listOf(1), seen) + } +} diff --git a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt index 1de7c6f9b5..cdf95fea76 100644 --- a/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt +++ b/server/core/src/test/java/dev/slimevr/solarxr/DataFeedTest.kt @@ -46,15 +46,14 @@ private fun TestScope.testConn(onSend: suspend (ByteArray) -> Unit): SolarXRBrid id = 1, context = context, appContext = appContext, - dataFeedDispatcher = EventDispatcher(), - rpcDispatcher = EventDispatcher(), + dataFeedDispatcher = EventDispatcher("test.datafeed", backgroundScope), + rpcDispatcher = EventDispatcher("test.rpc", backgroundScope), ) bridge.startObserving() bridge.outbound.on { onSend(ByteArray(0)) }.launchIn(backgroundScope) - // EventDispatcher is a replay-0 SharedFlow, so an emit with no subscriber yet is dropped, and - // launchIn above only subscribes once its coroutine is dispatched. Run those now, so the bridge - // this returns is actually listening. + // launchIn registers the handler synchronously, but the dispatcher's own drain loop still has to + // start before anything emitted here can reach it. runCurrent() return bridge } From e0b06f1598abc500ad7c40257e1b64a437193b67 Mon Sep 17 00:00:00 2001 From: lucas lelievre Date: Sat, 25 Jul 2026 22:52:08 +0200 Subject: [PATCH 300/350] Speed part 4 --- .../java/dev/slimevr/driver/behaviours.kt | 28 ++-- .../java/dev/slimevr/skeleton/behaviours.kt | 20 +-- .../slimevr/skeleton/bodypart-structure.kt | 122 +++++++++-------- .../dev/slimevr/skeleton/bone-geometry.kt | 3 +- .../java/dev/slimevr/skeleton/bone-map.kt | 27 ++++ .../src/main/java/dev/slimevr/skeleton/ik.kt | 4 +- .../main/java/dev/slimevr/skeleton/module.kt | 35 ++--- .../skeleton/processors/bone-active-link.kt | 30 ++--- .../skeleton/processors/bone-direct-link.kt | 13 +- .../skeleton/processors/bone-fallback.kt | 4 +- .../skeleton/processors/bone-prediction.kt | 10 +- .../skeleton/processors/bone-smoothing.kt | 14 +- .../processors/bone-yaw-roll-align.kt | 42 +++--- .../skeleton/processors/fingers-impute.kt | 9 +- .../skeleton/processors/spine-impute.kt | 126 +++++++++--------- .../java/dev/slimevr/skeleton/proportions.kt | 14 +- .../src/main/java/dev/slimevr/vmc/mapping.kt | 42 +++--- .../core/src/main/java/dev/slimevr/vmc/vrm.kt | 9 +- 18 files changed, 300 insertions(+), 252 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/bone-map.kt diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index b9458ee079..41bc8e8e46 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -4,6 +4,8 @@ import dev.slimevr.VRServerActions import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin +import dev.slimevr.skeleton.BodyPartMap +import dev.slimevr.skeleton.bodyPartMap import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import dev.slimevr.tracker.TrackerState @@ -17,17 +19,19 @@ import solarxr_protocol.datatypes.TrackerStatus class DriverOutgoingTrackersBehaviour : DriverBridgeBehaviour { // Keys should match with protocol.kt:bodyPartToRole - val bodyPartToNearest: Map> = mapOf( - BodyPart.UPPER_CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST), - BodyPart.HIP to setOf(BodyPart.HIP, BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), - BodyPart.LEFT_UPPER_LEG to setOf(BodyPart.LEFT_UPPER_LEG), - BodyPart.RIGHT_UPPER_LEG to setOf(BodyPart.RIGHT_UPPER_LEG), - BodyPart.LEFT_FOOT to setOf(BodyPart.LEFT_FOOT, BodyPart.LEFT_LOWER_LEG), - BodyPart.RIGHT_FOOT to setOf(BodyPart.RIGHT_FOOT, BodyPart.RIGHT_LOWER_LEG), - BodyPart.LEFT_UPPER_ARM to setOf(BodyPart.LEFT_UPPER_ARM, BodyPart.LEFT_LOWER_ARM), - BodyPart.RIGHT_UPPER_ARM to setOf(BodyPart.RIGHT_UPPER_ARM, BodyPart.RIGHT_LOWER_ARM), - BodyPart.LEFT_HAND to setOf(BodyPart.LEFT_HAND, BodyPart.LEFT_LOWER_ARM), - BodyPart.RIGHT_HAND to setOf(BodyPart.RIGHT_HAND, BodyPart.RIGHT_LOWER_ARM), + val bodyPartToNearest: BodyPartMap> = BodyPartMap( + mapOf( + BodyPart.UPPER_CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST), + BodyPart.HIP to setOf(BodyPart.HIP, BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), + BodyPart.LEFT_UPPER_LEG to setOf(BodyPart.LEFT_UPPER_LEG), + BodyPart.RIGHT_UPPER_LEG to setOf(BodyPart.RIGHT_UPPER_LEG), + BodyPart.LEFT_FOOT to setOf(BodyPart.LEFT_FOOT, BodyPart.LEFT_LOWER_LEG), + BodyPart.RIGHT_FOOT to setOf(BodyPart.RIGHT_FOOT, BodyPart.RIGHT_LOWER_LEG), + BodyPart.LEFT_UPPER_ARM to setOf(BodyPart.LEFT_UPPER_ARM, BodyPart.LEFT_LOWER_ARM), + BodyPart.RIGHT_UPPER_ARM to setOf(BodyPart.RIGHT_UPPER_ARM, BodyPart.RIGHT_LOWER_ARM), + BodyPart.LEFT_HAND to setOf(BodyPart.LEFT_HAND, BodyPart.LEFT_LOWER_ARM), + BodyPart.RIGHT_HAND to setOf(BodyPart.RIGHT_HAND, BodyPart.RIGHT_LOWER_ARM), + ), ) override fun observe(receiver: DriverBridge) { @@ -50,7 +54,7 @@ class DriverOutgoingTrackersBehaviour : DriverBridgeBehaviour { // One pass over the trackers instead of a scan per fallback part per bone. // putIfAbsent keeps the first match, which is what the find { } did. - val trackerStateByBodyPart = mutableMapOf() + val trackerStateByBodyPart = bodyPartMap() for (tracker in serverState.trackers.values) { val trackerState = tracker.context.state.value if (trackerState.origin == DeviceOrigin.DRIVER) continue diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index ba488e96e9..55129ba1f5 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -18,24 +18,18 @@ import kotlin.math.sin class BoneTransformBehaviour : SkeletonBehaviour { override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { is SkeletonActions.SetBoneRotation -> { - val bones = state.boneInputs.toMutableMap() - val bone = bones[action.bodyPart] ?: return state - bones[action.bodyPart] = bone.copy(rawRotation = action.rotation, isActive = true) - state.copy(boneInputs = bones) + val bone = state.boneInputs[action.bodyPart] ?: return state + state.copy(boneInputs = state.boneInputs.mutate { it[action.bodyPart] = bone.copy(rawRotation = action.rotation, isActive = true) }) } is SkeletonActions.SetBonePosition -> { - val bones = state.boneInputs.toMutableMap() - val bone = bones[action.bodyPart] ?: return state - bones[action.bodyPart] = bone.copy(rawPosition = action.position, isActive = true) - state.copy(boneInputs = bones) + val bone = state.boneInputs[action.bodyPart] ?: return state + state.copy(boneInputs = state.boneInputs.mutate { it[action.bodyPart] = bone.copy(rawPosition = action.position, isActive = true) }) } is SkeletonActions.DisableBone -> { - val bones = state.boneInputs.toMutableMap() - val bone = bones[action.bodyPart] ?: return state - bones[action.bodyPart] = bone.copy(isActive = false) - state.copy(boneInputs = bones) + val bone = state.boneInputs[action.bodyPart] ?: return state + state.copy(boneInputs = state.boneInputs.mutate { it[action.bodyPart] = bone.copy(isActive = false) }) } else -> state @@ -46,7 +40,7 @@ class ProportionsBehaviour(private val userConfig: UserConfig) : SkeletonBehavio override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { is SkeletonActions.SetProportions -> { val bones = action.lengths.toBoneOffsets() - val newBones = state.boneInputs.mapValues { (bodyPart, bone) -> + val newBones = state.boneInputs.mapValues { bodyPart, bone -> bone.copy(offset = bones[bodyPart] ?: bone.offset) } state.copy(boneInputs = newBones, skeletonHeight = action.lengths.height()) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt b/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt index 140b6d5c53..3d5e38e8d9 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bodypart-structure.kt @@ -2,77 +2,83 @@ package dev.slimevr.skeleton import solarxr_protocol.datatypes.BodyPart -val BODY_PART_HIERARCHY_MAP = mapOf( - BodyPart.HEAD to arrayOf(BodyPart.NECK), - BodyPart.NECK to arrayOf(BodyPart.UPPER_CHEST, BodyPart.LEFT_SHOULDER, BodyPart.RIGHT_SHOULDER), +val BODY_PART_HIERARCHY_MAP: BodyPartMap> = BodyPartMap( + mapOf( + BodyPart.HEAD to arrayOf(BodyPart.NECK), + BodyPart.NECK to arrayOf(BodyPart.UPPER_CHEST, BodyPart.LEFT_SHOULDER, BodyPart.RIGHT_SHOULDER), - BodyPart.LEFT_SHOULDER to arrayOf(BodyPart.LEFT_UPPER_ARM), - BodyPart.LEFT_UPPER_ARM to arrayOf(BodyPart.LEFT_LOWER_ARM), - BodyPart.LEFT_LOWER_ARM to arrayOf(BodyPart.LEFT_HAND), + BodyPart.LEFT_SHOULDER to arrayOf(BodyPart.LEFT_UPPER_ARM), + BodyPart.LEFT_UPPER_ARM to arrayOf(BodyPart.LEFT_LOWER_ARM), + BodyPart.LEFT_LOWER_ARM to arrayOf(BodyPart.LEFT_HAND), - BodyPart.LEFT_HAND to arrayOf( - BodyPart.LEFT_THUMB_METACARPAL, - BodyPart.LEFT_INDEX_PROXIMAL, - BodyPart.LEFT_MIDDLE_PROXIMAL, - BodyPart.LEFT_RING_PROXIMAL, - BodyPart.LEFT_LITTLE_PROXIMAL, - ), - BodyPart.LEFT_THUMB_METACARPAL to arrayOf(BodyPart.LEFT_THUMB_PROXIMAL), - BodyPart.LEFT_THUMB_PROXIMAL to arrayOf(BodyPart.LEFT_THUMB_DISTAL), - BodyPart.LEFT_INDEX_PROXIMAL to arrayOf(BodyPart.LEFT_INDEX_INTERMEDIATE), - BodyPart.LEFT_INDEX_INTERMEDIATE to arrayOf(BodyPart.LEFT_INDEX_DISTAL), - BodyPart.LEFT_MIDDLE_PROXIMAL to arrayOf(BodyPart.LEFT_MIDDLE_INTERMEDIATE), - BodyPart.LEFT_MIDDLE_INTERMEDIATE to arrayOf(BodyPart.LEFT_MIDDLE_DISTAL), - BodyPart.LEFT_RING_PROXIMAL to arrayOf(BodyPart.LEFT_RING_INTERMEDIATE), - BodyPart.LEFT_RING_INTERMEDIATE to arrayOf(BodyPart.LEFT_RING_DISTAL), - BodyPart.LEFT_LITTLE_PROXIMAL to arrayOf(BodyPart.LEFT_LITTLE_INTERMEDIATE), - BodyPart.LEFT_LITTLE_INTERMEDIATE to arrayOf(BodyPart.LEFT_LITTLE_DISTAL), + BodyPart.LEFT_HAND to arrayOf( + BodyPart.LEFT_THUMB_METACARPAL, + BodyPart.LEFT_INDEX_PROXIMAL, + BodyPart.LEFT_MIDDLE_PROXIMAL, + BodyPart.LEFT_RING_PROXIMAL, + BodyPart.LEFT_LITTLE_PROXIMAL, + ), + BodyPart.LEFT_THUMB_METACARPAL to arrayOf(BodyPart.LEFT_THUMB_PROXIMAL), + BodyPart.LEFT_THUMB_PROXIMAL to arrayOf(BodyPart.LEFT_THUMB_DISTAL), + BodyPart.LEFT_INDEX_PROXIMAL to arrayOf(BodyPart.LEFT_INDEX_INTERMEDIATE), + BodyPart.LEFT_INDEX_INTERMEDIATE to arrayOf(BodyPart.LEFT_INDEX_DISTAL), + BodyPart.LEFT_MIDDLE_PROXIMAL to arrayOf(BodyPart.LEFT_MIDDLE_INTERMEDIATE), + BodyPart.LEFT_MIDDLE_INTERMEDIATE to arrayOf(BodyPart.LEFT_MIDDLE_DISTAL), + BodyPart.LEFT_RING_PROXIMAL to arrayOf(BodyPart.LEFT_RING_INTERMEDIATE), + BodyPart.LEFT_RING_INTERMEDIATE to arrayOf(BodyPart.LEFT_RING_DISTAL), + BodyPart.LEFT_LITTLE_PROXIMAL to arrayOf(BodyPart.LEFT_LITTLE_INTERMEDIATE), + BodyPart.LEFT_LITTLE_INTERMEDIATE to arrayOf(BodyPart.LEFT_LITTLE_DISTAL), - BodyPart.RIGHT_SHOULDER to arrayOf(BodyPart.RIGHT_UPPER_ARM), - BodyPart.RIGHT_UPPER_ARM to arrayOf(BodyPart.RIGHT_LOWER_ARM), - BodyPart.RIGHT_LOWER_ARM to arrayOf(BodyPart.RIGHT_HAND), + BodyPart.RIGHT_SHOULDER to arrayOf(BodyPart.RIGHT_UPPER_ARM), + BodyPart.RIGHT_UPPER_ARM to arrayOf(BodyPart.RIGHT_LOWER_ARM), + BodyPart.RIGHT_LOWER_ARM to arrayOf(BodyPart.RIGHT_HAND), - BodyPart.RIGHT_HAND to arrayOf( - BodyPart.RIGHT_THUMB_METACARPAL, - BodyPart.RIGHT_INDEX_PROXIMAL, - BodyPart.RIGHT_MIDDLE_PROXIMAL, - BodyPart.RIGHT_RING_PROXIMAL, - BodyPart.RIGHT_LITTLE_PROXIMAL, - ), - BodyPart.RIGHT_THUMB_METACARPAL to arrayOf(BodyPart.RIGHT_THUMB_PROXIMAL), - BodyPart.RIGHT_THUMB_PROXIMAL to arrayOf(BodyPart.RIGHT_THUMB_DISTAL), - BodyPart.RIGHT_INDEX_PROXIMAL to arrayOf(BodyPart.RIGHT_INDEX_INTERMEDIATE), - BodyPart.RIGHT_INDEX_INTERMEDIATE to arrayOf(BodyPart.RIGHT_INDEX_DISTAL), - BodyPart.RIGHT_MIDDLE_PROXIMAL to arrayOf(BodyPart.RIGHT_MIDDLE_INTERMEDIATE), - BodyPart.RIGHT_MIDDLE_INTERMEDIATE to arrayOf(BodyPart.RIGHT_MIDDLE_DISTAL), - BodyPart.RIGHT_RING_PROXIMAL to arrayOf(BodyPart.RIGHT_RING_INTERMEDIATE), - BodyPart.RIGHT_RING_INTERMEDIATE to arrayOf(BodyPart.RIGHT_RING_DISTAL), - BodyPart.RIGHT_LITTLE_PROXIMAL to arrayOf(BodyPart.RIGHT_LITTLE_INTERMEDIATE), - BodyPart.RIGHT_LITTLE_INTERMEDIATE to arrayOf(BodyPart.RIGHT_LITTLE_DISTAL), + BodyPart.RIGHT_HAND to arrayOf( + BodyPart.RIGHT_THUMB_METACARPAL, + BodyPart.RIGHT_INDEX_PROXIMAL, + BodyPart.RIGHT_MIDDLE_PROXIMAL, + BodyPart.RIGHT_RING_PROXIMAL, + BodyPart.RIGHT_LITTLE_PROXIMAL, + ), + BodyPart.RIGHT_THUMB_METACARPAL to arrayOf(BodyPart.RIGHT_THUMB_PROXIMAL), + BodyPart.RIGHT_THUMB_PROXIMAL to arrayOf(BodyPart.RIGHT_THUMB_DISTAL), + BodyPart.RIGHT_INDEX_PROXIMAL to arrayOf(BodyPart.RIGHT_INDEX_INTERMEDIATE), + BodyPart.RIGHT_INDEX_INTERMEDIATE to arrayOf(BodyPart.RIGHT_INDEX_DISTAL), + BodyPart.RIGHT_MIDDLE_PROXIMAL to arrayOf(BodyPart.RIGHT_MIDDLE_INTERMEDIATE), + BodyPart.RIGHT_MIDDLE_INTERMEDIATE to arrayOf(BodyPart.RIGHT_MIDDLE_DISTAL), + BodyPart.RIGHT_RING_PROXIMAL to arrayOf(BodyPart.RIGHT_RING_INTERMEDIATE), + BodyPart.RIGHT_RING_INTERMEDIATE to arrayOf(BodyPart.RIGHT_RING_DISTAL), + BodyPart.RIGHT_LITTLE_PROXIMAL to arrayOf(BodyPart.RIGHT_LITTLE_INTERMEDIATE), + BodyPart.RIGHT_LITTLE_INTERMEDIATE to arrayOf(BodyPart.RIGHT_LITTLE_DISTAL), - BodyPart.UPPER_CHEST to arrayOf(BodyPart.CHEST), - BodyPart.CHEST to arrayOf(BodyPart.WAIST), - BodyPart.WAIST to arrayOf(BodyPart.HIP), - BodyPart.HIP to arrayOf(BodyPart.LEFT_HIP, BodyPart.RIGHT_HIP), + BodyPart.UPPER_CHEST to arrayOf(BodyPart.CHEST), + BodyPart.CHEST to arrayOf(BodyPart.WAIST), + BodyPart.WAIST to arrayOf(BodyPart.HIP), + BodyPart.HIP to arrayOf(BodyPart.LEFT_HIP, BodyPart.RIGHT_HIP), - BodyPart.LEFT_HIP to arrayOf(BodyPart.LEFT_UPPER_LEG), - BodyPart.LEFT_UPPER_LEG to arrayOf(BodyPart.LEFT_LOWER_LEG), - BodyPart.LEFT_LOWER_LEG to arrayOf(BodyPart.LEFT_FOOT), + BodyPart.LEFT_HIP to arrayOf(BodyPart.LEFT_UPPER_LEG), + BodyPart.LEFT_UPPER_LEG to arrayOf(BodyPart.LEFT_LOWER_LEG), + BodyPart.LEFT_LOWER_LEG to arrayOf(BodyPart.LEFT_FOOT), - BodyPart.RIGHT_HIP to arrayOf(BodyPart.RIGHT_UPPER_LEG), - BodyPart.RIGHT_UPPER_LEG to arrayOf(BodyPart.RIGHT_LOWER_LEG), - BodyPart.RIGHT_LOWER_LEG to arrayOf(BodyPart.RIGHT_FOOT), + BodyPart.RIGHT_HIP to arrayOf(BodyPart.RIGHT_UPPER_LEG), + BodyPart.RIGHT_UPPER_LEG to arrayOf(BodyPart.RIGHT_LOWER_LEG), + BodyPart.RIGHT_LOWER_LEG to arrayOf(BodyPart.RIGHT_FOOT), + ), ) -val BODY_PART_PARENT_MAP: Map = BODY_PART_HIERARCHY_MAP - .flatMap { (parent, children) -> children.map { child -> child to parent } } - .toMap() +private val BODY_PART_PARENTS: Array = arrayOfNulls(BodyPart.entries.size).also { parents -> + for ((parent, children) in BODY_PART_HIERARCHY_MAP) { + for (child in children) parents[child.ordinal] = parent + } +} + +fun parentOf(bodyPart: BodyPart): BodyPart? = BODY_PART_PARENTS[bodyPart.ordinal] inline fun BodyPart.findFirstParent(predicate: (BodyPart) -> Boolean): BodyPart? { - var current = BODY_PART_PARENT_MAP[this] + var current = parentOf(this) while (current != null) { if (predicate(current)) return current - current = BODY_PART_PARENT_MAP[current] + current = parentOf(current) } return null } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bone-geometry.kt b/server/core/src/main/java/dev/slimevr/skeleton/bone-geometry.kt index 1512e0f211..52970934d4 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/bone-geometry.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/bone-geometry.kt @@ -9,4 +9,5 @@ private val FINGER_OFFSETS = ( iterateBodyPartHierarchy(BodyPart.RIGHT_HAND, true) ).map { it.second }.associateWith { Vector3(0f, -0.025f, 0f) } -val DEFAULT_BONE_OFFSETS: Map = DEFAULT_PROPORTIONS.toBoneOffsets() + FINGER_OFFSETS +val DEFAULT_BONE_OFFSETS: BodyPartMap = DEFAULT_PROPORTIONS.toBoneOffsets() + .mutate { offsets -> for ((bodyPart, offset) in FINGER_OFFSETS) offsets[bodyPart] = offset } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/bone-map.kt b/server/core/src/main/java/dev/slimevr/skeleton/bone-map.kt new file mode 100644 index 0000000000..223c18505c --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/bone-map.kt @@ -0,0 +1,27 @@ +package dev.slimevr.skeleton + +import solarxr_protocol.datatypes.BodyPart +import java.util.EnumMap + +typealias BodyPartMap = EnumMap + +@PublishedApi +internal val ALL_BODY_PARTS: Array = BodyPart.entries.toTypedArray() + +fun bodyPartMap(): BodyPartMap = EnumMap(BodyPart::class.java) + +inline fun BodyPartMap.mutate(block: (BodyPartMap) -> Unit): BodyPartMap = EnumMap(this).also(block) + +inline fun BodyPartMap.mapValues(transform: (BodyPart, V) -> R): BodyPartMap { + val result = bodyPartMap() + for (bodyPart in ALL_BODY_PARTS) { + result[bodyPart] = transform(bodyPart, this[bodyPart] ?: continue) + } + return result +} + +inline fun BodyPartMap.forEachBone(action: (BodyPart, V) -> Unit) { + for (bodyPart in ALL_BODY_PARTS) { + action(bodyPart, this[bodyPart] ?: continue) + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/ik.kt b/server/core/src/main/java/dev/slimevr/skeleton/ik.kt index 765b34d69c..bee46f451b 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/ik.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/ik.kt @@ -21,8 +21,8 @@ fun chainCanReach(bones: Map, chain: List, target } // Single loop over a chain -fun ccdIk(boneInputs: Map, bones: Map, chain: List, target: Vector3): Map { - val workingBones = boneInputs.toMutableMap() +fun ccdIk(boneInputs: BodyPartMap, bones: Map, chain: List, target: Vector3): BodyPartMap { + val workingBones = BodyPartMap(boneInputs) // Reversed index, i increases backwards up the chain for ((i, bodyPart) in chain.reversed().withIndex()) { diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 4ea79acaeb..94603d634a 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -50,25 +50,28 @@ data class BoneState( /** * Returns the average rotation of the BodyParts. */ -fun Map.resolveRotationFor(bodyParts: Set): Quaternion { +fun Map.resolveRotationFor(bodyParts: Array): Quaternion { val rotations = bodyParts.mapNotNull { this[it]?.rawRotation } return rotations.reduceIndexedOrNull { index, acc, rotation -> acc.lerpQ(rotation, 1f / (index + 1)) } ?: Quaternion.IDENTITY } -data class SkeletonState(val boneInputs: Map, val skeletonHeight: Float, val paused: Boolean) +data class SkeletonState(val boneInputs: BodyPartMap, val skeletonHeight: Float, val paused: Boolean) -val DEFAULT_SKELETON_STATE: SkeletonState = run { - val bones = DEFAULT_BONE_OFFSETS.entries.associate { (bodyPart, tailOffset) -> - bodyPart to BoneState(bodyPart = bodyPart, offset = tailOffset) - } - SkeletonState( - boneInputs = bones.mapValues { (_, bone) -> BoneInput(rawRotation = bone.rotation, bodyPart = bone.bodyPart, offset = bone.offset, rawPosition = Vector3.NULL, isActive = false) }, - skeletonHeight = DEFAULT_HEIGHT, - paused = false, - ) -} +val DEFAULT_SKELETON_STATE: SkeletonState = SkeletonState( + boneInputs = DEFAULT_BONE_OFFSETS.mapValues { bodyPart, tailOffset -> + BoneInput( + rawRotation = Quaternion.IDENTITY, + bodyPart = bodyPart, + offset = tailOffset, + rawPosition = Vector3.NULL, + isActive = false, + ) + }, + skeletonHeight = DEFAULT_HEIGHT, + paused = false, +) fun buildBone(bone: BoneInput, parentBone: BoneState?, originPosition: Vector3 = Vector3.NULL): BoneState { val head = parentBone?.tailPosition ?: originPosition @@ -86,8 +89,8 @@ fun buildBones( state: Map, rootHead: Vector3 = Vector3.NULL, hierarchy: Sequence> = iterateBodyPartHierarchy(), -): Map { - val result = mutableMapOf() +): BodyPartMap { + val result = bodyPartMap() hierarchy.forEach { (parentPart, childPart) -> val rawBone = state[childPart] ?: return@forEach val parentBone = parentPart?.let { result[it] } @@ -100,7 +103,7 @@ fun buildBones( state: SkeletonState, rootHead: Vector3 = Vector3.NULL, hierarchy: Sequence> = iterateBodyPartHierarchy(), -): Map = buildBones(state.boneInputs, rootHead, hierarchy) +): BodyPartMap = buildBones(state.boneInputs, rootHead, hierarchy) sealed interface SkeletonActions { data class SetBoneRotation(val bodyPart: BodyPart, val rotation: Quaternion) : SkeletonActions @@ -118,7 +121,7 @@ interface SkeletonProcessor { class Skeleton( val context: SkeletonContext, - val computed: MutableStateFlow>, + val computed: MutableStateFlow>, ) { fun startObserving() = context.observeAll(this) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt index 72cb142cad..8bd547eaf6 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt @@ -2,6 +2,7 @@ package dev.slimevr.skeleton.processors import dev.slimevr.skeleton.SkeletonProcessor import dev.slimevr.skeleton.SkeletonState +import dev.slimevr.skeleton.mutate import solarxr_protocol.datatypes.BodyPart /** @@ -15,29 +16,28 @@ class BoneActiveLinkProcessor : SkeletonProcessor { * Second element contains a set of BodyParts whose rotation should be used as a fallback prioritized from first to last. * Only active bones will be used. */ - private val linkedToSources = mapOf( - BodyPart.UPPER_CHEST to setOf(BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), - BodyPart.CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.WAIST, BodyPart.HIP), - BodyPart.WAIST to setOf(BodyPart.CHEST, BodyPart.HIP, BodyPart.UPPER_CHEST), - BodyPart.HIP to setOf(BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), + private val linkedToSources = arrayOf( + BodyPart.UPPER_CHEST to arrayOf(BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), + BodyPart.CHEST to arrayOf(BodyPart.UPPER_CHEST, BodyPart.WAIST, BodyPart.HIP), + BodyPart.WAIST to arrayOf(BodyPart.CHEST, BodyPart.HIP, BodyPart.UPPER_CHEST), + BodyPart.HIP to arrayOf(BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), ) override fun process(state: SkeletonState): SkeletonState { val boneInputs = state.boneInputs - val updatedLinkedBones = linkedToSources.keys.associateWith { bodyPart -> - val bone = boneInputs.getValue(bodyPart) - if (bone.isActive) return@associateWith bone + val updatedLinkedBones = boneInputs.mutate { updated -> + for ((bodyPart, sources) in linkedToSources) { + val bone = boneInputs.getValue(bodyPart) + if (bone.isActive) continue - val closestActiveBone = linkedToSources.getValue(bodyPart) - .firstNotNullOfOrNull { part -> + val closestActiveBone = sources.firstNotNullOfOrNull { part -> boneInputs[part]?.takeIf { it.isActive } - } - bone.copy( - rawRotation = closestActiveBone?.rawRotation ?: bone.rawRotation, - ) + } ?: continue + updated[bodyPart] = bone.copy(rawRotation = closestActiveBone.rawRotation) + } } - return state.copy(boneInputs = boneInputs + updatedLinkedBones) + return state.copy(boneInputs = updatedLinkedBones) } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt index e8dd76b54d..78b526c56a 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt @@ -1,12 +1,9 @@ package dev.slimevr.skeleton.processors +import dev.slimevr.skeleton.BodyPartMap import dev.slimevr.skeleton.SkeletonProcessor import dev.slimevr.skeleton.SkeletonState import solarxr_protocol.datatypes.BodyPart -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.get -import kotlin.collections.iterator /** * Handles setting the rotation of an inactive bone with its source bone. @@ -17,7 +14,7 @@ class BoneDirectLinkProcessor : SkeletonProcessor { * * Second element is the BodyPart the first element is linked to. */ - private val linkedToSource = mapOf( + private val linkedToSource = arrayOf( BodyPart.HEAD to BodyPart.NECK, BodyPart.NECK to BodyPart.HEAD, @@ -41,13 +38,13 @@ class BoneDirectLinkProcessor : SkeletonProcessor { ) override fun process(state: SkeletonState): SkeletonState { - val updatedBoneInputs = state.boneInputs.toMutableMap() + val updatedBoneInputs = BodyPartMap(state.boneInputs) - for (bodyPart in linkedToSource.keys) { + for ((bodyPart, source) in linkedToSource) { val bone = updatedBoneInputs.getValue(bodyPart) if (bone.isActive) continue - val sourceBone = updatedBoneInputs[linkedToSource.getValue(bodyPart)] + val sourceBone = updatedBoneInputs[source] updatedBoneInputs[bodyPart] = bone.copy(rawRotation = sourceBone?.rawRotation ?: bone.rawRotation) } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-fallback.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-fallback.kt index 56d04236ba..04b3c334a1 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-fallback.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-fallback.kt @@ -3,9 +3,9 @@ package dev.slimevr.skeleton.processors import dev.slimevr.skeleton.SkeletonProcessor import dev.slimevr.skeleton.SkeletonState import dev.slimevr.skeleton.findFirstParent +import dev.slimevr.skeleton.mapValues import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 -import kotlin.collections.get /** * Handles replacing rotations of boneInputs that are not actively receiving data by either @@ -16,7 +16,7 @@ class BoneFallbackProcessor : SkeletonProcessor { val boneInputs = state.boneInputs return state.copy( - boneInputs = boneInputs.mapValues { (bodyPart, bone) -> + boneInputs = boneInputs.mapValues { bodyPart, bone -> if (bone.isActive) return@mapValues bone val firstActiveParentBone = boneInputs[bodyPart.findFirstParent { boneInputs[it]?.isActive == true }] diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt index 5799733f91..9cde924230 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt @@ -1,11 +1,13 @@ package dev.slimevr.skeleton.processors import dev.slimevr.config.Settings +import dev.slimevr.skeleton.BodyPartMap import dev.slimevr.skeleton.SkeletonProcessor import dev.slimevr.skeleton.SkeletonState +import dev.slimevr.skeleton.bodyPartMap +import dev.slimevr.skeleton.mapValues import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 -import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.FilteringType /** @@ -19,14 +21,14 @@ class BonePredictionProcessor(val settings: Settings) : SkeletonProcessor { val offsetDelta: Vector3, ) - private var velocities: Map = emptyMap() + private var velocities: BodyPartMap = bodyPartMap() override fun process(state: SkeletonState): SkeletonState { val config = settings.context.state.value.data.skeletonConfig.filtering if (config.type != FilteringType.PREDICTION) return state - val newVelocities = mutableMapOf() - val newBones = state.boneInputs.mapValues { (bodyPart, bone) -> + val newVelocities = bodyPartMap() + val newBones = state.boneInputs.mapValues { bodyPart, bone -> val prev = velocities[bodyPart] if (prev == null) { newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, Quaternion.IDENTITY, bone.offset, Vector3.NULL) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt index 69c7e576dd..dc691cb317 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt @@ -1,19 +1,21 @@ package dev.slimevr.skeleton.processors import dev.slimevr.config.Settings +import dev.slimevr.skeleton.BodyPartMap import dev.slimevr.skeleton.SkeletonProcessor import dev.slimevr.skeleton.SkeletonState +import dev.slimevr.skeleton.bodyPartMap +import dev.slimevr.skeleton.mapValues import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 -import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.FilteringType /** * Running average of bones to smooth them out. */ class BoneSmoothingProcessor(val settings: Settings) : SkeletonProcessor { - private var smoothedRotations: Map = emptyMap() - private var smoothedLengths: Map = emptyMap() + private var smoothedRotations: BodyPartMap = bodyPartMap() + private var smoothedLengths: BodyPartMap = bodyPartMap() // TODO this isn't linear. Do we want linear smoothing like in main? override fun process(state: SkeletonState): SkeletonState { @@ -22,15 +24,15 @@ class BoneSmoothingProcessor(val settings: Settings) : SkeletonProcessor { val alpha = 1 - (SMOOTH_MIN + config.amount.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN)) - smoothedRotations = state.boneInputs.mapValues { (bodyPart, bone) -> + smoothedRotations = state.boneInputs.mapValues { bodyPart, bone -> (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() } - smoothedLengths = state.boneInputs.mapValues { (bodyPart, bone) -> + smoothedLengths = state.boneInputs.mapValues { bodyPart, bone -> val prev = smoothedLengths[bodyPart] ?: bone.offset prev + (bone.offset - prev) * alpha } return state.copy( - boneInputs = state.boneInputs.mapValues { (bodyPart, bone) -> + boneInputs = state.boneInputs.mapValues { bodyPart, bone -> bone.copy( rawRotation = smoothedRotations[bodyPart] ?: bone.rawRotation, offset = smoothedLengths[bodyPart] ?: bone.offset, diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt index a96f6afa19..3c34a9c57a 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt @@ -3,6 +3,7 @@ package dev.slimevr.skeleton.processors import dev.slimevr.config.Settings import dev.slimevr.skeleton.SkeletonProcessor import dev.slimevr.skeleton.SkeletonState +import dev.slimevr.skeleton.mutate import dev.slimevr.skeleton.resolveRotationFor import io.github.axisangles.ktmath.Quaternion import solarxr_protocol.datatypes.BodyPart @@ -11,41 +12,40 @@ import solarxr_protocol.datatypes.BodyPart * Handles rotating bones' yaw and roll to match other bones' yaw and roll. */ class BoneYawRollAlignProcessor(val settings: Settings) : SkeletonProcessor { + private class SourceLink(val bodyPart: BodyPart, val sources: Array, val onlyWhenInactive: Boolean) + /** * First value is the BodyPart to be aligned. * - * Second value is a Pair containing a list of BodyParts to align with as well as a boolean specifying + * Second value is a SourceLink containing a list of BodyParts to align with as well as a boolean specifying * whether the BodyPart to be aligned must be inactive. */ - private val bodyPartToSources = mapOf( - BodyPart.HIP to (setOf(BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG) to true), - BodyPart.LEFT_UPPER_LEG to (setOf(BodyPart.LEFT_LOWER_LEG) to false), - BodyPart.RIGHT_UPPER_LEG to (setOf(BodyPart.RIGHT_LOWER_LEG) to false), + private val bodyPartToSources = arrayOf( + SourceLink(BodyPart.HIP, arrayOf(BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG), onlyWhenInactive = true), + SourceLink(BodyPart.LEFT_UPPER_LEG, arrayOf(BodyPart.LEFT_LOWER_LEG), onlyWhenInactive = false), + SourceLink(BodyPart.RIGHT_UPPER_LEG, arrayOf(BodyPart.RIGHT_LOWER_LEG), onlyWhenInactive = false), ) override fun process(state: SkeletonState): SkeletonState { val boneInputs = state.boneInputs val ratios = settings.context.state.value.data.skeletonConfig.ratios - val bodyPartToMixFactor = mapOf( - BodyPart.HIP to ratios.interpolateHipWithUpperLegs, - BodyPart.LEFT_UPPER_LEG to ratios.interpolateUpperLegsWithLowerLegs, - BodyPart.RIGHT_UPPER_LEG to ratios.interpolateUpperLegsWithLowerLegs, - ) - - val updatedAlignedBones = bodyPartToSources.keys.associateWith { bodyPart -> - val bone = boneInputs.getValue(bodyPart) - val sources = bodyPartToSources.getValue(bodyPart) - if (sources.second && bone.isActive) return@associateWith bone - val mixFactor = bodyPartToMixFactor[bodyPart] ?: return@associateWith bone - val sourceRotation = boneInputs.resolveRotationFor(sources.first) - val aligned = alignYawRoll(bone.rawRotation, sourceRotation) - val newRotation = bone.rawRotation.interpR(aligned, mixFactor) + val updatedAlignedBones = boneInputs.mutate { updated -> + for (link in bodyPartToSources) { + val bone = boneInputs.getValue(link.bodyPart) + if (link.onlyWhenInactive && bone.isActive) continue - bone.copy(rawRotation = newRotation) + val mixFactor = when (link.bodyPart) { + BodyPart.HIP -> ratios.interpolateHipWithUpperLegs + else -> ratios.interpolateUpperLegsWithLowerLegs + } + val sourceRotation = boneInputs.resolveRotationFor(link.sources) + val aligned = alignYawRoll(bone.rawRotation, sourceRotation) + updated[link.bodyPart] = bone.copy(rawRotation = bone.rawRotation.interpR(aligned, mixFactor)) + } } - return state.copy(boneInputs = boneInputs + updatedAlignedBones) + return state.copy(boneInputs = updatedAlignedBones) } /** diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt index 7fe2150dfe..6a4e85507f 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt @@ -1,5 +1,6 @@ package dev.slimevr.skeleton.processors +import dev.slimevr.skeleton.BodyPartMap import dev.slimevr.skeleton.SkeletonProcessor import dev.slimevr.skeleton.SkeletonState import solarxr_protocol.datatypes.BodyPart @@ -15,7 +16,7 @@ class FingerImputeProcessor : SkeletonProcessor { * * Second element is the BodyPart the first element is linked to. */ - private val fingerToSource = mapOf( + private val fingerToSource = arrayOf( BodyPart.LEFT_THUMB_METACARPAL to BodyPart.LEFT_HAND, BodyPart.LEFT_THUMB_PROXIMAL to BodyPart.LEFT_THUMB_METACARPAL, BodyPart.LEFT_THUMB_DISTAL to BodyPart.LEFT_THUMB_PROXIMAL, @@ -50,13 +51,13 @@ class FingerImputeProcessor : SkeletonProcessor { // TODO : There's more math to do here. Reference the original code. override fun process(state: SkeletonState): SkeletonState { - val updatedBoneInputs = state.boneInputs.toMutableMap() + val updatedBoneInputs = BodyPartMap(state.boneInputs) - for (bodyPart in fingerToSource.keys) { + for ((bodyPart, source) in fingerToSource) { val bone = updatedBoneInputs.getValue(bodyPart) if (bone.isActive) continue - val sourceBone = updatedBoneInputs[fingerToSource.getValue(bodyPart)] + val sourceBone = updatedBoneInputs[source] updatedBoneInputs[bodyPart] = bone.copy(rawRotation = sourceBone?.rawRotation ?: bone.rawRotation) } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt index 837518f30e..0ba1ea5595 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt @@ -3,6 +3,7 @@ package dev.slimevr.skeleton.processors import dev.slimevr.config.Settings import dev.slimevr.skeleton.SkeletonProcessor import dev.slimevr.skeleton.SkeletonState +import dev.slimevr.skeleton.mutate import dev.slimevr.skeleton.resolveRotationFor import solarxr_protocol.datatypes.BodyPart @@ -13,26 +14,28 @@ import solarxr_protocol.datatypes.BodyPart * Similar to FallbackProcessor specifically for the waist and hip. */ class SpineImputeProcessor(val settings: Settings) : SkeletonProcessor { - private val chestSet = setOf(BodyPart.CHEST) - private val hipSet = setOf(BodyPart.HIP) - private val waistSet = setOf(BodyPart.WAIST) - private val upperLegsSet = setOf(BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG) + private enum class SpineSource(val parts: Array) { + CHEST(arrayOf(BodyPart.CHEST)), + HIP(arrayOf(BodyPart.HIP)), + WAIST(arrayOf(BodyPart.WAIST)), + UPPER_LEGS(arrayOf(BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG)), + } - /** - * Used to skew the impute ratio for certain bone combinations. - * - * First element is a Pair containing the missing bone to the source bones. - * - * Second element is how reliable that pair is. Higher = missing bone relies more on source bones. - */ - private val combinationToReliability = mapOf( - (BodyPart.WAIST to chestSet) to 0.6f, - (BodyPart.WAIST to hipSet) to 1.0f, - (BodyPart.WAIST to upperLegsSet) to 1.0f, - (BodyPart.HIP to chestSet) to 1.0f, - (BodyPart.HIP to waistSet) to 0.8f, - (BodyPart.HIP to upperLegsSet) to 1.0f, - ) + private fun reliabilityOf(bodyPart: BodyPart, source: SpineSource): Float = when (bodyPart) { + BodyPart.WAIST -> when (source) { + SpineSource.CHEST -> 0.6f + SpineSource.HIP, SpineSource.UPPER_LEGS -> 1.0f + else -> error("Invalid spine combination $bodyPart, $source") + } + + BodyPart.HIP -> when (source) { + SpineSource.CHEST, SpineSource.UPPER_LEGS -> 1.0f + SpineSource.WAIST -> 0.8f + else -> error("Invalid spine combination $bodyPart, $source") + } + + else -> error("Invalid missing spine body part $bodyPart") + } override fun process(state: SkeletonState): SkeletonState { val boneInputs = state.boneInputs @@ -47,53 +50,52 @@ class SpineImputeProcessor(val settings: Settings) : SkeletonProcessor { if (!hasHip) add(BodyPart.HIP) } - val updatedSpineBones = missingSpineParts.withIndex().associate { (chainIndex, bodyPart) -> - val bone = boneInputs.getValue(bodyPart) - - // Get the first active bones above and below this one in the chain - val (fromBodyParts, toBodyParts) = when (bodyPart) { - BodyPart.WAIST -> { - val from = chestSet.takeIf { hasChest } ?: return@associate bodyPart to bone - val to = when { - hasHip -> hipSet - hasUpperLegs -> upperLegsSet - else -> return@associate bodyPart to bone + return state.copy( + boneInputs = boneInputs.mutate { updated -> + for ((chainIndex, bodyPart) in missingSpineParts.withIndex()) { + val bone = boneInputs.getValue(bodyPart) + + // Get the first active bones above and below this one in the chain + val (fromSource, toSource) = when (bodyPart) { + BodyPart.WAIST -> { + val from = SpineSource.CHEST.takeIf { hasChest } ?: continue + val to = when { + hasHip -> SpineSource.HIP + hasUpperLegs -> SpineSource.UPPER_LEGS + else -> continue + } + from to to + } + + BodyPart.HIP -> { + val from = when { + hasWaist -> SpineSource.WAIST + hasChest -> SpineSource.CHEST + else -> continue + } + val to = SpineSource.UPPER_LEGS.takeIf { hasUpperLegs } ?: continue + from to to + } + + else -> error("Invalid missing spine body part $bodyPart") } - from to to - } - BodyPart.HIP -> { - val from = when { - hasWaist -> waistSet - hasChest -> chestSet - else -> return@associate bodyPart to bone - } - val to = upperLegsSet.takeIf { hasUpperLegs } ?: return@associate bodyPart to bone - from to to - } - - else -> error("Invalid missing spine body part $bodyPart") - } - - val fromReliability = combinationToReliability[(bodyPart to fromBodyParts)] ?: error("Invalid from body part combination $bodyPart, $fromBodyParts") - val toReliability = combinationToReliability[(bodyPart to toBodyParts)] ?: error("Invalid to body part combination $bodyPart, $toBodyParts") + val interpolateRatio = interpolateRatio( + chainIndex, + missingSpineParts.size, + ratios.imputeSpineFromUpperToLower, + ratios.imputeSpineCurvature, + reliabilityOf(bodyPart, fromSource), + reliabilityOf(bodyPart, toSource), + ) - val interpolateRatio = interpolateRatio( - chainIndex, - missingSpineParts.size, - ratios.imputeSpineFromUpperToLower, - ratios.imputeSpineCurvature, - fromReliability, - toReliability, - ) + val fromRotation = boneInputs.resolveRotationFor(fromSource.parts) + val toRotation = boneInputs.resolveRotationFor(toSource.parts) - val fromRotation = boneInputs.resolveRotationFor(fromBodyParts) - val toRotation = boneInputs.resolveRotationFor(toBodyParts) - - bodyPart to bone.copy(rawRotation = fromRotation.interpQ(toRotation, interpolateRatio)) - } - - return state.copy(boneInputs = boneInputs + updatedSpineBones) + updated[bodyPart] = bone.copy(rawRotation = fromRotation.interpQ(toRotation, interpolateRatio)) + } + }, + ) } /** diff --git a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt index adb7608092..5b4fdd5961 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/proportions.kt @@ -105,12 +105,16 @@ fun computeAllDefaultProportionsByBone(height: Float): Map { return nonScaled + heightScaled } -fun Map.toBoneOffsets(): Map = this - .flatMap { (cfg, length) -> - BONE_VALUE_TO_OFFSETS[cfg]?.map { (bone, vec) -> bone to length * vec } ?: emptyList() +fun Map.toBoneOffsets(): BodyPartMap { + val offsets = bodyPartMap() + for ((cfg, length) in this) { + val boneOffsets = BONE_VALUE_TO_OFFSETS[cfg] ?: continue + for ((bone, vec) in boneOffsets) { + offsets[bone] = (offsets[bone] ?: Vector3.NULL) + length * vec + } } - .groupBy({ it.first }, { it.second }) - .mapValues { it.value.fold(Vector3.NULL) { acc, value -> acc + value } } + return offsets +} fun Map.toBoneValues(): Map = this .flatMap { (bone, vec) -> diff --git a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt index bb524e054f..3118747a63 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/mapping.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/mapping.kt @@ -1,7 +1,7 @@ package dev.slimevr.vmc import com.jme3.math.FastMath -import dev.slimevr.config.VMCConfig +import dev.slimevr.skeleton.BodyPartMap import dev.slimevr.skeleton.BoneState import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 @@ -120,8 +120,8 @@ private suspend fun SequenceScope>.visitVMC(parent: Bo fun iterateVMCHierarchy() = sequence { visitVMC(null, BodyPart.HIP) } -val VMC_BONE_PARENTS: Map = - iterateVMCHierarchy().associate { (parent, child) -> child to parent } +val VMC_BONE_PARENTS: BodyPartMap = + BodyPartMap(iterateVMCHierarchy().associate { (parent, child) -> child to parent }) val VMC_MIRROR_BONE_PAIRS: List> = listOf( BodyPart.LEFT_SHOULDER to BodyPart.RIGHT_SHOULDER, @@ -148,16 +148,18 @@ val VMC_MIRROR_BONE_PAIRS: List> = listOf( BodyPart.LEFT_LITTLE_DISTAL to BodyPart.RIGHT_LITTLE_DISTAL, ) -val VMC_MIRROR_BONES: Map = VMC_MIRROR_BONE_PAIRS - .flatMap { (left, right) -> listOf(left to right, right to left) } - .toMap() +val VMC_MIRROR_BONES: BodyPartMap = BodyPartMap( + VMC_MIRROR_BONE_PAIRS + .flatMap { (left, right) -> listOf(left to right, right to left) } + .toMap(), +) fun vmcMirrorSource(bodyPart: BodyPart): BodyPart = VMC_MIRROR_BONES[bodyPart] ?: bodyPart // Per-bone rest offset, subtracted from the live world rotation before computing the VMC local. // Foot cancels R(90deg,X) baked into DEFAULT_SKELETON_STATE. Arms remap our hanging rest (NEG_Y) // to the VRM rig's T-pose rest direction so the avatar isn't stuck at T regardless of our pose. -val VMC_REST_ROTATIONS: Map = run { +val VMC_REST_ROTATIONS: BodyPartMap = run { val leftArm = Quaternion.rotationAroundZAxis(-FastMath.HALF_PI) val rightArm = Quaternion.rotationAroundZAxis(FastMath.HALF_PI) val foot = Quaternion.rotationAroundXAxis(FastMath.HALF_PI) @@ -175,18 +177,20 @@ val VMC_REST_ROTATIONS: Map = run { BodyPart.RIGHT_RING_PROXIMAL, BodyPart.RIGHT_RING_INTERMEDIATE, BodyPart.RIGHT_RING_DISTAL, BodyPart.RIGHT_LITTLE_PROXIMAL, BodyPart.RIGHT_LITTLE_INTERMEDIATE, BodyPart.RIGHT_LITTLE_DISTAL, ) - mapOf( - BodyPart.LEFT_FOOT to foot, - BodyPart.RIGHT_FOOT to foot, - BodyPart.LEFT_UPPER_ARM to leftArm, - BodyPart.LEFT_LOWER_ARM to leftArm, - BodyPart.LEFT_HAND to leftArm, - BodyPart.RIGHT_UPPER_ARM to rightArm, - BodyPart.RIGHT_LOWER_ARM to rightArm, - BodyPart.RIGHT_HAND to rightArm, - ) + - leftFingers.associateWith { leftArm } + - rightFingers.associateWith { rightArm } + BodyPartMap( + mapOf( + BodyPart.LEFT_FOOT to foot, + BodyPart.RIGHT_FOOT to foot, + BodyPart.LEFT_UPPER_ARM to leftArm, + BodyPart.LEFT_LOWER_ARM to leftArm, + BodyPart.LEFT_HAND to leftArm, + BodyPart.RIGHT_UPPER_ARM to rightArm, + BodyPart.RIGHT_LOWER_ARM to rightArm, + BodyPart.RIGHT_HAND to rightArm, + ) + + leftFingers.associateWith { leftArm } + + rightFingers.associateWith { rightArm }, + ) } fun vmcMirrorPosition(pos: Vector3): Vector3 = Vector3(-pos.x, pos.y, pos.z) diff --git a/server/core/src/main/java/dev/slimevr/vmc/vrm.kt b/server/core/src/main/java/dev/slimevr/vmc/vrm.kt index 93bd13fdbb..5ad30ef7b7 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/vrm.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/vrm.kt @@ -1,5 +1,6 @@ package dev.slimevr.vmc +import dev.slimevr.skeleton.BodyPartMap import io.github.axisangles.ktmath.Vector3 import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -84,14 +85,14 @@ data class Node( // VRM bind-pose geometry derived from a parsed VRM JSON. Used to keep the avatar's // local bone offsets aligned with the model's own proportions. data class VrmGeometry( - val bindOffsets: Map, + val bindOffsets: BodyPartMap, val hipLocalPosition: Vector3, ) fun buildVrmGeometry(reader: VrmReader): VrmGeometry { - val bindOffsets = BODY_PART_TO_UNITY_BONE.mapValues { (_, unityName) -> - reader.offsetForBone(unityName) ?: Vector3.NULL - } + val bindOffsets = BodyPartMap( + BODY_PART_TO_UNITY_BONE.mapValues { (_, unityName) -> reader.offsetForBone(unityName) ?: Vector3.NULL }, + ) fun offset(bodyPart: BodyPart) = bindOffsets[bodyPart] ?: Vector3.NULL val hipLocalPosition = offset(BodyPart.HIP) From 5da53fcfe2408efe4779a13f4dcc9d1df0e693a3 Mon Sep 17 00:00:00 2001 From: lucas lelievre Date: Sat, 25 Jul 2026 22:53:15 +0200 Subject: [PATCH 301/350] spotless --- .../src/main/java/dev/slimevr/tracker/session-calibration.kt | 2 +- .../test/java/dev/slimevr/tracker/SessionCalibrationTest.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt b/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt index 6a73a984ca..364dc15d70 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt @@ -22,7 +22,7 @@ data class SessionCalibration( val headingCorrection: HeadingCorrection = Quaternion.IDENTITY, val attitudeAlignment: AttitudeAlignment = Quaternion.IDENTITY, val headingAlignment: HeadingAlignment = Quaternion.IDENTITY, - ) +) fun applyCalibration( rawRotation: RawRotation, diff --git a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt index e3c800d1c6..6cba2a24c9 100644 --- a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt @@ -299,7 +299,7 @@ class SessionCalibrationTest { ref * frontRot, hA, ref, - yawOffset + yawOffset, ) } } @@ -326,7 +326,7 @@ class SessionCalibrationTest { val estimateHeadingAlign = estimateHeadingAlign( rawRotation, reference, - yawOffset = yawOffset + yawOffset = yawOffset, ) quaternionAssertEquals( headingAlign, From 40d214b6b3bd9e78e4a1e21c233896f70f0d426f Mon Sep 17 00:00:00 2001 From: Erimel Date: Mon, 27 Jul 2026 17:04:17 -0400 Subject: [PATCH 302/350] Optimise fallbackprocessor, 200hz skeleton w/ smoothing --- .../java/dev/slimevr/skeleton/behaviours.kt | 5 +-- .../main/java/dev/slimevr/skeleton/module.kt | 8 +++-- .../skeleton/processors/bone-fallback.kt | 28 --------------- .../skeleton/processors/bone-prediction.kt | 5 +-- .../skeleton/processors/bone-smoothing.kt | 9 ++--- .../skeleton/processors/bone-yaw-fallback.kt | 34 +++++++++++++++++++ 6 files changed, 50 insertions(+), 39 deletions(-) delete mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/bone-fallback.kt create mode 100644 server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-fallback.kt diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 55129ba1f5..3375dbe24c 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart +import java.util.Vector import kotlin.math.cos import kotlin.math.sin @@ -29,7 +30,7 @@ class BoneTransformBehaviour : SkeletonBehaviour { is SkeletonActions.DisableBone -> { val bone = state.boneInputs[action.bodyPart] ?: return state - state.copy(boneInputs = state.boneInputs.mutate { it[action.bodyPart] = bone.copy(isActive = false) }) + state.copy(boneInputs = state.boneInputs.mutate { it[action.bodyPart] = bone.copy(rawRotation = Quaternion.IDENTITY, rawPosition = Vector3.NULL, isActive = false) }) } else -> state @@ -119,7 +120,7 @@ class PauseTrackingBehaviour : SkeletonBehaviour { } class ComputedSkeletonBehaviour( - val hz: Float = 100f, // TODO behaviours like smoothing will behave different based on hz + val hz: Float, val processors: List = emptyList(), ) : SkeletonBehaviour { override fun observe(receiver: Skeleton) { diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 94603d634a..b523e709c2 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -5,7 +5,7 @@ import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.skeleton.processors.BoneActiveLinkProcessor import dev.slimevr.skeleton.processors.BoneDirectLinkProcessor -import dev.slimevr.skeleton.processors.BoneFallbackProcessor +import dev.slimevr.skeleton.processors.BoneYawFallbackProcessor import dev.slimevr.skeleton.processors.BonePredictionProcessor import dev.slimevr.skeleton.processors.BoneSmoothingProcessor import dev.slimevr.skeleton.processors.BoneYawRollAlignProcessor @@ -127,6 +127,7 @@ class Skeleton( companion object { fun create(scope: CoroutineScope, ctx: Phase1ContextProvider): Skeleton { + val skeletonRefreshRate = 200f val behaviours = listOf( PauseTrackingBehaviour(), BoneTransformBehaviour(), @@ -134,15 +135,16 @@ class Skeleton( HeightLogBehaviour(), // YouSpinMeRightRoundBehaviour(inputHz = 50f), ComputedSkeletonBehaviour( + hz = skeletonRefreshRate, processors = listOf( - BoneFallbackProcessor(), + BoneYawFallbackProcessor(), BoneActiveLinkProcessor(), SpineImputeProcessor(ctx.config.settings), BoneYawRollAlignProcessor(ctx.config.settings), BoneDirectLinkProcessor(), FingerImputeProcessor(), BonePredictionProcessor(ctx.config.settings), - BoneSmoothingProcessor(ctx.config.settings), + BoneSmoothingProcessor(ctx.config.settings, skeletonRefreshRate), ), ), ) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-fallback.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-fallback.kt deleted file mode 100644 index 04b3c334a1..0000000000 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-fallback.kt +++ /dev/null @@ -1,28 +0,0 @@ -package dev.slimevr.skeleton.processors - -import dev.slimevr.skeleton.SkeletonProcessor -import dev.slimevr.skeleton.SkeletonState -import dev.slimevr.skeleton.findFirstParent -import dev.slimevr.skeleton.mapValues -import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 - -/** - * Handles replacing rotations of boneInputs that are not actively receiving data by either - * falling back to their parent's yaw or the identity rotation. - */ -class BoneFallbackProcessor : SkeletonProcessor { - override fun process(state: SkeletonState): SkeletonState { - val boneInputs = state.boneInputs - - return state.copy( - boneInputs = boneInputs.mapValues { bodyPart, bone -> - if (bone.isActive) return@mapValues bone - - val firstActiveParentBone = boneInputs[bodyPart.findFirstParent { boneInputs[it]?.isActive == true }] - val fallbackRotation = firstActiveParentBone?.rawRotation?.project(Vector3.POS_Y)?.unit() ?: Quaternion.IDENTITY - bone.copy(rawRotation = fallbackRotation) - }, - ) - } -} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt index 9cde924230..d517a36824 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt @@ -26,6 +26,7 @@ class BonePredictionProcessor(val settings: Settings) : SkeletonProcessor { override fun process(state: SkeletonState): SkeletonState { val config = settings.context.state.value.data.skeletonConfig.filtering if (config.type != FilteringType.PREDICTION) return state + val predictionAmount = config.amount val newVelocities = bodyPartMap() val newBones = state.boneInputs.mapValues { bodyPart, bone -> @@ -45,10 +46,10 @@ class BonePredictionProcessor(val settings: Settings) : SkeletonProcessor { prev.offsetDelta } newVelocities[bodyPart] = BoneVelocity(bone.rawRotation, rotationDelta, bone.offset, lengthDelta) - val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, config.amount).unit() + val scaledDelta = Quaternion.IDENTITY.lerpR(rotationDelta, predictionAmount).unit() bone.copy( rawRotation = (scaledDelta * bone.rawRotation).unit(), - offset = bone.offset + lengthDelta * config.amount, + offset = bone.offset + lengthDelta * predictionAmount, ) } velocities = newVelocities diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt index dc691cb317..c6803ebb0e 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt @@ -13,7 +13,7 @@ import solarxr_protocol.rpc.FilteringType /** * Running average of bones to smooth them out. */ -class BoneSmoothingProcessor(val settings: Settings) : SkeletonProcessor { +class BoneSmoothingProcessor(val settings: Settings, val skeletonRefreshRate: Float) : SkeletonProcessor { private var smoothedRotations: BodyPartMap = bodyPartMap() private var smoothedLengths: BodyPartMap = bodyPartMap() @@ -22,7 +22,8 @@ class BoneSmoothingProcessor(val settings: Settings) : SkeletonProcessor { val config = settings.context.state.value.data.skeletonConfig.filtering if (config.type != FilteringType.SMOOTHING) return state - val alpha = 1 - (SMOOTH_MIN + config.amount.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN)) + val smoothingAmount = SMOOTH_MIN + config.amount.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN) + val alpha = (1 - smoothingAmount) / (skeletonRefreshRate / 100f) smoothedRotations = state.boneInputs.mapValues { bodyPart, bone -> (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() @@ -42,7 +43,7 @@ class BoneSmoothingProcessor(val settings: Settings) : SkeletonProcessor { } companion object { - private const val SMOOTH_MIN = 0.63f - private const val SMOOTH_MAX = 0.94f + private const val SMOOTH_MIN = 0.62f + private const val SMOOTH_MAX = 0.9f } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-fallback.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-fallback.kt new file mode 100644 index 0000000000..cb29e1b8ef --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-fallback.kt @@ -0,0 +1,34 @@ +package dev.slimevr.skeleton.processors + +import dev.slimevr.skeleton.BODY_PART_HIERARCHY_MAP +import dev.slimevr.skeleton.SkeletonProcessor +import dev.slimevr.skeleton.SkeletonState +import dev.slimevr.skeleton.mutate +import io.github.axisangles.ktmath.Vector3 +import kotlin.collections.set + +/** + * Handles replacing rotations of boneInputs that are not actively receiving data by + * falling back to their parent's yaw. + */ +class BoneYawFallbackProcessor : SkeletonProcessor { + override fun process(state: SkeletonState): SkeletonState { + val boneInputs = state.boneInputs + + val updatedFallbackBones = boneInputs.mutate { updated -> + for ((parentPart, childParts) in BODY_PART_HIERARCHY_MAP) { + val parentBone = boneInputs.getValue(parentPart) + if (!parentBone.isActive) continue // Parent needs to be active + + for (childPart in childParts) { + val childBone = boneInputs.getValue(childPart) + if (childBone.isActive) continue // Child needs to be inactive + + updated[childPart] = childBone.copy(rawRotation = parentBone.rawRotation.project(Vector3.POS_Y).unit()) + } + } + } + + return state.copy(boneInputs = updatedFallbackBones) + } +} From 952bdd78847f6f8d62a0987e887676190bbf82b7 Mon Sep 17 00:00:00 2001 From: Erimel Date: Mon, 27 Jul 2026 19:43:21 -0400 Subject: [PATCH 303/350] Skeleton hz and driver fixes --- .../src/main/java/dev/slimevr/driver/behaviours.kt | 4 ++-- .../dev/slimevr/outputtrackertoggle/behaviours.kt | 7 ++++--- .../main/java/dev/slimevr/skeleton/behaviours.kt | 14 +++++++++----- .../src/main/java/dev/slimevr/skeleton/module.kt | 2 +- .../slimevr/skeleton/processors/bone-smoothing.kt | 2 +- .../main/java/dev/slimevr/desktop/ipc/protocol.kt | 2 ++ 6 files changed, 19 insertions(+), 12 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 41bc8e8e46..7cfd9c62eb 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -52,8 +52,7 @@ class DriverOutgoingTrackersBehaviour : DriverBridgeBehaviour { .onEach { (computedBones, enabledBodyParts) -> val serverState = receiver.appContext.server.context.state.value - // One pass over the trackers instead of a scan per fallback part per bone. - // putIfAbsent keeps the first match, which is what the find { } did. + // Map the nearest trackers to their body parts val trackerStateByBodyPart = bodyPartMap() for (tracker in serverState.trackers.values) { val trackerState = tracker.context.state.value @@ -70,6 +69,7 @@ class DriverOutgoingTrackersBehaviour : DriverBridgeBehaviour { val newTracker = subscribedTrackers.add(part.value) if (newTracker) { + // FIXME : sometimes doesn't work when launching SteamVR after SlimeVR receiver.outbound.emit( DriverBridgeOutbound.TrackerAdded( trackerId = part.value.toInt(), diff --git a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt index ac236f2a65..09a3193250 100644 --- a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt @@ -20,13 +20,14 @@ class OutputTrackerToggleBasicBehaviour : OutputTrackerToggleBehaviour { BodyPart.UPPER_CHEST to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST), BodyPart.LEFT_UPPER_ARM to setOf(BodyPart.LEFT_UPPER_ARM, BodyPart.LEFT_LOWER_ARM), BodyPart.RIGHT_UPPER_ARM to setOf(BodyPart.RIGHT_UPPER_ARM, BodyPart.RIGHT_LOWER_ARM), - BodyPart.HIP to setOf(BodyPart.UPPER_CHEST, BodyPart.CHEST, BodyPart.WAIST, BodyPart.HIP), + BodyPart.HIP to setOf(BodyPart.HIP, BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), BodyPart.LEFT_UPPER_LEG to setOf(BodyPart.LEFT_UPPER_LEG), BodyPart.RIGHT_UPPER_LEG to setOf(BodyPart.RIGHT_UPPER_LEG), - BodyPart.LEFT_FOOT to setOf(BodyPart.LEFT_UPPER_LEG, BodyPart.LEFT_LOWER_LEG, BodyPart.LEFT_FOOT), - BodyPart.RIGHT_FOOT to setOf(BodyPart.RIGHT_UPPER_LEG, BodyPart.RIGHT_LOWER_LEG, BodyPart.RIGHT_FOOT), + BodyPart.LEFT_FOOT to setOf(BodyPart.LEFT_FOOT, BodyPart.LEFT_LOWER_LEG, BodyPart.LEFT_UPPER_LEG), + BodyPart.RIGHT_FOOT to setOf(BodyPart.RIGHT_FOOT, BodyPart.RIGHT_LOWER_LEG, BodyPart.RIGHT_UPPER_LEG), ) + // TODO filter out DeviceOrigin.STEAMVR fun determineAutomaticOutputTrackers( config: OutputTrackersConfig, okBodyParts: Set, diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 3375dbe24c..246f2f3c35 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -12,9 +12,9 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart -import java.util.Vector import kotlin.math.cos import kotlin.math.sin +import kotlin.time.Duration.Companion.nanoseconds class BoneTransformBehaviour : SkeletonBehaviour { override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { @@ -120,15 +120,16 @@ class PauseTrackingBehaviour : SkeletonBehaviour { } class ComputedSkeletonBehaviour( - val hz: Float, + val hz: Long, val processors: List = emptyList(), ) : SkeletonBehaviour { override fun observe(receiver: Skeleton) { - val intervalMs = (1000f / hz).toLong() + val intervalNs = 1_000_000_000L / hz receiver.context.scope.launch { while (true) { try { - delay(intervalMs) + val startTime = System.nanoTime() + val targetState = receiver.context.state.value val processed = processors .fold(targetState) { state, processor -> processor.process(state) } // TODO: Add a constrain processor (maybe not needed) @@ -153,10 +154,13 @@ class ComputedSkeletonBehaviour( if (!targetState.paused) { receiver.computed.value = fk } + + // TODO log when we can't reach `hz` + delay((intervalNs - (System.nanoTime() - startTime)).nanoseconds) } catch (e: CancellationException) { throw e } catch (e: Exception) { - dev.slimevr.logging.AppLogger.coroutines.error(e, "Error in ComputedSkeletonBehaviour") + AppLogger.coroutines.error(e, "Error in ComputedSkeletonBehaviour") } } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index b523e709c2..b628200b5f 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -127,7 +127,7 @@ class Skeleton( companion object { fun create(scope: CoroutineScope, ctx: Phase1ContextProvider): Skeleton { - val skeletonRefreshRate = 200f + val skeletonRefreshRate = 300L val behaviours = listOf( PauseTrackingBehaviour(), BoneTransformBehaviour(), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt index c6803ebb0e..e45ddb774a 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt @@ -13,7 +13,7 @@ import solarxr_protocol.rpc.FilteringType /** * Running average of bones to smooth them out. */ -class BoneSmoothingProcessor(val settings: Settings, val skeletonRefreshRate: Float) : SkeletonProcessor { +class BoneSmoothingProcessor(val settings: Settings, val skeletonRefreshRate: Long) : SkeletonProcessor { private var smoothedRotations: BodyPartMap = bodyPartMap() private var smoothedLengths: BodyPartMap = bodyPartMap() diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 6373727b8d..2ef2df2f95 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -76,6 +76,8 @@ val bodyPartToRole = mapOf( BodyPart.RIGHT_UPPER_LEG to TrackerRole.RIGHT_KNEE, BodyPart.LEFT_FOOT to TrackerRole.LEFT_FOOT, BodyPart.RIGHT_FOOT to TrackerRole.RIGHT_FOOT, + BodyPart.LEFT_HAND to TrackerRole.LEFT_HAND, + BodyPart.RIGHT_HAND to TrackerRole.RIGHT_HAND, ) const val PROTOCOL_VERSION = 2 From 10c8088c8599067e345b563c8576f1636a34eb09 Mon Sep 17 00:00:00 2001 From: Erimel Date: Mon, 27 Jul 2026 19:46:54 -0400 Subject: [PATCH 304/350] Reset reference orientation fixes --- .../src/main/java/dev/slimevr/resets/module.kt | 2 +- .../main/java/dev/slimevr/tracker/behaviours.kt | 1 + .../dev/slimevr/tracker/session-calibration.kt | 14 +++++++++----- .../dev/slimevr/tracker/SessionCalibrationTest.kt | 3 ++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index 95168c21de..d6761533f7 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -115,7 +115,7 @@ class ResetsManager(val context: ResetsContext, val server: VRServer, val settin config.resetMountingFeet || it.context.state.value.bodyPart !in FOOT_PARTS } - } + }.filter { it.context.state.value.position == null } val referenceRotation = allTrackers .map { it.context.state.value } diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index f534f9b531..ddaa3ad790 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -101,6 +101,7 @@ class TrackerBasicBehaviour : TrackerBehaviour { val attitudeAlignment = estimateAttitudeAlign( state.rawRotation, headingCorrection, + action.referenceRotation, ) val sessionCalibration = state.sessionCalibration?.copy( diff --git a/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt b/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt index 364dc15d70..ba115295ba 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/session-calibration.kt @@ -3,6 +3,7 @@ package dev.slimevr.tracker import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 +import java.util.Vector import kotlin.math.atan2 typealias RawRotation = Quaternion @@ -64,22 +65,26 @@ fun undoCalibration( rawRotation: RawRotation, headingCorrect: HeadingCorrection = Quaternion.IDENTITY, headingAlign: HeadingAlignment = Quaternion.IDENTITY, - restOrientation: RestOrientation = Quaternion.IDENTITY, ): RawAcceleration = accelerationRotation(rawRotation, headingCorrect, headingAlign).inv() .sandwich(calibratedAcceleration) +// Used to get yaw. Works better for IMU trackers. private fun eulerHeading(q: Quaternion): Quaternion = Quaternion.rotationAroundYAxis(q.toEulerAngles(EulerOrder.YZX).y).twinNearest(q) +// Used to get yaw. Works better on an HMD. +private fun inverseYProjection(q: Quaternion) = q.project(Vector3.POS_Y).unit().inv() + fun estimateHeadingCorrect( rawRotation: RawRotation, referenceRotation: Quaternion, -): HeadingCorrection = eulerHeading(eulerHeading(referenceRotation).inv() * rawRotation).inv() +): HeadingCorrection = eulerHeading(inverseYProjection(referenceRotation) * rawRotation).inv() .twinNearest(referenceRotation) fun estimateAttitudeAlign( rawRotation: RawRotation, headingCorrect: HeadingCorrection, -): AttitudeAlignment = (headingCorrect * rawRotation).inv() + referenceRotation: Quaternion, +): AttitudeAlignment = (headingCorrect * (inverseYProjection(referenceRotation) * rawRotation)).inv() fun estimateHeadingAlign( rawRotation: RawRotation, @@ -89,14 +94,13 @@ fun estimateHeadingAlign( headingAlign: HeadingAlignment = Quaternion.IDENTITY, yawOffset: Float = 0.0f, ): HeadingAlignment { - val refHeading = eulerHeading(referenceRotation) val rotation = applyCalibration( rawRotation, headingCorrect, attitudeAlign, headingAlign, ) - val pitchRoll = (refHeading.inv() * rotation).sandwichUnitY() + val pitchRoll = (inverseYProjection(referenceRotation) * rotation).sandwichUnitY() val yawAngle = atan2(pitchRoll.x, pitchRoll.z) + yawOffset return Quaternion.rotationAroundYAxis(yawAngle) } diff --git a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt index 6cba2a24c9..c1a90ba470 100644 --- a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt @@ -257,6 +257,7 @@ class SessionCalibrationTest { } } + // TODO test different referenceRotation fun testEstimateSessionCalibration( calibratedRotation: CalibratedRotation, headingCorrect: HeadingCorrection, @@ -278,7 +279,7 @@ class SessionCalibrationTest { // TODO: See if we can avoid using twinNearest val estimatedAttitudeAlign = - estimateAttitudeAlign(rawRotation, estimatedHeadingCorrect) + estimateAttitudeAlign(rawRotation, estimatedHeadingCorrect, Quaternion.IDENTITY) quaternionAssertEquals( attitudeAlign, estimatedAttitudeAlign.twinNearest(attitudeAlign), From 8762da715d40350e0f1d137a6a34063451fb2463 Mon Sep 17 00:00:00 2001 From: Erimel Date: Mon, 27 Jul 2026 20:11:32 -0400 Subject: [PATCH 305/350] Fix upper legs bad behaviour BoneYawRollAlignProcessor --- .../slimevr/skeleton/processors/bone-yaw-roll-align.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt index 3c34a9c57a..950df344de 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt @@ -12,7 +12,7 @@ import solarxr_protocol.datatypes.BodyPart * Handles rotating bones' yaw and roll to match other bones' yaw and roll. */ class BoneYawRollAlignProcessor(val settings: Settings) : SkeletonProcessor { - private class SourceLink(val bodyPart: BodyPart, val sources: Array, val onlyWhenInactive: Boolean) + private class SourceLink(val bodyPart: BodyPart, val sources: Array, val mustBeActive: Boolean) /** * First value is the BodyPart to be aligned. @@ -21,9 +21,9 @@ class BoneYawRollAlignProcessor(val settings: Settings) : SkeletonProcessor { * whether the BodyPart to be aligned must be inactive. */ private val bodyPartToSources = arrayOf( - SourceLink(BodyPart.HIP, arrayOf(BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG), onlyWhenInactive = true), - SourceLink(BodyPart.LEFT_UPPER_LEG, arrayOf(BodyPart.LEFT_LOWER_LEG), onlyWhenInactive = false), - SourceLink(BodyPart.RIGHT_UPPER_LEG, arrayOf(BodyPart.RIGHT_LOWER_LEG), onlyWhenInactive = false), + SourceLink(BodyPart.HIP, arrayOf(BodyPart.LEFT_UPPER_LEG, BodyPart.RIGHT_UPPER_LEG), mustBeActive = false), + SourceLink(BodyPart.LEFT_UPPER_LEG, arrayOf(BodyPart.LEFT_LOWER_LEG), mustBeActive = true), + SourceLink(BodyPart.RIGHT_UPPER_LEG, arrayOf(BodyPart.RIGHT_LOWER_LEG), mustBeActive = true), ) override fun process(state: SkeletonState): SkeletonState { @@ -33,7 +33,7 @@ class BoneYawRollAlignProcessor(val settings: Settings) : SkeletonProcessor { val updatedAlignedBones = boneInputs.mutate { updated -> for (link in bodyPartToSources) { val bone = boneInputs.getValue(link.bodyPart) - if (link.onlyWhenInactive && bone.isActive) continue + if (bone.isActive != link.mustBeActive) continue val mixFactor = when (link.bodyPart) { BodyPart.HIP -> ratios.interpolateHipWithUpperLegs From c158ed821c1b7bce9da8cdf3382aba2cb2705187 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Mon, 27 Jul 2026 21:43:06 -0400 Subject: [PATCH 306/350] Add referenceRotation to session calibration test --- .../slimevr/tracker/SessionCalibrationTest.kt | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt index c1a90ba470..540ab7c54b 100644 --- a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt @@ -245,32 +245,34 @@ class SessionCalibrationTest { // We can only estimate session calibration with yaw and pitch, roll cannot be // compensated for return heading.flatMap { hC -> - pitch.map { aA -> - DynamicTest.dynamicTest( - "( hC: $hC, aA: $aA )", - ) { - // We can just use identity for the target orientation as only the - // calibration quaternions themselves matter. - testEstimateSessionCalibration(Quaternion.IDENTITY, hC, aA) + pitch.flatMap { aA -> + heading.map { rR -> + DynamicTest.dynamicTest( + "( hC: $hC, aA: $aA )", + ) { + // We can just use identity for the target orientation as only the + // calibration quaternions themselves matter. + testEstimateSessionCalibration(Quaternion.IDENTITY, hC, aA, rR) + } } } } } - // TODO test different referenceRotation fun testEstimateSessionCalibration( calibratedRotation: CalibratedRotation, headingCorrect: HeadingCorrection, attitudeAlign: AttitudeAlignment, + referenceRotation: Quaternion, ) { val rawRotation = - undoCalibration(calibratedRotation, headingCorrect, attitudeAlign) + undoCalibration(referenceRotation * calibratedRotation, headingCorrect, attitudeAlign) // TODO: Can we avoid needing to use twinNearest? It would be best if it was // just inherently in the right quaternion space (for both heading & attitude). // This might also just not be a problem, I'm not sure. val estimatedHeadingCorrect = - estimateHeadingCorrect(rawRotation, Quaternion.IDENTITY) + estimateHeadingCorrect(rawRotation, referenceRotation) quaternionAssertEquals( headingCorrect, estimatedHeadingCorrect.twinNearest(headingCorrect), @@ -279,7 +281,7 @@ class SessionCalibrationTest { // TODO: See if we can avoid using twinNearest val estimatedAttitudeAlign = - estimateAttitudeAlign(rawRotation, estimatedHeadingCorrect, Quaternion.IDENTITY) + estimateAttitudeAlign(rawRotation, estimatedHeadingCorrect, referenceRotation) quaternionAssertEquals( attitudeAlign, estimatedAttitudeAlign.twinNearest(attitudeAlign), From 6f537ab170a8e75d0d3dc6e4b779a9e0dd059caa Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Mon, 27 Jul 2026 21:44:21 -0400 Subject: [PATCH 307/350] Add missing restRotation variable to test comment --- .../src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt index 540ab7c54b..9500e77917 100644 --- a/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt +++ b/server/core/src/test/java/dev/slimevr/tracker/SessionCalibrationTest.kt @@ -248,7 +248,7 @@ class SessionCalibrationTest { pitch.flatMap { aA -> heading.map { rR -> DynamicTest.dynamicTest( - "( hC: $hC, aA: $aA )", + "( hC: $hC, aA: $aA, rR: $rR )", ) { // We can just use identity for the target orientation as only the // calibration quaternions themselves matter. From 20afbdb71ba69e4c13f6e5f37052d45bdd1db2a2 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Mon, 27 Jul 2026 22:33:33 -0400 Subject: [PATCH 308/350] Messy ccdIk loop --- .../src/main/java/dev/slimevr/skeleton/ik.kt | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/ik.kt b/server/core/src/main/java/dev/slimevr/skeleton/ik.kt index bee46f451b..78cf6906f9 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/ik.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/ik.kt @@ -4,13 +4,13 @@ import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.BodyPart -fun chainDistanceFromTarget(bones: Map, chain: List, target: Vector3): Float { +fun chainDistanceFromTarget(bones: BodyPartMap, chain: List, target: Vector3): Float { val endBone = bones[chain.last()] requireNotNull(endBone) return (target - endBone.tailPosition).len() } -fun chainCanReach(bones: Map, chain: List, target: Vector3): Boolean { +fun chainCanReach(bones: BodyPartMap, chain: List, target: Vector3): Boolean { val rootBone = bones[chain.first()] requireNotNull(rootBone) val chainLength = chain.fold(0f) { acc, bodyPart -> @@ -20,9 +20,8 @@ fun chainCanReach(bones: Map, chain: List, target return (target - rootBone.headPosition).len() <= chainLength } -// Single loop over a chain -fun ccdIk(boneInputs: BodyPartMap, bones: Map, chain: List, target: Vector3): BodyPartMap { - val workingBones = BodyPartMap(boneInputs) +fun ccdIkIteration(boneInputs: BodyPartMap, bones: BodyPartMap, chain: List, target: Vector3): BodyPartMap { + val workingBones = boneInputs.toMutableMap() // Reversed index, i increases backwards up the chain for ((i, bodyPart) in chain.reversed().withIndex()) { @@ -35,6 +34,7 @@ fun ccdIk(boneInputs: BodyPartMap, bones: Map, c (i.toFloat() / chain.size) * 0.5f, ).unit() + // TODO: Apply constraints workingBones[bodyPart] = currentRawBone.copy( rawRotation = currentBone.rotation * rotationChange, ) @@ -45,3 +45,35 @@ fun ccdIk(boneInputs: BodyPartMap, bones: Map, c val skeletonRootPos = bones[BodyPart.HEAD]?.headPosition ?: Vector3.NULL return buildBones(workingBones, skeletonRootPos) } + +data class IKChainGoal( + val chain: List, + val target: Vector3, +) + +data class IKOutput( + val bones: BodyPartMap, + val goalsReached: Map, +) + +fun ccdIk(boneInputs: BodyPartMap, bones: BodyPartMap, goals: List, threshold: Float, maxIterations: Int): IKOutput { + var curBones = bones + + for (i in 0..maxIterations) { + // Iterate chains not meeting the threshold + curBones = goals.filter { + chainDistanceFromTarget(bones, it.chain, it.target) > threshold + }.ifEmpty { + break + }.fold(curBones) { bones, goal -> + ccdIkIteration(boneInputs, bones, goal.chain, goal.target) + } + } + + return IKOutput( + curBones, + goals.associateWith { + chainDistanceFromTarget(bones, it.chain, it.target) <= threshold + }, + ) +} From 0105b4813c6aed458528f3008bf5bef324609144 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Mon, 27 Jul 2026 22:14:05 -0500 Subject: [PATCH 309/350] Use JDK 25 --- .github/workflows/build.yml | 12 ++++++------ flake.lock | 19 +------------------ flake.nix | 9 ++------- server/android/build.gradle.kts | 6 +++--- server/core/build.gradle.kts | 2 +- server/desktop/build.gradle.kts | 6 +++--- solarxr-protocol | 2 +- 7 files changed, 17 insertions(+), 39 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 361bcb2d0c..53c90563cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -92,10 +92,10 @@ jobs: uses: actions/checkout@v6 with: submodules: recursive - - name: Set up JDK 24 + - name: Set up JDK 25 uses: actions/setup-java@v5 with: - java-version: '24' + java-version: '25' distribution: 'adopt' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 @@ -115,10 +115,10 @@ jobs: submodules: recursive - name: Get tags run: git fetch --tags origin --recurse-submodules=no --force - - name: Set up JDK 24 + - name: Set up JDK 25 uses: actions/setup-java@v5 with: - java-version: '24' + java-version: '25' distribution: 'adopt' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 @@ -262,10 +262,10 @@ jobs: submodules: recursive - name: Get tags run: git fetch --tags origin --recurse-submodules=no --force - - name: Set up JDK 24 + - name: Set up JDK 25 uses: actions/setup-java@v5 with: - java-version: '24' + java-version: '25' distribution: 'adopt' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 diff --git a/flake.lock b/flake.lock index b5ed91e99d..0bf2adca63 100644 --- a/flake.lock +++ b/flake.lock @@ -34,22 +34,6 @@ "type": "github" } }, - "nixpkgs-jdk24": { - "locked": { - "lastModified": 1756787288, - "narHash": "sha256-rw/PHa1cqiePdBxhF66V7R+WAP8WekQ0mCDG4CFqT8Y=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "d0fc30899600b9b3466ddb260fd83deb486c32f1", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "d0fc30899600b9b3466ddb260fd83deb486c32f1", - "type": "github" - } - }, "nixpkgs-lib": { "locked": { "lastModified": 1777168982, @@ -68,8 +52,7 @@ "root": { "inputs": { "flake-parts": "flake-parts", - "nixpkgs": "nixpkgs", - "nixpkgs-jdk24": "nixpkgs-jdk24" + "nixpkgs": "nixpkgs" } } }, diff --git a/flake.nix b/flake.nix index 7240011ed6..8697f7b966 100644 --- a/flake.nix +++ b/flake.nix @@ -3,8 +3,6 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - # Pinned to a revision that still ships temurin jdk-24 - nixpkgs-jdk24.url = "github:NixOS/nixpkgs/d0fc30899600b9b3466ddb260fd83deb486c32f1"; flake-parts.url = "github:hercules-ci/flake-parts"; }; @@ -12,7 +10,6 @@ inputs@{ self, nixpkgs, - nixpkgs-jdk24, flake-parts, ... }: @@ -30,8 +27,6 @@ ... }: let - java = (import nixpkgs-jdk24 { inherit system; }).jdk24; - runtimeLibs = [ pkgs.alsa-lib pkgs.libpulseaudio @@ -123,7 +118,7 @@ # Toolchain shared by the dev shell and the packaging FHS env. commonPackages = [ # for running the jar - java + pkgs.jdk25 # for build pkgs.electron @@ -149,7 +144,7 @@ ]; buildEnv = { - JAVA_HOME = "${java}/lib/openjdk"; + JAVA_HOME = "${pkgs.jdk25}/lib/openjdk"; USE_SYSTEM_FPM = "true"; ELECTRON_BUILDER_7ZIP_PATH = "${pkgs.p7zip}/bin/7za"; APPIMAGE_TOOLS_PATH = "${appImageTools}"; diff --git a/server/android/build.gradle.kts b/server/android/build.gradle.kts index 45a2385467..597569b7a8 100644 --- a/server/android/build.gradle.kts +++ b/server/android/build.gradle.kts @@ -20,12 +20,12 @@ plugins { kotlin { jvmToolchain { - languageVersion.set(JavaLanguageVersion.of(24)) + languageVersion.set(JavaLanguageVersion.of(25)) } } java { toolchain { - languageVersion.set(JavaLanguageVersion.of(24)) + languageVersion.set(JavaLanguageVersion.of(25)) } } @@ -81,7 +81,7 @@ val deleteTempKeyStore = tasks.register("deleteTempKeyStore") { tasks.withType { compilerOptions { - jvmTarget.set(JvmTarget.JVM_24) + jvmTarget.set(JvmTarget.JVM_25) freeCompilerArgs.set(listOf("-Xvalue-classes")) } } diff --git a/server/core/build.gradle.kts b/server/core/build.gradle.kts index 4a789c0854..ca2bd30bcc 100644 --- a/server/core/build.gradle.kts +++ b/server/core/build.gradle.kts @@ -25,7 +25,7 @@ allprojects { } kotlin { - jvmToolchain(24) + jvmToolchain(25) jvm() sourceSets { diff --git a/server/desktop/build.gradle.kts b/server/desktop/build.gradle.kts index 17c7a2508a..957d0afb93 100644 --- a/server/desktop/build.gradle.kts +++ b/server/desktop/build.gradle.kts @@ -19,17 +19,17 @@ plugins { kotlin { jvmToolchain { - languageVersion.set(JavaLanguageVersion.of(24)) + languageVersion.set(JavaLanguageVersion.of(25)) } } java { toolchain { - languageVersion.set(JavaLanguageVersion.of(24)) + languageVersion.set(JavaLanguageVersion.of(25)) } } tasks.withType { compilerOptions { - jvmTarget.set(JvmTarget.JVM_24) + jvmTarget.set(JvmTarget.JVM_25) freeCompilerArgs.set(listOf("-Xvalue-classes")) } } diff --git a/solarxr-protocol b/solarxr-protocol index cc9d94b3df..b8c68382fe 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit cc9d94b3df1e02cfb1f5efbc5f5de5883ee684ee +Subproject commit b8c68382fe0bb1916479b9d3411fa3f332b9b8c2 From 59a76aa898284beeed537c6e07fa89075ad6ff6b Mon Sep 17 00:00:00 2001 From: Sapphire Date: Tue, 28 Jul 2026 13:36:59 -0500 Subject: [PATCH 310/350] fixup! Use JDK 25 --- server/android/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/android/build.gradle.kts b/server/android/build.gradle.kts index 597569b7a8..dff11d5f53 100644 --- a/server/android/build.gradle.kts +++ b/server/android/build.gradle.kts @@ -224,7 +224,7 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_24 - targetCompatibility = JavaVersion.VERSION_24 + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 } } From 484a65c1b2af3887f440080090a567992c38a76f Mon Sep 17 00:00:00 2001 From: Aed <145398159+Aed-1@users.noreply.github.com> Date: Tue, 28 Jul 2026 20:48:47 +0100 Subject: [PATCH 311/350] Allow the user to input into auto height proportions box (#1887) --- gui/app/public/i18n/en/translation.ftl | 2 + gui/app/src/components/commons/Input.tsx | 33 ++++- .../pages/body-proportions/HeightInput.tsx | 118 +++++++++++++++++- .../slimevr/solarxr/skeleton-proportions.kt | 2 +- 4 files changed, 144 insertions(+), 11 deletions(-) diff --git a/gui/app/public/i18n/en/translation.ftl b/gui/app/public/i18n/en/translation.ftl index 25ee5c2308..4aba5f119d 100644 --- a/gui/app/public/i18n/en/translation.ftl +++ b/gui/app/public/i18n/en/translation.ftl @@ -1357,6 +1357,8 @@ onboarding-user_height-title = What is your height? onboarding-user_height-description = We need your height to calculate your body proportions and accurately represent your movements. You can either let SlimeVR calculate it, or input your height manually. onboarding-user_height-need_head_tracker = A headset and controllers with positional tracking are required to perform the calibration. onboarding-user_height-calculate = Calculate my height automatically +onboarding-user_height-error_bounds = Input too high or too low +onboarding-user_height-error_format = Input wrong format onboarding-user_height-next_step = Continue and save onboarding-user_height-prev_step = Back onboarding-user_height-manual-proportions = Manual Proportions diff --git a/gui/app/src/components/commons/Input.tsx b/gui/app/src/components/commons/Input.tsx index ba93fc99b3..b597bf932f 100644 --- a/gui/app/src/components/commons/Input.tsx +++ b/gui/app/src/components/commons/Input.tsx @@ -24,6 +24,9 @@ export const InputInside = forwardRef< label?: string; error?: FieldError; autocomplete?: boolean | string; + className?: string; + errorClassName?: string; + onBlur?: () => void; } & Partial> >(function AppInput( { @@ -37,6 +40,9 @@ export const InputInside = forwardRef< value, error, variant = 'primary', + className, + errorClassName, + onBlur, }, ref ) { @@ -90,9 +96,13 @@ export const InputInside = forwardRef<

{type === 'password' && (
)} {error?.message && ( -
+
{error.message}
)} @@ -129,11 +145,17 @@ export const Input = ({ disabled, variant = 'primary', rules, + className, + errorClassName, + onBlur, }: { rules?: UseControllerProps>['rules']; control: Control; name: FieldPath; autocomplete?: boolean | string; + className?: string; + errorClassName?: string; + onBlur?: () => void; } & Omit & Partial>) => { return ( @@ -157,6 +179,9 @@ export const Input = ({ onChange={onChange} ref={ref} name={name} + className={className} + errorClassName={errorClassName} + onBlur={onBlur} /> )} /> diff --git a/gui/app/src/components/onboarding/pages/body-proportions/HeightInput.tsx b/gui/app/src/components/onboarding/pages/body-proportions/HeightInput.tsx index ac7b42a2dc..8b1cb19dc7 100644 --- a/gui/app/src/components/onboarding/pages/body-proportions/HeightInput.tsx +++ b/gui/app/src/components/onboarding/pages/body-proportions/HeightInput.tsx @@ -1,10 +1,15 @@ +import { Input } from '@/components/commons/Input'; import { Typography } from '@/components/commons/Typography'; import { useBreakpoint } from '@/hooks/breakpoint'; import { EYE_HEIGHT_TO_HEIGHT_RATIO } from '@/hooks/height'; import { useLocaleConfig } from '@/i18n/config'; import classNames from 'classnames'; import convert from 'convert'; -import { useMemo, useState } from 'react'; +import { useMemo, useState, useEffect, useRef } from 'react'; +import { useForm } from 'react-hook-form'; +import { useLocalization } from '@fluent/react'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { string, object } from 'yup'; function IncrementButton({ value, @@ -121,6 +126,9 @@ export function HeightSelectionInput({ const { isXs } = useBreakpoint('xs'); const [unit, setUnit] = useState<'meter' | 'foot'>('meter'); const { currentLocales } = useLocaleConfig(); + const { l10n } = useLocalization(); + const isSubmitting = useRef(false); + const footRegex = /^(\d+)(?:[′'.,\s]+(\d+(?:\.\d+)?)?[″"”]?)?$/; const formattedHeight = useMemo(() => { if (!hmdHeight) return '--'; @@ -140,6 +148,95 @@ export function HeightSelectionInput({ return formatInFoot(displayHeight, currentLocales); }, [hmdHeight, unit]); + const defaultValues: { height: string } = { + height: formattedHeight, + }; + + const { reset, control, watch, trigger, handleSubmit } = useForm<{ + height: string; + }>({ + defaultValues, + mode: 'onChange', + reValidateMode: 'onChange', + + resolver: yupResolver( + object({ + height: string() + .defined() + .test( + 'format', + l10n.getString('onboarding-user_height-error_format'), + function (value) { + if (unit === 'meter') { + return !isNaN(Number(value.replace(/[ m]/g, ''))); + } else { + return footRegex.test(value); + } + } + ) + .test( + 'bounds', + l10n.getString('onboarding-user_height-error_bounds'), + function (value) { + if (unit === 'meter') { + const newNum = Number(value.replace(/[ m]/g, '')); + return newNum >= 0.97 && newNum <= 2.56; + } else { + const match = value.match(footRegex); + if (!match) return; + const feet = Number(match[1]); + const inches = Number(match[2] || 0); + return ( + !(feet > 8 || (feet === 8 && inches > 4)) && + !(feet < 3 || (feet === 3 && inches < 2)) + ); + } + } + ), + }) + ), + }); + + const onSubmit = async (values: { height: string }) => { + if (!(await trigger('height'))) return; + isSubmitting.current = true; + + if (unit === 'meter') { + const newFullHeight = Number(values.height.replace(/[ m]/g, '')); + + setHmdHeight(round4Digit(newFullHeight * EYE_HEIGHT_TO_HEIGHT_RATIO)); + } else { + const match = values.height.match(footRegex); + if (!match) return; + + const feet = Number(match[1]); + const inches = Number(match[2] || 0); + + const newFullHeight = convert(feet + inches / 12, 'foot').to('meter'); + + setHmdHeight(round4Digit(newFullHeight * EYE_HEIGHT_TO_HEIGHT_RATIO)); + } + }; + + useEffect(() => { + const subscription = watch((value, { type }) => { + if (type === 'change') { + handleSubmit(onSubmit)(); + } + }); + return () => subscription.unsubscribe(); + }, []); + + useEffect(() => { + if (isSubmitting.current) return; + + reset({ height: defaultValues.height }); + }, [defaultValues.height]); + + useEffect(() => { + reset({ height: formattedHeight }); + }, [unit]); + const incrementMath = (unit: 'inch' | 'cm' | 'foot', value: number) => { const incrementInMeters = convert(value, unit).to('meter'); const oldFull = hmdHeight / EYE_HEIGHT_TO_HEIGHT_RATIO; @@ -182,6 +279,7 @@ export function HeightSelectionInput({ const newEyeHeight = round4Digit( snappedHeight * EYE_HEIGHT_TO_HEIGHT_RATIO ); + setHmdHeight(newEyeHeight); setUnit(newUnit); }; @@ -222,11 +320,19 @@ export function HeightSelectionInput({ )}
-
-
- {formattedHeight} -
-
+
+ { + reset({ height: formattedHeight }); + isSubmitting.current = false; + }} + /> +
Date: Tue, 28 Jul 2026 18:17:17 -0400 Subject: [PATCH 312/350] use cool TimeSource instead of System.nanoTime --- .../main/java/dev/slimevr/hid/behaviours.kt | 19 +++++----- .../java/dev/slimevr/resets/behaviours.kt | 12 +++---- .../main/java/dev/slimevr/resets/module.kt | 8 ++--- .../java/dev/slimevr/skeleton/behaviours.kt | 36 ++++++++++++++----- .../main/java/dev/slimevr/skeleton/module.kt | 2 +- .../skeleton/processors/bone-smoothing.kt | 4 ++- .../slimevr/skeleton/skating-correction.kt | 4 +-- .../dev/slimevr/tapdetection/behaviours.kt | 33 ++++++++--------- server/core/src/main/java/dev/slimevr/time.kt | 8 +++++ .../java/dev/slimevr/tracker/behaviours.kt | 7 ++-- .../src/main/java/dev/slimevr/udp/server.kt | 2 +- .../src/test/java/dev/slimevr/TestServer.kt | 2 +- .../slimevr/tapdetection/TapDetectionTest.kt | 31 ++++++++-------- .../desktop/trackingchecklist/behaviours.kt | 4 +-- 14 files changed, 102 insertions(+), 70 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/time.kt diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index b6a2459c79..65ad0002a9 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -5,14 +5,15 @@ import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.logging.AppLogger +import dev.slimevr.timeSource import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import kotlinx.coroutines.Job import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.launch import solarxr_protocol.datatypes.TrackerStatus -import kotlin.time.TimeSource +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds class HIDRegistrationBehaviour : HIDReceiverBehaviour { override fun reduce(state: HIDReceiverState, action: HIDReceiverActions) = when (action) { @@ -173,14 +174,14 @@ class HIDBatteryBehaviour : HIDReceiverBehaviour { } } -private const val HID_TIMEOUT_MS = 2_000L +private val hidTimeout = 2.seconds class HIDSleepBehaviour : HIDReceiverBehaviour { override fun observe(receiver: HIDReceiver) { - val startedAt = TimeSource.Monotonic.markNow() + val startedAt = timeSource.markNow() val sleepJobs = mutableMapOf() val idleJobs = mutableMapOf() - val lastSeen = mutableMapOf() + val lastSeen = mutableMapOf() fun scheduleSleep(hidId: Int, timeoutMs: Int) { if (timeoutMs == 0) return @@ -196,13 +197,13 @@ class HIDSleepBehaviour : HIDReceiverBehaviour { } fun armIdleTimeout(hidId: Int) { - lastSeen[hidId] = startedAt.elapsedNow().inWholeMilliseconds + lastSeen[hidId] = startedAt.elapsedNow() if (idleJobs[hidId]?.isActive == true) return idleJobs[hidId] = receiver.context.scope.launch { - var remaining = HID_TIMEOUT_MS - while (remaining > 0) { + var remaining = hidTimeout + while (remaining > Duration.ZERO) { delay(remaining) - remaining = (lastSeen[hidId] ?: 0L) + HID_TIMEOUT_MS - startedAt.elapsedNow().inWholeMilliseconds + remaining = (lastSeen[hidId] ?: Duration.ZERO) + hidTimeout - startedAt.elapsedNow() } receiver.getTracker(hidId)?.context?.dispatch(TrackerActions.SetStatus(TrackerStatus.TIMED_OUT)) } diff --git a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt index a79ae0d752..d0cbebdbc3 100644 --- a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt @@ -1,11 +1,13 @@ package dev.slimevr.resets +import dev.slimevr.timeSource import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapLatest import solarxr_protocol.rpc.ResetType +import kotlin.time.Duration.Companion.seconds class ResetsBasicBehaviour : ResetsBehaviour { override fun reduce(state: ResetsState, action: ResetsActions) = when (action) { @@ -20,7 +22,7 @@ class ResetsBasicBehaviour : ResetsBehaviour { // Whenever a reset is finished is ResetsActions.EndReset -> { if (action.resetType == ResetType.FULL) { - state.copy(canDoYawReset = true, canDoMountingReset = true, lastFullResetTime = System.nanoTime()) + state.copy(canDoYawReset = true, canDoMountingReset = true, lastFullResetTime = timeSource.markNow()) } else { state.copy() } @@ -29,18 +31,16 @@ class ResetsBasicBehaviour : ResetsBehaviour { } class ResetsMountingTimeoutBehaviour : ResetsBehaviour { + val mountingResetTimeout = 120.seconds + @OptIn(ExperimentalCoroutinesApi::class) override fun observe(receiver: ResetsManager) { receiver.context.state .distinctUntilChangedBy { it.lastFullResetTime } .mapLatest { - delay(MOUNTING_RESET_TIMEOUT) + delay(mountingResetTimeout) receiver.context.dispatch(ResetsActions.ClearResets(listOf(ResetType.MOUNTING))) } .launchIn(receiver.context.scope) } - - companion object { - const val MOUNTING_RESET_TIMEOUT = 120 * 1000L // 120 seconds - } } diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index d6761533f7..27eb3395ed 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -10,10 +10,7 @@ import dev.slimevr.config.SettingsActions import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.logging.AppLogger -import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions -import io.github.axisangles.ktmath.EulerAngles -import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -28,11 +25,12 @@ import solarxr_protocol.rpc.ResetStatus import solarxr_protocol.rpc.ResetType import kotlin.collections.contains import kotlin.collections.listOf +import kotlin.time.TimeMark data class ResetsState( val canDoYawReset: Boolean, val canDoMountingReset: Boolean, - val lastFullResetTime: Long, + val lastFullResetTime: TimeMark?, ) sealed interface ResetsActions { @@ -166,7 +164,7 @@ class ResetsManager(val context: ResetsContext, val server: VRServer, val settin initialState = ResetsState( canDoYawReset = false, canDoMountingReset = false, - lastFullResetTime = 0, + lastFullResetTime = null, ), scope = scope, behaviours = listOf(ResetsBasicBehaviour(), ResetsMountingTimeoutBehaviour()), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 246f2f3c35..ea1c47d73a 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -2,6 +2,7 @@ package dev.slimevr.skeleton import dev.slimevr.config.UserConfig import dev.slimevr.logging.AppLogger +import dev.slimevr.timeSource import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import io.ktor.utils.io.CancellationException @@ -15,6 +16,7 @@ import solarxr_protocol.datatypes.BodyPart import kotlin.math.cos import kotlin.math.sin import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds class BoneTransformBehaviour : SkeletonBehaviour { override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { @@ -78,10 +80,10 @@ class YouSpinMeRightRoundBehaviour(val inputHz: Float = 1f) : SkeletonBehaviour override fun observe(receiver: Skeleton) { receiver.context.scope.launch { val intervalMs = (1000f / inputHz).toLong() - val startTime = System.currentTimeMillis() + val startTime = timeSource.markNow() while (true) { delay(intervalMs) - val elapsed = (System.currentTimeMillis() - startTime) / 1000f + val elapsed = startTime.elapsedNow().inWholeMilliseconds / 1000f val state = receiver.context.state.value receiver.context.dispatch( @@ -120,17 +122,24 @@ class PauseTrackingBehaviour : SkeletonBehaviour { } class ComputedSkeletonBehaviour( - val hz: Long, + val hz: Int, val processors: List = emptyList(), ) : SkeletonBehaviour { + val logSpamWaitDuration = 10.seconds + val minimumDelay = 1.nanoseconds + override fun observe(receiver: Skeleton) { - val intervalNs = 1_000_000_000L / hz + val intervalDuration = (1.0 / hz).seconds + var nextLogTime = timeSource.markNow() + receiver.context.scope.launch { while (true) { try { - val startTime = System.nanoTime() + val startTime = timeSource.markNow() val targetState = receiver.context.state.value + + // Run processors val processed = processors .fold(targetState) { state, processor -> processor.process(state) } // TODO: Add a constrain processor (maybe not needed) @@ -139,6 +148,7 @@ class ComputedSkeletonBehaviour( ?.let { Vector3(it.rawPosition.x, it.rawPosition.y, it.rawPosition.z) } ?: Vector3(0f, targetState.skeletonHeight, 0f) + // Run FK val fk = buildBones(processed, rootHead = rootHead) // val targetProcessors = [FloorClip, FloorSkating, ToePlant, FootPlant] @@ -149,14 +159,22 @@ class ComputedSkeletonBehaviour( // // val ikOutput = solver.solve(fk, targets) -// receiver.computed.value = ikOutput - + // FIXME bones should still follow the head when paused if (!targetState.paused) { receiver.computed.value = fk +// receiver.computed.value = ikOutput + } + + val processTime = startTime.elapsedNow() + val delayDuration = (intervalDuration - processTime).coerceAtLeast(minimumDelay) + + // Warn if the process took too long to reach the target hz. + if (delayDuration <= minimumDelay && nextLogTime.hasPassedNow()) { + AppLogger.skeleton.warn("Can't reach target hz ($hz), process time = $processTime") + nextLogTime = timeSource.markNow() + logSpamWaitDuration } - // TODO log when we can't reach `hz` - delay((intervalNs - (System.nanoTime() - startTime)).nanoseconds) + delay(delayDuration) } catch (e: CancellationException) { throw e } catch (e: Exception) { diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index b628200b5f..9da823a0c4 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -127,7 +127,7 @@ class Skeleton( companion object { fun create(scope: CoroutineScope, ctx: Phase1ContextProvider): Skeleton { - val skeletonRefreshRate = 300L + val skeletonRefreshRate = 300 val behaviours = listOf( PauseTrackingBehaviour(), BoneTransformBehaviour(), diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt index e45ddb774a..bf7c45c3ed 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt @@ -13,7 +13,9 @@ import solarxr_protocol.rpc.FilteringType /** * Running average of bones to smooth them out. */ -class BoneSmoothingProcessor(val settings: Settings, val skeletonRefreshRate: Long) : SkeletonProcessor { +// TODO checking skeletonRefreshRate is not good enough +// since we're never gonna get that in practice. Need something more robust. +class BoneSmoothingProcessor(val settings: Settings, val skeletonRefreshRate: Int) : SkeletonProcessor { private var smoothedRotations: BodyPartMap = bodyPartMap() private var smoothedLengths: BodyPartMap = bodyPartMap() diff --git a/server/core/src/main/java/dev/slimevr/skeleton/skating-correction.kt b/server/core/src/main/java/dev/slimevr/skeleton/skating-correction.kt index 7784668b79..d37056281e 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/skating-correction.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/skating-correction.kt @@ -1,11 +1,11 @@ package dev.slimevr.skeleton +import dev.slimevr.timeSource import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.BodyPart import kotlin.time.ComparableTimeMark import kotlin.time.DurationUnit -import kotlin.time.TimeSource // TODO We should probably just have globally available velocity on the whole skeleton, // we could do that as a processing step @@ -87,7 +87,7 @@ class SkatingCorrectionProcessor : SkeletonProcessor { var comState: COMState? = null override fun process(state: SkeletonState): SkeletonState { - val curTime = TimeSource.Monotonic.markNow() + val curTime = timeSource.markNow() val lastComState = comState val com = centerOfMass(curPositions) diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt index 8f68cf7f12..50d1c37994 100644 --- a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt @@ -1,6 +1,7 @@ package dev.slimevr.tapdetection import dev.slimevr.config.TapDetectionConfig +import dev.slimevr.timeSource import dev.slimevr.tracker.Tracker import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -17,17 +18,18 @@ import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.ResetType import solarxr_protocol.rpc.TapDetectionSetupNotification +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeMark class TapDetectionBasicBehaviour : TapDetectionBehaviour { - data class TrackerTapDetectionState( var trackerId: Int, var numberTrackersOverThreshold: Int = 1, var resetType: ResetType? = null, var tapsNeeded: Int = 2, var actionDelay: Float = 0f, - val accelList: ArrayDeque> = ArrayDeque(), - val tapTimestamps: ArrayDeque = ArrayDeque(), + val accelList: ArrayDeque> = ArrayDeque(), + val tapTimestamps: ArrayDeque = ArrayDeque(), var waitForLowAccel: Boolean = false, ) @@ -92,7 +94,7 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { .distinctUntilChanged() .onEach { val tapTriggered = runTapDetection( - System.nanoTime(), + timeSource.markNow(), trackersOverThreshold, trackerTapDetectionState, it, @@ -168,21 +170,21 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { // Logic loop for tap detection fun runTapDetection( - now: Long, + now: TimeMark, trackersOverThreshold: MutableSet, trackerTapDetectionState: TrackerTapDetectionState, trackerAcceleration: Vector3, ): Boolean { - // Get the acceleration of the tracker and store it - trackerTapDetectionState.accelList.add(trackerAcceleration.len() to now) - // Remove old stored accelerations (if they are too old) - while (trackerTapDetectionState.accelList.isNotEmpty() && now - trackerTapDetectionState.accelList.first().second > ACCEL_WINDOW_NS) { + while (trackerTapDetectionState.accelList.isNotEmpty() && (trackerTapDetectionState.accelList.first().second + accelWindow).hasPassedNow()) { trackerTapDetectionState.accelList.removeFirst() } - val max = trackerTapDetectionState.accelList.maxOfOrNull { it.first } ?: 0f - val min = trackerTapDetectionState.accelList.minOfOrNull { it.first } ?: 0f + // Get the acceleration of the tracker and store it + trackerTapDetectionState.accelList.add(trackerAcceleration.len() to now) + + val max = trackerTapDetectionState.accelList.maxOf { it.first } + val min = trackerTapDetectionState.accelList.minOf { it.first } val accelDelta = max - min // Is this tracker over threshold for false positive prevention? @@ -207,8 +209,8 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { if (trackerTapDetectionState.tapTimestamps.isNotEmpty()) { // Remove old stored taps (if they are too old) - val totalTapWindowNs = (TAP_WINDOW_PER_TAP_NS * trackerTapDetectionState.tapTimestamps.size).toLong() - while (trackerTapDetectionState.tapTimestamps.isNotEmpty() && now - trackerTapDetectionState.tapTimestamps.first() > totalTapWindowNs) { + val totalTapWindow = tapWindowPerTap * trackerTapDetectionState.tapTimestamps.size + while (trackerTapDetectionState.tapTimestamps.isNotEmpty() && (trackerTapDetectionState.tapTimestamps.first() + totalTapWindow).hasPassedNow()) { trackerTapDetectionState.tapTimestamps.removeFirst() } @@ -231,11 +233,10 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { } companion object { - const val NS_CONVERTER = 1.0e9f - const val ACCEL_WINDOW_NS = 0.06f * NS_CONVERTER - const val TAP_WINDOW_PER_TAP_NS = 0.3f * NS_CONVERTER const val NEEDED_ACCEL_DELTA = 6.0f const val ALLOWED_BODY_ACCEL = 2.5f const val ALLOWED_BODY_ACCEL_SQUARED = ALLOWED_BODY_ACCEL * ALLOWED_BODY_ACCEL + val accelWindow = 0.06.seconds + val tapWindowPerTap = 0.3.seconds } } diff --git a/server/core/src/main/java/dev/slimevr/time.kt b/server/core/src/main/java/dev/slimevr/time.kt new file mode 100644 index 0000000000..c07c00a837 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/time.kt @@ -0,0 +1,8 @@ +package dev.slimevr + +import kotlin.time.TimeSource + +/** + * We use Monotonic timesource, which is the most precise (`System.nanoTime()`) + */ +val timeSource = TimeSource.Monotonic diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index ddaa3ad790..41a2ac8d8e 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -8,6 +8,7 @@ import dev.slimevr.resets.LEFT_FINGER_PARTS import dev.slimevr.resets.RIGHT_ARM_PARTS import dev.slimevr.resets.RIGHT_FINGER_PARTS import dev.slimevr.skeleton.SkeletonActions +import dev.slimevr.timeSource import io.github.axisangles.ktmath.EulerAngles import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion @@ -31,7 +32,7 @@ import solarxr_protocol.rpc.ArmsResetMode import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch -import kotlin.time.TimeSource +import kotlin.time.Duration.Companion.seconds class TrackerBasicBehaviour : TrackerBehaviour { override fun reduce(state: TrackerState, action: TrackerActions) = when (action) { @@ -229,14 +230,14 @@ class TrackerTPSBehaviour : TrackerBehaviour { }.launchIn(receiver.context.scope) receiver.context.scope.launch { - var mark = TimeSource.Monotonic.markNow() + var mark = timeSource.markNow() while (isActive) { try { delay(1000) val elapsed = mark.elapsedNow() val tps = count.exchange(0) * 1000L / elapsed.inWholeMilliseconds receiver.context.dispatch(TrackerActions.Update { copy(tps = tps.toUShort()) }) - mark = TimeSource.Monotonic.markNow() + mark = timeSource.markNow() } catch (e: Exception) { AppLogger.coroutines.error(e, "Error in TrackerTPSBehaviour") } diff --git a/server/core/src/main/java/dev/slimevr/udp/server.kt b/server/core/src/main/java/dev/slimevr/udp/server.kt index cd63f1fe80..4e1daeac7b 100644 --- a/server/core/src/main/java/dev/slimevr/udp/server.kt +++ b/server/core/src/main/java/dev/slimevr/udp/server.kt @@ -86,7 +86,7 @@ class UdpServer(val context: UdpServerContext, private val addressResolver: (Ine } } if (took.inWholeMilliseconds > 2) { - AppLogger.udp.warn("Packet processing took too long ${took.inWholeMilliseconds}") + AppLogger.udp.warn("Packet processing took too long (${took.inWholeMilliseconds}ms)") } } catch (e: Exception) { AppLogger.udp.error(e, "Error processing UDP packet") diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index e60abc56ca..816084cd34 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -97,7 +97,7 @@ fun buildTestResetsManager(server: VRServer, settings: Settings, scope: Coroutin initialState = ResetsState( canDoYawReset = true, canDoMountingReset = true, - lastFullResetTime = 0, + lastFullResetTime = null, ), scope = scope, behaviours = listOf(ResetsBasicBehaviour(), ResetsMountingTimeoutBehaviour()), diff --git a/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt b/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt index 9866373622..47f440228a 100644 --- a/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt +++ b/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt @@ -1,12 +1,15 @@ package dev.slimevr.tapdetection +import dev.slimevr.timeSource import io.github.axisangles.ktmath.Vector3 import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.time.Duration +import kotlin.time.TestTimeSource class TapDetectionTest { data class AccelEvent( - val delaySincePrevious: Long, + val delaySincePrevious: Duration, val accel: Vector3, val expectedTap: Boolean, ) @@ -15,16 +18,16 @@ class TapDetectionTest { val highAccel = Vector3(0f, 0f, TapDetectionBasicBehaviour.NEEDED_ACCEL_DELTA * 3f) // Low accel needed as rest between taps - val lowAccel = Vector3(0f, 0f, 0f) + val lowAccel = Vector3.NULL // Accelerations needed for a tap happen with this delay - val accelDelay = (TapDetectionBasicBehaviour.ACCEL_WINDOW_NS * 0.9f).toLong() + val accelDelay = TapDetectionBasicBehaviour.accelWindow * 0.9 // Delay between several taps - val tapDelay = (TapDetectionBasicBehaviour.TAP_WINDOW_PER_TAP_NS * 0.9f).toLong() + val tapDelay = TapDetectionBasicBehaviour.tapWindowPerTap * 0.9 // Too long of a delay between several taps - val tapTimeoutDelay = (TapDetectionBasicBehaviour.TAP_WINDOW_PER_TAP_NS * 1.1f).toLong() + val tapTimeoutDelay = TapDetectionBasicBehaviour.tapWindowPerTap * 1.1 /** * Runs a sequence of tap events through TapDetection, asserting the results. @@ -40,13 +43,13 @@ class TapDetectionTest { val trackersOverThreshold = mutableSetOf() val behaviour = TapDetectionBasicBehaviour() - var now = 0L + val timeSource = TestTimeSource() events.forEach { event -> - now += event.delaySincePrevious + timeSource += event.delaySincePrevious assertEquals( event.expectedTap, behaviour.runTapDetection( - now = now, + now = timeSource.markNow(), trackersOverThreshold = trackersOverThreshold, trackerTapDetectionState = state, trackerAcceleration = event.accel, @@ -59,7 +62,7 @@ class TapDetectionTest { fun `Detect double tap`() = runTapSequence( tapsNeeded = 2, events = listOf( - AccelEvent(0L, lowAccel, expectedTap = false), + AccelEvent(Duration.ZERO, lowAccel, expectedTap = false), AccelEvent(accelDelay, highAccel, expectedTap = false), AccelEvent(tapDelay, lowAccel, expectedTap = false), AccelEvent(accelDelay, highAccel, expectedTap = true), @@ -70,7 +73,7 @@ class TapDetectionTest { fun `Double tap timed out`() = runTapSequence( tapsNeeded = 2, events = listOf( - AccelEvent(0L, lowAccel, expectedTap = false), + AccelEvent(Duration.ZERO, lowAccel, expectedTap = false), AccelEvent(accelDelay, highAccel, expectedTap = false), AccelEvent(tapTimeoutDelay, lowAccel, expectedTap = false), AccelEvent(accelDelay, highAccel, expectedTap = false), @@ -81,7 +84,7 @@ class TapDetectionTest { fun `Detect triple tap`() = runTapSequence( tapsNeeded = 3, events = listOf( - AccelEvent(0L, lowAccel, expectedTap = false), + AccelEvent(Duration.ZERO, lowAccel, expectedTap = false), AccelEvent(accelDelay, highAccel, expectedTap = false), AccelEvent(tapDelay, lowAccel, expectedTap = false), AccelEvent(accelDelay, highAccel, expectedTap = false), @@ -94,7 +97,7 @@ class TapDetectionTest { fun `Triple tap timed out`() = runTapSequence( tapsNeeded = 3, events = listOf( - AccelEvent(0L, lowAccel, expectedTap = false), + AccelEvent(Duration.ZERO, lowAccel, expectedTap = false), AccelEvent(accelDelay, highAccel, expectedTap = false), AccelEvent(tapDelay, lowAccel, expectedTap = false), AccelEvent(accelDelay, highAccel, expectedTap = false), @@ -107,11 +110,11 @@ class TapDetectionTest { fun `Tap not repeated while accel stays high`() = runTapSequence( tapsNeeded = 2, events = listOf( - AccelEvent(0L, lowAccel, expectedTap = false), + AccelEvent(Duration.ZERO, lowAccel, expectedTap = false), AccelEvent(accelDelay, highAccel, expectedTap = false), AccelEvent(tapDelay, highAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), AccelEvent(tapDelay, highAccel, expectedTap = false), - AccelEvent(tapTimeoutDelay, highAccel, expectedTap = false), ), ) } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt b/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt index 1e1fb78f66..dcffeae63b 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt @@ -11,6 +11,7 @@ import dev.slimevr.Platform import dev.slimevr.VRServer import dev.slimevr.desktop.getSteamVRDriversList import dev.slimevr.logging.AppLogger +import dev.slimevr.timeSource import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.trackingchecklist.TrackingChecklistActions import dev.slimevr.trackingchecklist.TrackingChecklistBehaviourType @@ -35,7 +36,6 @@ import java.io.IOException import java.net.ConnectException import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeMark -import kotlin.time.TimeSource data class Process(val pid: ULong, val name: String) @@ -126,7 +126,7 @@ class SteamVRCheckBehaviour(private val server: VRServer) : TrackingChecklistBeh scannedRunning = getRunningProcesses().any { proc -> proc.name == steamVRProcName } - lastScan = TimeSource.Monotonic.markNow() + lastScan = timeSource.markNow() } val running = connected || drivers != null || scannedRunning From 6562d417219ff7f0fee56b776b23915d593fbd08 Mon Sep 17 00:00:00 2001 From: Erimel Date: Tue, 28 Jul 2026 20:54:48 -0400 Subject: [PATCH 313/350] Calculate frametimes for skeleton and make smoothing truly independant --- .../main/java/dev/slimevr/bvh/behaviours.kt | 6 +- .../java/dev/slimevr/driver/behaviours.kt | 4 +- .../java/dev/slimevr/skeleton/behaviours.kt | 65 +++++++++++-------- .../main/java/dev/slimevr/skeleton/module.kt | 23 ++++--- .../skeleton/processors/bone-active-link.kt | 3 +- .../skeleton/processors/bone-direct-link.kt | 3 +- .../skeleton/processors/bone-prediction.kt | 3 +- .../skeleton/processors/bone-smoothing.kt | 13 ++-- .../skeleton/processors/bone-yaw-fallback.kt | 3 +- .../processors/bone-yaw-roll-align.kt | 3 +- .../skeleton/processors/fingers-impute.kt | 3 +- .../skeleton/processors/spine-impute.kt | 3 +- .../slimevr/skeleton/skating-correction.kt | 5 +- .../main/java/dev/slimevr/solarxr/datafeed.kt | 2 +- .../main/java/dev/slimevr/vmc/behaviours.kt | 4 +- .../main/java/dev/slimevr/vrcosc/output.kt | 2 +- 16 files changed, 85 insertions(+), 60 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt b/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt index 8bb265989f..9b5ae80644 100644 --- a/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt @@ -47,7 +47,7 @@ class BVHRecordingBehaviour( try { AppLogger.bvh.info("Opening BVH recording at ${storage.displayPath(resolvedPath)}") stream = BvhStream(storage.openTextFile(resolvedPath)).also { - it.writeHeader(skeleton.computed.value) + it.writeHeader(skeleton.computed.value.bones) // TODO: we could write the initial T-Pose or whatever here } } catch (e: Exception) { @@ -66,9 +66,9 @@ class BVHRecordingBehaviour( } }.launchIn(receiver.context.scope) - skeleton.computed.onEach { bones -> + skeleton.computed.onEach { computedSkeleton -> try { - stream?.writeFrame(bones) + stream?.writeFrame(computedSkeleton.bones) } catch (e: Exception) { AppLogger.bvh.error("Failed to write BVH frame", e) receiver.context.dispatch(BVHActions.StopRecording) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 7cfd9c62eb..1e92fbf2f7 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -49,7 +49,7 @@ class DriverOutgoingTrackersBehaviour : DriverBridgeBehaviour { ::Pair, ) .distinctUntilChanged() - .onEach { (computedBones, enabledBodyParts) -> + .onEach { (computedSkeleton, enabledBodyParts) -> val serverState = receiver.appContext.server.context.state.value // Map the nearest trackers to their body parts @@ -61,7 +61,7 @@ class DriverOutgoingTrackersBehaviour : DriverBridgeBehaviour { trackerStateByBodyPart.putIfAbsent(bodyPart, trackerState) } - computedBones.forEach { (part, state) -> + computedSkeleton.bones.forEach { (part, state) -> if (enabledBodyParts.contains(part)) { val closestTracker = bodyPartToNearest[part].orEmpty() .firstNotNullOfOrNull { fallbackPart -> trackerStateByBodyPart[fallbackPart] } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index ea1c47d73a..cc917ab838 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -15,8 +15,10 @@ import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart import kotlin.math.cos import kotlin.math.sin +import kotlin.time.Duration import kotlin.time.Duration.Companion.nanoseconds import kotlin.time.Duration.Companion.seconds +import kotlin.time.measureTime class BoneTransformBehaviour : SkeletonBehaviour { override fun reduce(state: SkeletonState, action: SkeletonActions): SkeletonState = when (action) { @@ -125,55 +127,64 @@ class ComputedSkeletonBehaviour( val hz: Int, val processors: List = emptyList(), ) : SkeletonBehaviour { - val logSpamWaitDuration = 10.seconds - val minimumDelay = 1.nanoseconds + private val intervalDuration = (1.0 / hz).seconds + private val logSpamWaitDuration = 15.seconds + private val minimumDelay = 1.nanoseconds override fun observe(receiver: Skeleton) { - val intervalDuration = (1.0 / hz).seconds var nextLogTime = timeSource.markNow() + var frameStartTime = timeSource.markNow() + var lastFrameTime = Duration.ZERO receiver.context.scope.launch { while (true) { try { - val startTime = timeSource.markNow() + // Process starts + val processTime = measureTime { - val targetState = receiver.context.state.value + val targetState = receiver.context.state.value - // Run processors - val processed = processors - .fold(targetState) { state, processor -> processor.process(state) } // TODO: Add a constrain processor (maybe not needed) + // Run processors + val processed = processors + .fold(targetState) { state, processor -> processor.process(state, lastFrameTime) } // TODO: Add a constrain processor (maybe not needed) - // Get head position - val rootHead = processed.boneInputs[BodyPart.HEAD] - ?.let { Vector3(it.rawPosition.x, it.rawPosition.y, it.rawPosition.z) } - ?: Vector3(0f, targetState.skeletonHeight, 0f) + // Get head position + val rootHead = processed.boneInputs[BodyPart.HEAD] + ?.let { Vector3(it.rawPosition.x, it.rawPosition.y, it.rawPosition.z) } + ?: Vector3(0f, targetState.skeletonHeight, 0f) - // Run FK - val fk = buildBones(processed, rootHead = rootHead) + // Run FK + val fk = buildBones(processed, rootHead = rootHead) -// val targetProcessors = [FloorClip, FloorSkating, ToePlant, FootPlant] +// val targetProcessors = [FloorClip, FloorSkating, ToePlant, FootPlant] // -// val targets = targetProcessors -// .filter { targetProcessors -> targetProcessors.enabled } -// .fold(emptyList()) { targets, processor -> processor.process(fk, targets) } +// val targets = targetProcessors +// .filter { targetProcessors -> targetProcessors.enabled } +// .fold(emptyList()) { targets, processor -> processor.process(fk, targets) } // -// val ikOutput = solver.solve(fk, targets) +// val ikOutput = solver.solve(fk, targets) - // FIXME bones should still follow the head when paused - if (!targetState.paused) { - receiver.computed.value = fk -// receiver.computed.value = ikOutput + // Frame ends + lastFrameTime = frameStartTime.elapsedNow() + + // Updated the computed skeleton with the result + if (!targetState.paused) { // FIXME : bones should still follow the head when paused + receiver.computed.value = ComputedSkeleton(fk, lastFrameTime) +// receiver.computed.value = ComputedSkeleton(ikOutput, lastFrameTime) + } } + // Process ends - val processTime = startTime.elapsedNow() - val delayDuration = (intervalDuration - processTime).coerceAtLeast(minimumDelay) + // Frame starts + frameStartTime = timeSource.markNow() - // Warn if the process took too long to reach the target hz. + // Wait the remainder of last process + val delayDuration = (intervalDuration - processTime).coerceAtLeast(minimumDelay) if (delayDuration <= minimumDelay && nextLogTime.hasPassedNow()) { + // Warn if the frame took too long to reach the target hz. AppLogger.skeleton.warn("Can't reach target hz ($hz), process time = $processTime") nextLogTime = timeSource.markNow() + logSpamWaitDuration } - delay(delayDuration) } catch (e: CancellationException) { throw e diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 9da823a0c4..c7fd52a020 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.SkeletonBone +import kotlin.time.Duration data class BoneInput( val bodyPart: BodyPart, @@ -116,26 +117,32 @@ sealed interface SkeletonActions { typealias SkeletonContext = Context typealias SkeletonBehaviour = Behaviour interface SkeletonProcessor { - fun process(state: SkeletonState): SkeletonState + fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState } +data class ComputedSkeleton( + val bones: BodyPartMap, + val frameTime: Duration, +) + class Skeleton( val context: SkeletonContext, - val computed: MutableStateFlow>, + val computed: MutableStateFlow, ) { fun startObserving() = context.observeAll(this) companion object { - fun create(scope: CoroutineScope, ctx: Phase1ContextProvider): Skeleton { - val skeletonRefreshRate = 300 + const val DEFAULT_HZ = 300 + + fun create(scope: CoroutineScope, ctx: Phase1ContextProvider, hz: Int = DEFAULT_HZ): Skeleton { val behaviours = listOf( PauseTrackingBehaviour(), BoneTransformBehaviour(), ProportionsBehaviour(ctx.config.userConfig), HeightLogBehaviour(), - // YouSpinMeRightRoundBehaviour(inputHz = 50f), +// YouSpinMeRightRoundBehaviour(inputHz = 50f), ComputedSkeletonBehaviour( - hz = skeletonRefreshRate, + hz = hz, processors = listOf( BoneYawFallbackProcessor(), BoneActiveLinkProcessor(), @@ -144,7 +151,7 @@ class Skeleton( BoneDirectLinkProcessor(), FingerImputeProcessor(), BonePredictionProcessor(ctx.config.settings), - BoneSmoothingProcessor(ctx.config.settings, skeletonRefreshRate), + BoneSmoothingProcessor(ctx.config.settings), ), ), ) @@ -156,7 +163,7 @@ class Skeleton( name = "Skeleton", ) - return Skeleton(context, MutableStateFlow(buildBones(context.state.value))) + return Skeleton(context, MutableStateFlow(ComputedSkeleton(buildBones(context.state.value), Duration.ZERO))) } } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt index 8bd547eaf6..c2b297c64d 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt @@ -4,6 +4,7 @@ import dev.slimevr.skeleton.SkeletonProcessor import dev.slimevr.skeleton.SkeletonState import dev.slimevr.skeleton.mutate import solarxr_protocol.datatypes.BodyPart +import kotlin.time.Duration /** * Handles setting the rotation of an inactive bone with the first active bone in its sources, or keeps @@ -23,7 +24,7 @@ class BoneActiveLinkProcessor : SkeletonProcessor { BodyPart.HIP to arrayOf(BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), ) - override fun process(state: SkeletonState): SkeletonState { + override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { val boneInputs = state.boneInputs val updatedLinkedBones = boneInputs.mutate { updated -> diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt index 78b526c56a..efd1a4740b 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt @@ -4,6 +4,7 @@ import dev.slimevr.skeleton.BodyPartMap import dev.slimevr.skeleton.SkeletonProcessor import dev.slimevr.skeleton.SkeletonState import solarxr_protocol.datatypes.BodyPart +import kotlin.time.Duration /** * Handles setting the rotation of an inactive bone with its source bone. @@ -37,7 +38,7 @@ class BoneDirectLinkProcessor : SkeletonProcessor { BodyPart.RIGHT_HAND to BodyPart.RIGHT_LOWER_ARM, ) - override fun process(state: SkeletonState): SkeletonState { + override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { val updatedBoneInputs = BodyPartMap(state.boneInputs) for ((bodyPart, source) in linkedToSource) { diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt index d517a36824..1b6b384e34 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt @@ -9,6 +9,7 @@ import dev.slimevr.skeleton.mapValues import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.rpc.FilteringType +import kotlin.time.Duration /** * Tries to predict future rotations of bones. @@ -23,7 +24,7 @@ class BonePredictionProcessor(val settings: Settings) : SkeletonProcessor { private var velocities: BodyPartMap = bodyPartMap() - override fun process(state: SkeletonState): SkeletonState { + override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { val config = settings.context.state.value.data.skeletonConfig.filtering if (config.type != FilteringType.PREDICTION) return state val predictionAmount = config.amount diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt index bf7c45c3ed..81835709d3 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt @@ -9,23 +9,22 @@ import dev.slimevr.skeleton.mapValues import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.rpc.FilteringType +import kotlin.time.Duration /** * Running average of bones to smooth them out. */ -// TODO checking skeletonRefreshRate is not good enough -// since we're never gonna get that in practice. Need something more robust. -class BoneSmoothingProcessor(val settings: Settings, val skeletonRefreshRate: Int) : SkeletonProcessor { +class BoneSmoothingProcessor(val settings: Settings) : SkeletonProcessor { private var smoothedRotations: BodyPartMap = bodyPartMap() private var smoothedLengths: BodyPartMap = bodyPartMap() // TODO this isn't linear. Do we want linear smoothing like in main? - override fun process(state: SkeletonState): SkeletonState { + override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { val config = settings.context.state.value.data.skeletonConfig.filtering if (config.type != FilteringType.SMOOTHING) return state val smoothingAmount = SMOOTH_MIN + config.amount.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN) - val alpha = (1 - smoothingAmount) / (skeletonRefreshRate / 100f) + val alpha = (1 - smoothingAmount) * (lastFrameTime.inWholeMicroseconds / 10_000f) smoothedRotations = state.boneInputs.mapValues { bodyPart, bone -> (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() @@ -45,7 +44,7 @@ class BoneSmoothingProcessor(val settings: Settings, val skeletonRefreshRate: In } companion object { - private const val SMOOTH_MIN = 0.62f - private const val SMOOTH_MAX = 0.9f + private const val SMOOTH_MIN = 0.6f + private const val SMOOTH_MAX = 0.93f } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-fallback.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-fallback.kt index cb29e1b8ef..78771484e6 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-fallback.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-fallback.kt @@ -6,13 +6,14 @@ import dev.slimevr.skeleton.SkeletonState import dev.slimevr.skeleton.mutate import io.github.axisangles.ktmath.Vector3 import kotlin.collections.set +import kotlin.time.Duration /** * Handles replacing rotations of boneInputs that are not actively receiving data by * falling back to their parent's yaw. */ class BoneYawFallbackProcessor : SkeletonProcessor { - override fun process(state: SkeletonState): SkeletonState { + override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { val boneInputs = state.boneInputs val updatedFallbackBones = boneInputs.mutate { updated -> diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt index 950df344de..97addf012d 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt @@ -7,6 +7,7 @@ import dev.slimevr.skeleton.mutate import dev.slimevr.skeleton.resolveRotationFor import io.github.axisangles.ktmath.Quaternion import solarxr_protocol.datatypes.BodyPart +import kotlin.time.Duration /** * Handles rotating bones' yaw and roll to match other bones' yaw and roll. @@ -26,7 +27,7 @@ class BoneYawRollAlignProcessor(val settings: Settings) : SkeletonProcessor { SourceLink(BodyPart.RIGHT_UPPER_LEG, arrayOf(BodyPart.RIGHT_LOWER_LEG), mustBeActive = true), ) - override fun process(state: SkeletonState): SkeletonState { + override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { val boneInputs = state.boneInputs val ratios = settings.context.state.value.data.skeletonConfig.ratios diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt index 6a4e85507f..34e92f78aa 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt @@ -6,6 +6,7 @@ import dev.slimevr.skeleton.SkeletonState import solarxr_protocol.datatypes.BodyPart import kotlin.collections.get import kotlin.collections.iterator +import kotlin.time.Duration /** * Handles rotations of inactive finger bones. @@ -50,7 +51,7 @@ class FingerImputeProcessor : SkeletonProcessor { ) // TODO : There's more math to do here. Reference the original code. - override fun process(state: SkeletonState): SkeletonState { + override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { val updatedBoneInputs = BodyPartMap(state.boneInputs) for ((bodyPart, source) in fingerToSource) { diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt index 0ba1ea5595..96eefa5ba9 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt @@ -6,6 +6,7 @@ import dev.slimevr.skeleton.SkeletonState import dev.slimevr.skeleton.mutate import dev.slimevr.skeleton.resolveRotationFor import solarxr_protocol.datatypes.BodyPart +import kotlin.time.Duration /** * Handles imputing the rotation of spine bones that are not actively receiving data from the rotations @@ -37,7 +38,7 @@ class SpineImputeProcessor(val settings: Settings) : SkeletonProcessor { else -> error("Invalid missing spine body part $bodyPart") } - override fun process(state: SkeletonState): SkeletonState { + override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { val boneInputs = state.boneInputs val ratios = settings.context.state.value.data.skeletonConfig.ratios diff --git a/server/core/src/main/java/dev/slimevr/skeleton/skating-correction.kt b/server/core/src/main/java/dev/slimevr/skeleton/skating-correction.kt index d37056281e..9da763dd0b 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/skating-correction.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/skating-correction.kt @@ -5,6 +5,7 @@ import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.BodyPart import kotlin.time.ComparableTimeMark +import kotlin.time.Duration import kotlin.time.DurationUnit // TODO We should probably just have globally available velocity on the whole skeleton, @@ -86,7 +87,7 @@ class SkatingCorrectionProcessor : SkeletonProcessor { var comState: COMState? = null - override fun process(state: SkeletonState): SkeletonState { + override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { val curTime = timeSource.markNow() val lastComState = comState @@ -160,7 +161,7 @@ class SkatingCorrectionProcessor : SkeletonProcessor { class FloorClipProcessor : SkeletonProcessor { fun getDisplacement(footPos: Vector3): Vector3 = Vector3(0f, footPos.y.coerceAtMost(0f), 0f) - override fun process(state: SkeletonState): SkeletonState { + override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { val newBones = state.boneInputs.mapValues { (bodyPart, bone) -> } state.boneInputs[BodyPart.LEFT_FOOT]?.let { val correction = getDisplacement(it.offset) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index cb4b761934..196d6402a2 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -139,7 +139,7 @@ fun createDatafeedFrame( null } val bones = datafeedConfig.boneMask?.let { mask -> - skeleton.computed.value.values.map { createBone(it, mask) } + skeleton.computed.value.bones.values.map { createBone(it, mask) } } val serverGuards = if (datafeedConfig.serverGuardsMask == true) { createServerGuards(resetsManager, heightCalibrationManager) diff --git a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt index 39e18f0e1b..e09d22feba 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt @@ -65,14 +65,14 @@ class VMCOutputBehaviour( }.launchIn(receiver.context.scope) skeleton.computed - .onEach { bones -> + .onEach { computedSkeleton -> val s = sender ?: return@onEach val config = settings.context.state.value.data.vmcConfig val currentTime = System.currentTimeMillis() val vrm = vrmGeometry receiver.context.scope.launch { try { - s.send(buildBundle(bones, config, currentTime, vrm)) + s.send(buildBundle(computedSkeleton.bones, config, currentTime, vrm)) } catch (e: Exception) { AppLogger.vmc.error("Failed to send VMC frame", e) } diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt index 2d6fedd364..af3a3a77b3 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt @@ -72,7 +72,7 @@ class VRCOSCOutputBehaviour( private fun observeFrames(receiver: VRCOSCManager, runtime: OutputRuntime) { skeleton.computed - .onEach { bones -> sendFrame(receiver, runtime, bones) } + .onEach { computedSkeleton -> sendFrame(receiver, runtime, computedSkeleton.bones) } .launchIn(receiver.context.scope) } From 05e8faeb34d214c1856854a2daf3ff2df054dba3 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 29 Jul 2026 03:35:36 +0200 Subject: [PATCH 314/350] Flightlist steps + unit tests --- .../main/java/dev/slimevr/config/settings.kt | 2 +- .../java/dev/slimevr/resets/behaviours.kt | 35 ++- .../main/java/dev/slimevr/resets/module.kt | 27 +- .../java/dev/slimevr/skeleton/behaviours.kt | 11 +- .../dev/slimevr/solarxr/tracking-checklist.kt | 25 +- .../slimevr/trackingchecklist/behaviours.kt | 125 +++++++++ .../dev/slimevr/trackingchecklist/module.kt | 4 + .../src/test/java/dev/slimevr/TestServer.kt | 59 +++- .../TrackingChecklistTest.kt | 251 ++++++++++++++++++ 9 files changed, 521 insertions(+), 18 deletions(-) create mode 100644 server/core/src/test/java/dev/slimevr/trackingchecklist/TrackingChecklistTest.kt diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 2dc3b614d1..ff83b99acb 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -90,7 +90,7 @@ data class ResetsConfig( val saveMountingReset: Boolean = false, /** Reset the HMD's pitch upon full reset */ val resetHmdPitch: Boolean = false, // TODO - val lastMountingMethod: MountingMethods = MountingMethods.AUTOMATIC, // TODO in trackingchecklist + val lastMountingMethod: MountingMethods = MountingMethods.AUTOMATIC, ) @Serializable diff --git a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt index d0cbebdbc3..ada2c53258 100644 --- a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt @@ -20,13 +20,38 @@ class ResetsBasicBehaviour : ResetsBehaviour { } // Whenever a reset is finished - is ResetsActions.EndReset -> { - if (action.resetType == ResetType.FULL) { - state.copy(canDoYawReset = true, canDoMountingReset = true, lastFullResetTime = timeSource.markNow()) - } else { - state.copy() + is ResetsActions.EndReset -> when (action.resetType) { + ResetType.FULL -> state.copy( + canDoYawReset = true, + canDoMountingReset = true, + lastFullResetTime = timeSource.markNow(), + mountingResetCompleted = false, + feetMountingResetCompleted = false, + ) + + ResetType.MOUNTING -> { + val bodyParts = action.bodyParts + val feetOnly = !bodyParts.isNullOrEmpty() && bodyParts.all { it in FOOT_PARTS } + when { + feetOnly -> state.copy(feetMountingResetCompleted = true) + + bodyParts == null -> state.copy( + mountingResetCompleted = true, + feetMountingResetCompleted = action.resetMountingFeet || state.feetMountingResetCompleted, + ) + + else -> state + } } + + else -> state } + + // Mounting calibration was cleared, reset the session completion flags + is ResetsActions.ClearMountingCompleted -> state.copy( + mountingResetCompleted = false, + feetMountingResetCompleted = false, + ) } } diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index 27eb3395ed..f08f4f6b51 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -31,11 +31,17 @@ data class ResetsState( val canDoYawReset: Boolean, val canDoMountingReset: Boolean, val lastFullResetTime: TimeMark?, + + // Session-only flags: whether a mounting reset (and a feet mounting reset) has been done at + // least once since the last full reset + val mountingResetCompleted: Boolean = false, + val feetMountingResetCompleted: Boolean = false, ) sealed interface ResetsActions { data class ClearResets(val resetType: List) : ResetsActions - data class EndReset(val resetType: ResetType) : ResetsActions + data class EndReset(val resetType: ResetType, val bodyParts: List? = null, val resetMountingFeet: Boolean = false) : ResetsActions + data object ClearMountingCompleted : ResetsActions } typealias ResetsContext = Context @@ -43,7 +49,7 @@ typealias ResetsBehaviour = Behaviour class ResetsManager(val context: ResetsContext, val server: VRServer, val settings: Settings) { fun startObserving() = context.observeAll(this) - + private var resetJob: Job = Job() /** @@ -79,8 +85,20 @@ class ResetsManager(val context: ResetsContext, val server: VRServer, val settin executeTrackerResets(resetType, bodyParts, settings.context.state.value.data.resetsConfig) // Update state and config - context.dispatch(ResetsActions.EndReset(resetType)) - settings.context.dispatch(SettingsActions.Update { copy(resetsConfig = resetsConfig.copy(lastMountingMethod = MountingMethods.MANUAL)) }) + context.dispatch(ResetsActions.EndReset(resetType, bodyParts, settings.context.state.value.data.resetsConfig.resetMountingFeet)) + settings.context.dispatch( + SettingsActions.Update { + copy( + resetsConfig = resetsConfig.copy( + lastMountingMethod = if (resetType == ResetType.MOUNTING) { + MountingMethods.AUTOMATIC + } else { + resetsConfig.lastMountingMethod + }, + ), + ) + }, + ) AppLogger.resets.info("${resetType.name} Reset from $resetSourceName") @@ -94,6 +112,7 @@ class ResetsManager(val context: ResetsContext, val server: VRServer, val settin suspend fun clearTrackersMountingReset(resetSourceName: String) { val trackers = server.context.state.value.trackers.values trackers.forEach { it.context.dispatch(TrackerActions.ClearMountingReset) } + context.dispatch(ResetsActions.ClearMountingCompleted) AppLogger.resets.info("Clear Mounting Reset from $resetSourceName") } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index cc917ab838..394e0ff54e 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -141,7 +141,6 @@ class ComputedSkeletonBehaviour( try { // Process starts val processTime = measureTime { - val targetState = receiver.context.state.value // Run processors @@ -156,13 +155,13 @@ class ComputedSkeletonBehaviour( // Run FK val fk = buildBones(processed, rootHead = rootHead) -// val targetProcessors = [FloorClip, FloorSkating, ToePlant, FootPlant] +// val targetProcessors = [FloorClip, FloorSkating, ToePlant, FootPlant] // -// val targets = targetProcessors -// .filter { targetProcessors -> targetProcessors.enabled } -// .fold(emptyList()) { targets, processor -> processor.process(fk, targets) } +// val targets = targetProcessors +// .filter { targetProcessors -> targetProcessors.enabled } +// .fold(emptyList()) { targets, processor -> processor.process(fk, targets) } // -// val ikOutput = solver.solve(fk, targets) +// val ikOutput = solver.solve(fk, targets) // Frame ends lastFrameTime = frameStartTime.elapsedNow() diff --git a/server/core/src/main/java/dev/slimevr/solarxr/tracking-checklist.kt b/server/core/src/main/java/dev/slimevr/solarxr/tracking-checklist.kt index de8f1307d5..19bf0cce46 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/tracking-checklist.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/tracking-checklist.kt @@ -12,6 +12,27 @@ import solarxr_protocol.rpc.TrackingChecklistRequest import solarxr_protocol.rpc.TrackingChecklistResponse import solarxr_protocol.rpc.TrackingChecklistStepId +private val CHECKLIST_STEP_ORDER: List = listOf( + TrackingChecklistStepId.NETWORK_PROFILE_PUBLIC, + TrackingChecklistStepId.STEAMVR_DISCONNECTED, + TrackingChecklistStepId.STANDABLE_INSTALLED, + TrackingChecklistStepId.STEAMVR_HANDS_ENABLED, + TrackingChecklistStepId.TRACKER_ERROR, + TrackingChecklistStepId.TRACKERS_REST_CALIBRATION, + TrackingChecklistStepId.FULL_RESET, + TrackingChecklistStepId.MOUNTING_CALIBRATION, + TrackingChecklistStepId.FEET_MOUNTING_CALIBRATION, + TrackingChecklistStepId.UNASSIGNED_HMD, + TrackingChecklistStepId.STAY_ALIGNED_CONFIGURED, + TrackingChecklistStepId.VRCHAT_SETTINGS, +) + +private fun stepOrder(id: TrackingChecklistStepId): Int { + val order = CHECKLIST_STEP_ORDER.indexOf(id) + require(order >= 0) { "Checklist step $id is missing from CHECKLIST_STEP_ORDER" } + return order +} + class TrackingChecklistBehaviour( private val checklist: TrackingChecklist, private val settings: Settings, @@ -22,7 +43,9 @@ class TrackingChecklistBehaviour( .toSet() private fun buildResponse(): TrackingChecklistResponse { - val steps = checklist.context.state.value.steps.mapValues { it.value.copy(id = it.key) }.values.toList() + val steps = checklist.context.state.value.steps.entries + .sortedBy { stepOrder(it.key) } + .map { it.value.copy(id = it.key) } return TrackingChecklistResponse( steps = steps, ignoredSteps = parseMutedSteps().toList(), diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index 7c05f4635c..8d354546e6 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -4,9 +4,13 @@ package dev.slimevr.trackingchecklist import dev.slimevr.VRServer import dev.slimevr.VRServerState +import dev.slimevr.config.MountingMethods +import dev.slimevr.config.Settings import dev.slimevr.device.DeviceOrigin import dev.slimevr.device.DeviceState import dev.slimevr.networkprofile.NetworkProfileManager +import dev.slimevr.resets.FOOT_PARTS +import dev.slimevr.resets.ResetsManager import dev.slimevr.skeleton.Skeleton import dev.slimevr.tracker.TrackerState import dev.slimevr.vrchat.VRCConfigManager @@ -15,13 +19,19 @@ import dev.slimevr.vrchat.computeRecommendedValues import dev.slimevr.vrchat.computeValidity import dev.slimevr.vrchat.isVRCConfigValid import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.TrackingChecklistNeedCalibration @@ -29,7 +39,9 @@ import solarxr_protocol.rpc.TrackingChecklistPublicNetworks import solarxr_protocol.rpc.TrackingChecklistSteamVRDisconnected import solarxr_protocol.rpc.TrackingChecklistStep import solarxr_protocol.rpc.TrackingChecklistStepId +import solarxr_protocol.rpc.TrackingChecklistStepVisibility import solarxr_protocol.rpc.TrackingChecklistTrackerError +import solarxr_protocol.rpc.TrackingChecklistTrackerReset import solarxr_protocol.rpc.TrackingChecklistUnassignedHMD // Flat-maps a server state flow into a combined flow of all context states for a given collection. @@ -182,3 +194,116 @@ class NetworkProfileCheckBehaviour( .launchIn(receiver.context.scope) } } + +private fun isImuAssigned(tracker: TrackerState): Boolean = (tracker.origin == DeviceOrigin.UDP || tracker.origin == DeviceOrigin.HID) && + tracker.position == null && + tracker.sensorType !== null && + tracker.status != TrackerStatus.ERROR && + tracker.bodyPart != null + +private fun isConnectedAssignedImu(tracker: TrackerState): Boolean = (tracker.origin == DeviceOrigin.UDP || tracker.origin == DeviceOrigin.HID) && + tracker.position == null && + tracker.sensorType !== null && + tracker.status == TrackerStatus.OK && + tracker.bodyPart != null + +class FullResetCheckBehaviour( + private val server: VRServer, + private val resetsManager: ResetsManager, +) : TrackingChecklistBehaviourType { + private val needsReset = MutableStateFlow>(emptySet()) + + override fun observe(receiver: TrackingChecklist) { + val scope = receiver.context.scope + + server.context.state + .flatMapLatest { state -> + state.trackers.map { (id, tracker) -> + tracker.context.state + .distinctUntilChangedBy { it.status to it.bodyPart } + .filter { isConnectedAssignedImu(it) } + .map { id } + }.merge() + } + .onEach { id -> needsReset.update { ids -> ids + id } } + .launchIn(scope) + + resetsManager.context.state + .distinctUntilChangedBy { it.lastFullResetTime } + .drop(1) + .onEach { needsReset.value = emptySet() } + .launchIn(scope) + + combine(needsReset, trackerStatesFlow(server)) { ids, trackers -> + val assignedIds = trackers.filter { isImuAssigned(it) }.map { it.id }.toSet() + val pending = ids intersect assignedIds + TrackingChecklistStep( + valid = pending.isEmpty(), + enabled = assignedIds.isNotEmpty(), + ignorable = false, + visibility = TrackingChecklistStepVisibility.ALWAYS, + extraData = if (pending.isNotEmpty()) { + TrackingChecklistTrackerReset(trackersId = pending.map { it.toUShort() }) + } else { + null + }, + ) + } + .distinctUntilChanged() + .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.FULL_RESET, step)) } + .launchIn(scope) + } +} + +class MountingCalibrationCheckBehaviour( + private val server: VRServer, + private val resetsManager: ResetsManager, + private val settings: Settings, +) : TrackingChecklistBehaviourType { + override fun observe(receiver: TrackingChecklist) { + combine( + trackerStatesFlow(server), + resetsManager.context.state, + settings.context.state, + ) { trackers, resetsState, settingsState -> + val imuTrackers = trackers.filter { isImuAssigned(it) } + TrackingChecklistStep( + valid = resetsState.mountingResetCompleted, + enabled = settingsState.data.resetsConfig.lastMountingMethod == MountingMethods.AUTOMATIC && imuTrackers.isNotEmpty(), + ignorable = true, + visibility = TrackingChecklistStepVisibility.ALWAYS, + ) + } + .distinctUntilChanged() + .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.MOUNTING_CALIBRATION, step)) } + .launchIn(receiver.context.scope) + } +} + +class FeetMountingCalibrationCheckBehaviour( + private val server: VRServer, + private val resetsManager: ResetsManager, + private val settings: Settings, +) : TrackingChecklistBehaviourType { + override fun observe(receiver: TrackingChecklist) { + combine( + trackerStatesFlow(server), + resetsManager.context.state, + settings.context.state, + ) { trackers, resetsState, settingsState -> + val resetsConfig = settingsState.data.resetsConfig + val imuTrackers = trackers.filter { isImuAssigned(it) } + TrackingChecklistStep( + valid = resetsState.feetMountingResetCompleted, + enabled = resetsConfig.lastMountingMethod == MountingMethods.AUTOMATIC && + !resetsConfig.resetMountingFeet && + imuTrackers.any { it.bodyPart in FOOT_PARTS }, + ignorable = true, + visibility = TrackingChecklistStepVisibility.ALWAYS, + ) + } + .distinctUntilChanged() + .onEach { step -> receiver.context.dispatch(TrackingChecklistActions.UpdateStep(TrackingChecklistStepId.FEET_MOUNTING_CALIBRATION, step)) } + .launchIn(receiver.context.scope) + } +} diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt index 2e712d565b..dc28277012 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/module.kt @@ -32,7 +32,11 @@ class TrackingChecklist( val stepBehaviours: List = buildList { add(HMDCheckBehaviour(appContext.server)) add(TrackerRestCheckBehaviour(appContext.server)) + add(FullResetCheckBehaviour(appContext.server, appContext.resetsManager)) + add(MountingCalibrationCheckBehaviour(appContext.server, appContext.resetsManager, appContext.config.settings)) add(TrackerErrorCheckBehaviour(appContext.server)) + add(FeetMountingCalibrationCheckBehaviour(appContext.server, appContext.resetsManager, appContext.config.settings)) + // TODO: STAY_ALIGNED_CONFIGURED appContext.vrcConfigManager?.let { add(VRChatSettingsCheckBehaviour(appContext.server, appContext.skeleton, it)) } appContext.networkProfileManager?.let { add(NetworkProfileCheckBehaviour(it)) } diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 816084cd34..ecc3d484f0 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -12,6 +12,7 @@ import dev.slimevr.config.UserConfig import dev.slimevr.config.UserConfigData import dev.slimevr.config.UserConfigState import dev.slimevr.context.Context +import dev.slimevr.device.DeviceOrigin import dev.slimevr.firmware.FirmwareManager import dev.slimevr.heightcalibration.HeightCalibrationActions import dev.slimevr.heightcalibration.HeightCalibrationManager @@ -27,19 +28,30 @@ import dev.slimevr.resets.ResetsState import dev.slimevr.serial.FlashingHandler import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialServer +import dev.slimevr.skeleton.ComputedSkeleton import dev.slimevr.skeleton.DEFAULT_SKELETON_STATE import dev.slimevr.skeleton.ProportionsBehaviour import dev.slimevr.skeleton.Skeleton import dev.slimevr.skeleton.buildBones import dev.slimevr.tapdetection.TapDetectionManager +import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerBasicBehaviour +import dev.slimevr.tracker.TrackerState import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer import dev.slimevr.vmc.VMCManager import dev.slimevr.vrchat.VRCConfigManager import dev.slimevr.vrcosc.VRCOSCManager +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.MagnetometerStatus +import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.datatypes.hardware_info.ImuType import solarxr_protocol.rpc.UserHeightCalibrationStatus +import kotlin.time.Duration fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create( openPort = { loc, _, _ -> SerialPortHandle(loc, "Fake $loc", {}, {}) }, @@ -87,7 +99,7 @@ fun buildTestSkeleton(scope: CoroutineScope): Skeleton { behaviours = listOf(ProportionsBehaviour(buildTestUserConfig(scope))), name = "TestSkeleton", ) - val skeleton = Skeleton(context, MutableStateFlow(buildBones(context.state.value))) + val skeleton = Skeleton(context, MutableStateFlow(ComputedSkeleton(bones = buildBones(context.state.value), frameTime = Duration.ZERO))) skeleton.startObserving() return skeleton } @@ -108,6 +120,51 @@ fun buildTestResetsManager(server: VRServer, settings: Settings, scope: Coroutin return resetsManager } +fun buildTestTracker( + scope: CoroutineScope, + appContext: AppContextProvider, + settings: Settings, + id: Int, + bodyPart: BodyPart? = null, + status: TrackerStatus = TrackerStatus.DISCONNECTED, + origin: DeviceOrigin = DeviceOrigin.UDP, + sensorType: ImuType? = ImuType.BNO085, + position: Vector3? = null, + completedRestCalibration: Boolean? = true, +): Tracker { + val state = TrackerState( + id = id, + hardwareId = "test-$id", + name = "Tracker $id", + restOrientation = Quaternion.IDENTITY, + rawRotation = Quaternion.IDENTITY, + rotation = Quaternion.IDENTITY, + rawAcceleration = Vector3.NULL, + acceleration = Vector3.NULL, + rawMagnetometer = Vector3.NULL, + bodyPart = bodyPart, + mountingOrientation = Quaternion.IDENTITY, + origin = origin, + deviceId = 0, + customName = null, + sensorType = sensorType, + position = position, + tps = 0u, + imuTemp = null, + status = status, + completedRestCalibration = completedRestCalibration, + magStatus = MagnetometerStatus.NOT_SUPPORTED, + sessionCalibration = null, + ) + val context = Context.create( + initialState = state, + scope = scope, + behaviours = listOf(TrackerBasicBehaviour()), + name = "TestTracker[$id]", + ) + return Tracker(context, appContext, settings) +} + fun buildTestSettings(scope: CoroutineScope): Settings { val initialState = SettingsState(data = SettingsConfigState(), name = "test") val context = Context.create( diff --git a/server/core/src/test/java/dev/slimevr/trackingchecklist/TrackingChecklistTest.kt b/server/core/src/test/java/dev/slimevr/trackingchecklist/TrackingChecklistTest.kt new file mode 100644 index 0000000000..3d3080b171 --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/trackingchecklist/TrackingChecklistTest.kt @@ -0,0 +1,251 @@ +package dev.slimevr.trackingchecklist + +import dev.slimevr.TestAppContext +import dev.slimevr.VRServer +import dev.slimevr.VRServerActions +import dev.slimevr.buildTestResetsManager +import dev.slimevr.buildTestSettings +import dev.slimevr.buildTestTracker +import dev.slimevr.buildTestVrServer +import dev.slimevr.config.Settings +import dev.slimevr.device.DeviceOrigin +import dev.slimevr.networkprofile.NetworkInfo +import dev.slimevr.networkprofile.NetworkProfileActions +import dev.slimevr.networkprofile.NetworkProfileManager +import dev.slimevr.resets.FOOT_PARTS +import dev.slimevr.resets.ResetsActions +import dev.slimevr.resets.ResetsManager +import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerActions +import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.datatypes.hardware_info.ImuType +import solarxr_protocol.rpc.ResetType +import solarxr_protocol.rpc.TrackingChecklistStep +import solarxr_protocol.rpc.TrackingChecklistStepId +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class TrackingChecklistTest { + + // Registers every step behaviour together, matching production wiring, so the tests also cover + // that the behaviours coexist without clobbering each other's steps. + private class Harness(scope: TestScope, networkSupported: Boolean = true) { + val server: VRServer = buildTestVrServer(scope.backgroundScope) + val settings: Settings = buildTestSettings(scope.backgroundScope) + val resetsManager: ResetsManager = buildTestResetsManager(server, settings, scope.backgroundScope) + val networkProfileManager: NetworkProfileManager = NetworkProfileManager.create(scope.backgroundScope, isSupported = networkSupported) + private val appContext = object : TestAppContext() { + override val server = this@Harness.server + override val resetsManager = this@Harness.resetsManager + } + private val checklist = TrackingChecklist.create(scope.backgroundScope) + private var nextId = 1 + + init { + checklist.context.behaviours.addAll( + listOf( + HMDCheckBehaviour(server), + TrackerRestCheckBehaviour(server), + TrackerErrorCheckBehaviour(server), + FullResetCheckBehaviour(server, resetsManager), + MountingCalibrationCheckBehaviour(server, resetsManager, settings), + FeetMountingCalibrationCheckBehaviour(server, resetsManager, settings), + NetworkProfileCheckBehaviour(networkProfileManager), + ), + ) + checklist.context.observeAll(checklist) + } + + fun addTracker( + bodyPart: BodyPart?, + status: TrackerStatus = TrackerStatus.OK, + origin: DeviceOrigin = DeviceOrigin.UDP, + sensorType: ImuType? = ImuType.BNO085, + position: Vector3? = null, + completedRestCalibration: Boolean? = true, + ): Tracker { + val id = nextId++ + val tracker = buildTestTracker( + server.context.scope, + appContext, + settings, + id = id, + bodyPart = bodyPart, + status = status, + origin = origin, + sensorType = sensorType, + position = position, + completedRestCalibration = completedRestCalibration, + ) + server.context.dispatch(VRServerActions.NewTracker(id, tracker)) + return tracker + } + + fun step(id: TrackingChecklistStepId): TrackingChecklistStep = checklist.context.state.value.steps[id] ?: error("unknown step $id") + } + + @Test + fun `FULL_RESET is disabled with no trackers`() = runTest { + val h = Harness(this) + runCurrent() + + assertEquals(false, h.step(TrackingChecklistStepId.FULL_RESET).enabled) + assertEquals(true, h.step(TrackingChecklistStepId.FULL_RESET).valid) + } + + @Test + fun `FULL_RESET flags a connected assigned tracker and clears on full reset`() = runTest { + val h = Harness(this) + val tracker = h.addTracker(BodyPart.CHEST) + runCurrent() + + assertEquals(true, h.step(TrackingChecklistStepId.FULL_RESET).enabled) + assertEquals(false, h.step(TrackingChecklistStepId.FULL_RESET).valid) + + h.resetsManager.context.dispatch(ResetsActions.EndReset(ResetType.FULL)) + runCurrent() + assertEquals(true, h.step(TrackingChecklistStepId.FULL_RESET).valid) + + // Reassigning the tracker flags it again + tracker.context.dispatch(TrackerActions.Update { copy(bodyPart = BodyPart.HIP) }) + runCurrent() + assertEquals(false, h.step(TrackingChecklistStepId.FULL_RESET).valid) + } + + @Test + fun `FULL_RESET flags a tracker that reconnects`() = runTest { + val h = Harness(this) + val tracker = h.addTracker(BodyPart.CHEST) + runCurrent() + h.resetsManager.context.dispatch(ResetsActions.EndReset(ResetType.FULL)) + runCurrent() + assertEquals(true, h.step(TrackingChecklistStepId.FULL_RESET).valid) + + // Disconnect then reconnect + tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.DISCONNECTED)) + runCurrent() + assertEquals(true, h.step(TrackingChecklistStepId.FULL_RESET).valid) + tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) + runCurrent() + assertEquals(false, h.step(TrackingChecklistStepId.FULL_RESET).valid) + } + + @Test + fun `MOUNTING_CALIBRATION is enabled with an IMU tracker and valid after a mounting reset`() = runTest { + val h = Harness(this) + h.addTracker(BodyPart.CHEST) + runCurrent() + + assertEquals(true, h.step(TrackingChecklistStepId.MOUNTING_CALIBRATION).enabled) + assertEquals(false, h.step(TrackingChecklistStepId.MOUNTING_CALIBRATION).valid) + + h.resetsManager.context.dispatch(ResetsActions.EndReset(ResetType.MOUNTING, bodyParts = null)) + runCurrent() + assertEquals(true, h.step(TrackingChecklistStepId.MOUNTING_CALIBRATION).valid) + } + + @Test + fun `FEET_MOUNTING_CALIBRATION enables with a foot tracker and validates after a feet mounting reset`() = runTest { + val h = Harness(this) + h.addTracker(BodyPart.LEFT_FOOT) + runCurrent() + + assertEquals(true, h.step(TrackingChecklistStepId.FEET_MOUNTING_CALIBRATION).enabled) + assertEquals(false, h.step(TrackingChecklistStepId.FEET_MOUNTING_CALIBRATION).valid) + + h.resetsManager.context.dispatch(ResetsActions.EndReset(ResetType.MOUNTING, bodyParts = FOOT_PARTS.toList())) + runCurrent() + assertEquals(true, h.step(TrackingChecklistStepId.FEET_MOUNTING_CALIBRATION).valid) + } + + @Test + fun `FEET_MOUNTING_CALIBRATION is disabled without a foot tracker`() = runTest { + val h = Harness(this) + h.addTracker(BodyPart.CHEST) + runCurrent() + + assertEquals(false, h.step(TrackingChecklistStepId.FEET_MOUNTING_CALIBRATION).enabled) + } + + @Test + fun `UNASSIGNED_HMD is invalid until the HMD is assigned to the head`() = runTest { + val h = Harness(this) + // An HMD is a DRIVER-origin tracker with a computed position + val hmd = h.addTracker(bodyPart = null, origin = DeviceOrigin.DRIVER, sensorType = null, position = Vector3.NULL) + runCurrent() + + assertEquals(true, h.step(TrackingChecklistStepId.UNASSIGNED_HMD).enabled) + assertEquals(false, h.step(TrackingChecklistStepId.UNASSIGNED_HMD).valid) + + hmd.context.dispatch(TrackerActions.Update { copy(bodyPart = BodyPart.HEAD) }) + runCurrent() + assertEquals(true, h.step(TrackingChecklistStepId.UNASSIGNED_HMD).valid) + } + + @Test + fun `UNASSIGNED_HMD is disabled without a driver tracker`() = runTest { + val h = Harness(this) + h.addTracker(BodyPart.CHEST) + runCurrent() + + assertEquals(false, h.step(TrackingChecklistStepId.UNASSIGNED_HMD).enabled) + } + + @Test + fun `TRACKER_ERROR is invalid while an assigned tracker is in error`() = runTest { + val h = Harness(this) + val tracker = h.addTracker(BodyPart.CHEST, status = TrackerStatus.ERROR) + runCurrent() + + assertEquals(true, h.step(TrackingChecklistStepId.TRACKER_ERROR).enabled) + assertEquals(false, h.step(TrackingChecklistStepId.TRACKER_ERROR).valid) + + tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) + runCurrent() + assertEquals(true, h.step(TrackingChecklistStepId.TRACKER_ERROR).valid) + } + + @Test + fun `TRACKERS_REST_CALIBRATION is invalid until an uncalibrated tracker finishes rest calibration`() = runTest { + val h = Harness(this) + val tracker = h.addTracker(BodyPart.CHEST, completedRestCalibration = false) + runCurrent() + + assertEquals(true, h.step(TrackingChecklistStepId.TRACKERS_REST_CALIBRATION).enabled) + assertEquals(false, h.step(TrackingChecklistStepId.TRACKERS_REST_CALIBRATION).valid) + + tracker.context.dispatch(TrackerActions.Update { copy(completedRestCalibration = true) }) + runCurrent() + assertEquals(true, h.step(TrackingChecklistStepId.TRACKERS_REST_CALIBRATION).valid) + } + + @Test + fun `NETWORK_PROFILE_PUBLIC is invalid while a public network is present`() = runTest { + val h = Harness(this) + runCurrent() + + assertEquals(true, h.step(TrackingChecklistStepId.NETWORK_PROFILE_PUBLIC).enabled) + assertEquals(true, h.step(TrackingChecklistStepId.NETWORK_PROFILE_PUBLIC).valid) + + h.networkProfileManager.context.dispatch( + NetworkProfileActions.UpdateNetworks(listOf(NetworkInfo("wlan0", null, null, null, null))), + ) + runCurrent() + assertEquals(false, h.step(TrackingChecklistStepId.NETWORK_PROFILE_PUBLIC).valid) + } + + @Test + fun `NETWORK_PROFILE_PUBLIC is disabled when unsupported`() = runTest { + val h = Harness(this, networkSupported = false) + runCurrent() + + assertEquals(false, h.step(TrackingChecklistStepId.NETWORK_PROFILE_PUBLIC).enabled) + } +} From 5d0e6011dc4c60b3a94936f211448ba4e9021955 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 29 Jul 2026 04:20:08 +0200 Subject: [PATCH 315/350] Fix mounting reset not clearing the step --- server/core/src/main/java/dev/slimevr/resets/behaviours.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt index ada2c53258..794f1a0af9 100644 --- a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt @@ -34,8 +34,7 @@ class ResetsBasicBehaviour : ResetsBehaviour { val feetOnly = !bodyParts.isNullOrEmpty() && bodyParts.all { it in FOOT_PARTS } when { feetOnly -> state.copy(feetMountingResetCompleted = true) - - bodyParts == null -> state.copy( + bodyParts.isNullOrEmpty() -> state.copy( mountingResetCompleted = true, feetMountingResetCompleted = action.resetMountingFeet || state.feetMountingResetCompleted, ) From 1290b8f930db16057bf99d405c3f8eb0072c03ae Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 29 Jul 2026 04:35:22 +0200 Subject: [PATCH 316/350] Better flightlist checks / fix edge cases --- .../java/dev/slimevr/resets/behaviours.kt | 1 + .../slimevr/trackingchecklist/behaviours.kt | 37 +++++++++++++------ .../TrackingChecklistTest.kt | 25 ++++++++++++- 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt index 794f1a0af9..4a60adbd5b 100644 --- a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt @@ -34,6 +34,7 @@ class ResetsBasicBehaviour : ResetsBehaviour { val feetOnly = !bodyParts.isNullOrEmpty() && bodyParts.all { it in FOOT_PARTS } when { feetOnly -> state.copy(feetMountingResetCompleted = true) + bodyParts.isNullOrEmpty() -> state.copy( mountingResetCompleted = true, feetMountingResetCompleted = action.resetMountingFeet || state.feetMountingResetCompleted, diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index 8d354546e6..8b141f6c28 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -24,19 +24,16 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.drop -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.TrackingChecklistNeedCalibration import solarxr_protocol.rpc.TrackingChecklistPublicNetworks -import solarxr_protocol.rpc.TrackingChecklistSteamVRDisconnected import solarxr_protocol.rpc.TrackingChecklistStep import solarxr_protocol.rpc.TrackingChecklistStepId import solarxr_protocol.rpc.TrackingChecklistStepVisibility @@ -216,18 +213,34 @@ class FullResetCheckBehaviour( override fun observe(receiver: TrackingChecklist) { val scope = receiver.context.scope - server.context.state - .flatMapLatest { state -> - state.trackers.map { (id, tracker) -> - tracker.context.state - .distinctUntilChangedBy { it.status to it.bodyPart } - .filter { isConnectedAssignedImu(it) } - .map { id } - }.merge() + val connected = mutableSetOf() + trackerStatesFlow(server) + .map { trackers -> trackers.filter { isConnectedAssignedImu(it) }.map { it.id }.toSet() } + .distinctUntilChanged() + .onEach { current -> + needsReset.update { ids -> ids + (current - connected) } + connected.clear() + connected.addAll(current) + } + .launchIn(scope) + + val bodyParts = mutableMapOf() + trackerStatesFlow(server) + .map { trackers -> trackers.mapNotNull { tracker -> tracker.bodyPart?.let { tracker.id to it } }.toMap() } + .distinctUntilChanged() + .onEach { current -> + for ((id, bodyPart) in current) { + val previous = bodyParts[id] + if (previous != null && previous != bodyPart) { + needsReset.update { ids -> ids + id } + } + } + bodyParts.clear() + bodyParts.putAll(current) } - .onEach { id -> needsReset.update { ids -> ids + id } } .launchIn(scope) + // Clear everything on a full reset. resetsManager.context.state .distinctUntilChangedBy { it.lastFullResetTime } .drop(1) diff --git a/server/core/src/test/java/dev/slimevr/trackingchecklist/TrackingChecklistTest.kt b/server/core/src/test/java/dev/slimevr/trackingchecklist/TrackingChecklistTest.kt index 3d3080b171..44945547c2 100644 --- a/server/core/src/test/java/dev/slimevr/trackingchecklist/TrackingChecklistTest.kt +++ b/server/core/src/test/java/dev/slimevr/trackingchecklist/TrackingChecklistTest.kt @@ -28,14 +28,13 @@ import solarxr_protocol.datatypes.hardware_info.ImuType import solarxr_protocol.rpc.ResetType import solarxr_protocol.rpc.TrackingChecklistStep import solarxr_protocol.rpc.TrackingChecklistStepId +import solarxr_protocol.rpc.TrackingChecklistTrackerReset import kotlin.test.Test import kotlin.test.assertEquals @OptIn(ExperimentalCoroutinesApi::class) class TrackingChecklistTest { - // Registers every step behaviour together, matching production wiring, so the tests also cover - // that the behaviours coexist without clobbering each other's steps. private class Harness(scope: TestScope, networkSupported: Boolean = true) { val server: VRServer = buildTestVrServer(scope.backgroundScope) val settings: Settings = buildTestSettings(scope.backgroundScope) @@ -137,6 +136,28 @@ class TrackingChecklistTest { assertEquals(false, h.step(TrackingChecklistStepId.FULL_RESET).valid) } + @Test + fun `adding a tracker does not re-flag the existing already-reset trackers`() = runTest { + val h = Harness(this) + h.addTracker(BodyPart.CHEST) + runCurrent() + h.resetsManager.context.dispatch(ResetsActions.EndReset(ResetType.FULL)) + runCurrent() + assertEquals(true, h.step(TrackingChecklistStepId.FULL_RESET).valid) + + // A newly discovered tracker starts disconnected; adding it must not re-flag the existing one + val added = h.addTracker(BodyPart.HIP, status = TrackerStatus.DISCONNECTED) + runCurrent() + assertEquals(true, h.step(TrackingChecklistStepId.FULL_RESET).valid) + + // Only the new tracker gets flagged once it connects + added.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) + runCurrent() + assertEquals(false, h.step(TrackingChecklistStepId.FULL_RESET).valid) + val pending = (h.step(TrackingChecklistStepId.FULL_RESET).extraData as TrackingChecklistTrackerReset).trackersId + assertEquals(listOf(added.context.state.value.id.toUShort()), pending) + } + @Test fun `MOUNTING_CALIBRATION is enabled with an IMU tracker and valid after a mounting reset`() = runTest { val h = Harness(this) From f9a6e6afee0c1ed4e3ef1d26a457cdde7b88100a Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 29 Jul 2026 06:21:29 +0200 Subject: [PATCH 317/350] Yaw reset smoothing + tests --- .../main/java/dev/slimevr/config/settings.kt | 2 +- .../main/java/dev/slimevr/resets/module.kt | 3 +- .../java/dev/slimevr/tracker/behaviours.kt | 83 +++++++++-- .../main/java/dev/slimevr/tracker/module.kt | 12 +- .../slimevr/tracker/YawResetSmoothingTest.kt | 132 ++++++++++++++++++ 5 files changed, 220 insertions(+), 12 deletions(-) create mode 100644 server/core/src/test/java/dev/slimevr/tracker/YawResetSmoothingTest.kt diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index ff83b99acb..dd0b8e9964 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -85,7 +85,7 @@ data class ResetsConfig( /** Reset mode used for the arms */ val armsResetMode: ArmsResetMode = ArmsResetMode.BACK, /** Yaw reset smoothing time in seconds */ - val yawResetSmoothTime: Float = 0.0f, // TODO + val yawResetSmoothTime: Float = 0.0f, /** Save automatic mounting reset calibration */ val saveMountingReset: Boolean = false, /** Reset the HMD's pitch upon full reset */ diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index f08f4f6b51..eeb5158d57 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -25,6 +25,7 @@ import solarxr_protocol.rpc.ResetStatus import solarxr_protocol.rpc.ResetType import kotlin.collections.contains import kotlin.collections.listOf +import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeMark data class ResetsState( @@ -143,7 +144,7 @@ class ResetsManager(val context: ResetsContext, val server: VRServer, val settin trackers.forEach { it.context.dispatch( when (resetType) { - ResetType.YAW -> TrackerActions.YawReset(referenceRotation) + ResetType.YAW -> TrackerActions.YawReset(referenceRotation, config.yawResetSmoothTime.toDouble().seconds) ResetType.FULL -> TrackerActions.FullReset(referenceRotation) ResetType.MOUNTING -> TrackerActions.MountingReset(referenceRotation, getYawOffset(it.context.state.value.bodyPart, config.armsResetMode)) }, diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 41a2ac8d8e..0083fb6bab 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -14,6 +14,7 @@ import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy @@ -32,7 +33,8 @@ import solarxr_protocol.rpc.ArmsResetMode import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch -import kotlin.time.Duration.Companion.seconds +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds class TrackerBasicBehaviour : TrackerBehaviour { override fun reduce(state: TrackerState, action: TrackerActions) = when (action) { @@ -119,22 +121,37 @@ class TrackerBasicBehaviour : TrackerBehaviour { state.copy( sessionCalibration = sessionCalibration, rotation = defaultPolarityRotation, + // Full reset snaps: cancel any in-progress yaw smoothing. + yawResetSmoothing = null, ) } is TrackerActions.YawReset -> { - val headingCorrection = estimateHeadingCorrect( + val newHeading = estimateHeadingCorrect( state.rawRotation, action.referenceRotation, ) + val cal = state.sessionCalibration - val sessionCalibration = state.sessionCalibration?.copy( - headingCorrection = headingCorrection, - ) ?: SessionCalibration( - headingCorrection = headingCorrection, - ) - - state.copy(sessionCalibration = sessionCalibration) + if (action.smoothTime > Duration.ZERO && cal != null && cal.headingCorrection != newHeading) { + // Smooth: only set the target. Leave the applied heading where it is + // TrackerYawResetSmoothingBehaviour eases sessionCalibration.headingCorrection + // to newHeading over smoothTime. A reset mid-ease just replaces the seed. + state.copy( + yawResetSmoothing = YawResetSmoothing( + from = cal.headingCorrection, + to = newHeading, + duration = action.smoothTime, + ), + ) + } else { + // Snap: apply the new heading immediately (default, no smoothing configured). + state.copy( + sessionCalibration = cal?.copy(headingCorrection = newHeading) + ?: SessionCalibration(headingCorrection = newHeading), + yawResetSmoothing = null, + ) + } } is TrackerActions.MountingReset -> { @@ -161,6 +178,23 @@ class TrackerBasicBehaviour : TrackerBehaviour { is TrackerActions.ClearMountingReset -> { state.copy(sessionCalibration = state.sessionCalibration?.copy(headingAlignment = Quaternion.IDENTITY)) } + + is TrackerActions.TickYawResetSmoothing -> { + val cal = state.sessionCalibration + if (cal == null || state.yawResetSmoothing == null) { + // Nothing to advance. + state.copy(yawResetSmoothing = null) + } else { + // The behaviour computed the interpolated heading; store it in the session + // calibration. TrackerBasicBehaviour.observe re-applies it to the rotation + // (using the last raw rotation), so this progresses even with no new IMU + // data. On `done` the seed is cleared, leaving the target heading in place. + state.copy( + sessionCalibration = cal.copy(headingCorrection = action.heading), + yawResetSmoothing = if (action.done) null else state.yawResetSmoothing, + ) + } + } } override fun observe(receiver: Tracker) { @@ -177,6 +211,37 @@ class TrackerBasicBehaviour : TrackerBehaviour { } } +class TrackerYawResetSmoothingBehaviour : TrackerBehaviour { + + private fun animateEase(t: Float) = (3f - 2f * t) * t * t + + override fun observe(receiver: Tracker) { + receiver.context.scope.launch { + receiver.context.state + .map { it.yawResetSmoothing } + .distinctUntilChanged() + .collectLatest { smoothing -> + smoothing ?: return@collectLatest + val start = timeSource.markNow() + while (isActive) { + val t = (start.elapsedNow() / smoothing.duration).toFloat().coerceIn(0f, 1f) + val done = t >= 1f + val heading = if (done) smoothing.to else smoothing.from.interpR(smoothing.to, animateEase(t)) + receiver.context.dispatch(TrackerActions.TickYawResetSmoothing(heading, done)) + if (done) break + delay(YAW_RESET_SMOOTH_TICK) + } + } + } + } + + companion object { + // based over tracking rate not skeleton rate. tracking smoothing and shit + // should be able to compensate. Maybe its not the right call idk - Futura + private val YAW_RESET_SMOOTH_TICK = 10.milliseconds + } +} + class TrackerDefaultMountingOrientationBehaviour : TrackerBehaviour { /** * Returns the default mounting orientation for the body part diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index d15d92b74b..8da372855b 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -14,9 +14,16 @@ import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.ImuType +import kotlin.time.Duration data class TrackerSensorIds(val trackerId: Int, val sensorId: Int) +data class YawResetSmoothing( + val from: HeadingCorrection, + val to: HeadingCorrection, + val duration: Duration, +) + data class TrackerState( val id: Int, val name: String, @@ -40,6 +47,7 @@ data class TrackerState( val completedRestCalibration: Boolean?, val magStatus: MagnetometerStatus, val sessionCalibration: SessionCalibration?, + val yawResetSmoothing: YawResetSmoothing? = null, ) sealed interface TrackerActions { @@ -50,7 +58,8 @@ sealed interface TrackerActions { data class SetMountingOrientation(val mountingOrientation: HeadingAlignment) : TrackerActions data class SetRestOrientation(val restOrientation: Quaternion) : TrackerActions data class FullReset(val referenceRotation: Quaternion) : TrackerActions - data class YawReset(val referenceRotation: Quaternion) : TrackerActions + data class YawReset(val referenceRotation: Quaternion, val smoothTime: Duration = Duration.ZERO) : TrackerActions + data class TickYawResetSmoothing(val heading: HeadingCorrection, val done: Boolean) : TrackerActions data class MountingReset(val referenceRotation: Quaternion, val yawOffset: Float) : TrackerActions data object ClearMountingReset : TrackerActions } @@ -111,6 +120,7 @@ class Tracker( val behaviours = listOf( TrackerBasicBehaviour(), + TrackerYawResetSmoothingBehaviour(), TrackerDefaultMountingOrientationBehaviour(), TrackerRestOrientationBehaviour(settings), TrackerConfigBehaviour(settings, hardwareId), diff --git a/server/core/src/test/java/dev/slimevr/tracker/YawResetSmoothingTest.kt b/server/core/src/test/java/dev/slimevr/tracker/YawResetSmoothingTest.kt new file mode 100644 index 0000000000..46f530a39a --- /dev/null +++ b/server/core/src/test/java/dev/slimevr/tracker/YawResetSmoothingTest.kt @@ -0,0 +1,132 @@ +@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + +package dev.slimevr.tracker + +import dev.slimevr.buildTestAppContext +import dev.slimevr.buildTestSettings +import dev.slimevr.buildTestVrServerStub +import dev.slimevr.context.Context +import dev.slimevr.degreeToRadian +import dev.slimevr.device.DeviceOrigin +import dev.slimevr.quaternionAssertEquals +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.MagnetometerStatus +import solarxr_protocol.datatypes.TrackerStatus +import solarxr_protocol.datatypes.hardware_info.ImuType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +class YawResetSmoothingTest { + private val raw = Quaternion.rotationAroundYAxis(degreeToRadian(30f)) + private val prevHeading = Quaternion.rotationAroundYAxis(degreeToRadian(90f)) + + private fun TestScope.buildTracker(withCalibration: Boolean): Tracker { + val server = buildTestVrServerStub(backgroundScope) + val appContext = buildTestAppContext(server) + val settings = buildTestSettings(backgroundScope) + val state = TrackerState( + id = 0, + hardwareId = "test-0", + name = "Tracker 0", + restOrientation = Quaternion.IDENTITY, + rawRotation = raw, + rotation = Quaternion.IDENTITY, + rawAcceleration = Vector3.NULL, + acceleration = Vector3.NULL, + rawMagnetometer = Vector3.NULL, + bodyPart = BodyPart.CHEST, + mountingOrientation = Quaternion.IDENTITY, + origin = DeviceOrigin.UDP, + deviceId = 0, + customName = null, + sensorType = ImuType.BNO085, + position = null, + tps = 0u, + imuTemp = null, + status = TrackerStatus.OK, + completedRestCalibration = true, + magStatus = MagnetometerStatus.NOT_SUPPORTED, + sessionCalibration = if (withCalibration) SessionCalibration(headingCorrection = prevHeading) else null, + ) + val context = Context.create( + initialState = state, + scope = backgroundScope, + behaviours = listOf( + TrackerBasicBehaviour(), + TrackerYawResetSmoothingBehaviour(), + ), + name = "YawResetSmoothingTest", + ) + val tracker = Tracker(context, appContext, settings) + tracker.startObserving() + return tracker + } + + private fun heading(tracker: Tracker): Quaternion = assertNotNull(tracker.context.state.value.sessionCalibration).headingCorrection + + @Test + fun `yaw reset with smoothTime seeds and leaves applied heading untouched`() = runTest { + val tracker = buildTracker(withCalibration = true) + val newHeading = estimateHeadingCorrect(raw, Quaternion.IDENTITY) + + tracker.context.dispatch(TrackerActions.YawReset(Quaternion.IDENTITY, 500.milliseconds)) + + val smoothing = assertNotNull(tracker.context.state.value.yawResetSmoothing) + quaternionAssertEquals(prevHeading, smoothing.from) + quaternionAssertEquals(newHeading, smoothing.to) + assertEquals(500.milliseconds, smoothing.duration) + quaternionAssertEquals(prevHeading, heading(tracker)) + } + + @Test + fun `yaw reset with zero smoothTime snaps and does not seed`() = runTest { + val tracker = buildTracker(withCalibration = true) + val newHeading = estimateHeadingCorrect(raw, Quaternion.IDENTITY) + + tracker.context.dispatch(TrackerActions.YawReset(Quaternion.IDENTITY, Duration.ZERO)) + + assertNull(tracker.context.state.value.yawResetSmoothing) + quaternionAssertEquals(newHeading, heading(tracker)) + } + + @Test + fun `yaw reset without prior calibration snaps`() = runTest { + val tracker = buildTracker(withCalibration = false) + val newHeading = estimateHeadingCorrect(raw, Quaternion.IDENTITY) + + tracker.context.dispatch(TrackerActions.YawReset(Quaternion.IDENTITY, 500.milliseconds)) + + assertNull(tracker.context.state.value.yawResetSmoothing) + quaternionAssertEquals(newHeading, heading(tracker)) + } + + @Test + fun `full reset cancels in-progress smoothing`() = runTest { + val tracker = buildTracker(withCalibration = true) + tracker.context.dispatch(TrackerActions.YawReset(Quaternion.IDENTITY, 500.milliseconds)) + assertNotNull(tracker.context.state.value.yawResetSmoothing) + + tracker.context.dispatch(TrackerActions.FullReset(Quaternion.IDENTITY)) + + assertNull(tracker.context.state.value.yawResetSmoothing) + } + + @Test + fun `mounting reset leaves smoothing running untouched`() = runTest { + val tracker = buildTracker(withCalibration = true) + tracker.context.dispatch(TrackerActions.YawReset(Quaternion.IDENTITY, 500.milliseconds)) + val seed = assertNotNull(tracker.context.state.value.yawResetSmoothing) + + tracker.context.dispatch(TrackerActions.MountingReset(Quaternion.IDENTITY, 0f)) + + assertEquals(seed, tracker.context.state.value.yawResetSmoothing) + } +} From f535007ee0aebd53a74240d5649358c7536af075 Mon Sep 17 00:00:00 2001 From: loucass003 Date: Wed, 29 Jul 2026 07:58:32 +0200 Subject: [PATCH 318/350] Prevent full reset from clearing mounting status --- server/core/src/main/java/dev/slimevr/resets/behaviours.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt index 4a60adbd5b..8dd22ecbd6 100644 --- a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt @@ -25,8 +25,6 @@ class ResetsBasicBehaviour : ResetsBehaviour { canDoYawReset = true, canDoMountingReset = true, lastFullResetTime = timeSource.markNow(), - mountingResetCompleted = false, - feetMountingResetCompleted = false, ) ResetType.MOUNTING -> { From c23fa703a1192a9eb880c5087f53dcaac4ad1ac0 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Wed, 29 Jul 2026 12:26:42 -0500 Subject: [PATCH 319/350] Fix bindings provider build again --- bindings-provider/src/main.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bindings-provider/src/main.cpp b/bindings-provider/src/main.cpp index 52e21ce9c4..eb599086d1 100644 --- a/bindings-provider/src/main.cpp +++ b/bindings-provider/src/main.cpp @@ -32,7 +32,7 @@ static void onYawReset(SolarXRConnection &conn) { auto resetReq = rpc::CreateResetRequest(fbb, rpc::ResetType::YAW, 0, 0.f); auto msgHeader = rpc::CreateRpcMessageHeader( - fbb, nullptr, rpc::RpcMessage::ResetRequest, resetReq.Union()); + fbb, 0, rpc::RpcMessage::ResetRequest, resetReq.Union()); auto rpcMsgs = fbb.CreateVector({ msgHeader }); auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs); @@ -44,7 +44,7 @@ static void onFullReset(SolarXRConnection &conn) { auto resetReq = rpc::CreateResetRequest(fbb, rpc::ResetType::FULL, 0, 0.f); auto msgHeader = rpc::CreateRpcMessageHeader( - fbb, nullptr, rpc::RpcMessage::ResetRequest, resetReq.Union()); + fbb, 0, rpc::RpcMessage::ResetRequest, resetReq.Union()); auto rpcMsgs = fbb.CreateVector({ msgHeader }); auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs); @@ -56,7 +56,7 @@ static void onMountingCalibration(SolarXRConnection &conn) { auto resetReq = rpc::CreateResetRequest(fbb, rpc::ResetType::MOUNTING, 0, 0.f); auto msgHeader = rpc::CreateRpcMessageHeader( - fbb, nullptr, rpc::RpcMessage::ResetRequest, resetReq.Union()); + fbb, 0, rpc::RpcMessage::ResetRequest, resetReq.Union()); auto rpcMsgs = fbb.CreateVector({ msgHeader }); auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs); @@ -70,7 +70,7 @@ static void onFeetMountingCalibration(SolarXRConnection &conn) { { datatypes::BodyPart::LEFT_FOOT, datatypes::BodyPart::RIGHT_FOOT }); auto resetReq = rpc::CreateResetRequest(fbb, rpc::ResetType::MOUNTING, bodyParts, 0.f); auto msgHeader = rpc::CreateRpcMessageHeader( - fbb, nullptr, rpc::RpcMessage::ResetRequest, resetReq.Union()); + fbb, 0, rpc::RpcMessage::ResetRequest, resetReq.Union()); auto rpcMsgs = fbb.CreateVector({ msgHeader }); auto bundle = CreateMessageBundle(fbb, 0, rpcMsgs); @@ -84,7 +84,7 @@ static void onToggleTracking(SolarXRConnection &conn) { shouldPause = !shouldPause; auto toggleReq = rpc::CreateSetPauseTrackingRequest(fbb, shouldPause); auto msgHeader = rpc::CreateRpcMessageHeader( - fbb, nullptr, rpc::RpcMessage::SetPauseTrackingRequest, + fbb, 0, rpc::RpcMessage::SetPauseTrackingRequest, toggleReq.Union()); auto rpcMsgs = fbb.CreateVector({ msgHeader }); From d16a30ca17fa92c64f21a29d9edeb252964eb506 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Wed, 29 Jul 2026 12:45:21 -0500 Subject: [PATCH 320/350] Fix Android --- .../src/main/java/dev/slimevr/android/ForegroundService.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt index ce4e2b7385..c2ab3e0373 100644 --- a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt +++ b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt @@ -50,6 +50,7 @@ import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking +import solarxr_protocol.rpc.KeybindSupport private val logger = noCoLogger("ForegroundService") From c7a7eedc2aaefc8f1856bfd6fd2e8a57c924d857 Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 29 Jul 2026 15:05:31 -0400 Subject: [PATCH 321/350] Fix defaultMountingForBodyPart --- server/core/src/main/java/dev/slimevr/tracker/behaviours.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 0083fb6bab..5e6d9f2b6a 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -271,7 +271,7 @@ class TrackerDefaultMountingOrientationBehaviour : TrackerBehaviour { BodyPart.RIGHT_UPPER_ARM, BodyPart.RIGHT_LOWER_LEG -> Quaternion.SLIMEVR.FRONT_RIGHT - else -> Quaternion.SLIMEVR.BACK + else -> Quaternion.SLIMEVR.FRONT } override fun observe(receiver: Tracker) { From 3aa5616f2c3cc8d312935a98656b8e2b6be0ab43 Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 29 Jul 2026 17:12:49 -0400 Subject: [PATCH 322/350] yaw reset smoothing running on skeleton tick --- .../{fingers-impute.kt => finger-impute.kt} | 2 - .../dev/slimevr/tapdetection/behaviours.kt | 8 ++-- .../java/dev/slimevr/tracker/behaviours.kt | 42 ++++++++----------- .../slimevr/tapdetection/TapDetectionTest.kt | 7 ++-- 4 files changed, 25 insertions(+), 34 deletions(-) rename server/core/src/main/java/dev/slimevr/skeleton/processors/{fingers-impute.kt => finger-impute.kt} (97%) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/finger-impute.kt similarity index 97% rename from server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt rename to server/core/src/main/java/dev/slimevr/skeleton/processors/finger-impute.kt index 34e92f78aa..9aa6ae7937 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/fingers-impute.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/finger-impute.kt @@ -4,8 +4,6 @@ import dev.slimevr.skeleton.BodyPartMap import dev.slimevr.skeleton.SkeletonProcessor import dev.slimevr.skeleton.SkeletonState import solarxr_protocol.datatypes.BodyPart -import kotlin.collections.get -import kotlin.collections.iterator import kotlin.time.Duration /** diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt index 50d1c37994..b2d0885db7 100644 --- a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt @@ -176,7 +176,7 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { trackerAcceleration: Vector3, ): Boolean { // Remove old stored accelerations (if they are too old) - while (trackerTapDetectionState.accelList.isNotEmpty() && (trackerTapDetectionState.accelList.first().second + accelWindow).hasPassedNow()) { + while (trackerTapDetectionState.accelList.isNotEmpty() && (trackerTapDetectionState.accelList.first().second + ACCEL_WINDOW).hasPassedNow()) { trackerTapDetectionState.accelList.removeFirst() } @@ -209,7 +209,7 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { if (trackerTapDetectionState.tapTimestamps.isNotEmpty()) { // Remove old stored taps (if they are too old) - val totalTapWindow = tapWindowPerTap * trackerTapDetectionState.tapTimestamps.size + val totalTapWindow = TAP_WINDOW_PER_TAP * trackerTapDetectionState.tapTimestamps.size while (trackerTapDetectionState.tapTimestamps.isNotEmpty() && (trackerTapDetectionState.tapTimestamps.first() + totalTapWindow).hasPassedNow()) { trackerTapDetectionState.tapTimestamps.removeFirst() } @@ -236,7 +236,7 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { const val NEEDED_ACCEL_DELTA = 6.0f const val ALLOWED_BODY_ACCEL = 2.5f const val ALLOWED_BODY_ACCEL_SQUARED = ALLOWED_BODY_ACCEL * ALLOWED_BODY_ACCEL - val accelWindow = 0.06.seconds - val tapWindowPerTap = 0.3.seconds + val ACCEL_WINDOW = 0.06.seconds + val TAP_WINDOW_PER_TAP = 0.3.seconds } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 5e6d9f2b6a..6941bc1aa3 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -14,11 +14,11 @@ import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn @@ -34,7 +34,6 @@ import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds class TrackerBasicBehaviour : TrackerBehaviour { override fun reduce(state: TrackerState, action: TrackerActions) = when (action) { @@ -213,32 +212,27 @@ class TrackerBasicBehaviour : TrackerBehaviour { class TrackerYawResetSmoothingBehaviour : TrackerBehaviour { - private fun animateEase(t: Float) = (3f - 2f * t) * t * t + private fun animateEase(t: Float) = t * t + @OptIn(ExperimentalCoroutinesApi::class) override fun observe(receiver: Tracker) { - receiver.context.scope.launch { - receiver.context.state - .map { it.yawResetSmoothing } - .distinctUntilChanged() - .collectLatest { smoothing -> - smoothing ?: return@collectLatest - val start = timeSource.markNow() - while (isActive) { - val t = (start.elapsedNow() / smoothing.duration).toFloat().coerceIn(0f, 1f) + receiver.context.state + .map { it.yawResetSmoothing } + .distinctUntilChanged() + .flatMapLatest { yawResetSmoothing -> + yawResetSmoothing ?: return@flatMapLatest emptyFlow() + val startTime = timeSource.markNow() + + receiver.appContext.skeleton.computed + .onEach { + val t = (startTime.elapsedNow() / yawResetSmoothing.duration).toFloat().coerceIn(0f, 1f) val done = t >= 1f - val heading = if (done) smoothing.to else smoothing.from.interpR(smoothing.to, animateEase(t)) + val heading = if (done) yawResetSmoothing.to else yawResetSmoothing.from.interpR(yawResetSmoothing.to, animateEase(t)) receiver.context.dispatch(TrackerActions.TickYawResetSmoothing(heading, done)) - if (done) break - delay(YAW_RESET_SMOOTH_TICK) + if (done) return@onEach } - } - } - } - - companion object { - // based over tracking rate not skeleton rate. tracking smoothing and shit - // should be able to compensate. Maybe its not the right call idk - Futura - private val YAW_RESET_SMOOTH_TICK = 10.milliseconds + } + .launchIn(receiver.context.scope) } } @@ -317,7 +311,7 @@ class TrackerToSkeletonBehaviour : TrackerBehaviour { @OptIn(ExperimentalCoroutinesApi::class) override fun observe(receiver: Tracker) { receiver.context.state - .distinctUntilChanged { old, new -> old.status == new.status && old.bodyPart == new.bodyPart } + .distinctUntilChangedBy { it.status to it.bodyPart } .onEach { _ -> // Tell the skeleton the tracker has stopped sending data to the last bone it was sending data to. lastBodyPartSent?.let { diff --git a/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt b/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt index 47f440228a..539ee0f924 100644 --- a/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt +++ b/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt @@ -1,6 +1,5 @@ package dev.slimevr.tapdetection -import dev.slimevr.timeSource import io.github.axisangles.ktmath.Vector3 import kotlin.test.Test import kotlin.test.assertEquals @@ -21,13 +20,13 @@ class TapDetectionTest { val lowAccel = Vector3.NULL // Accelerations needed for a tap happen with this delay - val accelDelay = TapDetectionBasicBehaviour.accelWindow * 0.9 + val accelDelay = TapDetectionBasicBehaviour.ACCEL_WINDOW * 0.9 // Delay between several taps - val tapDelay = TapDetectionBasicBehaviour.tapWindowPerTap * 0.9 + val tapDelay = TapDetectionBasicBehaviour.TAP_WINDOW_PER_TAP * 0.9 // Too long of a delay between several taps - val tapTimeoutDelay = TapDetectionBasicBehaviour.tapWindowPerTap * 1.1 + val tapTimeoutDelay = TapDetectionBasicBehaviour.TAP_WINDOW_PER_TAP * 1.1 /** * Runs a sequence of tap events through TapDetection, asserting the results. From 992479a4c2cbeb64a4148eb9e2709fd3bd65b948 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Wed, 29 Jul 2026 16:35:33 -0500 Subject: [PATCH 323/350] bump solarxr-protocol --- solarxr-protocol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solarxr-protocol b/solarxr-protocol index b8c68382fe..0b311c4463 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit b8c68382fe0bb1916479b9d3411fa3f332b9b8c2 +Subproject commit 0b311c446310f6218d68a6c413571085a236bc5f From ad04ed9244a86e65d95b95fb771f3a8f318e519b Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 29 Jul 2026 18:20:43 -0400 Subject: [PATCH 324/350] Reuse TestServer buildTestTracker in YawResetSmoothingTest --- .../src/test/java/dev/slimevr/TestServer.kt | 11 +++- .../slimevr/tracker/YawResetSmoothingTest.kt | 66 +++++-------------- 2 files changed, 26 insertions(+), 51 deletions(-) diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index ecc3d484f0..24ef7a16c6 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -34,8 +34,10 @@ import dev.slimevr.skeleton.ProportionsBehaviour import dev.slimevr.skeleton.Skeleton import dev.slimevr.skeleton.buildBones import dev.slimevr.tapdetection.TapDetectionManager +import dev.slimevr.tracker.SessionCalibration import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerBasicBehaviour +import dev.slimevr.tracker.TrackerBehaviour import dev.slimevr.tracker.TrackerState import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer @@ -131,13 +133,16 @@ fun buildTestTracker( sensorType: ImuType? = ImuType.BNO085, position: Vector3? = null, completedRestCalibration: Boolean? = true, + rawRotation: Quaternion = Quaternion.IDENTITY, + additionalBehaviours: List = listOf(), + sessionCalibration: SessionCalibration? = null, ): Tracker { val state = TrackerState( id = id, hardwareId = "test-$id", name = "Tracker $id", restOrientation = Quaternion.IDENTITY, - rawRotation = Quaternion.IDENTITY, + rawRotation = rawRotation, rotation = Quaternion.IDENTITY, rawAcceleration = Vector3.NULL, acceleration = Vector3.NULL, @@ -154,12 +159,12 @@ fun buildTestTracker( status = status, completedRestCalibration = completedRestCalibration, magStatus = MagnetometerStatus.NOT_SUPPORTED, - sessionCalibration = null, + sessionCalibration = sessionCalibration, ) val context = Context.create( initialState = state, scope = scope, - behaviours = listOf(TrackerBasicBehaviour()), + behaviours = listOf(TrackerBasicBehaviour()) + additionalBehaviours, name = "TestTracker[$id]", ) return Tracker(context, appContext, settings) diff --git a/server/core/src/test/java/dev/slimevr/tracker/YawResetSmoothingTest.kt b/server/core/src/test/java/dev/slimevr/tracker/YawResetSmoothingTest.kt index 46f530a39a..ad20118f25 100644 --- a/server/core/src/test/java/dev/slimevr/tracker/YawResetSmoothingTest.kt +++ b/server/core/src/test/java/dev/slimevr/tracker/YawResetSmoothingTest.kt @@ -4,19 +4,15 @@ package dev.slimevr.tracker import dev.slimevr.buildTestAppContext import dev.slimevr.buildTestSettings +import dev.slimevr.buildTestTracker import dev.slimevr.buildTestVrServerStub -import dev.slimevr.context.Context import dev.slimevr.degreeToRadian -import dev.slimevr.device.DeviceOrigin import dev.slimevr.quaternionAssertEquals import io.github.axisangles.ktmath.Quaternion -import io.github.axisangles.ktmath.Vector3 -import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest import solarxr_protocol.datatypes.BodyPart -import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.datatypes.TrackerStatus -import solarxr_protocol.datatypes.hardware_info.ImuType import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -28,53 +24,27 @@ class YawResetSmoothingTest { private val raw = Quaternion.rotationAroundYAxis(degreeToRadian(30f)) private val prevHeading = Quaternion.rotationAroundYAxis(degreeToRadian(90f)) - private fun TestScope.buildTracker(withCalibration: Boolean): Tracker { - val server = buildTestVrServerStub(backgroundScope) + private fun buildTracker(scope: CoroutineScope, withCalibration: Boolean): Tracker { + val server = buildTestVrServerStub(scope) val appContext = buildTestAppContext(server) - val settings = buildTestSettings(backgroundScope) - val state = TrackerState( + val settings = buildTestSettings(scope) + return buildTestTracker( + scope, + appContext, + settings, id = 0, - hardwareId = "test-0", - name = "Tracker 0", - restOrientation = Quaternion.IDENTITY, + BodyPart.CHEST, + TrackerStatus.OK, rawRotation = raw, - rotation = Quaternion.IDENTITY, - rawAcceleration = Vector3.NULL, - acceleration = Vector3.NULL, - rawMagnetometer = Vector3.NULL, - bodyPart = BodyPart.CHEST, - mountingOrientation = Quaternion.IDENTITY, - origin = DeviceOrigin.UDP, - deviceId = 0, - customName = null, - sensorType = ImuType.BNO085, - position = null, - tps = 0u, - imuTemp = null, - status = TrackerStatus.OK, - completedRestCalibration = true, - magStatus = MagnetometerStatus.NOT_SUPPORTED, - sessionCalibration = if (withCalibration) SessionCalibration(headingCorrection = prevHeading) else null, - ) - val context = Context.create( - initialState = state, - scope = backgroundScope, - behaviours = listOf( - TrackerBasicBehaviour(), - TrackerYawResetSmoothingBehaviour(), - ), - name = "YawResetSmoothingTest", - ) - val tracker = Tracker(context, appContext, settings) - tracker.startObserving() - return tracker + additionalBehaviours = listOf(TrackerYawResetSmoothingBehaviour()), + sessionCalibration = if (withCalibration) SessionCalibration(headingCorrection = prevHeading) else null) } private fun heading(tracker: Tracker): Quaternion = assertNotNull(tracker.context.state.value.sessionCalibration).headingCorrection @Test fun `yaw reset with smoothTime seeds and leaves applied heading untouched`() = runTest { - val tracker = buildTracker(withCalibration = true) + val tracker = buildTracker(backgroundScope, true) val newHeading = estimateHeadingCorrect(raw, Quaternion.IDENTITY) tracker.context.dispatch(TrackerActions.YawReset(Quaternion.IDENTITY, 500.milliseconds)) @@ -88,7 +58,7 @@ class YawResetSmoothingTest { @Test fun `yaw reset with zero smoothTime snaps and does not seed`() = runTest { - val tracker = buildTracker(withCalibration = true) + val tracker = buildTracker(backgroundScope, true) val newHeading = estimateHeadingCorrect(raw, Quaternion.IDENTITY) tracker.context.dispatch(TrackerActions.YawReset(Quaternion.IDENTITY, Duration.ZERO)) @@ -99,7 +69,7 @@ class YawResetSmoothingTest { @Test fun `yaw reset without prior calibration snaps`() = runTest { - val tracker = buildTracker(withCalibration = false) + val tracker = buildTracker(backgroundScope, false) val newHeading = estimateHeadingCorrect(raw, Quaternion.IDENTITY) tracker.context.dispatch(TrackerActions.YawReset(Quaternion.IDENTITY, 500.milliseconds)) @@ -110,7 +80,7 @@ class YawResetSmoothingTest { @Test fun `full reset cancels in-progress smoothing`() = runTest { - val tracker = buildTracker(withCalibration = true) + val tracker = buildTracker(backgroundScope, true) tracker.context.dispatch(TrackerActions.YawReset(Quaternion.IDENTITY, 500.milliseconds)) assertNotNull(tracker.context.state.value.yawResetSmoothing) @@ -121,7 +91,7 @@ class YawResetSmoothingTest { @Test fun `mounting reset leaves smoothing running untouched`() = runTest { - val tracker = buildTracker(withCalibration = true) + val tracker = buildTracker(backgroundScope, true) tracker.context.dispatch(TrackerActions.YawReset(Quaternion.IDENTITY, 500.milliseconds)) val seed = assertNotNull(tracker.context.state.value.yawResetSmoothing) From 6d2c7014094a222376225e7e70dd0ec6accdf87c Mon Sep 17 00:00:00 2001 From: Erimel Date: Wed, 29 Jul 2026 18:45:58 -0400 Subject: [PATCH 325/350] Accept TIMED_OUT for tracking (needed for HID) --- .../main/java/dev/slimevr/device/module.kt | 2 +- .../slimevr/outputtrackertoggle/behaviours.kt | 24 +++++++-------- .../java/dev/slimevr/tracker/behaviours.kt | 6 ++-- .../OutputTrackerToggleTest.kt | 30 +++++++++---------- .../java/dev/slimevr/desktop/ipc/protocol.kt | 4 +-- 5 files changed, 31 insertions(+), 35 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/device/module.kt b/server/core/src/main/java/dev/slimevr/device/module.kt index 68eb66fa80..ccfeacb1a4 100644 --- a/server/core/src/main/java/dev/slimevr/device/module.kt +++ b/server/core/src/main/java/dev/slimevr/device/module.kt @@ -14,8 +14,8 @@ enum class DeviceOrigin { DRIVER, UDP, HID, - OSC, VRC, + VMC, } data class DeviceState( diff --git a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt index 09a3193250..11ef28048b 100644 --- a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt @@ -1,6 +1,7 @@ package dev.slimevr.outputtrackertoggle import dev.slimevr.config.OutputTrackersConfig +import dev.slimevr.device.DeviceOrigin import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -30,10 +31,10 @@ class OutputTrackerToggleBasicBehaviour : OutputTrackerToggleBehaviour { // TODO filter out DeviceOrigin.STEAMVR fun determineAutomaticOutputTrackers( config: OutputTrackersConfig, - okBodyParts: Set, + fineBodyParts: Set, ): List { val automaticTrackers = automaticToggleToTracker - .filterValues { it.any { bp -> bp in okBodyParts } } + .filterValues { it.any { bp -> bp in fineBodyParts } } .keys // Hands aren't toggled automatically val handTrackers = config.trackers.filter { @@ -65,21 +66,16 @@ class OutputTrackerToggleBasicBehaviour : OutputTrackerToggleBehaviour { tracker.context.state.distinctUntilChanged { a, b -> a.bodyPart == b.bodyPart && a.status == b.status } }, ) { states -> - states.map { it.bodyPart to it.status } - } - .distinctUntilChanged() - .map { trackerStates -> - trackerStates - .filter { it.second == TrackerStatus.OK } - .map { it.first } - .toSet() - } + states.filter { (it.status == TrackerStatus.OK || it.status == TrackerStatus.TIMED_OUT) && it.origin != DeviceOrigin.DRIVER && it.origin != DeviceOrigin.VRC } + .map { it.bodyPart } + .toSet() + }.distinctUntilChanged() } - .map { okBodyParts -> config to okBodyParts } + .map { fineBodyParts -> config to fineBodyParts } } - .onEach { (config, okBodyParts) -> + .onEach { (config, fineBodyParts) -> // Get the output trackers based on body parts - val outputTrackers = determineAutomaticOutputTrackers(config, okBodyParts) + val outputTrackers = determineAutomaticOutputTrackers(config, fineBodyParts) // Update state receiver.context.dispatch(OutputTrackerToggleActions.SetOutputTrackers(outputTrackers)) diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 6941bc1aa3..b0a32f0455 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -311,7 +311,7 @@ class TrackerToSkeletonBehaviour : TrackerBehaviour { @OptIn(ExperimentalCoroutinesApi::class) override fun observe(receiver: Tracker) { receiver.context.state - .distinctUntilChangedBy { it.status to it.bodyPart } + .distinctUntilChanged { old, new -> old.status == new.status && old.bodyPart == new.bodyPart } .onEach { _ -> // Tell the skeleton the tracker has stopped sending data to the last bone it was sending data to. lastBodyPartSent?.let { @@ -322,9 +322,9 @@ class TrackerToSkeletonBehaviour : TrackerBehaviour { } } .flatMapLatest { _ -> - // We only want trackers that are assigned to a BodyPart and are OK. + // We only want trackers that are assigned to a BodyPart and are OK or TIMED_OUT (needed for HID). val activeState = receiver.context.state - .filter { it.bodyPart != null && it.status == TrackerStatus.OK } + .filter { it.bodyPart != null && (it.status == TrackerStatus.OK || it.status == TrackerStatus.TIMED_OUT) } val rotationFlow = activeState .distinctUntilChangedBy { it.rotation } diff --git a/server/core/src/test/java/dev/slimevr/outputtrackertoggle/OutputTrackerToggleTest.kt b/server/core/src/test/java/dev/slimevr/outputtrackertoggle/OutputTrackerToggleTest.kt index a7cd6a7f65..d4f1417cf1 100644 --- a/server/core/src/test/java/dev/slimevr/outputtrackertoggle/OutputTrackerToggleTest.kt +++ b/server/core/src/test/java/dev/slimevr/outputtrackertoggle/OutputTrackerToggleTest.kt @@ -10,7 +10,7 @@ class OutputTrackerToggleTest { fun `Output trackers are automatically determined based off body parts`() { data class TestCase( val name: String, - val okBodyParts: Set, + val fineBodyParts: Set, val configuredTrackers: List, val expected: Set, ) @@ -18,13 +18,13 @@ class OutputTrackerToggleTest { val testCases = listOf( TestCase( name = "no body part", - okBodyParts = emptySet(), + fineBodyParts = emptySet(), configuredTrackers = emptyList(), expected = emptySet(), ), TestCase( name = "upper chest enables upper chest and hip", - okBodyParts = setOf(BodyPart.UPPER_CHEST), + fineBodyParts = setOf(BodyPart.UPPER_CHEST), configuredTrackers = emptyList(), expected = setOf( BodyPart.UPPER_CHEST, @@ -33,7 +33,7 @@ class OutputTrackerToggleTest { ), TestCase( name = "chest enables upper chest and hip", - okBodyParts = setOf(BodyPart.CHEST), + fineBodyParts = setOf(BodyPart.CHEST), configuredTrackers = emptyList(), expected = setOf( BodyPart.UPPER_CHEST, @@ -42,7 +42,7 @@ class OutputTrackerToggleTest { ), TestCase( name = "waist enables hip", - okBodyParts = setOf(BodyPart.WAIST), + fineBodyParts = setOf(BodyPart.WAIST), configuredTrackers = emptyList(), expected = setOf( BodyPart.HIP, @@ -50,7 +50,7 @@ class OutputTrackerToggleTest { ), TestCase( name = "hip enables hip", - okBodyParts = setOf(BodyPart.HIP), + fineBodyParts = setOf(BodyPart.HIP), configuredTrackers = emptyList(), expected = setOf( BodyPart.HIP, @@ -58,7 +58,7 @@ class OutputTrackerToggleTest { ), TestCase( name = "left lower arm enables left upper arm", - okBodyParts = setOf(BodyPart.LEFT_LOWER_ARM), + fineBodyParts = setOf(BodyPart.LEFT_LOWER_ARM), configuredTrackers = emptyList(), expected = setOf( BodyPart.LEFT_UPPER_ARM, @@ -66,7 +66,7 @@ class OutputTrackerToggleTest { ), TestCase( name = "right lower arm enables right upper arm", - okBodyParts = setOf(BodyPart.RIGHT_LOWER_ARM), + fineBodyParts = setOf(BodyPart.RIGHT_LOWER_ARM), configuredTrackers = emptyList(), expected = setOf( BodyPart.RIGHT_UPPER_ARM, @@ -74,7 +74,7 @@ class OutputTrackerToggleTest { ), TestCase( name = "left lower leg enables left foot", - okBodyParts = setOf(BodyPart.LEFT_LOWER_LEG), + fineBodyParts = setOf(BodyPart.LEFT_LOWER_LEG), configuredTrackers = emptyList(), expected = setOf( BodyPart.LEFT_FOOT, @@ -82,7 +82,7 @@ class OutputTrackerToggleTest { ), TestCase( name = "right lower leg enables right foot", - okBodyParts = setOf(BodyPart.RIGHT_LOWER_LEG), + fineBodyParts = setOf(BodyPart.RIGHT_LOWER_LEG), configuredTrackers = emptyList(), expected = setOf( BodyPart.RIGHT_FOOT, @@ -90,7 +90,7 @@ class OutputTrackerToggleTest { ), TestCase( name = "multiple automatic trackers", - okBodyParts = setOf( + fineBodyParts = setOf( BodyPart.LEFT_UPPER_ARM, BodyPart.RIGHT_FOOT, BodyPart.HIP, @@ -104,7 +104,7 @@ class OutputTrackerToggleTest { ), TestCase( name = "hands are always included from config", - okBodyParts = emptySet(), + fineBodyParts = emptySet(), configuredTrackers = listOf( BodyPart.LEFT_HAND, BodyPart.RIGHT_HAND, @@ -116,7 +116,7 @@ class OutputTrackerToggleTest { ), TestCase( name = "non-hand manual trackers are ignored", - okBodyParts = emptySet(), + fineBodyParts = emptySet(), configuredTrackers = listOf( BodyPart.CHEST, BodyPart.WAIST, @@ -128,7 +128,7 @@ class OutputTrackerToggleTest { ), TestCase( name = "automatic trackers combined with hands", - okBodyParts = setOf( + fineBodyParts = setOf( BodyPart.LEFT_FOOT, ), configuredTrackers = listOf( @@ -145,7 +145,7 @@ class OutputTrackerToggleTest { testCases.forEach { case -> val result = behaviour.determineAutomaticOutputTrackers( config = OutputTrackersConfig(trackers = case.configuredTrackers), - okBodyParts = case.okBodyParts, + fineBodyParts = case.fineBodyParts, ) assertEquals( diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 2ef2df2f95..f930590652 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -186,10 +186,10 @@ suspend fun handleDriverConnection( tracker_status = TrackerStatus( tracker_id = event.trackerId, status = when (event.status) { - solarxr_protocol.datatypes.TrackerStatus.OK -> TrackerStatus.Status.OK + solarxr_protocol.datatypes.TrackerStatus.OK, solarxr_protocol.datatypes.TrackerStatus.TIMED_OUT -> TrackerStatus.Status.OK solarxr_protocol.datatypes.TrackerStatus.ERROR -> TrackerStatus.Status.ERROR solarxr_protocol.datatypes.TrackerStatus.OCCLUDED -> TrackerStatus.Status.OCCLUDED - solarxr_protocol.datatypes.TrackerStatus.DISCONNECTED, solarxr_protocol.datatypes.TrackerStatus.TIMED_OUT -> TrackerStatus.Status.DISCONNECTED + solarxr_protocol.datatypes.TrackerStatus.DISCONNECTED -> TrackerStatus.Status.DISCONNECTED solarxr_protocol.datatypes.TrackerStatus.BUSY -> TrackerStatus.Status.BUSY else -> TrackerStatus.Status.ERROR }, From 21d61a47b09d53d063797786d4c994fa003f319b Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 30 Jul 2026 11:50:49 -0400 Subject: [PATCH 326/350] Clean up translations --- gui/app/public/i18n/en/translation.ftl | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/gui/app/public/i18n/en/translation.ftl b/gui/app/public/i18n/en/translation.ftl index 4aba5f119d..61cc723acd 100644 --- a/gui/app/public/i18n/en/translation.ftl +++ b/gui/app/public/i18n/en/translation.ftl @@ -425,7 +425,6 @@ tracker_selection_menu-RIGHT_HAND = { -tracker_selection-part } right hand? tracker_selection_menu-RIGHT_UPPER_LEG = { -tracker_selection-part } right thigh? tracker_selection_menu-RIGHT_LOWER_LEG = { -tracker_selection-part } right ankle? tracker_selection_menu-RIGHT_FOOT = { -tracker_selection-part } right foot? -tracker_selection_menu-RIGHT_CONTROLLER = { -tracker_selection-part } right controller? tracker_selection_menu-UPPER_CHEST = { -tracker_selection-part } upper chest? tracker_selection_menu-CHEST = { -tracker_selection-part } chest? tracker_selection_menu-WAIST = { -tracker_selection-part } waist? @@ -437,7 +436,6 @@ tracker_selection_menu-LEFT_HAND = { -tracker_selection-part } left hand? tracker_selection_menu-LEFT_UPPER_LEG = { -tracker_selection-part } left thigh? tracker_selection_menu-LEFT_LOWER_LEG = { -tracker_selection-part } left ankle? tracker_selection_menu-LEFT_FOOT = { -tracker_selection-part } left foot? -tracker_selection_menu-LEFT_CONTROLLER = { -tracker_selection-part } left controller? tracker_selection_menu-unassigned = Unassigned Trackers tracker_selection_menu-assigned = Assigned Trackers @@ -775,7 +773,7 @@ settings-serial-factory_reset-warning-ok = I know what I'm doing settings-serial-factory_reset-warning-cancel = Cancel settings-serial-serial_select = Select a serial port settings-serial-auto_dropdown_item = Auto -settings-serial-get_wifi_scan = Get WiFi Scan +settings-serial-get_wifi_scan = Get Wi-Fi Scan settings-serial-enter_pairing = Enter Pairing settings-serial-exit_pairing = Exit Pairing settings-serial-calibrate = Calibrate @@ -924,7 +922,7 @@ settings-home-list-layout-desc = Select one of the possible layouts of the home settings-home-list-layout-grid = Grid settings-home-list-layout-table = Table -## Tracking Checlist +## Tracking Checklist settings-tracking_checklist-active_steps = Active Steps settings-tracking_checklist-active_steps-desc = List of all the steps in the tracking checklist. You can choose to disable specific steps. @@ -1051,7 +1049,7 @@ onboarding-connect_tracker-issue-serial = I'm having trouble connecting! onboarding-connect_tracker-usb = USB Tracker onboarding-connect_tracker-connection_status-none = Looking for trackers onboarding-connect_tracker-connection_status-serial_init = Connecting to serial device -onboarding-connect_tracker-connection_status-obtaining_mac_address = Obtaining the tracker mac address +onboarding-connect_tracker-connection_status-obtaining_mac_address = Obtaining the tracker's mac address onboarding-connect_tracker-connection_status-provisioning = Sending Wi-Fi credentials onboarding-connect_tracker-connection_status-connecting = Trying to connect to Wi-Fi onboarding-connect_tracker-connection_status-looking_for_server = Looking for server @@ -1484,8 +1482,8 @@ firmware_tool-flash_method_step-ota-v2 = .label = Wi-Fi .description = Use the over-the-air method. Your tracker will use Wi-Fi to update its firmware. Only works on trackers that have been set up. firmware_tool-flash_method_step-ota-info = - We use your wifi credentials to flash the tracker and confirm that everything worked correctly. - We do not store your wifi credentials! + We use your Wi-Fi credentials to flash the tracker and confirm that everything worked correctly. + We do not store your Wi-Fi credentials! firmware_tool-flash_method_step-serial-v2 = .label = USB .description = Use a USB cable to update your tracker. From 339108022634ce210b80cfa5e9a0172a3d6fe3cc Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 30 Jul 2026 12:06:30 -0400 Subject: [PATCH 327/350] Add Sleeping TrackerStatus --- gui/app/public/i18n/en/translation.ftl | 1 + gui/app/src/components/tracker/TrackerStatus.tsx | 2 ++ server/core/src/main/java/dev/slimevr/hid/behaviours.kt | 6 +++--- .../main/java/dev/slimevr/outputtrackertoggle/behaviours.kt | 2 +- server/core/src/main/java/dev/slimevr/tracker/behaviours.kt | 4 ++-- .../src/main/java/dev/slimevr/desktop/ipc/protocol.kt | 4 ++-- solarxr-protocol | 2 +- 7 files changed, 12 insertions(+), 9 deletions(-) diff --git a/gui/app/public/i18n/en/translation.ftl b/gui/app/public/i18n/en/translation.ftl index 61cc723acd..58be90a816 100644 --- a/gui/app/public/i18n/en/translation.ftl +++ b/gui/app/public/i18n/en/translation.ftl @@ -310,6 +310,7 @@ tracker-status-disconnected = Disconnected tracker-status-occluded = Occluded tracker-status-ok = OK tracker-status-timed_out = Timed out +tracker-status-sleeping = Sleeping ## Tracker status columns tracker-table-column-name = Name diff --git a/gui/app/src/components/tracker/TrackerStatus.tsx b/gui/app/src/components/tracker/TrackerStatus.tsx index 87485ae501..f5e7e1ea34 100644 --- a/gui/app/src/components/tracker/TrackerStatus.tsx +++ b/gui/app/src/components/tracker/TrackerStatus.tsx @@ -12,6 +12,7 @@ const statusLabelMap: { [key: number]: string } = { [TrackerStatusEnum.OCCLUDED]: 'tracker-status-occluded', [TrackerStatusEnum.OK]: 'tracker-status-ok', [TrackerStatusEnum.TIMED_OUT]: 'tracker-status-timed_out', + [TrackerStatusEnum.SLEEPING]: 'tracker-status-sleeping', }; const statusClassMap: { [key: number]: string } = { @@ -22,6 +23,7 @@ const statusClassMap: { [key: number]: string } = { [TrackerStatusEnum.OCCLUDED]: 'bg-status-warning', [TrackerStatusEnum.OK]: 'bg-status-success', [TrackerStatusEnum.TIMED_OUT]: 'bg-status-warning', + [TrackerStatusEnum.SLEEPING]: 'bg-cyan-500', }; export function TrackerStatus({ status }: { status: number }) { diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 65ad0002a9..85c9309495 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -192,7 +192,7 @@ class HIDSleepBehaviour : HIDReceiverBehaviour { } sleepJobs[hidId] = receiver.context.scope.launch { delay(timeoutMs.toLong()) - receiver.getTracker(hidId)?.context?.dispatch(TrackerActions.SetStatus(TrackerStatus.TIMED_OUT)) + receiver.getTracker(hidId)?.context?.dispatch(TrackerActions.SetStatus(TrackerStatus.SLEEPING)) } } @@ -205,13 +205,13 @@ class HIDSleepBehaviour : HIDReceiverBehaviour { delay(remaining) remaining = (lastSeen[hidId] ?: Duration.ZERO) + hidTimeout - startedAt.elapsedNow() } - receiver.getTracker(hidId)?.context?.dispatch(TrackerActions.SetStatus(TrackerStatus.TIMED_OUT)) + receiver.getTracker(hidId)?.context?.dispatch(TrackerActions.SetStatus(TrackerStatus.SLEEPING)) } } fun onPacket(hidId: Int) { val tracker = receiver.getTracker(hidId) ?: return - if (tracker.context.state.value.status == TrackerStatus.TIMED_OUT) { + if (tracker.context.state.value.status == TrackerStatus.SLEEPING) { sleepJobs[hidId]?.cancel() sleepJobs.remove(hidId) tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) diff --git a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt index 11ef28048b..6b8bf79ac7 100644 --- a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt @@ -66,7 +66,7 @@ class OutputTrackerToggleBasicBehaviour : OutputTrackerToggleBehaviour { tracker.context.state.distinctUntilChanged { a, b -> a.bodyPart == b.bodyPart && a.status == b.status } }, ) { states -> - states.filter { (it.status == TrackerStatus.OK || it.status == TrackerStatus.TIMED_OUT) && it.origin != DeviceOrigin.DRIVER && it.origin != DeviceOrigin.VRC } + states.filter { (it.status == TrackerStatus.OK || it.status == TrackerStatus.SLEEPING) && it.origin != DeviceOrigin.DRIVER && it.origin != DeviceOrigin.VRC } .map { it.bodyPart } .toSet() }.distinctUntilChanged() diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index b0a32f0455..65bd78ee78 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -322,9 +322,9 @@ class TrackerToSkeletonBehaviour : TrackerBehaviour { } } .flatMapLatest { _ -> - // We only want trackers that are assigned to a BodyPart and are OK or TIMED_OUT (needed for HID). + // We only want trackers that are assigned to a BodyPart and are OK or SLEEPING. val activeState = receiver.context.state - .filter { it.bodyPart != null && (it.status == TrackerStatus.OK || it.status == TrackerStatus.TIMED_OUT) } + .filter { it.bodyPart != null && (it.status == TrackerStatus.OK || it.status == TrackerStatus.SLEEPING) } val rotationFlow = activeState .distinctUntilChangedBy { it.rotation } diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index f930590652..83f385ac40 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -186,10 +186,10 @@ suspend fun handleDriverConnection( tracker_status = TrackerStatus( tracker_id = event.trackerId, status = when (event.status) { - solarxr_protocol.datatypes.TrackerStatus.OK, solarxr_protocol.datatypes.TrackerStatus.TIMED_OUT -> TrackerStatus.Status.OK + solarxr_protocol.datatypes.TrackerStatus.OK, solarxr_protocol.datatypes.TrackerStatus.SLEEPING -> TrackerStatus.Status.OK solarxr_protocol.datatypes.TrackerStatus.ERROR -> TrackerStatus.Status.ERROR solarxr_protocol.datatypes.TrackerStatus.OCCLUDED -> TrackerStatus.Status.OCCLUDED - solarxr_protocol.datatypes.TrackerStatus.DISCONNECTED -> TrackerStatus.Status.DISCONNECTED + solarxr_protocol.datatypes.TrackerStatus.DISCONNECTED, solarxr_protocol.datatypes.TrackerStatus.TIMED_OUT -> TrackerStatus.Status.DISCONNECTED solarxr_protocol.datatypes.TrackerStatus.BUSY -> TrackerStatus.Status.BUSY else -> TrackerStatus.Status.ERROR }, diff --git a/solarxr-protocol b/solarxr-protocol index 0b311c4463..0b25dd5a15 160000 --- a/solarxr-protocol +++ b/solarxr-protocol @@ -1 +1 @@ -Subproject commit 0b311c446310f6218d68a6c413571085a236bc5f +Subproject commit 0b25dd5a15bcb894e71a351bb4a9fd6aa865d9ec From 9ec27d7b627bc3431090d8f0ab21697a1a1dcb89 Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 30 Jul 2026 12:50:16 -0400 Subject: [PATCH 328/350] bodypart-sets.kt in object --- .../java/dev/slimevr/resets/behaviours.kt | 2 +- .../java/dev/slimevr/resets/bodypart-sets.kt | 130 +++++++++--------- .../main/java/dev/slimevr/resets/module.kt | 20 +-- .../java/dev/slimevr/tracker/behaviours.kt | 9 +- .../slimevr/trackingchecklist/behaviours.kt | 4 +- 5 files changed, 82 insertions(+), 83 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt index 8dd22ecbd6..33f601063c 100644 --- a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt @@ -29,7 +29,7 @@ class ResetsBasicBehaviour : ResetsBehaviour { ResetType.MOUNTING -> { val bodyParts = action.bodyParts - val feetOnly = !bodyParts.isNullOrEmpty() && bodyParts.all { it in FOOT_PARTS } + val feetOnly = !bodyParts.isNullOrEmpty() && bodyParts.all { it in ResetBodyParts.FEET } when { feetOnly -> state.copy(feetMountingResetCompleted = true) diff --git a/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt b/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt index f5aafebb44..bb50378588 100644 --- a/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt +++ b/server/core/src/main/java/dev/slimevr/resets/bodypart-sets.kt @@ -2,76 +2,78 @@ package dev.slimevr.resets import solarxr_protocol.datatypes.BodyPart -val UPPER_LEG_PARTS = setOf( - BodyPart.LEFT_UPPER_LEG, - BodyPart.RIGHT_UPPER_LEG, -) +object ResetBodyParts { + val UPPER_LEGS = setOf( + BodyPart.LEFT_UPPER_LEG, + BodyPart.RIGHT_UPPER_LEG, + ) -val LEFT_ARM_PARTS = setOf( - BodyPart.LEFT_SHOULDER, - BodyPart.LEFT_UPPER_ARM, - BodyPart.LEFT_LOWER_ARM, - BodyPart.LEFT_HAND, -) + val LEFT_ARM = setOf( + BodyPart.LEFT_SHOULDER, + BodyPart.LEFT_UPPER_ARM, + BodyPart.LEFT_LOWER_ARM, + BodyPart.LEFT_HAND, + ) -val RIGHT_ARM_PARTS = setOf( - BodyPart.RIGHT_SHOULDER, - BodyPart.RIGHT_UPPER_ARM, - BodyPart.RIGHT_LOWER_ARM, - BodyPart.RIGHT_HAND, -) + val RIGHT_ARM = setOf( + BodyPart.RIGHT_SHOULDER, + BodyPart.RIGHT_UPPER_ARM, + BodyPart.RIGHT_LOWER_ARM, + BodyPart.RIGHT_HAND, + ) -val ARM_PARTS = LEFT_ARM_PARTS + RIGHT_ARM_PARTS + val ARMS = LEFT_ARM + RIGHT_ARM -val LEFT_LOWER_ARM_PARTS = setOf( - BodyPart.LEFT_LOWER_ARM, - BodyPart.LEFT_HAND, -) + val LEFT_LOWER_ARM = setOf( + BodyPart.LEFT_LOWER_ARM, + BodyPart.LEFT_HAND, + ) -val RIGHT_LOWER_ARM_PARTS = setOf( - BodyPart.RIGHT_LOWER_ARM, - BodyPart.RIGHT_HAND, -) + val RIGHT_LOWER_ARM = setOf( + BodyPart.RIGHT_LOWER_ARM, + BodyPart.RIGHT_HAND, + ) -val LOWER_ARM_PARTS = LEFT_LOWER_ARM_PARTS + RIGHT_LOWER_ARM_PARTS + val LOWER_ARMS = LEFT_LOWER_ARM + RIGHT_LOWER_ARM -val FOOT_PARTS = setOf( - BodyPart.LEFT_FOOT, - BodyPart.RIGHT_FOOT, -) + val FEET = setOf( + BodyPart.LEFT_FOOT, + BodyPart.RIGHT_FOOT, + ) -val LEFT_FINGER_PARTS = setOf( - BodyPart.LEFT_THUMB_METACARPAL, - BodyPart.LEFT_THUMB_PROXIMAL, - BodyPart.LEFT_THUMB_DISTAL, - BodyPart.LEFT_INDEX_PROXIMAL, - BodyPart.LEFT_INDEX_INTERMEDIATE, - BodyPart.LEFT_INDEX_DISTAL, - BodyPart.LEFT_MIDDLE_PROXIMAL, - BodyPart.LEFT_MIDDLE_INTERMEDIATE, - BodyPart.LEFT_MIDDLE_DISTAL, - BodyPart.LEFT_RING_PROXIMAL, - BodyPart.LEFT_RING_INTERMEDIATE, - BodyPart.LEFT_RING_DISTAL, - BodyPart.LEFT_LITTLE_PROXIMAL, - BodyPart.LEFT_LITTLE_INTERMEDIATE, - BodyPart.LEFT_LITTLE_DISTAL, -) + val LEFT_FINGERS = setOf( + BodyPart.LEFT_THUMB_METACARPAL, + BodyPart.LEFT_THUMB_PROXIMAL, + BodyPart.LEFT_THUMB_DISTAL, + BodyPart.LEFT_INDEX_PROXIMAL, + BodyPart.LEFT_INDEX_INTERMEDIATE, + BodyPart.LEFT_INDEX_DISTAL, + BodyPart.LEFT_MIDDLE_PROXIMAL, + BodyPart.LEFT_MIDDLE_INTERMEDIATE, + BodyPart.LEFT_MIDDLE_DISTAL, + BodyPart.LEFT_RING_PROXIMAL, + BodyPart.LEFT_RING_INTERMEDIATE, + BodyPart.LEFT_RING_DISTAL, + BodyPart.LEFT_LITTLE_PROXIMAL, + BodyPart.LEFT_LITTLE_INTERMEDIATE, + BodyPart.LEFT_LITTLE_DISTAL, + ) -val RIGHT_FINGER_PARTS = setOf( - BodyPart.RIGHT_THUMB_METACARPAL, - BodyPart.RIGHT_THUMB_PROXIMAL, - BodyPart.RIGHT_THUMB_DISTAL, - BodyPart.RIGHT_INDEX_PROXIMAL, - BodyPart.RIGHT_INDEX_INTERMEDIATE, - BodyPart.RIGHT_INDEX_DISTAL, - BodyPart.RIGHT_MIDDLE_PROXIMAL, - BodyPart.RIGHT_MIDDLE_INTERMEDIATE, - BodyPart.RIGHT_MIDDLE_DISTAL, - BodyPart.RIGHT_RING_PROXIMAL, - BodyPart.RIGHT_RING_INTERMEDIATE, - BodyPart.RIGHT_RING_DISTAL, - BodyPart.RIGHT_LITTLE_PROXIMAL, - BodyPart.RIGHT_LITTLE_INTERMEDIATE, - BodyPart.RIGHT_LITTLE_DISTAL, -) + val RIGHT_FINGERS = setOf( + BodyPart.RIGHT_THUMB_METACARPAL, + BodyPart.RIGHT_THUMB_PROXIMAL, + BodyPart.RIGHT_THUMB_DISTAL, + BodyPart.RIGHT_INDEX_PROXIMAL, + BodyPart.RIGHT_INDEX_INTERMEDIATE, + BodyPart.RIGHT_INDEX_DISTAL, + BodyPart.RIGHT_MIDDLE_PROXIMAL, + BodyPart.RIGHT_MIDDLE_INTERMEDIATE, + BodyPart.RIGHT_MIDDLE_DISTAL, + BodyPart.RIGHT_RING_PROXIMAL, + BodyPart.RIGHT_RING_INTERMEDIATE, + BodyPart.RIGHT_RING_DISTAL, + BodyPart.RIGHT_LITTLE_PROXIMAL, + BodyPart.RIGHT_LITTLE_INTERMEDIATE, + BodyPart.RIGHT_LITTLE_DISTAL, + ) +} diff --git a/server/core/src/main/java/dev/slimevr/resets/module.kt b/server/core/src/main/java/dev/slimevr/resets/module.kt index eeb5158d57..2f31de1eff 100644 --- a/server/core/src/main/java/dev/slimevr/resets/module.kt +++ b/server/core/src/main/java/dev/slimevr/resets/module.kt @@ -131,7 +131,7 @@ class ResetsManager(val context: ResetsContext, val server: VRServer, val settin allTrackers.filter { resetType != ResetType.MOUNTING || config.resetMountingFeet || - it.context.state.value.bodyPart !in FOOT_PARTS + it.context.state.value.bodyPart !in ResetBodyParts.FEET } }.filter { it.context.state.value.position == null } @@ -154,25 +154,25 @@ class ResetsManager(val context: ResetsContext, val server: VRServer, val settin private fun getYawOffset(bodyPart: BodyPart?, armsResetMode: ArmsResetMode) = when (bodyPart) { // Going forward - in UPPER_LEG_PARTS -> 0f + in ResetBodyParts.UPPER_LEGS -> 0f - in LOWER_ARM_PARTS if armsResetMode == ArmsResetMode.BACK -> 0f + in ResetBodyParts.LOWER_ARMS if armsResetMode == ArmsResetMode.BACK -> 0f - in ARM_PARTS if armsResetMode == ArmsResetMode.FORWARD -> 0f + in ResetBodyParts.ARMS if armsResetMode == ArmsResetMode.FORWARD -> 0f // Going left - in LEFT_ARM_PARTS if armsResetMode == ArmsResetMode.T_POSE_UP -> -FastMath.HALF_PI + in ResetBodyParts.LEFT_ARM if armsResetMode == ArmsResetMode.T_POSE_UP -> -FastMath.HALF_PI - in RIGHT_ARM_PARTS if armsResetMode == ArmsResetMode.T_POSE_DOWN -> -FastMath.HALF_PI + in ResetBodyParts.RIGHT_ARM if armsResetMode == ArmsResetMode.T_POSE_DOWN -> -FastMath.HALF_PI - in RIGHT_FINGER_PARTS -> -FastMath.HALF_PI + in ResetBodyParts.RIGHT_FINGERS -> -FastMath.HALF_PI // Going right - in LEFT_ARM_PARTS if armsResetMode == ArmsResetMode.T_POSE_DOWN -> FastMath.HALF_PI + in ResetBodyParts.LEFT_ARM if armsResetMode == ArmsResetMode.T_POSE_DOWN -> FastMath.HALF_PI - in RIGHT_ARM_PARTS if armsResetMode == ArmsResetMode.T_POSE_UP -> FastMath.HALF_PI + in ResetBodyParts.RIGHT_ARM if armsResetMode == ArmsResetMode.T_POSE_UP -> FastMath.HALF_PI - in LEFT_FINGER_PARTS -> FastMath.HALF_PI + in ResetBodyParts.LEFT_FINGERS -> FastMath.HALF_PI // Going back else -> FastMath.PI diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 65bd78ee78..6b80101808 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -3,10 +3,7 @@ package dev.slimevr.tracker import com.jme3.math.FastMath import dev.slimevr.config.Settings import dev.slimevr.logging.AppLogger -import dev.slimevr.resets.LEFT_ARM_PARTS -import dev.slimevr.resets.LEFT_FINGER_PARTS -import dev.slimevr.resets.RIGHT_ARM_PARTS -import dev.slimevr.resets.RIGHT_FINGER_PARTS +import dev.slimevr.resets.ResetBodyParts import dev.slimevr.skeleton.SkeletonActions import dev.slimevr.timeSource import io.github.axisangles.ktmath.EulerAngles @@ -373,8 +370,8 @@ class TrackerRestOrientationBehaviour( private val quarterRollRight = EulerAngles(EulerOrder.YZX, 0f, 0f, FastMath.HALF_PI).toQuaternion() private fun getRestOrientation(bodyPart: BodyPart?, armsResetMode: ArmsResetMode) = if (armsResetMode == ArmsResetMode.T_POSE_DOWN) { when (bodyPart) { - in LEFT_ARM_PARTS, in LEFT_FINGER_PARTS -> quarterRollLeft - in RIGHT_ARM_PARTS, in RIGHT_FINGER_PARTS -> quarterRollRight + in ResetBodyParts.LEFT_ARM, in ResetBodyParts.LEFT_FINGERS -> quarterRollLeft + in ResetBodyParts.RIGHT_ARM, in ResetBodyParts.RIGHT_FINGERS -> quarterRollRight else -> Quaternion.IDENTITY } } else { diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index 8b141f6c28..aeb06a8879 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -9,7 +9,7 @@ import dev.slimevr.config.Settings import dev.slimevr.device.DeviceOrigin import dev.slimevr.device.DeviceState import dev.slimevr.networkprofile.NetworkProfileManager -import dev.slimevr.resets.FOOT_PARTS +import dev.slimevr.resets.ResetBodyParts import dev.slimevr.resets.ResetsManager import dev.slimevr.skeleton.Skeleton import dev.slimevr.tracker.TrackerState @@ -310,7 +310,7 @@ class FeetMountingCalibrationCheckBehaviour( valid = resetsState.feetMountingResetCompleted, enabled = resetsConfig.lastMountingMethod == MountingMethods.AUTOMATIC && !resetsConfig.resetMountingFeet && - imuTrackers.any { it.bodyPart in FOOT_PARTS }, + imuTrackers.any { it.bodyPart in ResetBodyParts.FEET }, ignorable = true, visibility = TrackingChecklistStepVisibility.ALWAYS, ) From 96ff0b088a2659c93093ca7ebb71e0e6ce5d72e0 Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 30 Jul 2026 13:09:29 -0400 Subject: [PATCH 329/350] Remove stale CODEOWNERS entries --- .github/CODEOWNERS | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 27eb2dcbb6..75feff94c6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -19,14 +19,6 @@ # Some server code~ /server/ @ButterscotchV @loucass003 @Erimelowo -/server/src/main/java/dev/slimevr/autobone/ @ButterscotchV -/server/src/main/java/dev/slimevr/poserecorder/ @ButterscotchV -/server/src/main/java/dev/slimevr/posestreamer/ @ButterscotchV - -/server/src/main/java/dev/slimevr/osc/ @Erimelowo -/server/src/main/java/dev/slimevr/tracking/processor/ @Erimelowo -/server/src/main/java/dev/slimevr/filtering/ @Erimelowo - /bindings-provider/ @ImSapphire # Linux files From 05e12223163fd83280cf0cb2642ee6aae9495527 Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 30 Jul 2026 13:45:24 -0400 Subject: [PATCH 330/350] Don't spam warning logs as much --- .../java/dev/slimevr/skeleton/behaviours.kt | 26 +++++++++++-------- .../main/java/dev/slimevr/vmc/behaviours.kt | 9 ++++++- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 394e0ff54e..33c8259750 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -16,6 +16,7 @@ import solarxr_protocol.datatypes.BodyPart import kotlin.math.cos import kotlin.math.sin import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.nanoseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.measureTime @@ -128,13 +129,14 @@ class ComputedSkeletonBehaviour( val processors: List = emptyList(), ) : SkeletonBehaviour { private val intervalDuration = (1.0 / hz).seconds - private val logSpamWaitDuration = 15.seconds private val minimumDelay = 1.nanoseconds + private val logSpamWait = 3.minutes override fun observe(receiver: Skeleton) { var nextLogTime = timeSource.markNow() var frameStartTime = timeSource.markNow() var lastFrameTime = Duration.ZERO + var processTooLongCount = 0 receiver.context.scope.launch { while (true) { @@ -163,8 +165,9 @@ class ComputedSkeletonBehaviour( // // val ikOutput = solver.solve(fk, targets) - // Frame ends + // Frame time tracking ends and restarts here lastFrameTime = frameStartTime.elapsedNow() + frameStartTime = timeSource.markNow() // Updated the computed skeleton with the result if (!targetState.paused) { // FIXME : bones should still follow the head when paused @@ -174,17 +177,18 @@ class ComputedSkeletonBehaviour( } // Process ends - // Frame starts - frameStartTime = timeSource.markNow() - // Wait the remainder of last process - val delayDuration = (intervalDuration - processTime).coerceAtLeast(minimumDelay) - if (delayDuration <= minimumDelay && nextLogTime.hasPassedNow()) { - // Warn if the frame took too long to reach the target hz. - AppLogger.skeleton.warn("Can't reach target hz ($hz), process time = $processTime") - nextLogTime = timeSource.markNow() + logSpamWaitDuration + val delayDuration = intervalDuration - processTime + if (delayDuration <= Duration.ZERO) { + // Skeleton took to long to compute this frame + processTooLongCount++ + if (nextLogTime.hasPassedNow()) { + AppLogger.skeleton.warn("Couldn't reach ${hz}Hz $processTooLongCount ${if (processTooLongCount > 1) "times" else "time"} in the last $logSpamWait") + processTooLongCount = 0 + nextLogTime = timeSource.markNow() + logSpamWait + } } - delay(delayDuration) + delay(delayDuration.coerceAtLeast(minimumDelay)) } catch (e: CancellationException) { throw e } catch (e: Exception) { diff --git a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt index e09d22feba..45f1f10fb7 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt @@ -12,6 +12,7 @@ import dev.slimevr.osc.OscReceiver import dev.slimevr.osc.OscSender import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.Skeleton +import dev.slimevr.timeSource import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.flow.distinctUntilChanged @@ -20,14 +21,17 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart +import kotlin.time.Duration.Companion.seconds class VMCOutputBehaviour( private val skeleton: Skeleton, private val settings: Settings, ) : VMCBehaviourType { + private val logSpamWaitDuration = 1.seconds private val initTime = System.currentTimeMillis() override fun observe(receiver: VMCManager) { + var nextLogTime = timeSource.markNow() var sender: OscSender? = null var vrmGeometry: VrmGeometry? = null @@ -74,7 +78,10 @@ class VMCOutputBehaviour( try { s.send(buildBundle(computedSkeleton.bones, config, currentTime, vrm)) } catch (e: Exception) { - AppLogger.vmc.error("Failed to send VMC frame", e) + if (nextLogTime.hasPassedNow()) { + AppLogger.vmc.error("Failed to send VMC frame", e) + nextLogTime = timeSource.markNow() + logSpamWaitDuration + } } } }.launchIn(receiver.context.scope) From 1b7c333b815beaebed4c319b424ac335343e584c Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 30 Jul 2026 14:45:26 -0400 Subject: [PATCH 331/350] Use SetRotation for non-imu trackers and position --- .../java/dev/slimevr/driver/behaviours.kt | 10 +++------- .../main/java/dev/slimevr/hid/behaviours.kt | 2 +- .../main/java/dev/slimevr/solarxr/datafeed.kt | 5 ++--- .../java/dev/slimevr/tracker/behaviours.kt | 20 +++++++++++++------ .../main/java/dev/slimevr/tracker/module.kt | 6 +++--- .../slimevr/trackingchecklist/behaviours.kt | 4 ++-- .../main/java/dev/slimevr/udp/behaviours.kt | 4 ++-- .../src/main/java/dev/slimevr/vrcosc/input.kt | 9 ++------- .../src/test/java/dev/slimevr/TestServer.kt | 3 ++- .../TrackingChecklistTest.kt | 4 ++-- 10 files changed, 33 insertions(+), 34 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 1e92fbf2f7..25f216ce00 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -133,13 +133,9 @@ class DriverIncomingTrackersBehaviour : DriverBridgeBehaviour { receiver.inbound.on { event -> val trackerId = receiver.context.state.value.trackers[event.id] ?: return@on - receiver.appContext.server.getTracker(trackerId)?.let { tracker -> - tracker.context.dispatch( // TODO should maybe use TrackerActions.SetRotation? - TrackerActions.Update { - copy(rawRotation = event.rotation, rotation = event.rotation, position = event.position) - }, - ) - } + receiver.appContext.server.getTracker(trackerId)?.context?.dispatch( + TrackerActions.SetRotation(rotation = event.rotation, position = event.position), + ) }.launchIn(receiver.context.scope) receiver.inbound.on { event -> diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 85c9309495..a5355d76fe 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -111,7 +111,7 @@ class HIDDeviceInfoBehaviour : HIDReceiverBehaviour { } receiver.context.dispatch(HIDReceiverActions.TrackerRegistered(packet.hidId, tracker.context.state.value.id)) // HID does not have a rest calibration signal - tracker.context.dispatch(TrackerActions.Update { copy(sensorType = packet.imuType, completedRestCalibration = true, magStatus = packet.magStatus) }) + tracker.context.dispatch(TrackerActions.Update { copy(imuType = packet.imuType, completedRestCalibration = true, magStatus = packet.magStatus) }) tracker.context.dispatch(TrackerActions.SetStatus(TrackerStatus.OK)) }.launchIn(receiver.context.scope) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index 196d6402a2..a608f352d9 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -10,7 +10,6 @@ import dev.slimevr.skeleton.Skeleton import dev.slimevr.tracker.TrackerState import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import solarxr_protocol.data_feed.BoneMask @@ -47,12 +46,12 @@ private fun createTracker(device: DeviceState, tracker: TrackerState, trackerMas position = if (trackerMask.position == true && tracker.position != null) tracker.position.let { Vec3f(it.x, it.y, it.z) } else null, info = if (trackerMask.info == true) { TrackerInfo( - imuType = tracker.sensorType, + imuType = tracker.imuType, bodyPart = tracker.bodyPart, displayName = tracker.name, customName = tracker.customName, mountingOrientation = tracker.mountingOrientation.let { Quat(it.x, it.y, it.z, it.w) }, - isImu = tracker.sensorType != null, + isImu = tracker.imuType != null, magnetometer = tracker.magStatus, ) } else { diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 6b80101808..82f2cb3669 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -41,10 +41,18 @@ class TrackerBasicBehaviour : TrackerBehaviour { is TrackerActions.SetStatus -> state.copy(status = action.status) is TrackerActions.SetRotation -> { + val rawRotation: RawRotation = action.rotation ?: state.rawRotation + val rawAcceleration: RawAcceleration = action.acceleration ?: state.rawAcceleration + val rawMagnetometer = action.magnetometer ?: state.rawMagnetometer + val rawPosition = action.position ?: state.position + + // TODO non-IMU trackers still want some form of calibration applied val cal = state.sessionCalibration - val rawRotation: RawRotation = action.rotation ?: state.rawRotation + // Rotation calibration val rotation: CalibratedRotation = when { + state.imuType == null -> rawRotation + cal != null && action.rotation != null -> applyCalibration( rawRotation, cal.headingCorrection, @@ -57,9 +65,10 @@ class TrackerBasicBehaviour : TrackerBehaviour { else -> rawRotation } - val rawAcceleration: RawAcceleration = - action.acceleration ?: state.rawAcceleration + // Accel calibration val acceleration: CalibratedAcceleration = when { + state.imuType == null -> rawAcceleration + cal != null && action.acceleration != null -> applyCalibration( rawAcceleration, rawRotation, @@ -72,14 +81,13 @@ class TrackerBasicBehaviour : TrackerBehaviour { else -> rawAcceleration } - val rawMagnetometer = action.magnetometer ?: state.rawMagnetometer - state.copy( rawRotation = rawRotation, rotation = rotation, rawAcceleration = rawAcceleration, acceleration = acceleration, rawMagnetometer = rawMagnetometer, + position = rawPosition, ) } @@ -202,7 +210,7 @@ class TrackerBasicBehaviour : TrackerBehaviour { old.mountingOrientation == new.mountingOrientation } .onEach { - receiver.context.dispatch(TrackerActions.SetRotation(it.rawRotation, it.rawAcceleration, it.rawMagnetometer)) + receiver.context.dispatch(TrackerActions.SetRotation(it.rawRotation, it.rawAcceleration, it.rawMagnetometer, it.position)) }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 8da372855b..e1a1707a09 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -28,7 +28,7 @@ data class TrackerState( val id: Int, val name: String, val hardwareId: String, - val sensorType: ImuType?, + val imuType: ImuType?, val bodyPart: BodyPart?, val customName: String?, val mountingOrientation: HeadingAlignment, @@ -54,7 +54,7 @@ sealed interface TrackerActions { data class Update(val transform: TrackerState.() -> TrackerState) : TrackerActions data class SetMagStatus(val status: MagnetometerStatus) : TrackerActions data class SetStatus(val status: TrackerStatus) : TrackerActions - data class SetRotation(val rotation: Quaternion? = null, val acceleration: Vector3? = null, val magnetometer: Vector3? = null) : TrackerActions + data class SetRotation(val rotation: Quaternion? = null, val acceleration: Vector3? = null, val magnetometer: Vector3? = null, val position: Vector3? = null) : TrackerActions data class SetMountingOrientation(val mountingOrientation: HeadingAlignment) : TrackerActions data class SetRestOrientation(val restOrientation: Quaternion) : TrackerActions data class FullReset(val referenceRotation: Quaternion) : TrackerActions @@ -103,7 +103,7 @@ class Tracker( origin = origin, deviceId = deviceId, customName = null, - sensorType = sensorType, + imuType = sensorType, position = null, tps = 0u, imuTemp = null, diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index aeb06a8879..ac0d38cb7c 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -194,13 +194,13 @@ class NetworkProfileCheckBehaviour( private fun isImuAssigned(tracker: TrackerState): Boolean = (tracker.origin == DeviceOrigin.UDP || tracker.origin == DeviceOrigin.HID) && tracker.position == null && - tracker.sensorType !== null && + tracker.imuType !== null && tracker.status != TrackerStatus.ERROR && tracker.bodyPart != null private fun isConnectedAssignedImu(tracker: TrackerState): Boolean = (tracker.origin == DeviceOrigin.UDP || tracker.origin == DeviceOrigin.HID) && tracker.position == null && - tracker.sensorType !== null && + tracker.imuType !== null && tracker.status == TrackerStatus.OK && tracker.bodyPart != null diff --git a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt index 4954db13a8..ccae21c3f0 100644 --- a/server/core/src/main/java/dev/slimevr/udp/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/udp/behaviours.kt @@ -336,7 +336,7 @@ class SensorInfoBehaviour : UDPConnectionBehaviour { if (existingTracker != null) { existingTracker.context.dispatchAll( listOf( - TrackerActions.Update { copy(sensorType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration) }, + TrackerActions.Update { copy(imuType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration) }, TrackerActions.SetStatus(event.data.status), ), ) @@ -346,7 +346,7 @@ class SensorInfoBehaviour : UDPConnectionBehaviour { val (tracker, isNew) = assignTracker(receiver, device, event) tracker.context.dispatchAll( listOf( - TrackerActions.Update { copy(sensorType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration) }, + TrackerActions.Update { copy(imuType = event.data.imuType, completedRestCalibration = event.data.hasCompletedRestCalibration) }, TrackerActions.SetStatus(event.data.status), ), ) diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt index d77e0c070c..7e0a7382f2 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt @@ -236,13 +236,8 @@ class VRCOSCInputBehaviour( val runtimeTracker = registry.trackerFor(tracker) runtimeTracker.context.dispatchAll( listOf( - TrackerActions.Update { - copy( - position = position, - status = TrackerStatus.OK, - ) - }, - TrackerActions.SetRotation(rotation = rotation), + TrackerActions.SetStatus(TrackerStatus.OK), + TrackerActions.SetRotation(rotation = rotation, position = position), ), ) registry.setStatus(TrackerStatus.OK) diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 24ef7a16c6..b41bab0534 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -33,6 +33,7 @@ import dev.slimevr.skeleton.DEFAULT_SKELETON_STATE import dev.slimevr.skeleton.ProportionsBehaviour import dev.slimevr.skeleton.Skeleton import dev.slimevr.skeleton.buildBones +import dev.slimevr.stayaligned.StayAlignedManager import dev.slimevr.tapdetection.TapDetectionManager import dev.slimevr.tracker.SessionCalibration import dev.slimevr.tracker.Tracker @@ -152,7 +153,7 @@ fun buildTestTracker( origin = origin, deviceId = 0, customName = null, - sensorType = sensorType, + imuType = sensorType, position = position, tps = 0u, imuTemp = null, diff --git a/server/core/src/test/java/dev/slimevr/trackingchecklist/TrackingChecklistTest.kt b/server/core/src/test/java/dev/slimevr/trackingchecklist/TrackingChecklistTest.kt index 44945547c2..162be43335 100644 --- a/server/core/src/test/java/dev/slimevr/trackingchecklist/TrackingChecklistTest.kt +++ b/server/core/src/test/java/dev/slimevr/trackingchecklist/TrackingChecklistTest.kt @@ -12,7 +12,7 @@ import dev.slimevr.device.DeviceOrigin import dev.slimevr.networkprofile.NetworkInfo import dev.slimevr.networkprofile.NetworkProfileActions import dev.slimevr.networkprofile.NetworkProfileManager -import dev.slimevr.resets.FOOT_PARTS +import dev.slimevr.resets.ResetBodyParts import dev.slimevr.resets.ResetsActions import dev.slimevr.resets.ResetsManager import dev.slimevr.tracker.Tracker @@ -181,7 +181,7 @@ class TrackingChecklistTest { assertEquals(true, h.step(TrackingChecklistStepId.FEET_MOUNTING_CALIBRATION).enabled) assertEquals(false, h.step(TrackingChecklistStepId.FEET_MOUNTING_CALIBRATION).valid) - h.resetsManager.context.dispatch(ResetsActions.EndReset(ResetType.MOUNTING, bodyParts = FOOT_PARTS.toList())) + h.resetsManager.context.dispatch(ResetsActions.EndReset(ResetType.MOUNTING, bodyParts = ResetBodyParts.FEET.toList())) runCurrent() assertEquals(true, h.step(TrackingChecklistStepId.FEET_MOUNTING_CALIBRATION).valid) } From 9b06eec608a7b216404774d632fda10ea329208a Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 30 Jul 2026 18:34:27 -0400 Subject: [PATCH 332/350] Create side.kt --- .../core/src/main/java/dev/slimevr/util/side.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 server/core/src/main/java/dev/slimevr/util/side.kt diff --git a/server/core/src/main/java/dev/slimevr/util/side.kt b/server/core/src/main/java/dev/slimevr/util/side.kt new file mode 100644 index 0000000000..d233f55ade --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/util/side.kt @@ -0,0 +1,15 @@ +package dev.slimevr.util + +/** + * Used to represent the side of a bone. + */ +enum class Side { + LEFT, + RIGHT, +} + +val Side.opposite: Side + get() = when (this) { + Side.LEFT -> Side.RIGHT + Side.RIGHT -> Side.LEFT + } From bde1c78bec33490a55d51ab346ad7a2e08c08e40 Mon Sep 17 00:00:00 2001 From: Butterscotch! Date: Thu, 30 Jul 2026 19:45:08 -0400 Subject: [PATCH 333/350] Add Android SDK to Nix flake --- flake.nix | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index 8697f7b966..3e01ee2b33 100644 --- a/flake.nix +++ b/flake.nix @@ -115,6 +115,16 @@ ''; }; + buildToolsVersion = "36.0.0"; + androidComposition = pkgs.androidenv.composeAndroidPackages { + buildToolsVersions = [ + buildToolsVersion + ]; + platformVersions = [ + "36" + ]; + }; + # Toolchain shared by the dev shell and the packaging FHS env. commonPackages = [ # for running the jar @@ -141,15 +151,18 @@ pkgs.binutils pkgs.git pkgs.node-gyp-build + androidComposition.androidsdk ]; - buildEnv = { + buildEnv = rec { JAVA_HOME = "${pkgs.jdk25}/lib/openjdk"; USE_SYSTEM_FPM = "true"; ELECTRON_BUILDER_7ZIP_PATH = "${pkgs.p7zip}/bin/7za"; APPIMAGE_TOOLS_PATH = "${appImageTools}"; # for electron-vite, so `pnpm gui` works ELECTRON_EXEC_PATH = "${pkgs.electron}/bin/electron"; + ANDROID_HOME = "${androidComposition.androidsdk}/libexec/android-sdk"; + GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/${buildToolsVersion}/aapt2"; }; # electron-builder produces generic dynamically-linked binaries that @@ -174,6 +187,13 @@ }; in { + # For Android SDK + _module.args.pkgs = import nixpkgs { + inherit system; + config.allowUnfree = true; + config.android_sdk.accept_license = true; + }; + devShells.default = pkgs.mkShell ( buildEnv // { From cc64682d35b35ef9a9ac76cdbb5373f8e6d23635 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Thu, 30 Jul 2026 19:28:44 -0500 Subject: [PATCH 334/350] Assign driver-created trackers to hinted body part by default --- server/core/src/main/java/dev/slimevr/driver/behaviours.kt | 4 +++- server/core/src/main/java/dev/slimevr/driver/module.kt | 2 +- server/core/src/main/java/dev/slimevr/tracker/module.kt | 5 +++-- server/core/src/main/java/dev/slimevr/vrcosc/input.kt | 1 - .../src/main/java/dev/slimevr/desktop/ipc/protocol.kt | 5 +++++ 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index 25f216ce00..a69fe52907 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -128,6 +128,7 @@ class DriverIncomingTrackersBehaviour : DriverBridgeBehaviour { event.name, event.manufacturer, event.serial, + event.bodyPart, ) }.launchIn(receiver.context.scope) @@ -162,6 +163,7 @@ class DriverIncomingTrackersBehaviour : DriverBridgeBehaviour { name: String, manufacturer: String, serial: String, + bodyPart: BodyPart?, ) { val server = receiver.appContext.server val scope = server.context.scope @@ -192,8 +194,8 @@ class DriverIncomingTrackersBehaviour : DriverBridgeBehaviour { scope = scope, id = trackerId, name = name, + bodyPart = bodyPart, deviceId = deviceId, - sensorType = null, hardwareId = serial, origin = DeviceOrigin.DRIVER, appContext = receiver.appContext, diff --git a/server/core/src/main/java/dev/slimevr/driver/module.kt b/server/core/src/main/java/dev/slimevr/driver/module.kt index 6d4f8fb561..47e5bae3f7 100644 --- a/server/core/src/main/java/dev/slimevr/driver/module.kt +++ b/server/core/src/main/java/dev/slimevr/driver/module.kt @@ -26,7 +26,7 @@ sealed interface DriverBridgeActions { sealed interface DriverBridgeInbound { data class Version(val protocolVersion: Int) : DriverBridgeInbound - data class TrackerAdded(val id: Int, val name: String, val manufacturer: String, val serial: String) : DriverBridgeInbound + data class TrackerAdded(val id: Int, val name: String, val manufacturer: String, val serial: String, val bodyPart: BodyPart?) : DriverBridgeInbound data class TrackerPosition(val id: Int, val rotation: Quaternion, val position: Vector3?) : DriverBridgeInbound data class TrackerBattery(val id: Int, val batteryLevel: Float, val charging: Boolean) : DriverBridgeInbound } diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index e1a1707a09..b4756eb2a7 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -79,8 +79,9 @@ class Tracker( scope: CoroutineScope, id: Int, name: String = "Tracker #$id", + bodyPart: BodyPart? = null, deviceId: Int, - sensorType: ImuType?, + sensorType: ImuType? = null, hardwareId: String, origin: DeviceOrigin, appContext: AppContextProvider, @@ -98,7 +99,7 @@ class Tracker( rawAcceleration = Vector3.NULL, acceleration = Vector3.NULL, rawMagnetometer = Vector3.NULL, - bodyPart = null, + bodyPart = bodyPart, mountingOrientation = Quaternion.IDENTITY, origin = origin, deviceId = deviceId, diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt index 7e0a7382f2..4490f78c30 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/input.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/input.kt @@ -54,7 +54,6 @@ private class VRSystemTrackerRegistry( scope = manager.context.scope, id = trackerId, deviceId = device.context.state.value.id, - sensorType = null, hardwareId = "vrcosc:vrsystem:${tracker.name.lowercase()}", origin = DeviceOrigin.VRC, appContext = appContext, diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index 83f385ac40..cb81c5dd16 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -68,6 +68,7 @@ enum class TrackerRole( } val bodyPartToRole = mapOf( + BodyPart.HEAD to TrackerRole.HMD, BodyPart.UPPER_CHEST to TrackerRole.CHEST, BodyPart.LEFT_UPPER_ARM to TrackerRole.LEFT_ELBOW, BodyPart.RIGHT_UPPER_ARM to TrackerRole.RIGHT_ELBOW, @@ -76,6 +77,8 @@ val bodyPartToRole = mapOf( BodyPart.RIGHT_UPPER_LEG to TrackerRole.RIGHT_KNEE, BodyPart.LEFT_FOOT to TrackerRole.LEFT_FOOT, BodyPart.RIGHT_FOOT to TrackerRole.RIGHT_FOOT, + BodyPart.LEFT_SHOULDER to TrackerRole.LEFT_SHOULDER, + BodyPart.RIGHT_SHOULDER to TrackerRole.RIGHT_SHOULDER, BodyPart.LEFT_HAND to TrackerRole.LEFT_HAND, BodyPart.RIGHT_HAND to TrackerRole.RIGHT_HAND, ) @@ -224,12 +227,14 @@ suspend fun handleDriverConnection( } } msg.tracker_added?.let { ta -> + val roleToBodyPart = bodyPartToRole.entries.associate { (k, v) -> v to k } bridge.inbound.emit( DriverBridgeInbound.TrackerAdded( id = ta.tracker_id, name = ta.tracker_name, manufacturer = ta.manufacturer.ifEmpty { "OpenVR" }, serial = ta.tracker_serial, + bodyPart = TrackerRole.fromValue(ta.tracker_role.toUByte())?.let { role -> roleToBodyPart[role] }, ), ) } From 4bfb4287b65bff24e3f6e41af783cc87c025000f Mon Sep 17 00:00:00 2001 From: Sapphire Date: Thu, 30 Jul 2026 19:31:35 -0500 Subject: [PATCH 335/350] fixup! Assign driver-created trackers to hinted body part by default --- .../desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt index cb81c5dd16..87c43c3616 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/ipc/protocol.kt @@ -82,6 +82,7 @@ val bodyPartToRole = mapOf( BodyPart.LEFT_HAND to TrackerRole.LEFT_HAND, BodyPart.RIGHT_HAND to TrackerRole.RIGHT_HAND, ) +val roleToBodyPart = bodyPartToRole.entries.associate { (k, v) -> v to k } const val PROTOCOL_VERSION = 2 @@ -227,7 +228,6 @@ suspend fun handleDriverConnection( } } msg.tracker_added?.let { ta -> - val roleToBodyPart = bodyPartToRole.entries.associate { (k, v) -> v to k } bridge.inbound.emit( DriverBridgeInbound.TrackerAdded( id = ta.tracker_id, From 9d3558cd2a56c9d238f15b603d6627cd427f5d71 Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 30 Jul 2026 20:37:40 -0400 Subject: [PATCH 336/350] StayAligned WIP --- .../dev/slimevr/android/ForegroundService.kt | 3 + .../src/main/java/dev/slimevr/app-context.kt | 4 + .../main/java/dev/slimevr/bvh/behaviours.kt | 4 +- .../main/java/dev/slimevr/config/settings.kt | 28 ++ .../java/dev/slimevr/driver/behaviours.kt | 2 +- .../main/java/dev/slimevr/hid/behaviours.kt | 2 +- .../dev/slimevr/math/angle/angle-average.kt | 37 ++ .../dev/slimevr/math/angle/angle-errors.kt | 13 + .../main/java/dev/slimevr/math/angle/angle.kt | 66 +++ .../java/dev/slimevr/resets/behaviours.kt | 2 +- .../java/dev/slimevr/skeleton/behaviours.kt | 16 +- .../src/main/java/dev/slimevr/skeleton/ik.kt | 10 +- .../main/java/dev/slimevr/skeleton/module.kt | 33 +- .../skeleton/processors/bone-active-link.kt | 2 +- .../skeleton/processors/bone-direct-link.kt | 2 +- .../skeleton/processors/bone-prediction.kt | 2 +- .../skeleton/processors/bone-smoothing.kt | 13 +- .../skeleton/processors/bone-yaw-fallback.kt | 2 +- .../processors/bone-yaw-roll-align.kt | 7 +- .../skeleton/processors/finger-impute.kt | 2 +- .../skeleton/processors/spine-impute.kt | 9 +- .../slimevr/skeleton/skating-correction.kt | 7 +- .../main/java/dev/slimevr/solarxr/datafeed.kt | 2 +- .../main/java/dev/slimevr/solarxr/module.kt | 1 + .../java/dev/slimevr/solarxr/stay-aligned.kt | 34 ++ .../java/dev/slimevr/stayaligned/behaviour.kt | 7 + .../java/dev/slimevr/stayaligned/defaults.kt | 63 +++ .../java/dev/slimevr/stayaligned/module.kt | 76 ++++ .../stayaligned/todo/adjust-tracker-yaw.kt | 168 +++++++ .../stayaligned/todo/center-error-visitor.kt | 90 ++++ .../slimevr/stayaligned/todo/center-yaw.kt | 57 +++ .../stayaligned/todo/locked-error-visitor.kt | 143 ++++++ .../todo/neighbor-error-visitor.kt | 162 +++++++ .../slimevr/stayaligned/todo/player-pose.kt | 87 ++++ .../slimevr/stayaligned/todo/relaxed-pose.kt | 106 +++++ .../slimevr/stayaligned/todo/tracker-pose.kt | 61 +++ .../stayaligned/todo/tracker-skeleton.kt | 415 ++++++++++++++++++ .../slimevr/stayaligned/todo/tracker-yaw.kt | 55 +++ .../dev/slimevr/tapdetection/behaviours.kt | 2 +- server/core/src/main/java/dev/slimevr/time.kt | 8 - .../java/dev/slimevr/tracker/behaviours.kt | 221 ++++++++-- .../main/java/dev/slimevr/tracker/module.kt | 49 ++- .../src/main/java/dev/slimevr/util/side.kt | 2 +- .../src/main/java/dev/slimevr/util/time.kt | 21 + .../main/java/dev/slimevr/vmc/behaviours.kt | 4 +- .../java/dev/slimevr/vrcosc/output-encoder.kt | 1 - .../main/java/dev/slimevr/vrcosc/output.kt | 2 +- .../src/test/java/dev/slimevr/TestServer.kt | 1 + .../src/main/java/dev/slimevr/desktop/Main.kt | 3 + .../desktop/trackingchecklist/behaviours.kt | 2 +- 50 files changed, 2003 insertions(+), 106 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/math/angle/angle-average.kt create mode 100644 server/core/src/main/java/dev/slimevr/math/angle/angle-errors.kt create mode 100644 server/core/src/main/java/dev/slimevr/math/angle/angle.kt create mode 100644 server/core/src/main/java/dev/slimevr/solarxr/stay-aligned.kt create mode 100644 server/core/src/main/java/dev/slimevr/stayaligned/behaviour.kt create mode 100644 server/core/src/main/java/dev/slimevr/stayaligned/defaults.kt create mode 100644 server/core/src/main/java/dev/slimevr/stayaligned/module.kt create mode 100644 server/core/src/main/java/dev/slimevr/stayaligned/todo/adjust-tracker-yaw.kt create mode 100644 server/core/src/main/java/dev/slimevr/stayaligned/todo/center-error-visitor.kt create mode 100644 server/core/src/main/java/dev/slimevr/stayaligned/todo/center-yaw.kt create mode 100644 server/core/src/main/java/dev/slimevr/stayaligned/todo/locked-error-visitor.kt create mode 100644 server/core/src/main/java/dev/slimevr/stayaligned/todo/neighbor-error-visitor.kt create mode 100644 server/core/src/main/java/dev/slimevr/stayaligned/todo/player-pose.kt create mode 100644 server/core/src/main/java/dev/slimevr/stayaligned/todo/relaxed-pose.kt create mode 100644 server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-pose.kt create mode 100644 server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-skeleton.kt create mode 100644 server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-yaw.kt delete mode 100644 server/core/src/main/java/dev/slimevr/time.kt create mode 100644 server/core/src/main/java/dev/slimevr/util/time.kt diff --git a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt index c2ab3e0373..e84786ac94 100644 --- a/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt +++ b/server/android/src/main/java/dev/slimevr/android/ForegroundService.kt @@ -34,6 +34,7 @@ import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resets.ResetsManager import dev.slimevr.skeleton.Skeleton +import dev.slimevr.stayaligned.StayAlignedManager import dev.slimevr.tapdetection.TapDetectionManager import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer @@ -168,6 +169,7 @@ class ForegroundService : Service() { val resetsManager = ResetsManager.create(ctx = phase1, scope = scope) val tapDetectionManager = TapDetectionManager.create(ctx = phase1, resetsManager = resetsManager, scope = scope) val keybindManager = KeybindManager.create(scope = scope) + val stayAlignedManager = StayAlignedManager.create(ctx = phase1, skeleton = skeleton, scope = scope) val appContext = AppContext( server = server, @@ -193,6 +195,7 @@ class ForegroundService : Service() { vrcOscManager = vrcOscManager, resetsManager = resetsManager, tapDetectionManager = tapDetectionManager, + stayAlignedManager = stayAlignedManager, ) acquireLocks() diff --git a/server/core/src/main/java/dev/slimevr/app-context.kt b/server/core/src/main/java/dev/slimevr/app-context.kt index 247bd060f6..0fc85999fc 100644 --- a/server/core/src/main/java/dev/slimevr/app-context.kt +++ b/server/core/src/main/java/dev/slimevr/app-context.kt @@ -11,6 +11,7 @@ import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resets.ResetsManager import dev.slimevr.serial.SerialServer import dev.slimevr.skeleton.Skeleton +import dev.slimevr.stayaligned.StayAlignedManager import dev.slimevr.tapdetection.TapDetectionManager import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer @@ -47,6 +48,7 @@ interface AppContextProvider : Phase1ContextProvider { val vrcOscManager: VRCOSCManager val resetsManager: ResetsManager val tapDetectionManager: TapDetectionManager + val stayAlignedManager: StayAlignedManager fun startObserving() suspend fun dispose() } @@ -71,6 +73,7 @@ class AppContext( override val vrcOscManager: VRCOSCManager, override val resetsManager: ResetsManager, override val tapDetectionManager: TapDetectionManager, + override val stayAlignedManager: StayAlignedManager, ) : AppContextProvider { override fun startObserving() { keybindManager.startObserving(this) @@ -87,6 +90,7 @@ class AppContext( vrcOscManager.startObserving(this) resetsManager.startObserving() tapDetectionManager.startObserving() + stayAlignedManager.startObserving() } override suspend fun dispose() { diff --git a/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt b/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt index 9b5ae80644..79f0005d17 100644 --- a/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/bvh/behaviours.kt @@ -47,7 +47,7 @@ class BVHRecordingBehaviour( try { AppLogger.bvh.info("Opening BVH recording at ${storage.displayPath(resolvedPath)}") stream = BvhStream(storage.openTextFile(resolvedPath)).also { - it.writeHeader(skeleton.computed.value.bones) + it.writeHeader(skeleton.computed.value) // TODO: we could write the initial T-Pose or whatever here } } catch (e: Exception) { @@ -68,7 +68,7 @@ class BVHRecordingBehaviour( skeleton.computed.onEach { computedSkeleton -> try { - stream?.writeFrame(computedSkeleton.bones) + stream?.writeFrame(computedSkeleton) } catch (e: Exception) { AppLogger.bvh.error("Failed to write BVH frame", e) receiver.context.dispatch(BVHActions.StopRecording) diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index dd0b8e9964..9b28b8a058 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -174,6 +174,33 @@ data class VMCConfig( val vrmJson: String? = null, ) +// Used in StayAlignedConfig +@Serializable +data class StayAlignedRelaxedPoseConfig( + /** Whether Stay Aligned should adjust the tracker yaws when the player is in this pose. */ + var enabled: Boolean = false, + /** Angle between the upper leg yaw and the center yaw. */ + var upperLegAngleInDeg: Float = 0.0f, + /** Angle between the lower leg yaw and the center yaw. */ + var lowerLegAngleInDeg: Float = 0.0f, + /** Angle between the foot and the center yaw. */ + var footAngleInDeg: Float = 0.0f, +) + +@Serializable +data class StayAlignedConfig ( + /** Global enable toggle for all poses */ + var enabled: Boolean = false, + /** Standing relaxed pose */ + val standingRelaxedPose: StayAlignedRelaxedPoseConfig = StayAlignedRelaxedPoseConfig(), + /** Sitting relaxed pose */ + val sittingRelaxedPose: StayAlignedRelaxedPoseConfig = StayAlignedRelaxedPoseConfig(), + /** Flat relaxed pose */ + val flatRelaxedPose: StayAlignedRelaxedPoseConfig = StayAlignedRelaxedPoseConfig(), + /** Whether setup has been completed */ + var setupComplete: Boolean = false, +) + @Serializable data class SettingsConfigState( val version: Int = SETTINGS_CONFIG_VERSION, @@ -190,6 +217,7 @@ data class SettingsConfigState( val skeletonConfig: SkeletonConfig = SkeletonConfig(), val vrcOscConfig: VRCOSCConfig = VRCOSCConfig(), val vmcConfig: VMCConfig = VMCConfig(), + val stayAlignedConfig: StayAlignedConfig = StayAlignedConfig(), ) private fun migrateSettingsConfig(json: JsonObject): JsonObject { diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index a69fe52907..f767434bcd 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -61,7 +61,7 @@ class DriverOutgoingTrackersBehaviour : DriverBridgeBehaviour { trackerStateByBodyPart.putIfAbsent(bodyPart, trackerState) } - computedSkeleton.bones.forEach { (part, state) -> + computedSkeleton.forEach { (part, state) -> if (enabledBodyParts.contains(part)) { val closestTracker = bodyPartToNearest[part].orEmpty() .firstNotNullOfOrNull { fallbackPart -> trackerStateByBodyPart[fallbackPart] } diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index a5355d76fe..9b99e5a85c 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -5,7 +5,7 @@ import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.logging.AppLogger -import dev.slimevr.timeSource +import dev.slimevr.util.timeSource import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions import kotlinx.coroutines.Job diff --git a/server/core/src/main/java/dev/slimevr/math/angle/angle-average.kt b/server/core/src/main/java/dev/slimevr/math/angle/angle-average.kt new file mode 100644 index 0000000000..0e3795cde1 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/math/angle/angle-average.kt @@ -0,0 +1,37 @@ +package dev.slimevr.math.angle + +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.sin + +/** + * Averages angles by summing vectors. + * + * See https://www.themathdoctors.org/averaging-angles/ + */ +class AngleAverage { + private var sumX = 0.0f + private var sumY = 0.0f + + /** + * Adds another angle to the average. + */ + fun add(angle: Angle, weight: Float = 1.0f) { + sumX += cos(angle.toRad()) * weight + sumY += sin(angle.toRad()) * weight + } + + /** + * Gets the average angle. + */ + fun toAngle(): Angle = if (isEmpty()) { + Angle.ZERO + } else { + Angle.ofRad(atan2(sumY, sumX)) + } + + /** + * Whether there are any angles to average. + */ + fun isEmpty() = sumX == 0.0f && sumY == 0.0f +} diff --git a/server/core/src/main/java/dev/slimevr/math/angle/angle-errors.kt b/server/core/src/main/java/dev/slimevr/math/angle/angle-errors.kt new file mode 100644 index 0000000000..7cca0b3a18 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/math/angle/angle-errors.kt @@ -0,0 +1,13 @@ +package dev.slimevr.math.angle + +import kotlin.math.sqrt + +class AngleErrors { + private var sumSqrErrors = 0.0f + + fun add(error: Angle) { + sumSqrErrors += error.toRad() * error.toRad() + } + + fun toL2Norm() = Angle.ofRad(sqrt(sumSqrErrors)) +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/math/angle/angle.kt b/server/core/src/main/java/dev/slimevr/math/angle/angle.kt new file mode 100644 index 0000000000..090d0ca48b --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/math/angle/angle.kt @@ -0,0 +1,66 @@ +package dev.slimevr.math.angle + +import com.jme3.math.FastMath +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import kotlin.math.floor + +/** + * An angle between [-PI, PI). + */ +@JvmInline +value class Angle(private val rad: Float) { + fun toRad() = rad + + fun toDeg() = rad * FastMath.RAD_TO_DEG + + operator fun unaryPlus() = this + + operator fun unaryMinus() = Angle(normalize(-rad)) + + operator fun plus(other: Angle) = Angle(normalize(rad + other.rad)) + + operator fun minus(other: Angle) = Angle(normalize(rad - other.rad)) + + operator fun times(scale: Float) = Angle(normalize(rad * scale)) + + operator fun div(scale: Float) = Angle(normalize(rad / scale)) + + operator fun compareTo(other: Angle) = rad.compareTo(other.rad) + + override fun toString() = "${toDeg()} deg" + + companion object { + val ZERO = Angle(0.0f) + + fun ofRad(rad: Float) = Angle(normalize(rad)) + + fun ofDeg(deg: Float) = Angle(normalize(deg * FastMath.DEG_TO_RAD)) + + // Angle between two vectors + fun absBetween(a: Vector3, b: Vector3) = Angle(normalize(a.angleTo(b))) + + // Angle between two rotations in rotation space + fun absBetween(a: Quaternion, b: Quaternion) = Angle(normalize(a.angleToR(b))) + + /** + * Normalizes an angle to [-PI, PI) + */ + private fun normalize(rad: Float): Float { + // Normalize to [0, 2*PI) + val r = + if (rad < 0.0f || rad >= FastMath.TWO_PI) { + rad - floor(rad * FastMath.INV_TWO_PI) * FastMath.TWO_PI + } else { + rad + } + + // Normalize to [-PI, PI) + return if (r > FastMath.PI) { + r - FastMath.TWO_PI + } else { + r + } + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt index 33f601063c..d7c8e9a1f2 100644 --- a/server/core/src/main/java/dev/slimevr/resets/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/resets/behaviours.kt @@ -1,6 +1,6 @@ package dev.slimevr.resets -import dev.slimevr.timeSource +import dev.slimevr.util.timeSource import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChangedBy diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 33c8259750..6058510098 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -2,7 +2,7 @@ package dev.slimevr.skeleton import dev.slimevr.config.UserConfig import dev.slimevr.logging.AppLogger -import dev.slimevr.timeSource +import dev.slimevr.util.timeSource import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import io.ktor.utils.io.CancellationException @@ -130,10 +130,11 @@ class ComputedSkeletonBehaviour( ) : SkeletonBehaviour { private val intervalDuration = (1.0 / hz).seconds private val minimumDelay = 1.nanoseconds - private val logSpamWait = 3.minutes + private val logSpamWait = 1.minutes + private val minimumFramesToLog = 10 override fun observe(receiver: Skeleton) { - var nextLogTime = timeSource.markNow() + var nextLogTime = timeSource.markNow() + logSpamWait var frameStartTime = timeSource.markNow() var lastFrameTime = Duration.ZERO var processTooLongCount = 0 @@ -147,7 +148,7 @@ class ComputedSkeletonBehaviour( // Run processors val processed = processors - .fold(targetState) { state, processor -> processor.process(state, lastFrameTime) } // TODO: Add a constrain processor (maybe not needed) + .fold(targetState) { state, processor -> processor.process(state) } // TODO: Add a constrain processor (maybe not needed) // Get head position val rootHead = processed.boneInputs[BodyPart.HEAD] @@ -171,8 +172,8 @@ class ComputedSkeletonBehaviour( // Updated the computed skeleton with the result if (!targetState.paused) { // FIXME : bones should still follow the head when paused - receiver.computed.value = ComputedSkeleton(fk, lastFrameTime) -// receiver.computed.value = ComputedSkeleton(ikOutput, lastFrameTime) + receiver.computed.value = fk +// receiver.computed.value = ikOutput } } // Process ends @@ -183,7 +184,8 @@ class ComputedSkeletonBehaviour( // Skeleton took to long to compute this frame processTooLongCount++ if (nextLogTime.hasPassedNow()) { - AppLogger.skeleton.warn("Couldn't reach ${hz}Hz $processTooLongCount ${if (processTooLongCount > 1) "times" else "time"} in the last $logSpamWait") + if (processTooLongCount >= minimumFramesToLog) + AppLogger.skeleton.warn("Couldn't reach ${hz}Hz $processTooLongCount times in the last $logSpamWait") processTooLongCount = 0 nextLogTime = timeSource.markNow() + logSpamWait } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/ik.kt b/server/core/src/main/java/dev/slimevr/skeleton/ik.kt index 78cf6906f9..ef196a7e22 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/ik.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/ik.kt @@ -4,13 +4,13 @@ import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.BodyPart -fun chainDistanceFromTarget(bones: BodyPartMap, chain: List, target: Vector3): Float { +fun chainDistanceFromTarget(bones: ComputedSkeleton, chain: List, target: Vector3): Float { val endBone = bones[chain.last()] requireNotNull(endBone) return (target - endBone.tailPosition).len() } -fun chainCanReach(bones: BodyPartMap, chain: List, target: Vector3): Boolean { +fun chainCanReach(bones: ComputedSkeleton, chain: List, target: Vector3): Boolean { val rootBone = bones[chain.first()] requireNotNull(rootBone) val chainLength = chain.fold(0f) { acc, bodyPart -> @@ -20,7 +20,7 @@ fun chainCanReach(bones: BodyPartMap, chain: List, target: return (target - rootBone.headPosition).len() <= chainLength } -fun ccdIkIteration(boneInputs: BodyPartMap, bones: BodyPartMap, chain: List, target: Vector3): BodyPartMap { +fun ccdIkIteration(boneInputs: InputSkeleton, bones: ComputedSkeleton, chain: List, target: Vector3): ComputedSkeleton { val workingBones = boneInputs.toMutableMap() // Reversed index, i increases backwards up the chain @@ -52,11 +52,11 @@ data class IKChainGoal( ) data class IKOutput( - val bones: BodyPartMap, + val bones: ComputedSkeleton, val goalsReached: Map, ) -fun ccdIk(boneInputs: BodyPartMap, bones: BodyPartMap, goals: List, threshold: Float, maxIterations: Int): IKOutput { +fun ccdIk(boneInputs: InputSkeleton, bones: ComputedSkeleton, goals: List, threshold: Float, maxIterations: Int): IKOutput { var curBones = bones for (i in 0..maxIterations) { diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index c7fd52a020..691d46b542 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.SkeletonBone -import kotlin.time.Duration data class BoneInput( val bodyPart: BodyPart, @@ -48,17 +47,26 @@ data class BoneState( val localTailPosition = tailPosition - headPosition } -/** - * Returns the average rotation of the BodyParts. - */ -fun Map.resolveRotationFor(bodyParts: Array): Quaternion { +typealias InputSkeleton = BodyPartMap +typealias ComputedSkeleton = BodyPartMap + +@JvmName("resolveAverageRotationForInput") +fun InputSkeleton.resolveAverageRotationFor(bodyParts: Array): Quaternion { val rotations = bodyParts.mapNotNull { this[it]?.rawRotation } return rotations.reduceIndexedOrNull { index, acc, rotation -> acc.lerpQ(rotation, 1f / (index + 1)) } ?: Quaternion.IDENTITY } -data class SkeletonState(val boneInputs: BodyPartMap, val skeletonHeight: Float, val paused: Boolean) +@JvmName("resolveAverageRotationForComputed") +fun ComputedSkeleton.resolveAverageRotationFor(bodyParts: Array): Quaternion { + val rotations = bodyParts.mapNotNull { this[it]?.rotation } + return rotations.reduceIndexedOrNull { index, acc, rotation -> + acc.lerpQ(rotation, 1f / (index + 1)) + } ?: Quaternion.IDENTITY +} + +data class SkeletonState(val boneInputs: InputSkeleton, val skeletonHeight: Float, val paused: Boolean) val DEFAULT_SKELETON_STATE: SkeletonState = SkeletonState( boneInputs = DEFAULT_BONE_OFFSETS.mapValues { bodyPart, tailOffset -> @@ -90,7 +98,7 @@ fun buildBones( state: Map, rootHead: Vector3 = Vector3.NULL, hierarchy: Sequence> = iterateBodyPartHierarchy(), -): BodyPartMap { +): ComputedSkeleton { val result = bodyPartMap() hierarchy.forEach { (parentPart, childPart) -> val rawBone = state[childPart] ?: return@forEach @@ -104,7 +112,7 @@ fun buildBones( state: SkeletonState, rootHead: Vector3 = Vector3.NULL, hierarchy: Sequence> = iterateBodyPartHierarchy(), -): BodyPartMap = buildBones(state.boneInputs, rootHead, hierarchy) +): ComputedSkeleton = buildBones(state.boneInputs, rootHead, hierarchy) sealed interface SkeletonActions { data class SetBoneRotation(val bodyPart: BodyPart, val rotation: Quaternion) : SkeletonActions @@ -117,14 +125,9 @@ sealed interface SkeletonActions { typealias SkeletonContext = Context typealias SkeletonBehaviour = Behaviour interface SkeletonProcessor { - fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState + fun process(state: SkeletonState): SkeletonState } -data class ComputedSkeleton( - val bones: BodyPartMap, - val frameTime: Duration, -) - class Skeleton( val context: SkeletonContext, val computed: MutableStateFlow, @@ -163,7 +166,7 @@ class Skeleton( name = "Skeleton", ) - return Skeleton(context, MutableStateFlow(ComputedSkeleton(buildBones(context.state.value), Duration.ZERO))) + return Skeleton(context, MutableStateFlow(buildBones(context.state.value))) } } } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt index c2b297c64d..30c209a047 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-active-link.kt @@ -24,7 +24,7 @@ class BoneActiveLinkProcessor : SkeletonProcessor { BodyPart.HIP to arrayOf(BodyPart.WAIST, BodyPart.CHEST, BodyPart.UPPER_CHEST), ) - override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { + override fun process(state: SkeletonState): SkeletonState { val boneInputs = state.boneInputs val updatedLinkedBones = boneInputs.mutate { updated -> diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt index efd1a4740b..2c35cbbc9e 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-direct-link.kt @@ -38,7 +38,7 @@ class BoneDirectLinkProcessor : SkeletonProcessor { BodyPart.RIGHT_HAND to BodyPart.RIGHT_LOWER_ARM, ) - override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { + override fun process(state: SkeletonState): SkeletonState { val updatedBoneInputs = BodyPartMap(state.boneInputs) for ((bodyPart, source) in linkedToSource) { diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt index 1b6b384e34..d29f26bde9 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-prediction.kt @@ -24,7 +24,7 @@ class BonePredictionProcessor(val settings: Settings) : SkeletonProcessor { private var velocities: BodyPartMap = bodyPartMap() - override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { + override fun process(state: SkeletonState): SkeletonState { val config = settings.context.state.value.data.skeletonConfig.filtering if (config.type != FilteringType.PREDICTION) return state val predictionAmount = config.amount diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt index 81835709d3..1ff00902fb 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt @@ -1,15 +1,16 @@ package dev.slimevr.skeleton.processors import dev.slimevr.config.Settings +import dev.slimevr.util.inFloatingSeconds import dev.slimevr.skeleton.BodyPartMap import dev.slimevr.skeleton.SkeletonProcessor import dev.slimevr.skeleton.SkeletonState import dev.slimevr.skeleton.bodyPartMap import dev.slimevr.skeleton.mapValues +import dev.slimevr.util.timeSource import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.rpc.FilteringType -import kotlin.time.Duration /** * Running average of bones to smooth them out. @@ -17,14 +18,19 @@ import kotlin.time.Duration class BoneSmoothingProcessor(val settings: Settings) : SkeletonProcessor { private var smoothedRotations: BodyPartMap = bodyPartMap() private var smoothedLengths: BodyPartMap = bodyPartMap() + private var lastProcessTime = timeSource.markNow() // TODO this isn't linear. Do we want linear smoothing like in main? - override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { + override fun process(state: SkeletonState): SkeletonState { val config = settings.context.state.value.data.skeletonConfig.filtering if (config.type != FilteringType.SMOOTHING) return state + // Normalize with frame time + val lastFrameTimeSeconds = lastProcessTime.elapsedNow().inFloatingSeconds + lastProcessTime = timeSource.markNow() + val smoothingAmount = SMOOTH_MIN + config.amount.coerceIn(0f, 1f) * (SMOOTH_MAX - SMOOTH_MIN) - val alpha = (1 - smoothingAmount) * (lastFrameTime.inWholeMicroseconds / 10_000f) + val alpha = (1 - smoothingAmount) * lastFrameTimeSeconds * SMOOTHING_MULTIPLIER smoothedRotations = state.boneInputs.mapValues { bodyPart, bone -> (smoothedRotations[bodyPart] ?: bone.rawRotation).lerpR(bone.rawRotation, alpha).unit() @@ -44,6 +50,7 @@ class BoneSmoothingProcessor(val settings: Settings) : SkeletonProcessor { } companion object { + private const val SMOOTHING_MULTIPLIER = 100f private const val SMOOTH_MIN = 0.6f private const val SMOOTH_MAX = 0.93f } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-fallback.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-fallback.kt index 78771484e6..efdba73b80 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-fallback.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-fallback.kt @@ -13,7 +13,7 @@ import kotlin.time.Duration * falling back to their parent's yaw. */ class BoneYawFallbackProcessor : SkeletonProcessor { - override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { + override fun process(state: SkeletonState): SkeletonState { val boneInputs = state.boneInputs val updatedFallbackBones = boneInputs.mutate { updated -> diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt index 97addf012d..7a034aac51 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-yaw-roll-align.kt @@ -4,10 +4,9 @@ import dev.slimevr.config.Settings import dev.slimevr.skeleton.SkeletonProcessor import dev.slimevr.skeleton.SkeletonState import dev.slimevr.skeleton.mutate -import dev.slimevr.skeleton.resolveRotationFor +import dev.slimevr.skeleton.resolveAverageRotationFor import io.github.axisangles.ktmath.Quaternion import solarxr_protocol.datatypes.BodyPart -import kotlin.time.Duration /** * Handles rotating bones' yaw and roll to match other bones' yaw and roll. @@ -27,7 +26,7 @@ class BoneYawRollAlignProcessor(val settings: Settings) : SkeletonProcessor { SourceLink(BodyPart.RIGHT_UPPER_LEG, arrayOf(BodyPart.RIGHT_LOWER_LEG), mustBeActive = true), ) - override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { + override fun process(state: SkeletonState): SkeletonState { val boneInputs = state.boneInputs val ratios = settings.context.state.value.data.skeletonConfig.ratios @@ -40,7 +39,7 @@ class BoneYawRollAlignProcessor(val settings: Settings) : SkeletonProcessor { BodyPart.HIP -> ratios.interpolateHipWithUpperLegs else -> ratios.interpolateUpperLegsWithLowerLegs } - val sourceRotation = boneInputs.resolveRotationFor(link.sources) + val sourceRotation = boneInputs.resolveAverageRotationFor(link.sources) val aligned = alignYawRoll(bone.rawRotation, sourceRotation) updated[link.bodyPart] = bone.copy(rawRotation = bone.rawRotation.interpR(aligned, mixFactor)) } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/finger-impute.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/finger-impute.kt index 9aa6ae7937..bba210b419 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/finger-impute.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/finger-impute.kt @@ -49,7 +49,7 @@ class FingerImputeProcessor : SkeletonProcessor { ) // TODO : There's more math to do here. Reference the original code. - override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { + override fun process(state: SkeletonState): SkeletonState { val updatedBoneInputs = BodyPartMap(state.boneInputs) for ((bodyPart, source) in fingerToSource) { diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt index 96eefa5ba9..2dbd2a88dc 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/spine-impute.kt @@ -4,9 +4,8 @@ import dev.slimevr.config.Settings import dev.slimevr.skeleton.SkeletonProcessor import dev.slimevr.skeleton.SkeletonState import dev.slimevr.skeleton.mutate -import dev.slimevr.skeleton.resolveRotationFor +import dev.slimevr.skeleton.resolveAverageRotationFor import solarxr_protocol.datatypes.BodyPart -import kotlin.time.Duration /** * Handles imputing the rotation of spine bones that are not actively receiving data from the rotations @@ -38,7 +37,7 @@ class SpineImputeProcessor(val settings: Settings) : SkeletonProcessor { else -> error("Invalid missing spine body part $bodyPart") } - override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { + override fun process(state: SkeletonState): SkeletonState { val boneInputs = state.boneInputs val ratios = settings.context.state.value.data.skeletonConfig.ratios @@ -90,8 +89,8 @@ class SpineImputeProcessor(val settings: Settings) : SkeletonProcessor { reliabilityOf(bodyPart, toSource), ) - val fromRotation = boneInputs.resolveRotationFor(fromSource.parts) - val toRotation = boneInputs.resolveRotationFor(toSource.parts) + val fromRotation = boneInputs.resolveAverageRotationFor(fromSource.parts) + val toRotation = boneInputs.resolveAverageRotationFor(toSource.parts) updated[bodyPart] = bone.copy(rawRotation = fromRotation.interpQ(toRotation, interpolateRatio)) } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/skating-correction.kt b/server/core/src/main/java/dev/slimevr/skeleton/skating-correction.kt index 9da763dd0b..d87b4281cc 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/skating-correction.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/skating-correction.kt @@ -1,11 +1,10 @@ package dev.slimevr.skeleton -import dev.slimevr.timeSource +import dev.slimevr.util.timeSource import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import solarxr_protocol.datatypes.BodyPart import kotlin.time.ComparableTimeMark -import kotlin.time.Duration import kotlin.time.DurationUnit // TODO We should probably just have globally available velocity on the whole skeleton, @@ -87,7 +86,7 @@ class SkatingCorrectionProcessor : SkeletonProcessor { var comState: COMState? = null - override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { + override fun process(state: SkeletonState): SkeletonState { val curTime = timeSource.markNow() val lastComState = comState @@ -161,7 +160,7 @@ class SkatingCorrectionProcessor : SkeletonProcessor { class FloorClipProcessor : SkeletonProcessor { fun getDisplacement(footPos: Vector3): Vector3 = Vector3(0f, footPos.y.coerceAtMost(0f), 0f) - override fun process(state: SkeletonState, lastFrameTime: Duration): SkeletonState { + override fun process(state: SkeletonState): SkeletonState { val newBones = state.boneInputs.mapValues { (bodyPart, bone) -> } state.boneInputs[BodyPart.LEFT_FOOT]?.let { val correction = getDisplacement(it.offset) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt index a608f352d9..9712af1918 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/datafeed.kt @@ -138,7 +138,7 @@ fun createDatafeedFrame( null } val bones = datafeedConfig.boneMask?.let { mask -> - skeleton.computed.value.bones.values.map { createBone(it, mask) } + skeleton.computed.value.values.map { createBone(it, mask) } } val serverGuards = if (datafeedConfig.serverGuardsMask == true) { createServerGuards(resetsManager, heightCalibrationManager) diff --git a/server/core/src/main/java/dev/slimevr/solarxr/module.kt b/server/core/src/main/java/dev/slimevr/solarxr/module.kt index 99725704b6..c6bad5859d 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/module.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/module.kt @@ -88,6 +88,7 @@ class SolarXRBridge( add(InstalledInfoBehaviour()) add(KeybindsBehaviour(appContext.config.settings, appContext.keybindManager)) add(SessionCalibrationBehaviour(appContext.resetsManager)) + add(StayAlignedBehaviour(appContext.config.settings, appContext.stayAlignedManager)) } fun create( diff --git a/server/core/src/main/java/dev/slimevr/solarxr/stay-aligned.kt b/server/core/src/main/java/dev/slimevr/solarxr/stay-aligned.kt new file mode 100644 index 0000000000..1634ec92af --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/solarxr/stay-aligned.kt @@ -0,0 +1,34 @@ +package dev.slimevr.solarxr + +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions +import dev.slimevr.stayaligned.StayAlignedManager +import solarxr_protocol.rpc.DetectStayAlignedRelaxedPoseRequest +import solarxr_protocol.rpc.EnableStayAlignedRequest +import solarxr_protocol.rpc.ResetStayAlignedRelaxedPoseRequest + +class StayAlignedBehaviour( + private val settings: Settings, + private val stayAlignedManager: StayAlignedManager, +) : SolarXRBridgeBehaviour { + override fun observe(receiver: SolarXRBridge) { + // Enable request + receiver.rpcDispatcher.on { req -> + settings.context.dispatch( + SettingsActions.Update { + copy(stayAlignedConfig = stayAlignedConfig.copy(enabled = req.enable == true)) + } + ) + }.launchIn(receiver.context.scope) + + // Detect pose + receiver.rpcDispatcher.on { req -> + req.pose?.let { stayAlignedManager.detectRelaxedPose(it) } + }.launchIn(receiver.context.scope) + + // Reset pose + receiver.rpcDispatcher.on { req -> + req.pose?.let { stayAlignedManager.resetRelaxedPose(it) } + }.launchIn(receiver.context.scope) + } +} diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/behaviour.kt b/server/core/src/main/java/dev/slimevr/stayaligned/behaviour.kt new file mode 100644 index 0000000000..6216cf01f0 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/stayaligned/behaviour.kt @@ -0,0 +1,7 @@ +package dev.slimevr.stayaligned + +class StayAlignedBasicBehaviour : StayAlignedBehaviour { + override fun reduce(state: StayAlignedState, action: StayAlignedActions) = when (action) { + is StayAlignedActions.SetHideYawCorrection -> state.copy(hideYawCorrection = action.hideYawCorrection) + } +} diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/defaults.kt b/server/core/src/main/java/dev/slimevr/stayaligned/defaults.kt new file mode 100644 index 0000000000..bb733dc9e8 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/stayaligned/defaults.kt @@ -0,0 +1,63 @@ +package dev.slimevr.stayaligned + +import dev.slimevr.math.angle.Angle +import dev.slimevr.stayaligned.todo.RelaxedPose +import solarxr_protocol.datatypes.hardware_info.ImuType + +/** + * All non-user-configurable defaults used by Stay Aligned, so that we can tune the + * algorithm from a single place. + */ +object StayAlignedDefaults { + // Relaxed pose for kneeling. This isn't that common, so we don't want to ask + // players to provide this relaxed pose during setup. + val RELAXED_POSE_KNEELING = + RelaxedPose( + upperLeg = Angle.ofDeg(0.0f), + lowerLeg = Angle.ofDeg(0.0f), + foot = Angle.ofDeg(0.0f), + ) + + // Weights to calculate the average yaw of the skeleton + const val CENTER_ERROR_HEAD_WEIGHT = 0.5f + const val CENTER_ERROR_UPPER_BODY_WEIGHT = 1.0f + const val CENTER_ERROR_UPPER_LEG_WEIGHT = 0.4f + const val CENTER_ERROR_LOWER_LEG_WEIGHT = 0.3f + + // Weight of each force + const val YAW_ERRORS_LOCKED_ERROR_WEIGHT = 10.0f + const val YAW_ERRORS_CENTER_ERROR_WEIGHT = 2.0f + const val YAW_ERRORS_NEIGHBOR_ERROR_WEIGHT = 1.0f + + // Yaw correction for each type of IMU + val YAW_CORRECTION_IMU_GOOD = Angle.ofDeg(0.15f) + val YAW_CORRECTION_IMU_OK = Angle.ofDeg(0.20f) + val YAW_CORRECTION_IMU_BAD = Angle.ofDeg(0.40f) + val YAW_CORRECTION_IMU_DISABLED = Angle.ZERO + + val IMU_TO_YAW_CORRECTION = buildMap { + // Mag is enabled on MPU9250 but server doesn't know about it + set(ImuType.MPU9250, YAW_CORRECTION_IMU_DISABLED) + set(ImuType.MPU6500, YAW_CORRECTION_IMU_BAD) + set(ImuType.BNO080, YAW_CORRECTION_IMU_GOOD) + set(ImuType.BNO085, YAW_CORRECTION_IMU_GOOD) + set(ImuType.BNO055, YAW_CORRECTION_IMU_BAD) + set(ImuType.MPU6050, YAW_CORRECTION_IMU_BAD) + set(ImuType.BNO086, YAW_CORRECTION_IMU_GOOD) + set(ImuType.BMI160, YAW_CORRECTION_IMU_BAD) + set(ImuType.ICM20948, YAW_CORRECTION_IMU_BAD) + set(ImuType.ICM42688, YAW_CORRECTION_IMU_OK) + set(ImuType.BMI270, YAW_CORRECTION_IMU_OK) + set(ImuType.LSM6DS3TRC, YAW_CORRECTION_IMU_BAD) + set(ImuType.LSM6DSV, YAW_CORRECTION_IMU_GOOD) + set(ImuType.LSM6DSO, YAW_CORRECTION_IMU_OK) + set(ImuType.LSM6DSR, YAW_CORRECTION_IMU_GOOD) + set(ImuType.ICM45686, YAW_CORRECTION_IMU_GOOD) + set(ImuType.ICM45605, YAW_CORRECTION_IMU_GOOD) + set(ImuType.ICM55686, YAW_CORRECTION_IMU_GOOD) + } + + // Assume any new IMUs are at least OK, or else we wouldn't be writing firmware to + // support it. Please classify and add new IMUs to the map above! + val YAW_CORRECTION_DEFAULT = YAW_CORRECTION_IMU_OK +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/module.kt b/server/core/src/main/java/dev/slimevr/stayaligned/module.kt new file mode 100644 index 0000000000..701a633921 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/stayaligned/module.kt @@ -0,0 +1,76 @@ +package dev.slimevr.stayaligned + +import dev.slimevr.Phase1ContextProvider +import dev.slimevr.VRServer +import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions +import dev.slimevr.config.StayAlignedRelaxedPoseConfig +import dev.slimevr.context.Behaviour +import dev.slimevr.context.Context +import dev.slimevr.skeleton.Skeleton +import kotlinx.coroutines.CoroutineScope +import solarxr_protocol.rpc.StayAlignedRelaxedPose +import kotlin.collections.listOf + +data class StayAlignedState( + /** + * Temporarily hides the yaw correction from Stay Aligned. + * + * Used to compare to when Stay Aligned is not enabled. + * Useful to verify if Stay Aligned improved the situation or is responsible for bad tracking. + */ + val hideYawCorrection : Boolean, +) + +sealed interface StayAlignedActions { + data class SetHideYawCorrection(val hideYawCorrection: Boolean) : StayAlignedActions +} + +typealias StayAlignedContext = Context +typealias StayAlignedBehaviour = Behaviour + +class StayAlignedManager(val context: StayAlignedContext, val server: VRServer, val skeleton: Skeleton, val settings: Settings) { + fun startObserving() = context.observeAll(this) + + /** + * Sets and enables a relaxed pose from the user's current pose. + */ + fun detectRelaxedPose(pose: StayAlignedRelaxedPose) { + // TODO get user pose + updatePoseInConfig(pose, StayAlignedRelaxedPoseConfig(true)) + } + + /** + * Resets and disables a relaxed pose. + */ + fun resetRelaxedPose(pose: StayAlignedRelaxedPose) = + updatePoseInConfig(pose, StayAlignedRelaxedPoseConfig(false, 0f, 0f, 0f)) + + private fun updatePoseInConfig(pose: StayAlignedRelaxedPose, poseConfig: StayAlignedRelaxedPoseConfig) { + settings.context.dispatch(SettingsActions.Update { + copy( + stayAlignedConfig = when (pose) { + StayAlignedRelaxedPose.STANDING -> stayAlignedConfig.copy(standingRelaxedPose = poseConfig) + + StayAlignedRelaxedPose.SITTING -> stayAlignedConfig.copy(sittingRelaxedPose = poseConfig) + + StayAlignedRelaxedPose.FLAT -> stayAlignedConfig.copy(flatRelaxedPose = poseConfig) + } + ) + }) + } + + companion object { + fun create(ctx: Phase1ContextProvider, skeleton: Skeleton, scope: CoroutineScope): StayAlignedManager { + val context = Context.create( + initialState = StayAlignedState( + hideYawCorrection = false, + ), + scope = scope, + behaviours = listOf(StayAlignedBasicBehaviour()), + name = "StayAlignedManager", + ) + return StayAlignedManager(context, ctx.server, skeleton, ctx.config.settings) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/adjust-tracker-yaw.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/adjust-tracker-yaw.kt new file mode 100644 index 0000000000..d9bc3227cd --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/adjust-tracker-yaw.kt @@ -0,0 +1,168 @@ +package dev.slimevr.stayaligned.todo + +import dev.slimevr.config.StayAlignedConfig +import dev.slimevr.skeleton.ComputedSkeleton +import dev.slimevr.stayaligned.StayAlignedDefaults +import dev.slimevr.math.angle.Angle +import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.YawErrors + +object AdjustTrackerYaw { + + /** + * Adjusts the yaw of a tracker. + * + * Locked Trackers + * --------------- + * After a tracker is at rest for a short time, we lock it and save its initial + * rotation. We assume that locked trackers really are at rest, and that any + * rotation is due to drift. We adjust the tracker's yaw towards its initial + * rotation. If the tracker rotates beyonds a certain angle, we unlock the + * tracker. + * + * This works very well when the player is still and the tracker is supported by + * some surface, e.g. sitting in a chair or lying in a bed. However, it does not + * work well when the player is standing or moving around because the trackers + * will never lock. + * + * Centering Force + * --------------- + * When the player is moving around, we assume that the player will often be in + * a relaxed pose, or will eventually return to a relaxed pose. During setup, we + * collect the player's relaxed posed when standing, sitting and lying on their + * back. + * + * The centering force adjusts the tracker's yaw towards the relaxed pose. + * Upper body trackers are adjusted towards the average yaw of the body. Leg + * trackers are also adjusted towards the average yaw of the body, but with a + * yaw offset corresponding to their relaxed pose. + * + * This works well when the player is moving a lot. However, it doesn't work + * well when some of the trackers are locked, and others are moving. The locked + * trackers will stay in place, while the moving trackers will pull towards the + * relaxed pose, which can result in imbalanced poses. + * + * Neighbor Trackers + * ----------------- + * The neighboring force adjusts the tracker's yaw so that it is balanced + * between its neighboring trackers. For example, if the player is standing in a + * very wide stance, the neighboring force will push the upper leg tracker to a + * position that is proportional to their relaxed pose. This keeps the poses + * balanced. + * + * We use gradient descent to find the direction to apply a yaw correction. By + * applying this 50 times a second, the whole body is nudged into a reasonable + * alignment. + */ + fun adjust( + tracker: Tracker, + trackers: ComputedSkeleton, + yawCorrection: Angle, + config: StayAlignedConfig, + ) { +// // Clear errors, in case we don't adjust the tracker +// var state = tracker.context.state.value.stayAlignedData +// state.yawErrors = YawErrors() +// +// val restDetector = state.restDetector +// when (restDetector.state) { +// RestDetector.State.MOVING -> +// adjustMovingTracker(tracker, trackers, yawCorrection, config) +// +// RestDetector.State.AT_REST -> +// adjustLockedTracker(tracker, trackers, yawCorrection) +// +// RestDetector.State.RECENTLY_AT_REST -> { +// // Do not adjust trackers that were recently at rest, to support play +// // styles that are primarily at rest +// } +// } + } + + /** + * Adjusts a locked tracker. + */ +// private fun adjustLockedTracker( +// tracker: Tracker, +// trackers: TrackerSkeleton, +// yawCorrection: Angle, +// ) { +// val lockedRotation = tracker.stayAligned.lockedRotation ?: return +// +// adjustByError(tracker, yawCorrection) { +// YawErrors().also { +// trackers.visit(tracker, LockedErrorVisitor(lockedRotation, it.lockedError)) +// } +// } +// } + + /** + * Adjusts a tracker that is moving. + */ +// private fun adjustMovingTracker( +// tracker: Tracker, +// trackers: TrackerSkeleton, +// yawCorrection: Angle, +// config: StayAlignedConfig, +// ) { +// val centerYaw = CenterYaw.ofSkeleton(trackers) ?: return +// +// val pose = PlayerPose.ofTrackers(trackers) +// val relaxedPose = RelaxedPose.forPose(pose, config) ?: return +// +// adjustByError(tracker, yawCorrection) { +// YawErrors().also { +// trackers.visit(tracker, CenterErrorVisitor(centerYaw, relaxedPose, it.centerError)) +// trackers.visit(tracker, NeighborErrorVisitor(relaxedPose, it.neighborError)) +// } +// } +// } + + /** + * Adjusts the yaw by applying gradient descent. + */ +// private fun adjustByError( +// tracker: Tracker, +// yawCorrection: Angle, +// errorFn: (tracker: Tracker) -> YawErrors, +// ) { +// val state = tracker.stayAligned +// +// val curYaw = state.yawCorrection +// val curError = errorFn(tracker) +// +// val posYaw = curYaw + yawCorrection +// state.yawCorrection = posYaw +// val posError = errorFn(tracker) +// +// val negYaw = curYaw - yawCorrection +// state.yawCorrection = negYaw +// val negError = errorFn(tracker) +// +// val posYawDelta = gradient(posError, curError) +// val negYawDelta = gradient(negError, curError) +// +// // Pick the yaw correction that minimizes the error +// if ((posYawDelta < Angle.ZERO) && (posYawDelta < negYawDelta)) { +// state.yawCorrection = posYaw +// state.yawErrors = posError +// } else if (negYawDelta < Angle.ZERO) { +// state.yawCorrection = negYaw +// state.yawErrors = negError +// } else { +// state.yawCorrection = curYaw +// state.yawErrors = curError +// } +// } + + /** + * Calculates the gradient between two errors. A negative gradient means that there + * is less error in that direction. + */ + private fun gradient(errors: YawErrors, base: YawErrors) = (errors.lockedError.toL2Norm() - base.lockedError.toL2Norm()) * + StayAlignedDefaults.YAW_ERRORS_LOCKED_ERROR_WEIGHT + + (errors.centerError.toL2Norm() - base.centerError.toL2Norm()) * + StayAlignedDefaults.YAW_ERRORS_CENTER_ERROR_WEIGHT + + (errors.neighborError.toL2Norm() - base.neighborError.toL2Norm()) * + StayAlignedDefaults.YAW_ERRORS_NEIGHBOR_ERROR_WEIGHT +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-error-visitor.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-error-visitor.kt new file mode 100644 index 0000000000..347f14265b --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-error-visitor.kt @@ -0,0 +1,90 @@ +package dev.slimevr.stayaligned.todo + +import dev.slimevr.stayaligned.todo.TrackerYaw.extraYaw +import dev.slimevr.stayaligned.todo.TrackerYaw.trackerYaw +import dev.slimevr.math.angle.Angle +import dev.slimevr.math.angle.AngleErrors +import dev.slimevr.util.Side +import dev.slimevr.tracker.Tracker + +/** + * Assumes that the body is centered around an average yaw, and returns the error of the + * tracker with respect to that average yaw. + */ +class CenterErrorVisitor( + val centerYaw: Angle, + val relaxedPose: RelaxedPose, + val errors: AngleErrors, +) : TrackerSkeleton.TrackerVisitor { + + override fun visitHeadTracker( + tracker: Tracker, + belowUpperBody: Tracker?, + ) { + errors.add(centerYaw - trackerYaw(tracker)) + } + + override fun visitUpperBodyTracker( + tracker: Tracker, + aboveHeadOrUpperBody: Tracker?, + belowUpperBody: Tracker?, + ) { + errors.add(centerYaw - trackerYaw(tracker)) + } + + override fun visitUpperBodyTracker( + tracker: Tracker, + aboveHeadOrUpperBody: Tracker?, + belowLeftUpperLeg: Tracker?, + belowRightUpperLeg: Tracker?, + ) { + errors.add(centerYaw - trackerYaw(tracker)) + } + + override fun visitArmTracker( + side: Side, + tracker: Tracker, + aboveUpperBodyOrArm: Tracker?, + belowHandOrArm: Tracker?, + ) { + // No error because arms can go anywhere + } + + override fun visitHandTracker( + side: Side, + tracker: Tracker, + aboveArm: Tracker?, + oppositeHand: Tracker?, + ) { + // No error because hands can go anywhere + } + + override fun visitUpperLegTracker( + side: Side, + tracker: Tracker, + aboveUpperBody: Tracker?, + belowLowerLeg: Tracker?, + oppositeUpperLeg: Tracker?, + ) { + errors.add(centerYaw + extraYaw(side, relaxedPose.upperLeg) - trackerYaw(tracker)) + } + + override fun visitLowerLegTracker( + side: Side, + tracker: Tracker, + aboveUpperLeg: Tracker?, + belowFoot: Tracker?, + oppositeLowerLeg: Tracker?, + ) { + errors.add(centerYaw + extraYaw(side, relaxedPose.lowerLeg) - trackerYaw(tracker)) + } + + override fun visitFootTracker( + side: Side, + tracker: Tracker, + aboveLowerLeg: Tracker?, + oppositeFoot: Tracker?, + ) { + errors.add(centerYaw + extraYaw(side, relaxedPose.foot) - trackerYaw(tracker)) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-yaw.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-yaw.kt new file mode 100644 index 0000000000..e9a573acab --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-yaw.kt @@ -0,0 +1,57 @@ +package dev.slimevr.stayaligned.todo + +object CenterYaw { +// fun ofSkeleton( +// trackers: TrackerSkeleton, +// ): Angle? { +// +// val head = trackers.head +// val upperBody = trackers.upperBody +// val leftUpperLeg = trackers.leftUpperLeg +// val rightUpperLeg = trackers.rightUpperLeg +// val leftLowerLeg = trackers.leftLowerLeg +// val rightLowerLeg = trackers.rightLowerLeg +// +// if ( +// // Head optional, because some mocap scenarios don't use one +// upperBody.isEmpty() || +// leftUpperLeg == null || +// rightUpperLeg == null || +// leftLowerLeg == null || +// rightLowerLeg == null +// ) { +// return null +// } +// +// // Need a minimum set of trackers, and the trackers need to be oriented in a +// // way where we can actually calculate its yaw. +// val hasCenterYaw = +// upperBody.all(::hasTrackerYaw) && +// hasTrackerYaw(leftUpperLeg) && +// hasTrackerYaw(rightUpperLeg) && +// hasTrackerYaw(leftLowerLeg) && +// hasTrackerYaw(rightLowerLeg) +// if (!hasCenterYaw) { +// return null +// } +// +// // Calculate average yaw of the body +// val averageYaw = AngleAverage() +// +// if (head != null && hasTrackerYaw(head)) { +// averageYaw.add(trackerYaw(head), StayAlignedDefaults.CENTER_ERROR_HEAD_WEIGHT) +// } +// +// upperBody.forEach { +// averageYaw.add(trackerYaw(it), StayAlignedDefaults.CENTER_ERROR_UPPER_BODY_WEIGHT) +// } +// +// averageYaw.add(trackerYaw(leftUpperLeg), StayAlignedDefaults.CENTER_ERROR_UPPER_LEG_WEIGHT) +// averageYaw.add(trackerYaw(rightUpperLeg), StayAlignedDefaults.CENTER_ERROR_UPPER_LEG_WEIGHT) +// +// averageYaw.add(trackerYaw(leftLowerLeg), StayAlignedDefaults.CENTER_ERROR_LOWER_LEG_WEIGHT) +// averageYaw.add(trackerYaw(rightLowerLeg), StayAlignedDefaults.CENTER_ERROR_LOWER_LEG_WEIGHT) +// +// return averageYaw.toAngle() +// } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/locked-error-visitor.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/locked-error-visitor.kt new file mode 100644 index 0000000000..7c037b300f --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/locked-error-visitor.kt @@ -0,0 +1,143 @@ +package dev.slimevr.stayaligned.todo + +import dev.slimevr.math.angle.Angle +import dev.slimevr.math.angle.AngleErrors +import dev.slimevr.util.Side +import dev.slimevr.tracker.Tracker +import io.github.axisangles.ktmath.Quaternion +import io.github.axisangles.ktmath.Vector3 +import kotlin.math.abs +import kotlin.math.atan2 + +/** + * Error between a locked tracker's yaw and its yaw when it was initially locked. + */ +class LockedErrorVisitor( + val lockedRotation: Quaternion, + val errors: AngleErrors, +) : TrackerSkeleton.TrackerVisitor { + + override fun visitHeadTracker( + tracker: Tracker, + belowUpperBody: Tracker?, + ) { + errors.add(error(tracker)) + } + + override fun visitUpperBodyTracker( + tracker: Tracker, + aboveHeadOrUpperBody: Tracker?, + belowUpperBody: Tracker?, + ) { + errors.add(error(tracker)) + } + + override fun visitUpperBodyTracker( + tracker: Tracker, + aboveHeadOrUpperBody: Tracker?, + belowLeftUpperLeg: Tracker?, + belowRightUpperLeg: Tracker?, + ) { + errors.add(error(tracker)) + } + + override fun visitArmTracker( + side: Side, + tracker: Tracker, + aboveUpperBodyOrArm: Tracker?, + belowHandOrArm: Tracker?, + ) { + errors.add(error(tracker)) + } + + override fun visitHandTracker( + side: Side, + tracker: Tracker, + aboveArm: Tracker?, + oppositeHand: Tracker?, + ) { + errors.add(error(tracker)) + } + + override fun visitUpperLegTracker( + side: Side, + tracker: Tracker, + aboveUpperBody: Tracker?, + belowLowerLeg: Tracker?, + oppositeUpperLeg: Tracker?, + ) { + errors.add(error(tracker)) + } + + override fun visitLowerLegTracker( + side: Side, + tracker: Tracker, + aboveUpperLeg: Tracker?, + belowFoot: Tracker?, + oppositeLowerLeg: Tracker?, + ) { + errors.add(error(tracker)) + } + + override fun visitFootTracker( + side: Side, + tracker: Tracker, + aboveLowerLeg: Tracker?, + oppositeFoot: Tracker?, + ) { + errors.add(error(tracker)) + } + + private fun error(tracker: Tracker): Angle = yawDifference(tracker.context.state.value.rotation, lockedRotation) // TODO tracker.getAdjustedRotationForceStayAligned() + + companion object { + + /** + * Gets the yaw between two rotations, for small rotations. + * + * A locked tracker can be in any rotation, so we cannot use + * TrackerYaw::trackerYaw, which doesn't work for a tracker that is on its + * side. + * + * WARNING: DO NOT USE for large rotations because the chosen axis might have + * a very small projection on the yaw plane, which yields a low confidence yaw. + * + * TODO: It might be possible to pick a different EulerOrder when we encounter + * singularities, but I wasn't able to get this working correctly. + */ + private fun yawDifference( + rotation: Quaternion, + targetRotation: Quaternion, + ): Angle { + val targetX = targetRotation.sandwichUnitX() + val targetY = targetRotation.sandwichUnitY() + val targetZ = targetRotation.sandwichUnitZ() + + // Find the axis that is closest to the yaw plane + val axis: Vector3 + val targetAxis: Vector3 + + val targetXScore = abs(targetX.dot(Vector3.POS_Y)) + val targetYScore = abs(targetY.dot(Vector3.POS_Y)) + val targetZScore = abs(targetZ.dot(Vector3.POS_Y)) + + // The axis that is closest to the yaw plane has the smallest absolute dot + // product with the Y axis + if ((targetXScore <= targetYScore) && (targetXScore <= targetZScore)) { + axis = rotation.sandwichUnitX() + targetAxis = targetX + } else if ((targetYScore <= targetXScore) && (targetYScore <= targetZScore)) { + axis = rotation.sandwichUnitY() + targetAxis = targetY + } else { + axis = rotation.sandwichUnitZ() + targetAxis = targetZ + } + + val yaw = Angle.ofRad(atan2(axis.z, axis.x)) + val targetYaw = Angle.ofRad(atan2(targetAxis.z, targetAxis.x)) + + return targetYaw - yaw + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/neighbor-error-visitor.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/neighbor-error-visitor.kt new file mode 100644 index 0000000000..fc89ce054c --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/neighbor-error-visitor.kt @@ -0,0 +1,162 @@ +package dev.slimevr.stayaligned.todo + +import dev.slimevr.stayaligned.todo.TrackerYaw.extraYaw +import dev.slimevr.stayaligned.todo.TrackerYaw.trackerYaw +import dev.slimevr.math.angle.AngleErrors +import dev.slimevr.util.Side +import dev.slimevr.tracker.Tracker +import solarxr_protocol.datatypes.BodyPart + +/** + * Error between a tracker's yaw and its neighbors' yaws. + */ +class NeighborErrorVisitor( + val relaxedPose: RelaxedPose, + val errors: AngleErrors, +) : TrackerSkeleton.TrackerVisitor { + + override fun visitHeadTracker( + tracker: Tracker, + belowUpperBody: Tracker?, + ) { + if (belowUpperBody != null) { + errors.add(trackerYaw(belowUpperBody) - trackerYaw(tracker)) + } + } + + override fun visitUpperBodyTracker( + tracker: Tracker, + aboveHeadOrUpperBody: Tracker?, + belowUpperBody: Tracker?, + ) { + if ( + aboveHeadOrUpperBody != null && + // Head often drags the upper body trackers off to the side, so ignore it + aboveHeadOrUpperBody.context.state.value.bodyPart != BodyPart.HEAD + ) { + errors.add(trackerYaw(aboveHeadOrUpperBody) - trackerYaw(tracker)) + } + + if (belowUpperBody != null) { + errors.add(trackerYaw(belowUpperBody) - trackerYaw(tracker)) + } + } + + override fun visitUpperBodyTracker( + tracker: Tracker, + aboveHeadOrUpperBody: Tracker?, + belowLeftUpperLeg: Tracker?, + belowRightUpperLeg: Tracker?, + ) { + if ( + aboveHeadOrUpperBody != null && + // Head often drags the upper body trackers off to the side, so ignore it + aboveHeadOrUpperBody.context.state.value.bodyPart != BodyPart.HEAD + ) { + errors.add(trackerYaw(aboveHeadOrUpperBody) - trackerYaw(tracker)) + } + + // Only consider upper leg trackers if both are available, so that the upper + // body tracker can be balanced between both + if ( + belowLeftUpperLeg != null && + belowRightUpperLeg != null + ) { + errors.add( + trackerYaw(belowLeftUpperLeg) - + extraYaw(Side.LEFT, relaxedPose.upperLeg) - + trackerYaw(tracker), + ) + errors.add( + trackerYaw(belowRightUpperLeg) - + extraYaw(Side.RIGHT, relaxedPose.upperLeg) - + trackerYaw(tracker), + ) + } + } + + override fun visitArmTracker( + side: Side, + tracker: Tracker, + aboveUpperBodyOrArm: Tracker?, + belowHandOrArm: Tracker?, + ) { + // No error because arms can go anywhere + } + + override fun visitHandTracker( + side: Side, + tracker: Tracker, + aboveArm: Tracker?, + oppositeHand: Tracker?, + ) { + // No error because hands can go anywhere + } + + override fun visitUpperLegTracker( + side: Side, + tracker: Tracker, + aboveUpperBody: Tracker?, + belowLowerLeg: Tracker?, + oppositeUpperLeg: Tracker?, + ) { + if (aboveUpperBody != null) { + errors.add( + trackerYaw(aboveUpperBody) + + extraYaw(side, relaxedPose.upperLeg) - + trackerYaw(tracker), + ) + } + + if (belowLowerLeg != null) { + errors.add( + trackerYaw(belowLowerLeg) - + extraYaw(side, relaxedPose.lowerLeg) + + extraYaw(side, relaxedPose.upperLeg) - + trackerYaw(tracker), + ) + } + } + + override fun visitLowerLegTracker( + side: Side, + tracker: Tracker, + aboveUpperLeg: Tracker?, + belowFoot: Tracker?, + oppositeLowerLeg: Tracker?, + ) { + if (aboveUpperLeg != null) { + errors.add( + trackerYaw(aboveUpperLeg) - + extraYaw(side, relaxedPose.upperLeg) + + extraYaw(side, relaxedPose.lowerLeg) - + trackerYaw(tracker), + ) + } + + if (belowFoot != null) { + errors.add( + trackerYaw(belowFoot) - + extraYaw(side, relaxedPose.foot) + + extraYaw(side, relaxedPose.lowerLeg) - + trackerYaw(tracker), + ) + } + } + + override fun visitFootTracker( + side: Side, + tracker: Tracker, + aboveLowerLeg: Tracker?, + oppositeFoot: Tracker?, + ) { + if (aboveLowerLeg != null) { + errors.add( + trackerYaw(aboveLowerLeg) - + extraYaw(side, relaxedPose.lowerLeg) + + extraYaw(side, relaxedPose.foot) - + trackerYaw(tracker), + ) + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/player-pose.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/player-pose.kt new file mode 100644 index 0000000000..8e12e9bc3a --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/player-pose.kt @@ -0,0 +1,87 @@ +package dev.slimevr.stayaligned.todo + +/** + * The pose of the player. + */ +enum class PlayerPose { + UNKNOWN, + STANDING, + SITTING_IN_CHAIR, + SITTING_ON_GROUND, + LYING_ON_BACK, + KNEELING, + ; + + companion object { + +// fun ofTrackers(trackers: TrackerSkeleton): PlayerPose { +// val poses = +// TrackerPoses( +// trackers.upperBody.map(TrackerPose.Companion::ofTracker), +// TrackerPose.ofTracker(trackers.leftUpperLeg), +// TrackerPose.ofTracker(trackers.rightUpperLeg), +// TrackerPose.ofTracker(trackers.leftLowerLeg), +// TrackerPose.ofTracker(trackers.rightLowerLeg), +// ) +// +// return ( +// if (isStanding(poses)) { +// STANDING +// } else if (isSittingInChair(poses)) { +// SITTING_IN_CHAIR +// } else if (isSittingOnGround(poses)) { +// SITTING_ON_GROUND +// } else if (isLyingOnBack(poses)) { +// LYING_ON_BACK +// } else if (isKneeling(poses)) { +// KNEELING +// } else { +// UNKNOWN +// } +// ) +// } + + private class TrackerPoses( + val upperBody: List, + val leftUpperLeg: TrackerPose, + val rightUpperLeg: TrackerPose, + val leftLowerLeg: TrackerPose, + val rightLowerLeg: TrackerPose, + ) + + private fun isStanding(pose: TrackerPoses) = pose.upperBody.all { it == TrackerPose.TOP_FACING_UP } && + pose.leftUpperLeg == TrackerPose.TOP_FACING_UP && + pose.rightUpperLeg == TrackerPose.TOP_FACING_UP && + pose.leftLowerLeg == TrackerPose.TOP_FACING_UP && + pose.rightLowerLeg == TrackerPose.TOP_FACING_UP + + private fun isSittingInChair(pose: TrackerPoses) = pose.upperBody.isNotEmpty() && + pose.upperBody[0] == TrackerPose.TOP_FACING_UP && + pose.upperBody.all { it == TrackerPose.TOP_FACING_UP || it == TrackerPose.FRONT_FACING_UP } && + pose.leftUpperLeg == TrackerPose.FRONT_FACING_UP && + pose.rightUpperLeg == TrackerPose.FRONT_FACING_UP && + pose.leftLowerLeg == TrackerPose.TOP_FACING_UP && + pose.rightLowerLeg == TrackerPose.TOP_FACING_UP + + private fun isSittingOnGround(pose: TrackerPoses) = pose.upperBody.isNotEmpty() && + pose.upperBody[0] == TrackerPose.TOP_FACING_UP && + pose.upperBody.all { it == TrackerPose.TOP_FACING_UP || it == TrackerPose.FRONT_FACING_UP } && + // Allow legs to be flat on ground, or knees-up + pose.leftUpperLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_DOWN } && + pose.rightUpperLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_DOWN } && + pose.leftLowerLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_UP } && + pose.rightLowerLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_UP } + + private fun isLyingOnBack(pose: TrackerPoses) = pose.upperBody.all { it == TrackerPose.FRONT_FACING_UP } && + // Allow legs to be flat on ground, or knees-up + pose.leftUpperLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_DOWN } && + pose.rightUpperLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_DOWN } && + pose.leftLowerLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_UP } && + pose.rightLowerLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_UP } + + private fun isKneeling(pose: TrackerPoses) = pose.leftUpperLeg.let { it == TrackerPose.TOP_FACING_UP || it == TrackerPose.FRONT_FACING_UP } && + pose.rightUpperLeg.let { it == TrackerPose.TOP_FACING_UP || it == TrackerPose.FRONT_FACING_UP } && + pose.leftLowerLeg == TrackerPose.FRONT_FACING_DOWN && + pose.rightLowerLeg == TrackerPose.FRONT_FACING_DOWN + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/relaxed-pose.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/relaxed-pose.kt new file mode 100644 index 0000000000..728c6b688a --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/relaxed-pose.kt @@ -0,0 +1,106 @@ +package dev.slimevr.stayaligned.todo + +import dev.slimevr.config.StayAlignedConfig +import dev.slimevr.stayaligned.StayAlignedDefaults +import dev.slimevr.math.angle.Angle + +class RelaxedPose( + val upperLeg: Angle, + val lowerLeg: Angle, + val foot: Angle, +) { + override fun toString(): String = "upperLeg=$upperLeg lowerLeg=$lowerLeg foot=$foot" + + companion object { + + val ZERO = RelaxedPose(Angle.ZERO, Angle.ZERO, Angle.ZERO) + + /** + * Gets the relaxed angles for a particular pose. May provide defaults if the + * angles aren't configured for the pose. + */ + fun forPose( + playerPose: PlayerPose, + config: StayAlignedConfig, + ) = when (playerPose) { + PlayerPose.STANDING -> { + val poseConfig = config.standingRelaxedPose + if (poseConfig.enabled) { + RelaxedPose( + Angle.ofDeg(poseConfig.upperLegAngleInDeg), + Angle.ofDeg(poseConfig.lowerLegAngleInDeg), + Angle.ofDeg(poseConfig.footAngleInDeg), + ) + } else { + null + } + } + + PlayerPose.SITTING_IN_CHAIR -> { + val poseConfig = config.sittingRelaxedPose + if (poseConfig.enabled) { + RelaxedPose( + Angle.ofDeg(poseConfig.upperLegAngleInDeg), + Angle.ofDeg(poseConfig.lowerLegAngleInDeg), + Angle.ofDeg(poseConfig.footAngleInDeg), + ) + } else { + null + } + } + + PlayerPose.SITTING_ON_GROUND, + PlayerPose.LYING_ON_BACK, + -> { + val poseConfig = config.flatRelaxedPose + if (poseConfig.enabled) { + RelaxedPose( + Angle.ofDeg(poseConfig.upperLegAngleInDeg), + Angle.ofDeg(poseConfig.lowerLegAngleInDeg), + Angle.ofDeg(poseConfig.footAngleInDeg), + ) + } else { + null + } + } + + PlayerPose.KNEELING -> + StayAlignedDefaults.RELAXED_POSE_KNEELING + + else -> + null + } + +// /** +// * Gets the relaxed angles from the trackers. +// */ +// fun fromTrackers(humanSkeleton: HumanSkeleton): RelaxedPose { +// val halfAngleBetween = { left: Tracker, right: Tracker -> +// (trackerYaw(left) - trackerYaw(right)) * 0.5f +// } +// +// var upperLegAngle = Angle.ZERO +// humanSkeleton.leftUpperLegTracker?.let { left -> +// humanSkeleton.rightUpperLegTracker?.let { right -> +// upperLegAngle = halfAngleBetween(left, right) +// } +// } +// +// var lowerLegAngle = Angle.ZERO +// humanSkeleton.leftLowerLegTracker?.let { left -> +// humanSkeleton.rightLowerLegTracker?.let { right -> +// lowerLegAngle = halfAngleBetween(left, right) +// } +// } +// +// var footAngle = Angle.ZERO +// humanSkeleton.leftFootTracker?.let { left -> +// humanSkeleton.rightFootTracker?.let { right -> +// footAngle = halfAngleBetween(left, right) +// } +// } +// +// return RelaxedPose(upperLegAngle, lowerLegAngle, footAngle) +// } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-pose.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-pose.kt new file mode 100644 index 0000000000..008807ff0c --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-pose.kt @@ -0,0 +1,61 @@ +package dev.slimevr.stayaligned.todo + +import dev.slimevr.tracker.Tracker +import io.github.axisangles.ktmath.Vector3 +import kotlin.math.abs + +/** + * The orientation of a tracker. + */ +enum class TrackerPose { + NONE, + TOP_FACING_UP, + TOP_FACING_DOWN, + FRONT_FACING_UP, + FRONT_FACING_DOWN, + ON_SIDE, + ; + + companion object { + + fun ofTracker(tracker: Tracker?): TrackerPose { + if (tracker == null) { + return NONE + } + + val rotation = tracker.context.state.value.rotation // TODO tracker.getAdjustedRotationForceStayAligned() + + val x = rotation.sandwichUnitX() + val y = rotation.sandwichUnitY() + val z = rotation.sandwichUnitZ() + + val xDot = x.dot(Vector3.POS_Y) + val yDot = y.dot(Vector3.POS_Y) + val zDot = z.dot(Vector3.POS_Y) + + val xAbsDot = abs(xDot) + val yAbsDot = abs(yDot) + val zAbsDot = abs(zDot) + + val pose = + if ((xAbsDot >= yAbsDot) && (xAbsDot >= zAbsDot)) { + ON_SIDE + } else if ((yAbsDot >= xAbsDot) && (yAbsDot >= zAbsDot)) { + if (yDot >= 0) { + TOP_FACING_UP + } else { + TOP_FACING_DOWN + } + } else { + // Tracker local POS_Z is behind + if (zDot >= 0) { + FRONT_FACING_DOWN + } else { + FRONT_FACING_UP + } + } + + return pose + } + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-skeleton.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-skeleton.kt new file mode 100644 index 0000000000..e1ed544823 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-skeleton.kt @@ -0,0 +1,415 @@ +package dev.slimevr.stayaligned.todo + +import dev.slimevr.skeleton.Skeleton +import dev.slimevr.tracker.Tracker +import dev.slimevr.util.Side + +/** + * Represents a skeleton of trackers. + * + * The skeleton consists of: + * - An upper body group + * - A head tracker, connected to the top of the upper body group + * - Two arm groups, connected to the top of the upper body group + * - Two hands connected to the bottom of the corresponding arm group + * - Two upper legs, connected to the bottom of the upper body group + * - Two lower legs, connected to the bottom of each corresponding upper leg + * - Two feet, connected to the bottom of each corresponding lower leg + */ +class TrackerSkeleton(skeleton: Skeleton) { + +// val allTrackers = with(skeleton) { +// listOfNotNull( +// headTracker, +// // Upper body +// neckTracker, +// upperChestTracker, +// chestTracker, +// waistTracker, +// hipTracker, +// // Left arm +// leftShoulderTracker, +// leftUpperArmTracker, +// leftLowerArmTracker, +// leftHandTracker, +// // Right arm +// rightShoulderTracker, +// rightUpperArmTracker, +// rightLowerArmTracker, +// rightHandTracker, +// // Left leg +// leftUpperLegTracker, +// leftLowerLegTracker, +// leftFootTracker, +// // Right leg +// rightUpperLegTracker, +// rightLowerLegTracker, +// rightFootTracker, +// ) +// } +// +// // Tracker groups +// val upperBody = with(skeleton) { +// listOfNotNull( +// neckTracker, +// upperChestTracker, +// chestTracker, +// waistTracker, +// hipTracker, +// ) +// } +// +// val leftArm = with(skeleton) { +// listOfNotNull( +// leftShoulderTracker, +// leftUpperArmTracker, +// leftLowerArmTracker, +// ) +// } +// +// val rightArm = with(skeleton) { +// listOfNotNull( +// rightShoulderTracker, +// rightUpperArmTracker, +// rightLowerArmTracker, +// ) +// } +// +// // Individual trackers +// val head = skeleton.headTracker +// val leftHand = skeleton.leftHandTracker +// val rightHand = skeleton.rightHandTracker +// val leftUpperLeg = skeleton.leftUpperLegTracker +// val leftLowerLeg = skeleton.leftLowerLegTracker +// val leftFoot = skeleton.leftFootTracker +// val rightUpperLeg = skeleton.rightUpperLegTracker +// val rightLowerLeg = skeleton.rightLowerLegTracker +// val rightFoot = skeleton.rightFootTracker +// +// /** +// * Visits a tracker within the skeleton. +// */ +// fun visit( +// tracker: Tracker, +// visitor: TrackerVisitor, +// ) { +// when (tracker.context.state.value.bodyPart) { +// BodyPart.HEAD -> +// if (tracker == head) { +// visitor.visitHeadTracker(tracker, upperBody.firstOrNull()) +// } +// +// // Upper body +// BodyPart.NECK, +// BodyPart.UPPER_CHEST, +// BodyPart.CHEST, +// BodyPart.WAIST, +// BodyPart.HIP, +// -> +// visitUpperBodyTrackers( +// tracker, +// visitor, +// head, +// upperBody, +// leftUpperLeg, +// rightUpperLeg, +// ) +// +// // Left arm +// BodyPart.LEFT_SHOULDER, +// BodyPart.LEFT_UPPER_ARM, +// BodyPart.LEFT_LOWER_ARM, +// -> +// visitArmTrackers( +// tracker, +// visitor, +// Side.LEFT, +// upperBody.firstOrNull(), +// leftArm, +// leftHand, +// ) +// +// // Right arm +// BodyPart.RIGHT_SHOULDER, +// BodyPart.RIGHT_UPPER_ARM, +// BodyPart.RIGHT_LOWER_ARM, +// -> +// visitArmTrackers( +// tracker, +// visitor, +// Side.RIGHT, +// upperBody.firstOrNull(), +// rightArm, +// rightHand, +// ) +// +// BodyPart.LEFT_HAND -> +// if (tracker == leftHand) { +// visitor.visitHandTracker( +// Side.LEFT, +// tracker, +// leftArm.lastOrNull(), +// rightHand, +// ) +// } +// +// BodyPart.RIGHT_HAND -> +// if (tracker == rightHand) { +// visitor.visitHandTracker( +// Side.RIGHT, +// tracker, +// rightArm.lastOrNull(), +// leftHand, +// ) +// } +// +// BodyPart.LEFT_UPPER_LEG -> +// if (tracker == leftUpperLeg) { +// visitor.visitUpperLegTracker( +// Side.LEFT, +// tracker, +// upperBody.lastOrNull(), +// leftLowerLeg, +// rightUpperLeg, +// ) +// } +// +// BodyPart.RIGHT_UPPER_LEG -> +// if (tracker == rightUpperLeg) { +// visitor.visitUpperLegTracker( +// Side.RIGHT, +// tracker, +// upperBody.lastOrNull(), +// rightLowerLeg, +// leftUpperLeg, +// ) +// } +// +// BodyPart.LEFT_LOWER_LEG -> +// if (tracker == leftLowerLeg) { +// visitor.visitLowerLegTracker( +// Side.LEFT, +// tracker, +// leftUpperLeg, +// leftFoot, +// rightLowerLeg, +// ) +// } +// +// BodyPart.RIGHT_LOWER_LEG -> +// if (tracker == rightLowerLeg) { +// visitor.visitLowerLegTracker( +// Side.RIGHT, +// tracker, +// rightUpperLeg, +// rightFoot, +// leftLowerLeg, +// ) +// } +// +// BodyPart.LEFT_FOOT -> +// if (tracker == leftFoot) { +// visitor.visitFootTracker( +// Side.LEFT, +// tracker, +// leftLowerLeg, +// rightFoot, +// ) +// } +// +// BodyPart.RIGHT_FOOT -> +// if (tracker == rightFoot) { +// visitor.visitFootTracker( +// Side.RIGHT, +// tracker, +// rightLowerLeg, +// leftFoot, +// ) +// } +// +// else -> { +// // No tracker to visit +// } +// } +// } + + private fun visitUpperBodyTrackers( + tracker: Tracker, + visitor: TrackerVisitor, + head: Tracker?, + upperBody: List, + leftUpperLeg: Tracker?, + rightUpperLeg: Tracker?, + ) { + val index = upperBody.indexOf(tracker) + if (index < 0) { + return + } + + if (index == 0) { + if (upperBody.size == 1) { + // Only upper body tracker + visitor.visitUpperBodyTracker( + tracker, + head, + leftUpperLeg, + rightUpperLeg, + ) + } else { + // First upper body tracker + visitor.visitUpperBodyTracker( + tracker, + head, + upperBody[1], + ) + } + } else if (index < upperBody.size - 1) { + // Middle upper body tracker + visitor.visitUpperBodyTracker( + tracker, + upperBody[index - 1], + upperBody[index + 1], + ) + } else { + // Last upper body tracker + visitor.visitUpperBodyTracker( + tracker, + upperBody[index - 1], + leftUpperLeg, + rightUpperLeg, + ) + } + } + + private fun visitArmTrackers( + tracker: Tracker, + visitor: TrackerVisitor, + side: Side, + upperBody: Tracker?, + arm: List, + hand: Tracker?, + ) { + val index = arm.indexOf(tracker) + if (index < 0) { + return + } + + if (index == 0) { + if (arm.size == 1) { + // Only arm tracker + visitor.visitArmTracker( + side, + tracker, + upperBody, + hand, + ) + } else { + // First arm tracker + visitor.visitArmTracker( + side, + tracker, + upperBody, + arm[1], + ) + } + } else if (index < arm.size - 1) { + // Middle arm tracker + visitor.visitArmTracker( + side, + tracker, + arm[index - 1], + arm[index + 1], + ) + } else { + // Last arm tracker + visitor.visitArmTracker( + side, + tracker, + arm[index - 1], + hand, + ) + } + } + + interface TrackerVisitor { + + /** + * Visits the head tracker. + */ + fun visitHeadTracker( + tracker: Tracker, + belowUpperBody: Tracker?, + ) + + /** + * Visits an upper body tracker (except for the bottom-most tracker). + */ + fun visitUpperBodyTracker( + tracker: Tracker, + aboveHeadOrUpperBody: Tracker?, + belowUpperBody: Tracker?, + ) + + /** + * Visits the bottom-most upper body tracker. + */ + fun visitUpperBodyTracker( + tracker: Tracker, + aboveHeadOrUpperBody: Tracker?, + belowLeftUpperLeg: Tracker?, + belowRightUpperLeg: Tracker?, + ) + + /** + * Visits an arm tracker. + */ + fun visitArmTracker( + side: Side, + tracker: Tracker, + aboveUpperBodyOrArm: Tracker?, + belowHandOrArm: Tracker?, + ) + + /** + * Visits a hand tracker. + */ + fun visitHandTracker( + side: Side, + tracker: Tracker, + aboveArm: Tracker?, + oppositeHand: Tracker?, + ) + + /** + * Visits an upper leg tracker. + */ + fun visitUpperLegTracker( + side: Side, + tracker: Tracker, + aboveUpperBody: Tracker?, + belowLowerLeg: Tracker?, + oppositeUpperLeg: Tracker?, + ) + + /** + * Visits a lower leg tracker. + */ + fun visitLowerLegTracker( + side: Side, + tracker: Tracker, + aboveUpperLeg: Tracker?, + belowFoot: Tracker?, + oppositeLowerLeg: Tracker?, + ) + + /** + * Visits a foot tracker. + */ + fun visitFootTracker( + side: Side, + tracker: Tracker, + aboveLowerLeg: Tracker?, + oppositeFoot: Tracker?, + ) + } +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-yaw.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-yaw.kt new file mode 100644 index 0000000000..ec41e901d5 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-yaw.kt @@ -0,0 +1,55 @@ +package dev.slimevr.stayaligned.todo + +import dev.slimevr.math.angle.Angle +import dev.slimevr.util.Side +import dev.slimevr.tracker.Tracker +import io.github.axisangles.ktmath.EulerOrder +import io.github.axisangles.ktmath.Vector3 + +/** + * Utilities for tracker yaw. + * + * The SlimeVR coordinate system is x-right, y-up, z-back, which is a right-handed + * coordinate system. + * + * Rotations follow the right-hand rule, for example, a positive rotation around the + * y-axis is a counter-clockwise rotation from z to x. From the perspective of a player, + * left is positive yaw, right is negative yaw. + */ +object TrackerYaw { + /** + * Whether we can get the yaw of a tracker. + */ + fun hasTrackerYaw(tracker: Tracker) = Angle.absBetween( + tracker.context.state.value.rotation.sandwichUnitX(), // TODO tracker.getAdjustedRotationForceStayAligned() + Vector3.POS_Y, + ) > MIN_ON_SIDE_ANGLE + + /** + * Gets the yaw of the tracker, for trackers that are not on its side. + * + * WARNING: DO NOT USE for a tracker that is on its side. Euler YZX angles have a + * singularity for a tracker that is on its side, and can yield arbitrary yaws. + * For example, the Euler YZX angles (Y=0°, Z=90°, X=30°) and (Y=30°, Z=90°, X=0°) + * are equivalent but yield completely different yaws. + * + * WARNING: It is possible to use another EulerOrder which does not have a + * singularity for this rotation to get "some" yaw, but this yaw will be very + * different from the from YZX. DO NOT ATTEMPT! + */ + fun trackerYaw(tracker: Tracker) = Angle.ofRad( + tracker.context.state.value.rotation // TODO tracker.getAdjustedRotationForceStayAligned() + .toEulerAngles(EulerOrder.YZX) + .y, + ) + + /** + * Applies an extra yaw in the specified direction. + */ + fun extraYaw(direction: Side, angle: Angle) = when (direction) { + Side.LEFT -> angle + Side.RIGHT -> -angle + } + + private val MIN_ON_SIDE_ANGLE = Angle.ofDeg(30.0f) +} \ No newline at end of file diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt index b2d0885db7..37d98506e2 100644 --- a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt @@ -1,7 +1,7 @@ package dev.slimevr.tapdetection import dev.slimevr.config.TapDetectionConfig -import dev.slimevr.timeSource +import dev.slimevr.util.timeSource import dev.slimevr.tracker.Tracker import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.ExperimentalCoroutinesApi diff --git a/server/core/src/main/java/dev/slimevr/time.kt b/server/core/src/main/java/dev/slimevr/time.kt deleted file mode 100644 index c07c00a837..0000000000 --- a/server/core/src/main/java/dev/slimevr/time.kt +++ /dev/null @@ -1,8 +0,0 @@ -package dev.slimevr - -import kotlin.time.TimeSource - -/** - * We use Monotonic timesource, which is the most precise (`System.nanoTime()`) - */ -val timeSource = TimeSource.Monotonic diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 82f2cb3669..f128a80156 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -2,10 +2,16 @@ package dev.slimevr.tracker import com.jme3.math.FastMath import dev.slimevr.config.Settings +import dev.slimevr.util.inFloatingSeconds import dev.slimevr.logging.AppLogger import dev.slimevr.resets.ResetBodyParts import dev.slimevr.skeleton.SkeletonActions -import dev.slimevr.timeSource +import dev.slimevr.stayaligned.StayAlignedDefaults.IMU_TO_YAW_CORRECTION +import dev.slimevr.stayaligned.StayAlignedDefaults.YAW_CORRECTION_DEFAULT +import dev.slimevr.stayaligned.StayAlignedManager +import dev.slimevr.stayaligned.todo.AdjustTrackerYaw +import dev.slimevr.math.angle.Angle +import dev.slimevr.util.timeSource import io.github.axisangles.ktmath.EulerAngles import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Quaternion @@ -20,17 +26,18 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import solarxr_protocol.datatypes.BodyPart +import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.rpc.ArmsResetMode import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds class TrackerBasicBehaviour : TrackerBehaviour { override fun reduce(state: TrackerState, action: TrackerActions) = when (action) { @@ -183,23 +190,8 @@ class TrackerBasicBehaviour : TrackerBehaviour { state.copy(sessionCalibration = state.sessionCalibration?.copy(headingAlignment = Quaternion.IDENTITY)) } - is TrackerActions.TickYawResetSmoothing -> { - val cal = state.sessionCalibration - if (cal == null || state.yawResetSmoothing == null) { - // Nothing to advance. - state.copy(yawResetSmoothing = null) - } else { - // The behaviour computed the interpolated heading; store it in the session - // calibration. TrackerBasicBehaviour.observe re-applies it to the rotation - // (using the last raw rotation), so this progresses even with no new IMU - // data. On `done` the seed is cleared, leaving the target heading in place. - state.copy( - sessionCalibration = cal.copy(headingCorrection = action.heading), - yawResetSmoothing = if (action.done) null else state.yawResetSmoothing, - ) - } - } - } + else -> state + } override fun observe(receiver: Tracker) { // Refresh the tracker's rotation whenever calibration gets updated @@ -239,6 +231,27 @@ class TrackerYawResetSmoothingBehaviour : TrackerBehaviour { } .launchIn(receiver.context.scope) } + + override fun reduce(state: TrackerState, action: TrackerActions) = when (action) { + is TrackerActions.TickYawResetSmoothing -> { + val cal = state.sessionCalibration + if (cal == null || state.yawResetSmoothing == null) { + // Nothing to advance. + state.copy(yawResetSmoothing = null) + } else { + // The behaviour computed the interpolated heading; store it in the session + // calibration. TrackerBasicBehaviour.observe re-applies it to the rotation + // (using the last raw rotation), so this progresses even with no new IMU + // data. On `done` the seed is cleared, leaving the target heading in place. + state.copy( + sessionCalibration = cal.copy(headingCorrection = action.heading), + yawResetSmoothing = if (action.done) null else state.yawResetSmoothing, + ) + } + } + + else -> state + } } class TrackerDefaultMountingOrientationBehaviour : TrackerBehaviour { @@ -331,27 +344,19 @@ class TrackerToSkeletonBehaviour : TrackerBehaviour { val activeState = receiver.context.state .filter { it.bodyPart != null && (it.status == TrackerStatus.OK || it.status == TrackerStatus.SLEEPING) } - val rotationFlow = activeState - .distinctUntilChangedBy { it.rotation } + activeState + .distinctUntilChangedBy { it.rotation to it.position } .onEach { trackerState -> - receiver.appContext.skeleton.context.dispatch( - SkeletonActions.SetBoneRotation(trackerState.bodyPart ?: BodyPart.NONE, trackerState.rotation), - ) - lastBodyPartSent = trackerState.bodyPart - } - - val positionFlow = activeState - .distinctUntilChangedBy { it.position } - .onEach { trackerState -> - trackerState.position?.let { - receiver.appContext.skeleton.context.dispatch( - SkeletonActions.SetBonePosition(trackerState.bodyPart ?: BodyPart.NONE, it), + trackerState.bodyPart?.let { bodyPart -> + receiver.appContext.skeleton.context.dispatchAll( + listOfNotNull( + SkeletonActions.SetBoneRotation(bodyPart, trackerState.rotation), + if (trackerState.position != null) SkeletonActions.SetBonePosition(bodyPart, trackerState.position) else null + ) ) lastBodyPartSent = trackerState.bodyPart } } - - merge(rotationFlow, positionFlow) } .launchIn(receiver.context.scope) } @@ -386,3 +391,149 @@ class TrackerRestOrientationBehaviour( Quaternion.IDENTITY } } + +/** + * Detects whether a tracker is at rest. + * + * A tracker is at rest when it stays within a certain rotational range for a given + * amount of time. If it rotates past that range, it is no longer at rest. + * + * TODO: Consider accel and use for tap detection as well + */ +class TrackerRestDetectionBehaviour : TrackerBehaviour { + + override fun observe(receiver: Tracker) { + var startTime = timeSource.markNow() + var lastUpdateTime = timeSource.markNow() + var lastRotation = Quaternion.IDENTITY + + // To reset rest detection + receiver.context.state + .distinctUntilChanged { old, new -> old.sessionCalibration == new.sessionCalibration } + .onEach { + lastRotation = Quaternion.IDENTITY + val now = timeSource.markNow() + startTime = now + lastUpdateTime = now + receiver.context.dispatch(TrackerActions.SetRestState(RestState.MOVING)) // TODO : should this be AT_REST? + }.launchIn(receiver.context.scope) + + // For update loop + receiver.context.state + .map { it.rotation to it.acceleration } + .distinctUntilChanged() + .onEach { (rotation, acceleration) -> + val now = timeSource.markNow() + + if ( + receiver.context.state.value.restState == RestState.RECENTLY_AT_REST && + now > startTime.plus(ENTER_MOVING_TIME) + ) { + receiver.context.dispatch(TrackerActions.SetRestState(RestState.MOVING)) + startTime = now + lastRotation = rotation + lastUpdateTime = now + } + + when (receiver.context.state.value.restState) { + RestState.MOVING, + RestState.RECENTLY_AT_REST, + -> + if (Angle.absBetween(lastRotation, rotation) > MAX_ROTATION) { + lastRotation = rotation + lastUpdateTime = now + } else { + // When we detect the tracker is at rest, use the current rotation as the + // new start rotation for continuing to detect the tracker is at rest + if (now > lastUpdateTime.plus(ENTER_REST_TIME)) { + receiver.context.dispatch(TrackerActions.SetRestState(RestState.AT_REST)) + startTime = now + lastRotation = rotation + lastUpdateTime = now + } + } + + RestState.AT_REST -> + if (Angle.absBetween(lastRotation, rotation) > MAX_ROTATION) { + receiver.context.dispatch(TrackerActions.SetRestState(RestState.RECENTLY_AT_REST)) + startTime = now + lastRotation = rotation + lastUpdateTime = now + } + } + } + .launchIn(receiver.context.scope) + } + + override fun reduce(state: TrackerState, action: TrackerActions): TrackerState = when (action) { + is TrackerActions.SetRestState -> state.copy(restState = action.restState) + + else -> state + } + + companion object { + val MAX_ROTATION = Angle.ofDeg(2.0f) + val ENTER_REST_TIME = 1.seconds + val ENTER_MOVING_TIME = 3.seconds + } +} + +class TrackerStayAlignedBehaviour( + private val settings: Settings, + private val stayAlignedManager: StayAlignedManager, +) : TrackerBehaviour { + private var lastRotationTime = timeSource.markNow() + + @OptIn(ExperimentalCoroutinesApi::class) + override fun observe(receiver: Tracker) { + receiver.context.state + .distinctUntilChanged { old, new -> old.sessionCalibration == new.sessionCalibration } + .onEach { +// lockedRotation = null +// yawCorrection = Angle.ZERO +// yawErrors = YawErrors() +// receiver.context.dispatch(TrackerActions.) + }.launchIn(receiver.context.scope) + + + val stayAlignedConfigFlow = settings.context.state.map { it.data.stayAlignedConfig }.distinctUntilChanged() + val imuTypeFlow = receiver.context.state.map { it.imuType }.distinctUntilChanged() + val magStatusFlow = receiver.context.state.map { it.magStatus }.distinctUntilChanged() + val skeleton = receiver.appContext.skeleton + + combine(stayAlignedConfigFlow, imuTypeFlow, magStatusFlow, ::Triple) + .flatMapLatest { (stayAlignedConfig, imuType, magStatus) -> + if (magStatus == MagnetometerStatus.ENABLED || imuType == null || !stayAlignedConfig.enabled) return@flatMapLatest emptyFlow() + + receiver.context.state + .distinctUntilChangedBy { it.rawRotation } + .onEach { + val yawCorrectionPerSec = IMU_TO_YAW_CORRECTION.getOrDefault(receiver.context.state.value.imuType, YAW_CORRECTION_DEFAULT) + if (yawCorrectionPerSec == Angle.ZERO) return@onEach + + val lastFrameTimeSeconds = lastRotationTime.elapsedNow().inFloatingSeconds + lastRotationTime = timeSource.markNow() + + val normalizedYawCorrection = yawCorrectionPerSec * lastFrameTimeSeconds + val hideYawCorrection = stayAlignedManager.context.state.value.hideYawCorrection + + // TODO + AdjustTrackerYaw.adjust( + receiver, + skeleton.computed.value, + normalizedYawCorrection, + stayAlignedConfig, + ) + +// if (receiver.context.state.value.restState == RestDetector.State.AT_REST) { +// if (lockedRotation == null) { +// lockedRotation = receiver.context.state.value.rotation // TODO tracker.getAdjustedRotationForceStayAligned() +// } +// } else { +// lockedRotation = null +// } + } + } + .launchIn(receiver.context.scope) + } +} diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index b4756eb2a7..35e6d5d189 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -7,6 +7,8 @@ import dev.slimevr.context.Context import dev.slimevr.context.debug.DiffStyle import dev.slimevr.context.debug.LoggingMiddleware import dev.slimevr.device.DeviceOrigin +import dev.slimevr.math.angle.Angle +import dev.slimevr.math.angle.AngleErrors import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.CoroutineScope @@ -18,12 +20,39 @@ import kotlin.time.Duration data class TrackerSensorIds(val trackerId: Int, val sensorId: Int) +/** + * Indicates if the tracker is moving, at rest or recently at rest. + */ +enum class RestState { + MOVING, + AT_REST, + RECENTLY_AT_REST, +} + data class YawResetSmoothing( val from: HeadingCorrection, val to: HeadingCorrection, val duration: Duration, ) +/** + * Aggregates the yaw errors from multiple forces. + */ +data class YawErrors ( + val lockedError: AngleErrors, + val centerError: AngleErrors, + val neighborError: AngleErrors, +) + +data class StayAlignedData( + // Rotation of the tracker when it was locked + val lockedRotation: Quaternion?, + // Yaw correction to apply to tracker rotation + val yawCorrection: Angle, + // Alignment error that yaw correction attempts to minimize + val yawErrors: YawErrors, +) + data class TrackerState( val id: Int, val name: String, @@ -47,7 +76,9 @@ data class TrackerState( val completedRestCalibration: Boolean?, val magStatus: MagnetometerStatus, val sessionCalibration: SessionCalibration?, - val yawResetSmoothing: YawResetSmoothing? = null, + val restState: RestState, + val yawResetSmoothing: YawResetSmoothing?, + val stayAlignedData: StayAlignedData, ) sealed interface TrackerActions { @@ -62,6 +93,7 @@ sealed interface TrackerActions { data class TickYawResetSmoothing(val heading: HeadingCorrection, val done: Boolean) : TrackerActions data class MountingReset(val referenceRotation: Quaternion, val yawOffset: Float) : TrackerActions data object ClearMountingReset : TrackerActions + data class SetRestState(val restState: RestState) : TrackerActions } typealias TrackerContext = Context @@ -112,6 +144,17 @@ class Tracker( completedRestCalibration = false, magStatus = MagnetometerStatus.NOT_SUPPORTED, sessionCalibration = null, + restState = RestState.MOVING, // TODO : should this be AT_REST? + yawResetSmoothing = null, + stayAlignedData = StayAlignedData( + null, + Angle.ZERO, + YawErrors( + AngleErrors(), + AngleErrors(), + AngleErrors() + ) + ) ) val trackerState = if (savedConfig != null) { restoreFromConfig(baseState, savedConfig, settings.context.state.value.data.resetsConfig.saveMountingReset) @@ -123,10 +166,12 @@ class Tracker( TrackerBasicBehaviour(), TrackerYawResetSmoothingBehaviour(), TrackerDefaultMountingOrientationBehaviour(), - TrackerRestOrientationBehaviour(settings), TrackerConfigBehaviour(settings, hardwareId), TrackerTPSBehaviour(), TrackerToSkeletonBehaviour(), + TrackerRestOrientationBehaviour(settings), + TrackerRestDetectionBehaviour(), + TrackerStayAlignedBehaviour(settings, appContext.stayAlignedManager), ) val context = Context.create( initialState = trackerState, diff --git a/server/core/src/main/java/dev/slimevr/util/side.kt b/server/core/src/main/java/dev/slimevr/util/side.kt index d233f55ade..c463a67312 100644 --- a/server/core/src/main/java/dev/slimevr/util/side.kt +++ b/server/core/src/main/java/dev/slimevr/util/side.kt @@ -1,7 +1,7 @@ package dev.slimevr.util /** - * Used to represent the side of a bone. + * Used to represent the side of the skeleton a bone is on. */ enum class Side { LEFT, diff --git a/server/core/src/main/java/dev/slimevr/util/time.kt b/server/core/src/main/java/dev/slimevr/util/time.kt new file mode 100644 index 0000000000..07ed1c709b --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/util/time.kt @@ -0,0 +1,21 @@ +package dev.slimevr.util + +import kotlin.time.Duration +import kotlin.time.TimeSource + +/** + * We use Monotonic timesource, which is the most precise (`System.nanoTime()`) + */ +val timeSource = TimeSource.Monotonic + +const val millisecondsInSecond = 1_000L +const val microsecondsInSecond = 1_000_000L +const val nanosecondsInSecond = 1_000_000_000L + +/** + * Returns the duration in un-rounded seconds with Float precision. + * + * Microseconds are used under the hood due to Float precision being limited. + */ +val Duration.inFloatingSeconds: Float + get() = this.inWholeMicroseconds / microsecondsInSecond.toFloat() diff --git a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt index 45f1f10fb7..113bc6d773 100644 --- a/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/vmc/behaviours.kt @@ -12,7 +12,7 @@ import dev.slimevr.osc.OscReceiver import dev.slimevr.osc.OscSender import dev.slimevr.skeleton.BoneState import dev.slimevr.skeleton.Skeleton -import dev.slimevr.timeSource +import dev.slimevr.util.timeSource import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.flow.distinctUntilChanged @@ -76,7 +76,7 @@ class VMCOutputBehaviour( val vrm = vrmGeometry receiver.context.scope.launch { try { - s.send(buildBundle(computedSkeleton.bones, config, currentTime, vrm)) + s.send(buildBundle(computedSkeleton, config, currentTime, vrm)) } catch (e: Exception) { if (nextLogTime.hasPassedNow()) { AppLogger.vmc.error("Failed to send VMC frame", e) diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt index 3d5bd49d7e..1faa458ca2 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output-encoder.kt @@ -1,6 +1,5 @@ package dev.slimevr.vrcosc -import dev.slimevr.config.VRCOSCConfig import dev.slimevr.osc.OscArg import dev.slimevr.osc.OscBundle import dev.slimevr.osc.OscContent diff --git a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt index af3a3a77b3..29894903bb 100644 --- a/server/core/src/main/java/dev/slimevr/vrcosc/output.kt +++ b/server/core/src/main/java/dev/slimevr/vrcosc/output.kt @@ -72,7 +72,7 @@ class VRCOSCOutputBehaviour( private fun observeFrames(receiver: VRCOSCManager, runtime: OutputRuntime) { skeleton.computed - .onEach { computedSkeleton -> sendFrame(receiver, runtime, computedSkeleton.bones) } + .onEach { computedSkeleton -> sendFrame(receiver, runtime, computedSkeleton) } .launchIn(receiver.context.scope) } diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index b41bab0534..79a3aa477e 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -225,6 +225,7 @@ abstract class TestAppContext : AppContextProvider { override val resetsManager: ResetsManager get() = error("not used in test") override val tapDetectionManager: TapDetectionManager get() = error("not used in test") override val outputTrackerToggle: OutputTrackerToggleManager get() = error("not used in test") + override val stayAlignedManager: StayAlignedManager get() = error("not used in test") override fun startObserving() {} override suspend fun dispose() = Unit } 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 2f474de28b..742a5e9147 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/Main.kt @@ -36,6 +36,7 @@ import dev.slimevr.provisioning.ProvisioningManager import dev.slimevr.resets.ResetsManager import dev.slimevr.resolveConfigDirectory import dev.slimevr.skeleton.Skeleton +import dev.slimevr.stayaligned.StayAlignedManager import dev.slimevr.tapdetection.TapDetectionManager import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer @@ -129,6 +130,7 @@ fun main(args: Array) = runBlocking(appCoroutineExceptionHandler + val resetsManager = ResetsManager.create(ctx = phase1, scope = this) val tapDetectionManager = TapDetectionManager.create(ctx = phase1, resetsManager = resetsManager, scope = this) val keybindManager = KeybindManager.create(scope = this) + val stayAlignedManager = StayAlignedManager.create(ctx = phase1, skeleton = skeleton, scope = this) val appContext = AppContext( server = server, @@ -150,6 +152,7 @@ fun main(args: Array) = runBlocking(appCoroutineExceptionHandler + vrcOscManager = vrcOscManager, resetsManager = resetsManager, tapDetectionManager = tapDetectionManager, + stayAlignedManager = stayAlignedManager, ) try { diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt b/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt index dcffeae63b..d7c8ebed92 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt @@ -11,7 +11,7 @@ import dev.slimevr.Platform import dev.slimevr.VRServer import dev.slimevr.desktop.getSteamVRDriversList import dev.slimevr.logging.AppLogger -import dev.slimevr.timeSource +import dev.slimevr.util.timeSource import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.trackingchecklist.TrackingChecklistActions import dev.slimevr.trackingchecklist.TrackingChecklistBehaviourType From ecc345bd715eb07f14e27c125e83c5125ba409f3 Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 30 Jul 2026 20:50:03 -0400 Subject: [PATCH 337/350] spotlessApply --- .../main/java/dev/slimevr/config/settings.kt | 2 +- .../java/dev/slimevr/driver/behaviours.kt | 6 +- .../main/java/dev/slimevr/hid/behaviours.kt | 2 +- .../dev/slimevr/math/angle/angle-average.kt | 42 +-- .../dev/slimevr/math/angle/angle-errors.kt | 12 +- .../main/java/dev/slimevr/math/angle/angle.kt | 76 ++-- .../java/dev/slimevr/skeleton/behaviours.kt | 3 +- .../main/java/dev/slimevr/skeleton/module.kt | 4 +- .../skeleton/processors/bone-smoothing.kt | 2 +- .../java/dev/slimevr/solarxr/stay-aligned.kt | 40 +-- .../java/dev/slimevr/stayaligned/behaviour.kt | 6 +- .../java/dev/slimevr/stayaligned/defaults.kt | 94 ++--- .../java/dev/slimevr/stayaligned/module.kt | 93 +++-- .../stayaligned/todo/adjust-tracker-yaw.kt | 184 +++++----- .../stayaligned/todo/center-error-visitor.kt | 140 ++++---- .../slimevr/stayaligned/todo/center-yaw.kt | 2 +- .../stayaligned/todo/locked-error-visitor.kt | 254 ++++++------- .../todo/neighbor-error-visitor.kt | 276 +++++++------- .../slimevr/stayaligned/todo/player-pose.kt | 94 ++--- .../slimevr/stayaligned/todo/relaxed-pose.kt | 120 +++---- .../slimevr/stayaligned/todo/tracker-pose.kt | 102 +++--- .../stayaligned/todo/tracker-skeleton.kt | 336 +++++++++--------- .../slimevr/stayaligned/todo/tracker-yaw.kt | 68 ++-- .../dev/slimevr/tapdetection/behaviours.kt | 2 +- .../java/dev/slimevr/tracker/behaviours.kt | 40 +-- .../main/java/dev/slimevr/tracker/module.kt | 8 +- .../src/main/java/dev/slimevr/util/side.kt | 12 +- .../src/main/java/dev/slimevr/util/time.kt | 2 +- .../slimevr/tracker/YawResetSmoothingTest.kt | 3 +- .../desktop/trackingchecklist/behaviours.kt | 2 +- 30 files changed, 1013 insertions(+), 1014 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/config/settings.kt b/server/core/src/main/java/dev/slimevr/config/settings.kt index 9b28b8a058..20c60766ff 100644 --- a/server/core/src/main/java/dev/slimevr/config/settings.kt +++ b/server/core/src/main/java/dev/slimevr/config/settings.kt @@ -188,7 +188,7 @@ data class StayAlignedRelaxedPoseConfig( ) @Serializable -data class StayAlignedConfig ( +data class StayAlignedConfig( /** Global enable toggle for all poses */ var enabled: Boolean = false, /** Standing relaxed pose */ diff --git a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt index f767434bcd..01919faf85 100644 --- a/server/core/src/main/java/dev/slimevr/driver/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/driver/behaviours.kt @@ -134,9 +134,9 @@ class DriverIncomingTrackersBehaviour : DriverBridgeBehaviour { receiver.inbound.on { event -> val trackerId = receiver.context.state.value.trackers[event.id] ?: return@on - receiver.appContext.server.getTracker(trackerId)?.context?.dispatch( - TrackerActions.SetRotation(rotation = event.rotation, position = event.position), - ) + receiver.appContext.server.getTracker(trackerId)?.context?.dispatch( + TrackerActions.SetRotation(rotation = event.rotation, position = event.position), + ) }.launchIn(receiver.context.scope) receiver.inbound.on { event -> diff --git a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt index 9b99e5a85c..6b1c8d03b5 100644 --- a/server/core/src/main/java/dev/slimevr/hid/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/hid/behaviours.kt @@ -5,9 +5,9 @@ import dev.slimevr.device.Device import dev.slimevr.device.DeviceActions import dev.slimevr.device.DeviceOrigin import dev.slimevr.logging.AppLogger -import dev.slimevr.util.timeSource import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerActions +import dev.slimevr.util.timeSource import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch diff --git a/server/core/src/main/java/dev/slimevr/math/angle/angle-average.kt b/server/core/src/main/java/dev/slimevr/math/angle/angle-average.kt index 0e3795cde1..c88cce487a 100644 --- a/server/core/src/main/java/dev/slimevr/math/angle/angle-average.kt +++ b/server/core/src/main/java/dev/slimevr/math/angle/angle-average.kt @@ -10,28 +10,28 @@ import kotlin.math.sin * See https://www.themathdoctors.org/averaging-angles/ */ class AngleAverage { - private var sumX = 0.0f - private var sumY = 0.0f + private var sumX = 0.0f + private var sumY = 0.0f - /** - * Adds another angle to the average. - */ - fun add(angle: Angle, weight: Float = 1.0f) { - sumX += cos(angle.toRad()) * weight - sumY += sin(angle.toRad()) * weight - } + /** + * Adds another angle to the average. + */ + fun add(angle: Angle, weight: Float = 1.0f) { + sumX += cos(angle.toRad()) * weight + sumY += sin(angle.toRad()) * weight + } - /** - * Gets the average angle. - */ - fun toAngle(): Angle = if (isEmpty()) { - Angle.ZERO - } else { - Angle.ofRad(atan2(sumY, sumX)) - } + /** + * Gets the average angle. + */ + fun toAngle(): Angle = if (isEmpty()) { + Angle.ZERO + } else { + Angle.ofRad(atan2(sumY, sumX)) + } - /** - * Whether there are any angles to average. - */ - fun isEmpty() = sumX == 0.0f && sumY == 0.0f + /** + * Whether there are any angles to average. + */ + fun isEmpty() = sumX == 0.0f && sumY == 0.0f } diff --git a/server/core/src/main/java/dev/slimevr/math/angle/angle-errors.kt b/server/core/src/main/java/dev/slimevr/math/angle/angle-errors.kt index 7cca0b3a18..6522e320d5 100644 --- a/server/core/src/main/java/dev/slimevr/math/angle/angle-errors.kt +++ b/server/core/src/main/java/dev/slimevr/math/angle/angle-errors.kt @@ -3,11 +3,11 @@ package dev.slimevr.math.angle import kotlin.math.sqrt class AngleErrors { - private var sumSqrErrors = 0.0f + private var sumSqrErrors = 0.0f - fun add(error: Angle) { - sumSqrErrors += error.toRad() * error.toRad() - } + fun add(error: Angle) { + sumSqrErrors += error.toRad() * error.toRad() + } - fun toL2Norm() = Angle.ofRad(sqrt(sumSqrErrors)) -} \ No newline at end of file + fun toL2Norm() = Angle.ofRad(sqrt(sumSqrErrors)) +} diff --git a/server/core/src/main/java/dev/slimevr/math/angle/angle.kt b/server/core/src/main/java/dev/slimevr/math/angle/angle.kt index 090d0ca48b..6bcceb9ede 100644 --- a/server/core/src/main/java/dev/slimevr/math/angle/angle.kt +++ b/server/core/src/main/java/dev/slimevr/math/angle/angle.kt @@ -10,57 +10,57 @@ import kotlin.math.floor */ @JvmInline value class Angle(private val rad: Float) { - fun toRad() = rad + fun toRad() = rad - fun toDeg() = rad * FastMath.RAD_TO_DEG + fun toDeg() = rad * FastMath.RAD_TO_DEG - operator fun unaryPlus() = this + operator fun unaryPlus() = this - operator fun unaryMinus() = Angle(normalize(-rad)) + operator fun unaryMinus() = Angle(normalize(-rad)) - operator fun plus(other: Angle) = Angle(normalize(rad + other.rad)) + operator fun plus(other: Angle) = Angle(normalize(rad + other.rad)) - operator fun minus(other: Angle) = Angle(normalize(rad - other.rad)) + operator fun minus(other: Angle) = Angle(normalize(rad - other.rad)) - operator fun times(scale: Float) = Angle(normalize(rad * scale)) + operator fun times(scale: Float) = Angle(normalize(rad * scale)) - operator fun div(scale: Float) = Angle(normalize(rad / scale)) + operator fun div(scale: Float) = Angle(normalize(rad / scale)) - operator fun compareTo(other: Angle) = rad.compareTo(other.rad) + operator fun compareTo(other: Angle) = rad.compareTo(other.rad) - override fun toString() = "${toDeg()} deg" + override fun toString() = "${toDeg()} deg" - companion object { - val ZERO = Angle(0.0f) + companion object { + val ZERO = Angle(0.0f) - fun ofRad(rad: Float) = Angle(normalize(rad)) + fun ofRad(rad: Float) = Angle(normalize(rad)) - fun ofDeg(deg: Float) = Angle(normalize(deg * FastMath.DEG_TO_RAD)) + fun ofDeg(deg: Float) = Angle(normalize(deg * FastMath.DEG_TO_RAD)) - // Angle between two vectors - fun absBetween(a: Vector3, b: Vector3) = Angle(normalize(a.angleTo(b))) + // Angle between two vectors + fun absBetween(a: Vector3, b: Vector3) = Angle(normalize(a.angleTo(b))) - // Angle between two rotations in rotation space - fun absBetween(a: Quaternion, b: Quaternion) = Angle(normalize(a.angleToR(b))) + // Angle between two rotations in rotation space + fun absBetween(a: Quaternion, b: Quaternion) = Angle(normalize(a.angleToR(b))) - /** - * Normalizes an angle to [-PI, PI) - */ - private fun normalize(rad: Float): Float { - // Normalize to [0, 2*PI) - val r = - if (rad < 0.0f || rad >= FastMath.TWO_PI) { - rad - floor(rad * FastMath.INV_TWO_PI) * FastMath.TWO_PI - } else { - rad - } + /** + * Normalizes an angle to [-PI, PI) + */ + private fun normalize(rad: Float): Float { + // Normalize to [0, 2*PI) + val r = + if (rad < 0.0f || rad >= FastMath.TWO_PI) { + rad - floor(rad * FastMath.INV_TWO_PI) * FastMath.TWO_PI + } else { + rad + } - // Normalize to [-PI, PI) - return if (r > FastMath.PI) { - r - FastMath.TWO_PI - } else { - r - } - } - } -} \ No newline at end of file + // Normalize to [-PI, PI) + return if (r > FastMath.PI) { + r - FastMath.TWO_PI + } else { + r + } + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt index 6058510098..05713c16fc 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/behaviours.kt @@ -184,8 +184,9 @@ class ComputedSkeletonBehaviour( // Skeleton took to long to compute this frame processTooLongCount++ if (nextLogTime.hasPassedNow()) { - if (processTooLongCount >= minimumFramesToLog) + if (processTooLongCount >= minimumFramesToLog) { AppLogger.skeleton.warn("Couldn't reach ${hz}Hz $processTooLongCount times in the last $logSpamWait") + } processTooLongCount = 0 nextLogTime = timeSource.markNow() + logSpamWait } diff --git a/server/core/src/main/java/dev/slimevr/skeleton/module.kt b/server/core/src/main/java/dev/slimevr/skeleton/module.kt index 691d46b542..97c6b40bfd 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/module.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/module.kt @@ -5,9 +5,9 @@ import dev.slimevr.context.Behaviour import dev.slimevr.context.Context import dev.slimevr.skeleton.processors.BoneActiveLinkProcessor import dev.slimevr.skeleton.processors.BoneDirectLinkProcessor -import dev.slimevr.skeleton.processors.BoneYawFallbackProcessor import dev.slimevr.skeleton.processors.BonePredictionProcessor import dev.slimevr.skeleton.processors.BoneSmoothingProcessor +import dev.slimevr.skeleton.processors.BoneYawFallbackProcessor import dev.slimevr.skeleton.processors.BoneYawRollAlignProcessor import dev.slimevr.skeleton.processors.FingerImputeProcessor import dev.slimevr.skeleton.processors.SpineImputeProcessor @@ -143,7 +143,7 @@ class Skeleton( BoneTransformBehaviour(), ProportionsBehaviour(ctx.config.userConfig), HeightLogBehaviour(), -// YouSpinMeRightRoundBehaviour(inputHz = 50f), +// YouSpinMeRightRoundBehaviour(inputHz = 50f), ComputedSkeletonBehaviour( hz = hz, processors = listOf( diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt index 1ff00902fb..b995ecd4ce 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt @@ -1,12 +1,12 @@ package dev.slimevr.skeleton.processors import dev.slimevr.config.Settings -import dev.slimevr.util.inFloatingSeconds import dev.slimevr.skeleton.BodyPartMap import dev.slimevr.skeleton.SkeletonProcessor import dev.slimevr.skeleton.SkeletonState import dev.slimevr.skeleton.bodyPartMap import dev.slimevr.skeleton.mapValues +import dev.slimevr.util.inFloatingSeconds import dev.slimevr.util.timeSource import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 diff --git a/server/core/src/main/java/dev/slimevr/solarxr/stay-aligned.kt b/server/core/src/main/java/dev/slimevr/solarxr/stay-aligned.kt index 1634ec92af..12c4e0d5a8 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/stay-aligned.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/stay-aligned.kt @@ -8,27 +8,27 @@ import solarxr_protocol.rpc.EnableStayAlignedRequest import solarxr_protocol.rpc.ResetStayAlignedRelaxedPoseRequest class StayAlignedBehaviour( - private val settings: Settings, - private val stayAlignedManager: StayAlignedManager, + private val settings: Settings, + private val stayAlignedManager: StayAlignedManager, ) : SolarXRBridgeBehaviour { - override fun observe(receiver: SolarXRBridge) { - // Enable request - receiver.rpcDispatcher.on { req -> - settings.context.dispatch( - SettingsActions.Update { - copy(stayAlignedConfig = stayAlignedConfig.copy(enabled = req.enable == true)) - } - ) - }.launchIn(receiver.context.scope) + override fun observe(receiver: SolarXRBridge) { + // Enable request + receiver.rpcDispatcher.on { req -> + settings.context.dispatch( + SettingsActions.Update { + copy(stayAlignedConfig = stayAlignedConfig.copy(enabled = req.enable == true)) + }, + ) + }.launchIn(receiver.context.scope) - // Detect pose - receiver.rpcDispatcher.on { req -> - req.pose?.let { stayAlignedManager.detectRelaxedPose(it) } - }.launchIn(receiver.context.scope) + // Detect pose + receiver.rpcDispatcher.on { req -> + req.pose?.let { stayAlignedManager.detectRelaxedPose(it) } + }.launchIn(receiver.context.scope) - // Reset pose - receiver.rpcDispatcher.on { req -> - req.pose?.let { stayAlignedManager.resetRelaxedPose(it) } - }.launchIn(receiver.context.scope) - } + // Reset pose + receiver.rpcDispatcher.on { req -> + req.pose?.let { stayAlignedManager.resetRelaxedPose(it) } + }.launchIn(receiver.context.scope) + } } diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/behaviour.kt b/server/core/src/main/java/dev/slimevr/stayaligned/behaviour.kt index 6216cf01f0..80436d470b 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/behaviour.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/behaviour.kt @@ -1,7 +1,7 @@ package dev.slimevr.stayaligned class StayAlignedBasicBehaviour : StayAlignedBehaviour { - override fun reduce(state: StayAlignedState, action: StayAlignedActions) = when (action) { - is StayAlignedActions.SetHideYawCorrection -> state.copy(hideYawCorrection = action.hideYawCorrection) - } + override fun reduce(state: StayAlignedState, action: StayAlignedActions) = when (action) { + is StayAlignedActions.SetHideYawCorrection -> state.copy(hideYawCorrection = action.hideYawCorrection) + } } diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/defaults.kt b/server/core/src/main/java/dev/slimevr/stayaligned/defaults.kt index bb733dc9e8..7370242c22 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/defaults.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/defaults.kt @@ -9,55 +9,55 @@ import solarxr_protocol.datatypes.hardware_info.ImuType * algorithm from a single place. */ object StayAlignedDefaults { - // Relaxed pose for kneeling. This isn't that common, so we don't want to ask - // players to provide this relaxed pose during setup. - val RELAXED_POSE_KNEELING = - RelaxedPose( - upperLeg = Angle.ofDeg(0.0f), - lowerLeg = Angle.ofDeg(0.0f), - foot = Angle.ofDeg(0.0f), - ) + // Relaxed pose for kneeling. This isn't that common, so we don't want to ask + // players to provide this relaxed pose during setup. + val RELAXED_POSE_KNEELING = + RelaxedPose( + upperLeg = Angle.ofDeg(0.0f), + lowerLeg = Angle.ofDeg(0.0f), + foot = Angle.ofDeg(0.0f), + ) - // Weights to calculate the average yaw of the skeleton - const val CENTER_ERROR_HEAD_WEIGHT = 0.5f - const val CENTER_ERROR_UPPER_BODY_WEIGHT = 1.0f - const val CENTER_ERROR_UPPER_LEG_WEIGHT = 0.4f - const val CENTER_ERROR_LOWER_LEG_WEIGHT = 0.3f + // Weights to calculate the average yaw of the skeleton + const val CENTER_ERROR_HEAD_WEIGHT = 0.5f + const val CENTER_ERROR_UPPER_BODY_WEIGHT = 1.0f + const val CENTER_ERROR_UPPER_LEG_WEIGHT = 0.4f + const val CENTER_ERROR_LOWER_LEG_WEIGHT = 0.3f - // Weight of each force - const val YAW_ERRORS_LOCKED_ERROR_WEIGHT = 10.0f - const val YAW_ERRORS_CENTER_ERROR_WEIGHT = 2.0f - const val YAW_ERRORS_NEIGHBOR_ERROR_WEIGHT = 1.0f + // Weight of each force + const val YAW_ERRORS_LOCKED_ERROR_WEIGHT = 10.0f + const val YAW_ERRORS_CENTER_ERROR_WEIGHT = 2.0f + const val YAW_ERRORS_NEIGHBOR_ERROR_WEIGHT = 1.0f - // Yaw correction for each type of IMU - val YAW_CORRECTION_IMU_GOOD = Angle.ofDeg(0.15f) - val YAW_CORRECTION_IMU_OK = Angle.ofDeg(0.20f) - val YAW_CORRECTION_IMU_BAD = Angle.ofDeg(0.40f) - val YAW_CORRECTION_IMU_DISABLED = Angle.ZERO + // Yaw correction for each type of IMU + val YAW_CORRECTION_IMU_GOOD = Angle.ofDeg(0.15f) + val YAW_CORRECTION_IMU_OK = Angle.ofDeg(0.20f) + val YAW_CORRECTION_IMU_BAD = Angle.ofDeg(0.40f) + val YAW_CORRECTION_IMU_DISABLED = Angle.ZERO - val IMU_TO_YAW_CORRECTION = buildMap { - // Mag is enabled on MPU9250 but server doesn't know about it - set(ImuType.MPU9250, YAW_CORRECTION_IMU_DISABLED) - set(ImuType.MPU6500, YAW_CORRECTION_IMU_BAD) - set(ImuType.BNO080, YAW_CORRECTION_IMU_GOOD) - set(ImuType.BNO085, YAW_CORRECTION_IMU_GOOD) - set(ImuType.BNO055, YAW_CORRECTION_IMU_BAD) - set(ImuType.MPU6050, YAW_CORRECTION_IMU_BAD) - set(ImuType.BNO086, YAW_CORRECTION_IMU_GOOD) - set(ImuType.BMI160, YAW_CORRECTION_IMU_BAD) - set(ImuType.ICM20948, YAW_CORRECTION_IMU_BAD) - set(ImuType.ICM42688, YAW_CORRECTION_IMU_OK) - set(ImuType.BMI270, YAW_CORRECTION_IMU_OK) - set(ImuType.LSM6DS3TRC, YAW_CORRECTION_IMU_BAD) - set(ImuType.LSM6DSV, YAW_CORRECTION_IMU_GOOD) - set(ImuType.LSM6DSO, YAW_CORRECTION_IMU_OK) - set(ImuType.LSM6DSR, YAW_CORRECTION_IMU_GOOD) - set(ImuType.ICM45686, YAW_CORRECTION_IMU_GOOD) - set(ImuType.ICM45605, YAW_CORRECTION_IMU_GOOD) - set(ImuType.ICM55686, YAW_CORRECTION_IMU_GOOD) - } + val IMU_TO_YAW_CORRECTION = buildMap { + // Mag is enabled on MPU9250 but server doesn't know about it + set(ImuType.MPU9250, YAW_CORRECTION_IMU_DISABLED) + set(ImuType.MPU6500, YAW_CORRECTION_IMU_BAD) + set(ImuType.BNO080, YAW_CORRECTION_IMU_GOOD) + set(ImuType.BNO085, YAW_CORRECTION_IMU_GOOD) + set(ImuType.BNO055, YAW_CORRECTION_IMU_BAD) + set(ImuType.MPU6050, YAW_CORRECTION_IMU_BAD) + set(ImuType.BNO086, YAW_CORRECTION_IMU_GOOD) + set(ImuType.BMI160, YAW_CORRECTION_IMU_BAD) + set(ImuType.ICM20948, YAW_CORRECTION_IMU_BAD) + set(ImuType.ICM42688, YAW_CORRECTION_IMU_OK) + set(ImuType.BMI270, YAW_CORRECTION_IMU_OK) + set(ImuType.LSM6DS3TRC, YAW_CORRECTION_IMU_BAD) + set(ImuType.LSM6DSV, YAW_CORRECTION_IMU_GOOD) + set(ImuType.LSM6DSO, YAW_CORRECTION_IMU_OK) + set(ImuType.LSM6DSR, YAW_CORRECTION_IMU_GOOD) + set(ImuType.ICM45686, YAW_CORRECTION_IMU_GOOD) + set(ImuType.ICM45605, YAW_CORRECTION_IMU_GOOD) + set(ImuType.ICM55686, YAW_CORRECTION_IMU_GOOD) + } - // Assume any new IMUs are at least OK, or else we wouldn't be writing firmware to - // support it. Please classify and add new IMUs to the map above! - val YAW_CORRECTION_DEFAULT = YAW_CORRECTION_IMU_OK -} \ No newline at end of file + // Assume any new IMUs are at least OK, or else we wouldn't be writing firmware to + // support it. Please classify and add new IMUs to the map above! + val YAW_CORRECTION_DEFAULT = YAW_CORRECTION_IMU_OK +} diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/module.kt b/server/core/src/main/java/dev/slimevr/stayaligned/module.kt index 701a633921..1040f22c96 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/module.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/module.kt @@ -13,64 +13,63 @@ import solarxr_protocol.rpc.StayAlignedRelaxedPose import kotlin.collections.listOf data class StayAlignedState( - /** - * Temporarily hides the yaw correction from Stay Aligned. - * - * Used to compare to when Stay Aligned is not enabled. - * Useful to verify if Stay Aligned improved the situation or is responsible for bad tracking. - */ - val hideYawCorrection : Boolean, + /** + * Temporarily hides the yaw correction from Stay Aligned. + * + * Used to compare to when Stay Aligned is not enabled. + * Useful to verify if Stay Aligned improved the situation or is responsible for bad tracking. + */ + val hideYawCorrection: Boolean, ) sealed interface StayAlignedActions { - data class SetHideYawCorrection(val hideYawCorrection: Boolean) : StayAlignedActions + data class SetHideYawCorrection(val hideYawCorrection: Boolean) : StayAlignedActions } typealias StayAlignedContext = Context typealias StayAlignedBehaviour = Behaviour class StayAlignedManager(val context: StayAlignedContext, val server: VRServer, val skeleton: Skeleton, val settings: Settings) { - fun startObserving() = context.observeAll(this) + fun startObserving() = context.observeAll(this) - /** - * Sets and enables a relaxed pose from the user's current pose. - */ - fun detectRelaxedPose(pose: StayAlignedRelaxedPose) { - // TODO get user pose - updatePoseInConfig(pose, StayAlignedRelaxedPoseConfig(true)) - } + /** + * Sets and enables a relaxed pose from the user's current pose. + */ + fun detectRelaxedPose(pose: StayAlignedRelaxedPose) { + // TODO get user pose + updatePoseInConfig(pose, StayAlignedRelaxedPoseConfig(true)) + } - /** - * Resets and disables a relaxed pose. - */ - fun resetRelaxedPose(pose: StayAlignedRelaxedPose) = - updatePoseInConfig(pose, StayAlignedRelaxedPoseConfig(false, 0f, 0f, 0f)) + /** + * Resets and disables a relaxed pose. + */ + fun resetRelaxedPose(pose: StayAlignedRelaxedPose) = updatePoseInConfig(pose, StayAlignedRelaxedPoseConfig(false, 0f, 0f, 0f)) - private fun updatePoseInConfig(pose: StayAlignedRelaxedPose, poseConfig: StayAlignedRelaxedPoseConfig) { - settings.context.dispatch(SettingsActions.Update { - copy( - stayAlignedConfig = when (pose) { - StayAlignedRelaxedPose.STANDING -> stayAlignedConfig.copy(standingRelaxedPose = poseConfig) + private fun updatePoseInConfig(pose: StayAlignedRelaxedPose, poseConfig: StayAlignedRelaxedPoseConfig) { + settings.context.dispatch( + SettingsActions.Update { + copy( + stayAlignedConfig = when (pose) { + StayAlignedRelaxedPose.STANDING -> stayAlignedConfig.copy(standingRelaxedPose = poseConfig) + StayAlignedRelaxedPose.SITTING -> stayAlignedConfig.copy(sittingRelaxedPose = poseConfig) + StayAlignedRelaxedPose.FLAT -> stayAlignedConfig.copy(flatRelaxedPose = poseConfig) + }, + ) + }, + ) + } - StayAlignedRelaxedPose.SITTING -> stayAlignedConfig.copy(sittingRelaxedPose = poseConfig) - - StayAlignedRelaxedPose.FLAT -> stayAlignedConfig.copy(flatRelaxedPose = poseConfig) - } - ) - }) - } - - companion object { - fun create(ctx: Phase1ContextProvider, skeleton: Skeleton, scope: CoroutineScope): StayAlignedManager { - val context = Context.create( - initialState = StayAlignedState( - hideYawCorrection = false, - ), - scope = scope, - behaviours = listOf(StayAlignedBasicBehaviour()), - name = "StayAlignedManager", - ) - return StayAlignedManager(context, ctx.server, skeleton, ctx.config.settings) - } - } + companion object { + fun create(ctx: Phase1ContextProvider, skeleton: Skeleton, scope: CoroutineScope): StayAlignedManager { + val context = Context.create( + initialState = StayAlignedState( + hideYawCorrection = false, + ), + scope = scope, + behaviours = listOf(StayAlignedBasicBehaviour()), + name = "StayAlignedManager", + ) + return StayAlignedManager(context, ctx.server, skeleton, ctx.config.settings) + } + } } diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/adjust-tracker-yaw.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/adjust-tracker-yaw.kt index d9bc3227cd..3a66ee55f8 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/adjust-tracker-yaw.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/adjust-tracker-yaw.kt @@ -1,65 +1,65 @@ package dev.slimevr.stayaligned.todo import dev.slimevr.config.StayAlignedConfig +import dev.slimevr.math.angle.Angle import dev.slimevr.skeleton.ComputedSkeleton import dev.slimevr.stayaligned.StayAlignedDefaults -import dev.slimevr.math.angle.Angle import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.YawErrors object AdjustTrackerYaw { - /** - * Adjusts the yaw of a tracker. - * - * Locked Trackers - * --------------- - * After a tracker is at rest for a short time, we lock it and save its initial - * rotation. We assume that locked trackers really are at rest, and that any - * rotation is due to drift. We adjust the tracker's yaw towards its initial - * rotation. If the tracker rotates beyonds a certain angle, we unlock the - * tracker. - * - * This works very well when the player is still and the tracker is supported by - * some surface, e.g. sitting in a chair or lying in a bed. However, it does not - * work well when the player is standing or moving around because the trackers - * will never lock. - * - * Centering Force - * --------------- - * When the player is moving around, we assume that the player will often be in - * a relaxed pose, or will eventually return to a relaxed pose. During setup, we - * collect the player's relaxed posed when standing, sitting and lying on their - * back. - * - * The centering force adjusts the tracker's yaw towards the relaxed pose. - * Upper body trackers are adjusted towards the average yaw of the body. Leg - * trackers are also adjusted towards the average yaw of the body, but with a - * yaw offset corresponding to their relaxed pose. - * - * This works well when the player is moving a lot. However, it doesn't work - * well when some of the trackers are locked, and others are moving. The locked - * trackers will stay in place, while the moving trackers will pull towards the - * relaxed pose, which can result in imbalanced poses. - * - * Neighbor Trackers - * ----------------- - * The neighboring force adjusts the tracker's yaw so that it is balanced - * between its neighboring trackers. For example, if the player is standing in a - * very wide stance, the neighboring force will push the upper leg tracker to a - * position that is proportional to their relaxed pose. This keeps the poses - * balanced. - * - * We use gradient descent to find the direction to apply a yaw correction. By - * applying this 50 times a second, the whole body is nudged into a reasonable - * alignment. - */ - fun adjust( - tracker: Tracker, - trackers: ComputedSkeleton, - yawCorrection: Angle, - config: StayAlignedConfig, - ) { + /** + * Adjusts the yaw of a tracker. + * + * Locked Trackers + * --------------- + * After a tracker is at rest for a short time, we lock it and save its initial + * rotation. We assume that locked trackers really are at rest, and that any + * rotation is due to drift. We adjust the tracker's yaw towards its initial + * rotation. If the tracker rotates beyonds a certain angle, we unlock the + * tracker. + * + * This works very well when the player is still and the tracker is supported by + * some surface, e.g. sitting in a chair or lying in a bed. However, it does not + * work well when the player is standing or moving around because the trackers + * will never lock. + * + * Centering Force + * --------------- + * When the player is moving around, we assume that the player will often be in + * a relaxed pose, or will eventually return to a relaxed pose. During setup, we + * collect the player's relaxed posed when standing, sitting and lying on their + * back. + * + * The centering force adjusts the tracker's yaw towards the relaxed pose. + * Upper body trackers are adjusted towards the average yaw of the body. Leg + * trackers are also adjusted towards the average yaw of the body, but with a + * yaw offset corresponding to their relaxed pose. + * + * This works well when the player is moving a lot. However, it doesn't work + * well when some of the trackers are locked, and others are moving. The locked + * trackers will stay in place, while the moving trackers will pull towards the + * relaxed pose, which can result in imbalanced poses. + * + * Neighbor Trackers + * ----------------- + * The neighboring force adjusts the tracker's yaw so that it is balanced + * between its neighboring trackers. For example, if the player is standing in a + * very wide stance, the neighboring force will push the upper leg tracker to a + * position that is proportional to their relaxed pose. This keeps the poses + * balanced. + * + * We use gradient descent to find the direction to apply a yaw correction. By + * applying this 50 times a second, the whole body is nudged into a reasonable + * alignment. + */ + fun adjust( + tracker: Tracker, + trackers: ComputedSkeleton, + yawCorrection: Angle, + config: StayAlignedConfig, + ) { // // Clear errors, in case we don't adjust the tracker // var state = tracker.context.state.value.stayAlignedData // state.yawErrors = YawErrors() @@ -77,16 +77,16 @@ object AdjustTrackerYaw { // // styles that are primarily at rest // } // } - } + } - /** - * Adjusts a locked tracker. - */ -// private fun adjustLockedTracker( -// tracker: Tracker, -// trackers: TrackerSkeleton, -// yawCorrection: Angle, -// ) { + /** + * Adjusts a locked tracker. + */ + private fun adjustLockedTracker( + tracker: Tracker, + trackers: TrackerSkeleton, + yawCorrection: Angle, + ) { // val lockedRotation = tracker.stayAligned.lockedRotation ?: return // // adjustByError(tracker, yawCorrection) { @@ -94,17 +94,17 @@ object AdjustTrackerYaw { // trackers.visit(tracker, LockedErrorVisitor(lockedRotation, it.lockedError)) // } // } -// } + } - /** - * Adjusts a tracker that is moving. - */ -// private fun adjustMovingTracker( -// tracker: Tracker, -// trackers: TrackerSkeleton, -// yawCorrection: Angle, -// config: StayAlignedConfig, -// ) { + /** + * Adjusts a tracker that is moving. + */ + private fun adjustMovingTracker( + tracker: Tracker, + trackers: TrackerSkeleton, + yawCorrection: Angle, + config: StayAlignedConfig, + ) { // val centerYaw = CenterYaw.ofSkeleton(trackers) ?: return // // val pose = PlayerPose.ofTrackers(trackers) @@ -116,16 +116,16 @@ object AdjustTrackerYaw { // trackers.visit(tracker, NeighborErrorVisitor(relaxedPose, it.neighborError)) // } // } -// } + } - /** - * Adjusts the yaw by applying gradient descent. - */ -// private fun adjustByError( -// tracker: Tracker, -// yawCorrection: Angle, -// errorFn: (tracker: Tracker) -> YawErrors, -// ) { + /** + * Adjusts the yaw by applying gradient descent. + */ + private fun adjustByError( + tracker: Tracker, + yawCorrection: Angle, + errorFn: (tracker: Tracker) -> YawErrors, + ) { // val state = tracker.stayAligned // // val curYaw = state.yawCorrection @@ -153,16 +153,16 @@ object AdjustTrackerYaw { // state.yawCorrection = curYaw // state.yawErrors = curError // } -// } + } - /** - * Calculates the gradient between two errors. A negative gradient means that there - * is less error in that direction. - */ - private fun gradient(errors: YawErrors, base: YawErrors) = (errors.lockedError.toL2Norm() - base.lockedError.toL2Norm()) * - StayAlignedDefaults.YAW_ERRORS_LOCKED_ERROR_WEIGHT + - (errors.centerError.toL2Norm() - base.centerError.toL2Norm()) * - StayAlignedDefaults.YAW_ERRORS_CENTER_ERROR_WEIGHT + - (errors.neighborError.toL2Norm() - base.neighborError.toL2Norm()) * - StayAlignedDefaults.YAW_ERRORS_NEIGHBOR_ERROR_WEIGHT -} \ No newline at end of file + /** + * Calculates the gradient between two errors. A negative gradient means that there + * is less error in that direction. + */ + private fun gradient(errors: YawErrors, base: YawErrors) = (errors.lockedError.toL2Norm() - base.lockedError.toL2Norm()) * + StayAlignedDefaults.YAW_ERRORS_LOCKED_ERROR_WEIGHT + + (errors.centerError.toL2Norm() - base.centerError.toL2Norm()) * + StayAlignedDefaults.YAW_ERRORS_CENTER_ERROR_WEIGHT + + (errors.neighborError.toL2Norm() - base.neighborError.toL2Norm()) * + StayAlignedDefaults.YAW_ERRORS_NEIGHBOR_ERROR_WEIGHT +} diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-error-visitor.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-error-visitor.kt index 347f14265b..ba12e3a721 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-error-visitor.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-error-visitor.kt @@ -1,90 +1,90 @@ package dev.slimevr.stayaligned.todo -import dev.slimevr.stayaligned.todo.TrackerYaw.extraYaw -import dev.slimevr.stayaligned.todo.TrackerYaw.trackerYaw import dev.slimevr.math.angle.Angle import dev.slimevr.math.angle.AngleErrors -import dev.slimevr.util.Side +import dev.slimevr.stayaligned.todo.TrackerYaw.extraYaw +import dev.slimevr.stayaligned.todo.TrackerYaw.trackerYaw import dev.slimevr.tracker.Tracker +import dev.slimevr.util.Side /** * Assumes that the body is centered around an average yaw, and returns the error of the * tracker with respect to that average yaw. */ class CenterErrorVisitor( - val centerYaw: Angle, - val relaxedPose: RelaxedPose, - val errors: AngleErrors, + val centerYaw: Angle, + val relaxedPose: RelaxedPose, + val errors: AngleErrors, ) : TrackerSkeleton.TrackerVisitor { - override fun visitHeadTracker( - tracker: Tracker, - belowUpperBody: Tracker?, - ) { - errors.add(centerYaw - trackerYaw(tracker)) - } + override fun visitHeadTracker( + tracker: Tracker, + belowUpperBody: Tracker?, + ) { + errors.add(centerYaw - trackerYaw(tracker)) + } - override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowUpperBody: Tracker?, - ) { - errors.add(centerYaw - trackerYaw(tracker)) - } + override fun visitUpperBodyTracker( + tracker: Tracker, + aboveHeadOrUpperBody: Tracker?, + belowUpperBody: Tracker?, + ) { + errors.add(centerYaw - trackerYaw(tracker)) + } - override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowLeftUpperLeg: Tracker?, - belowRightUpperLeg: Tracker?, - ) { - errors.add(centerYaw - trackerYaw(tracker)) - } + override fun visitUpperBodyTracker( + tracker: Tracker, + aboveHeadOrUpperBody: Tracker?, + belowLeftUpperLeg: Tracker?, + belowRightUpperLeg: Tracker?, + ) { + errors.add(centerYaw - trackerYaw(tracker)) + } - override fun visitArmTracker( - side: Side, - tracker: Tracker, - aboveUpperBodyOrArm: Tracker?, - belowHandOrArm: Tracker?, - ) { - // No error because arms can go anywhere - } + override fun visitArmTracker( + side: Side, + tracker: Tracker, + aboveUpperBodyOrArm: Tracker?, + belowHandOrArm: Tracker?, + ) { + // No error because arms can go anywhere + } - override fun visitHandTracker( - side: Side, - tracker: Tracker, - aboveArm: Tracker?, - oppositeHand: Tracker?, - ) { - // No error because hands can go anywhere - } + override fun visitHandTracker( + side: Side, + tracker: Tracker, + aboveArm: Tracker?, + oppositeHand: Tracker?, + ) { + // No error because hands can go anywhere + } - override fun visitUpperLegTracker( - side: Side, - tracker: Tracker, - aboveUpperBody: Tracker?, - belowLowerLeg: Tracker?, - oppositeUpperLeg: Tracker?, - ) { - errors.add(centerYaw + extraYaw(side, relaxedPose.upperLeg) - trackerYaw(tracker)) - } + override fun visitUpperLegTracker( + side: Side, + tracker: Tracker, + aboveUpperBody: Tracker?, + belowLowerLeg: Tracker?, + oppositeUpperLeg: Tracker?, + ) { + errors.add(centerYaw + extraYaw(side, relaxedPose.upperLeg) - trackerYaw(tracker)) + } - override fun visitLowerLegTracker( - side: Side, - tracker: Tracker, - aboveUpperLeg: Tracker?, - belowFoot: Tracker?, - oppositeLowerLeg: Tracker?, - ) { - errors.add(centerYaw + extraYaw(side, relaxedPose.lowerLeg) - trackerYaw(tracker)) - } + override fun visitLowerLegTracker( + side: Side, + tracker: Tracker, + aboveUpperLeg: Tracker?, + belowFoot: Tracker?, + oppositeLowerLeg: Tracker?, + ) { + errors.add(centerYaw + extraYaw(side, relaxedPose.lowerLeg) - trackerYaw(tracker)) + } - override fun visitFootTracker( - side: Side, - tracker: Tracker, - aboveLowerLeg: Tracker?, - oppositeFoot: Tracker?, - ) { - errors.add(centerYaw + extraYaw(side, relaxedPose.foot) - trackerYaw(tracker)) - } -} \ No newline at end of file + override fun visitFootTracker( + side: Side, + tracker: Tracker, + aboveLowerLeg: Tracker?, + oppositeFoot: Tracker?, + ) { + errors.add(centerYaw + extraYaw(side, relaxedPose.foot) - trackerYaw(tracker)) + } +} diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-yaw.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-yaw.kt index e9a573acab..f1f2aaec28 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-yaw.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-yaw.kt @@ -54,4 +54,4 @@ object CenterYaw { // // return averageYaw.toAngle() // } -} \ No newline at end of file +} diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/locked-error-visitor.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/locked-error-visitor.kt index 7c037b300f..899609364a 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/locked-error-visitor.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/locked-error-visitor.kt @@ -2,8 +2,8 @@ package dev.slimevr.stayaligned.todo import dev.slimevr.math.angle.Angle import dev.slimevr.math.angle.AngleErrors -import dev.slimevr.util.Side import dev.slimevr.tracker.Tracker +import dev.slimevr.util.Side import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 import kotlin.math.abs @@ -13,131 +13,131 @@ import kotlin.math.atan2 * Error between a locked tracker's yaw and its yaw when it was initially locked. */ class LockedErrorVisitor( - val lockedRotation: Quaternion, - val errors: AngleErrors, + val lockedRotation: Quaternion, + val errors: AngleErrors, ) : TrackerSkeleton.TrackerVisitor { - override fun visitHeadTracker( - tracker: Tracker, - belowUpperBody: Tracker?, - ) { - errors.add(error(tracker)) - } - - override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowUpperBody: Tracker?, - ) { - errors.add(error(tracker)) - } - - override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowLeftUpperLeg: Tracker?, - belowRightUpperLeg: Tracker?, - ) { - errors.add(error(tracker)) - } - - override fun visitArmTracker( - side: Side, - tracker: Tracker, - aboveUpperBodyOrArm: Tracker?, - belowHandOrArm: Tracker?, - ) { - errors.add(error(tracker)) - } - - override fun visitHandTracker( - side: Side, - tracker: Tracker, - aboveArm: Tracker?, - oppositeHand: Tracker?, - ) { - errors.add(error(tracker)) - } - - override fun visitUpperLegTracker( - side: Side, - tracker: Tracker, - aboveUpperBody: Tracker?, - belowLowerLeg: Tracker?, - oppositeUpperLeg: Tracker?, - ) { - errors.add(error(tracker)) - } - - override fun visitLowerLegTracker( - side: Side, - tracker: Tracker, - aboveUpperLeg: Tracker?, - belowFoot: Tracker?, - oppositeLowerLeg: Tracker?, - ) { - errors.add(error(tracker)) - } - - override fun visitFootTracker( - side: Side, - tracker: Tracker, - aboveLowerLeg: Tracker?, - oppositeFoot: Tracker?, - ) { - errors.add(error(tracker)) - } - - private fun error(tracker: Tracker): Angle = yawDifference(tracker.context.state.value.rotation, lockedRotation) // TODO tracker.getAdjustedRotationForceStayAligned() - - companion object { - - /** - * Gets the yaw between two rotations, for small rotations. - * - * A locked tracker can be in any rotation, so we cannot use - * TrackerYaw::trackerYaw, which doesn't work for a tracker that is on its - * side. - * - * WARNING: DO NOT USE for large rotations because the chosen axis might have - * a very small projection on the yaw plane, which yields a low confidence yaw. - * - * TODO: It might be possible to pick a different EulerOrder when we encounter - * singularities, but I wasn't able to get this working correctly. - */ - private fun yawDifference( - rotation: Quaternion, - targetRotation: Quaternion, - ): Angle { - val targetX = targetRotation.sandwichUnitX() - val targetY = targetRotation.sandwichUnitY() - val targetZ = targetRotation.sandwichUnitZ() - - // Find the axis that is closest to the yaw plane - val axis: Vector3 - val targetAxis: Vector3 - - val targetXScore = abs(targetX.dot(Vector3.POS_Y)) - val targetYScore = abs(targetY.dot(Vector3.POS_Y)) - val targetZScore = abs(targetZ.dot(Vector3.POS_Y)) - - // The axis that is closest to the yaw plane has the smallest absolute dot - // product with the Y axis - if ((targetXScore <= targetYScore) && (targetXScore <= targetZScore)) { - axis = rotation.sandwichUnitX() - targetAxis = targetX - } else if ((targetYScore <= targetXScore) && (targetYScore <= targetZScore)) { - axis = rotation.sandwichUnitY() - targetAxis = targetY - } else { - axis = rotation.sandwichUnitZ() - targetAxis = targetZ - } - - val yaw = Angle.ofRad(atan2(axis.z, axis.x)) - val targetYaw = Angle.ofRad(atan2(targetAxis.z, targetAxis.x)) - - return targetYaw - yaw - } - } -} \ No newline at end of file + override fun visitHeadTracker( + tracker: Tracker, + belowUpperBody: Tracker?, + ) { + errors.add(error(tracker)) + } + + override fun visitUpperBodyTracker( + tracker: Tracker, + aboveHeadOrUpperBody: Tracker?, + belowUpperBody: Tracker?, + ) { + errors.add(error(tracker)) + } + + override fun visitUpperBodyTracker( + tracker: Tracker, + aboveHeadOrUpperBody: Tracker?, + belowLeftUpperLeg: Tracker?, + belowRightUpperLeg: Tracker?, + ) { + errors.add(error(tracker)) + } + + override fun visitArmTracker( + side: Side, + tracker: Tracker, + aboveUpperBodyOrArm: Tracker?, + belowHandOrArm: Tracker?, + ) { + errors.add(error(tracker)) + } + + override fun visitHandTracker( + side: Side, + tracker: Tracker, + aboveArm: Tracker?, + oppositeHand: Tracker?, + ) { + errors.add(error(tracker)) + } + + override fun visitUpperLegTracker( + side: Side, + tracker: Tracker, + aboveUpperBody: Tracker?, + belowLowerLeg: Tracker?, + oppositeUpperLeg: Tracker?, + ) { + errors.add(error(tracker)) + } + + override fun visitLowerLegTracker( + side: Side, + tracker: Tracker, + aboveUpperLeg: Tracker?, + belowFoot: Tracker?, + oppositeLowerLeg: Tracker?, + ) { + errors.add(error(tracker)) + } + + override fun visitFootTracker( + side: Side, + tracker: Tracker, + aboveLowerLeg: Tracker?, + oppositeFoot: Tracker?, + ) { + errors.add(error(tracker)) + } + + private fun error(tracker: Tracker): Angle = yawDifference(tracker.context.state.value.rotation, lockedRotation) // TODO tracker.getAdjustedRotationForceStayAligned() + + companion object { + + /** + * Gets the yaw between two rotations, for small rotations. + * + * A locked tracker can be in any rotation, so we cannot use + * TrackerYaw::trackerYaw, which doesn't work for a tracker that is on its + * side. + * + * WARNING: DO NOT USE for large rotations because the chosen axis might have + * a very small projection on the yaw plane, which yields a low confidence yaw. + * + * TODO: It might be possible to pick a different EulerOrder when we encounter + * singularities, but I wasn't able to get this working correctly. + */ + private fun yawDifference( + rotation: Quaternion, + targetRotation: Quaternion, + ): Angle { + val targetX = targetRotation.sandwichUnitX() + val targetY = targetRotation.sandwichUnitY() + val targetZ = targetRotation.sandwichUnitZ() + + // Find the axis that is closest to the yaw plane + val axis: Vector3 + val targetAxis: Vector3 + + val targetXScore = abs(targetX.dot(Vector3.POS_Y)) + val targetYScore = abs(targetY.dot(Vector3.POS_Y)) + val targetZScore = abs(targetZ.dot(Vector3.POS_Y)) + + // The axis that is closest to the yaw plane has the smallest absolute dot + // product with the Y axis + if ((targetXScore <= targetYScore) && (targetXScore <= targetZScore)) { + axis = rotation.sandwichUnitX() + targetAxis = targetX + } else if ((targetYScore <= targetXScore) && (targetYScore <= targetZScore)) { + axis = rotation.sandwichUnitY() + targetAxis = targetY + } else { + axis = rotation.sandwichUnitZ() + targetAxis = targetZ + } + + val yaw = Angle.ofRad(atan2(axis.z, axis.x)) + val targetYaw = Angle.ofRad(atan2(targetAxis.z, targetAxis.x)) + + return targetYaw - yaw + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/neighbor-error-visitor.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/neighbor-error-visitor.kt index fc89ce054c..bae5d742a1 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/neighbor-error-visitor.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/neighbor-error-visitor.kt @@ -1,162 +1,162 @@ package dev.slimevr.stayaligned.todo +import dev.slimevr.math.angle.AngleErrors import dev.slimevr.stayaligned.todo.TrackerYaw.extraYaw import dev.slimevr.stayaligned.todo.TrackerYaw.trackerYaw -import dev.slimevr.math.angle.AngleErrors -import dev.slimevr.util.Side import dev.slimevr.tracker.Tracker +import dev.slimevr.util.Side import solarxr_protocol.datatypes.BodyPart /** * Error between a tracker's yaw and its neighbors' yaws. */ class NeighborErrorVisitor( - val relaxedPose: RelaxedPose, - val errors: AngleErrors, + val relaxedPose: RelaxedPose, + val errors: AngleErrors, ) : TrackerSkeleton.TrackerVisitor { - override fun visitHeadTracker( - tracker: Tracker, - belowUpperBody: Tracker?, - ) { - if (belowUpperBody != null) { - errors.add(trackerYaw(belowUpperBody) - trackerYaw(tracker)) - } - } + override fun visitHeadTracker( + tracker: Tracker, + belowUpperBody: Tracker?, + ) { + if (belowUpperBody != null) { + errors.add(trackerYaw(belowUpperBody) - trackerYaw(tracker)) + } + } - override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowUpperBody: Tracker?, - ) { - if ( - aboveHeadOrUpperBody != null && - // Head often drags the upper body trackers off to the side, so ignore it - aboveHeadOrUpperBody.context.state.value.bodyPart != BodyPart.HEAD - ) { - errors.add(trackerYaw(aboveHeadOrUpperBody) - trackerYaw(tracker)) - } + override fun visitUpperBodyTracker( + tracker: Tracker, + aboveHeadOrUpperBody: Tracker?, + belowUpperBody: Tracker?, + ) { + if ( + aboveHeadOrUpperBody != null && + // Head often drags the upper body trackers off to the side, so ignore it + aboveHeadOrUpperBody.context.state.value.bodyPart != BodyPart.HEAD + ) { + errors.add(trackerYaw(aboveHeadOrUpperBody) - trackerYaw(tracker)) + } - if (belowUpperBody != null) { - errors.add(trackerYaw(belowUpperBody) - trackerYaw(tracker)) - } - } + if (belowUpperBody != null) { + errors.add(trackerYaw(belowUpperBody) - trackerYaw(tracker)) + } + } - override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowLeftUpperLeg: Tracker?, - belowRightUpperLeg: Tracker?, - ) { - if ( - aboveHeadOrUpperBody != null && - // Head often drags the upper body trackers off to the side, so ignore it - aboveHeadOrUpperBody.context.state.value.bodyPart != BodyPart.HEAD - ) { - errors.add(trackerYaw(aboveHeadOrUpperBody) - trackerYaw(tracker)) - } + override fun visitUpperBodyTracker( + tracker: Tracker, + aboveHeadOrUpperBody: Tracker?, + belowLeftUpperLeg: Tracker?, + belowRightUpperLeg: Tracker?, + ) { + if ( + aboveHeadOrUpperBody != null && + // Head often drags the upper body trackers off to the side, so ignore it + aboveHeadOrUpperBody.context.state.value.bodyPart != BodyPart.HEAD + ) { + errors.add(trackerYaw(aboveHeadOrUpperBody) - trackerYaw(tracker)) + } - // Only consider upper leg trackers if both are available, so that the upper - // body tracker can be balanced between both - if ( - belowLeftUpperLeg != null && - belowRightUpperLeg != null - ) { - errors.add( - trackerYaw(belowLeftUpperLeg) - - extraYaw(Side.LEFT, relaxedPose.upperLeg) - - trackerYaw(tracker), - ) - errors.add( - trackerYaw(belowRightUpperLeg) - - extraYaw(Side.RIGHT, relaxedPose.upperLeg) - - trackerYaw(tracker), - ) - } - } + // Only consider upper leg trackers if both are available, so that the upper + // body tracker can be balanced between both + if ( + belowLeftUpperLeg != null && + belowRightUpperLeg != null + ) { + errors.add( + trackerYaw(belowLeftUpperLeg) - + extraYaw(Side.LEFT, relaxedPose.upperLeg) - + trackerYaw(tracker), + ) + errors.add( + trackerYaw(belowRightUpperLeg) - + extraYaw(Side.RIGHT, relaxedPose.upperLeg) - + trackerYaw(tracker), + ) + } + } - override fun visitArmTracker( - side: Side, - tracker: Tracker, - aboveUpperBodyOrArm: Tracker?, - belowHandOrArm: Tracker?, - ) { - // No error because arms can go anywhere - } + override fun visitArmTracker( + side: Side, + tracker: Tracker, + aboveUpperBodyOrArm: Tracker?, + belowHandOrArm: Tracker?, + ) { + // No error because arms can go anywhere + } - override fun visitHandTracker( - side: Side, - tracker: Tracker, - aboveArm: Tracker?, - oppositeHand: Tracker?, - ) { - // No error because hands can go anywhere - } + override fun visitHandTracker( + side: Side, + tracker: Tracker, + aboveArm: Tracker?, + oppositeHand: Tracker?, + ) { + // No error because hands can go anywhere + } - override fun visitUpperLegTracker( - side: Side, - tracker: Tracker, - aboveUpperBody: Tracker?, - belowLowerLeg: Tracker?, - oppositeUpperLeg: Tracker?, - ) { - if (aboveUpperBody != null) { - errors.add( - trackerYaw(aboveUpperBody) + - extraYaw(side, relaxedPose.upperLeg) - - trackerYaw(tracker), - ) - } + override fun visitUpperLegTracker( + side: Side, + tracker: Tracker, + aboveUpperBody: Tracker?, + belowLowerLeg: Tracker?, + oppositeUpperLeg: Tracker?, + ) { + if (aboveUpperBody != null) { + errors.add( + trackerYaw(aboveUpperBody) + + extraYaw(side, relaxedPose.upperLeg) - + trackerYaw(tracker), + ) + } - if (belowLowerLeg != null) { - errors.add( - trackerYaw(belowLowerLeg) - - extraYaw(side, relaxedPose.lowerLeg) + - extraYaw(side, relaxedPose.upperLeg) - - trackerYaw(tracker), - ) - } - } + if (belowLowerLeg != null) { + errors.add( + trackerYaw(belowLowerLeg) - + extraYaw(side, relaxedPose.lowerLeg) + + extraYaw(side, relaxedPose.upperLeg) - + trackerYaw(tracker), + ) + } + } - override fun visitLowerLegTracker( - side: Side, - tracker: Tracker, - aboveUpperLeg: Tracker?, - belowFoot: Tracker?, - oppositeLowerLeg: Tracker?, - ) { - if (aboveUpperLeg != null) { - errors.add( - trackerYaw(aboveUpperLeg) - - extraYaw(side, relaxedPose.upperLeg) + - extraYaw(side, relaxedPose.lowerLeg) - - trackerYaw(tracker), - ) - } + override fun visitLowerLegTracker( + side: Side, + tracker: Tracker, + aboveUpperLeg: Tracker?, + belowFoot: Tracker?, + oppositeLowerLeg: Tracker?, + ) { + if (aboveUpperLeg != null) { + errors.add( + trackerYaw(aboveUpperLeg) - + extraYaw(side, relaxedPose.upperLeg) + + extraYaw(side, relaxedPose.lowerLeg) - + trackerYaw(tracker), + ) + } - if (belowFoot != null) { - errors.add( - trackerYaw(belowFoot) - - extraYaw(side, relaxedPose.foot) + - extraYaw(side, relaxedPose.lowerLeg) - - trackerYaw(tracker), - ) - } - } + if (belowFoot != null) { + errors.add( + trackerYaw(belowFoot) - + extraYaw(side, relaxedPose.foot) + + extraYaw(side, relaxedPose.lowerLeg) - + trackerYaw(tracker), + ) + } + } - override fun visitFootTracker( - side: Side, - tracker: Tracker, - aboveLowerLeg: Tracker?, - oppositeFoot: Tracker?, - ) { - if (aboveLowerLeg != null) { - errors.add( - trackerYaw(aboveLowerLeg) - - extraYaw(side, relaxedPose.lowerLeg) + - extraYaw(side, relaxedPose.foot) - - trackerYaw(tracker), - ) - } - } -} \ No newline at end of file + override fun visitFootTracker( + side: Side, + tracker: Tracker, + aboveLowerLeg: Tracker?, + oppositeFoot: Tracker?, + ) { + if (aboveLowerLeg != null) { + errors.add( + trackerYaw(aboveLowerLeg) - + extraYaw(side, relaxedPose.lowerLeg) + + extraYaw(side, relaxedPose.foot) - + trackerYaw(tracker), + ) + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/player-pose.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/player-pose.kt index 8e12e9bc3a..0882fbc8dd 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/player-pose.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/player-pose.kt @@ -4,15 +4,15 @@ package dev.slimevr.stayaligned.todo * The pose of the player. */ enum class PlayerPose { - UNKNOWN, - STANDING, - SITTING_IN_CHAIR, - SITTING_ON_GROUND, - LYING_ON_BACK, - KNEELING, - ; + UNKNOWN, + STANDING, + SITTING_IN_CHAIR, + SITTING_ON_GROUND, + LYING_ON_BACK, + KNEELING, + ; - companion object { + companion object { // fun ofTrackers(trackers: TrackerSkeleton): PlayerPose { // val poses = @@ -41,47 +41,47 @@ enum class PlayerPose { // ) // } - private class TrackerPoses( - val upperBody: List, - val leftUpperLeg: TrackerPose, - val rightUpperLeg: TrackerPose, - val leftLowerLeg: TrackerPose, - val rightLowerLeg: TrackerPose, - ) + private class TrackerPoses( + val upperBody: List, + val leftUpperLeg: TrackerPose, + val rightUpperLeg: TrackerPose, + val leftLowerLeg: TrackerPose, + val rightLowerLeg: TrackerPose, + ) - private fun isStanding(pose: TrackerPoses) = pose.upperBody.all { it == TrackerPose.TOP_FACING_UP } && - pose.leftUpperLeg == TrackerPose.TOP_FACING_UP && - pose.rightUpperLeg == TrackerPose.TOP_FACING_UP && - pose.leftLowerLeg == TrackerPose.TOP_FACING_UP && - pose.rightLowerLeg == TrackerPose.TOP_FACING_UP + private fun isStanding(pose: TrackerPoses) = pose.upperBody.all { it == TrackerPose.TOP_FACING_UP } && + pose.leftUpperLeg == TrackerPose.TOP_FACING_UP && + pose.rightUpperLeg == TrackerPose.TOP_FACING_UP && + pose.leftLowerLeg == TrackerPose.TOP_FACING_UP && + pose.rightLowerLeg == TrackerPose.TOP_FACING_UP - private fun isSittingInChair(pose: TrackerPoses) = pose.upperBody.isNotEmpty() && - pose.upperBody[0] == TrackerPose.TOP_FACING_UP && - pose.upperBody.all { it == TrackerPose.TOP_FACING_UP || it == TrackerPose.FRONT_FACING_UP } && - pose.leftUpperLeg == TrackerPose.FRONT_FACING_UP && - pose.rightUpperLeg == TrackerPose.FRONT_FACING_UP && - pose.leftLowerLeg == TrackerPose.TOP_FACING_UP && - pose.rightLowerLeg == TrackerPose.TOP_FACING_UP + private fun isSittingInChair(pose: TrackerPoses) = pose.upperBody.isNotEmpty() && + pose.upperBody[0] == TrackerPose.TOP_FACING_UP && + pose.upperBody.all { it == TrackerPose.TOP_FACING_UP || it == TrackerPose.FRONT_FACING_UP } && + pose.leftUpperLeg == TrackerPose.FRONT_FACING_UP && + pose.rightUpperLeg == TrackerPose.FRONT_FACING_UP && + pose.leftLowerLeg == TrackerPose.TOP_FACING_UP && + pose.rightLowerLeg == TrackerPose.TOP_FACING_UP - private fun isSittingOnGround(pose: TrackerPoses) = pose.upperBody.isNotEmpty() && - pose.upperBody[0] == TrackerPose.TOP_FACING_UP && - pose.upperBody.all { it == TrackerPose.TOP_FACING_UP || it == TrackerPose.FRONT_FACING_UP } && - // Allow legs to be flat on ground, or knees-up - pose.leftUpperLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_DOWN } && - pose.rightUpperLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_DOWN } && - pose.leftLowerLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_UP } && - pose.rightLowerLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_UP } + private fun isSittingOnGround(pose: TrackerPoses) = pose.upperBody.isNotEmpty() && + pose.upperBody[0] == TrackerPose.TOP_FACING_UP && + pose.upperBody.all { it == TrackerPose.TOP_FACING_UP || it == TrackerPose.FRONT_FACING_UP } && + // Allow legs to be flat on ground, or knees-up + pose.leftUpperLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_DOWN } && + pose.rightUpperLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_DOWN } && + pose.leftLowerLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_UP } && + pose.rightLowerLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_UP } - private fun isLyingOnBack(pose: TrackerPoses) = pose.upperBody.all { it == TrackerPose.FRONT_FACING_UP } && - // Allow legs to be flat on ground, or knees-up - pose.leftUpperLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_DOWN } && - pose.rightUpperLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_DOWN } && - pose.leftLowerLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_UP } && - pose.rightLowerLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_UP } + private fun isLyingOnBack(pose: TrackerPoses) = pose.upperBody.all { it == TrackerPose.FRONT_FACING_UP } && + // Allow legs to be flat on ground, or knees-up + pose.leftUpperLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_DOWN } && + pose.rightUpperLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_DOWN } && + pose.leftLowerLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_UP } && + pose.rightLowerLeg.let { it == TrackerPose.FRONT_FACING_UP || it == TrackerPose.TOP_FACING_UP } - private fun isKneeling(pose: TrackerPoses) = pose.leftUpperLeg.let { it == TrackerPose.TOP_FACING_UP || it == TrackerPose.FRONT_FACING_UP } && - pose.rightUpperLeg.let { it == TrackerPose.TOP_FACING_UP || it == TrackerPose.FRONT_FACING_UP } && - pose.leftLowerLeg == TrackerPose.FRONT_FACING_DOWN && - pose.rightLowerLeg == TrackerPose.FRONT_FACING_DOWN - } -} \ No newline at end of file + private fun isKneeling(pose: TrackerPoses) = pose.leftUpperLeg.let { it == TrackerPose.TOP_FACING_UP || it == TrackerPose.FRONT_FACING_UP } && + pose.rightUpperLeg.let { it == TrackerPose.TOP_FACING_UP || it == TrackerPose.FRONT_FACING_UP } && + pose.leftLowerLeg == TrackerPose.FRONT_FACING_DOWN && + pose.rightLowerLeg == TrackerPose.FRONT_FACING_DOWN + } +} diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/relaxed-pose.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/relaxed-pose.kt index 728c6b688a..6140d26845 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/relaxed-pose.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/relaxed-pose.kt @@ -1,75 +1,75 @@ package dev.slimevr.stayaligned.todo import dev.slimevr.config.StayAlignedConfig -import dev.slimevr.stayaligned.StayAlignedDefaults import dev.slimevr.math.angle.Angle +import dev.slimevr.stayaligned.StayAlignedDefaults class RelaxedPose( - val upperLeg: Angle, - val lowerLeg: Angle, - val foot: Angle, + val upperLeg: Angle, + val lowerLeg: Angle, + val foot: Angle, ) { - override fun toString(): String = "upperLeg=$upperLeg lowerLeg=$lowerLeg foot=$foot" + override fun toString(): String = "upperLeg=$upperLeg lowerLeg=$lowerLeg foot=$foot" - companion object { + companion object { - val ZERO = RelaxedPose(Angle.ZERO, Angle.ZERO, Angle.ZERO) + val ZERO = RelaxedPose(Angle.ZERO, Angle.ZERO, Angle.ZERO) - /** - * Gets the relaxed angles for a particular pose. May provide defaults if the - * angles aren't configured for the pose. - */ - fun forPose( - playerPose: PlayerPose, - config: StayAlignedConfig, - ) = when (playerPose) { - PlayerPose.STANDING -> { - val poseConfig = config.standingRelaxedPose - if (poseConfig.enabled) { - RelaxedPose( - Angle.ofDeg(poseConfig.upperLegAngleInDeg), - Angle.ofDeg(poseConfig.lowerLegAngleInDeg), - Angle.ofDeg(poseConfig.footAngleInDeg), - ) - } else { - null - } - } + /** + * Gets the relaxed angles for a particular pose. May provide defaults if the + * angles aren't configured for the pose. + */ + fun forPose( + playerPose: PlayerPose, + config: StayAlignedConfig, + ) = when (playerPose) { + PlayerPose.STANDING -> { + val poseConfig = config.standingRelaxedPose + if (poseConfig.enabled) { + RelaxedPose( + Angle.ofDeg(poseConfig.upperLegAngleInDeg), + Angle.ofDeg(poseConfig.lowerLegAngleInDeg), + Angle.ofDeg(poseConfig.footAngleInDeg), + ) + } else { + null + } + } - PlayerPose.SITTING_IN_CHAIR -> { - val poseConfig = config.sittingRelaxedPose - if (poseConfig.enabled) { - RelaxedPose( - Angle.ofDeg(poseConfig.upperLegAngleInDeg), - Angle.ofDeg(poseConfig.lowerLegAngleInDeg), - Angle.ofDeg(poseConfig.footAngleInDeg), - ) - } else { - null - } - } + PlayerPose.SITTING_IN_CHAIR -> { + val poseConfig = config.sittingRelaxedPose + if (poseConfig.enabled) { + RelaxedPose( + Angle.ofDeg(poseConfig.upperLegAngleInDeg), + Angle.ofDeg(poseConfig.lowerLegAngleInDeg), + Angle.ofDeg(poseConfig.footAngleInDeg), + ) + } else { + null + } + } - PlayerPose.SITTING_ON_GROUND, - PlayerPose.LYING_ON_BACK, - -> { - val poseConfig = config.flatRelaxedPose - if (poseConfig.enabled) { - RelaxedPose( - Angle.ofDeg(poseConfig.upperLegAngleInDeg), - Angle.ofDeg(poseConfig.lowerLegAngleInDeg), - Angle.ofDeg(poseConfig.footAngleInDeg), - ) - } else { - null - } - } + PlayerPose.SITTING_ON_GROUND, + PlayerPose.LYING_ON_BACK, + -> { + val poseConfig = config.flatRelaxedPose + if (poseConfig.enabled) { + RelaxedPose( + Angle.ofDeg(poseConfig.upperLegAngleInDeg), + Angle.ofDeg(poseConfig.lowerLegAngleInDeg), + Angle.ofDeg(poseConfig.footAngleInDeg), + ) + } else { + null + } + } - PlayerPose.KNEELING -> - StayAlignedDefaults.RELAXED_POSE_KNEELING + PlayerPose.KNEELING -> + StayAlignedDefaults.RELAXED_POSE_KNEELING - else -> - null - } + else -> + null + } // /** // * Gets the relaxed angles from the trackers. @@ -102,5 +102,5 @@ class RelaxedPose( // // return RelaxedPose(upperLegAngle, lowerLegAngle, footAngle) // } - } -} \ No newline at end of file + } +} diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-pose.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-pose.kt index 008807ff0c..a6c8452871 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-pose.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-pose.kt @@ -8,54 +8,54 @@ import kotlin.math.abs * The orientation of a tracker. */ enum class TrackerPose { - NONE, - TOP_FACING_UP, - TOP_FACING_DOWN, - FRONT_FACING_UP, - FRONT_FACING_DOWN, - ON_SIDE, - ; - - companion object { - - fun ofTracker(tracker: Tracker?): TrackerPose { - if (tracker == null) { - return NONE - } - - val rotation = tracker.context.state.value.rotation // TODO tracker.getAdjustedRotationForceStayAligned() - - val x = rotation.sandwichUnitX() - val y = rotation.sandwichUnitY() - val z = rotation.sandwichUnitZ() - - val xDot = x.dot(Vector3.POS_Y) - val yDot = y.dot(Vector3.POS_Y) - val zDot = z.dot(Vector3.POS_Y) - - val xAbsDot = abs(xDot) - val yAbsDot = abs(yDot) - val zAbsDot = abs(zDot) - - val pose = - if ((xAbsDot >= yAbsDot) && (xAbsDot >= zAbsDot)) { - ON_SIDE - } else if ((yAbsDot >= xAbsDot) && (yAbsDot >= zAbsDot)) { - if (yDot >= 0) { - TOP_FACING_UP - } else { - TOP_FACING_DOWN - } - } else { - // Tracker local POS_Z is behind - if (zDot >= 0) { - FRONT_FACING_DOWN - } else { - FRONT_FACING_UP - } - } - - return pose - } - } -} \ No newline at end of file + NONE, + TOP_FACING_UP, + TOP_FACING_DOWN, + FRONT_FACING_UP, + FRONT_FACING_DOWN, + ON_SIDE, + ; + + companion object { + + fun ofTracker(tracker: Tracker?): TrackerPose { + if (tracker == null) { + return NONE + } + + val rotation = tracker.context.state.value.rotation // TODO tracker.getAdjustedRotationForceStayAligned() + + val x = rotation.sandwichUnitX() + val y = rotation.sandwichUnitY() + val z = rotation.sandwichUnitZ() + + val xDot = x.dot(Vector3.POS_Y) + val yDot = y.dot(Vector3.POS_Y) + val zDot = z.dot(Vector3.POS_Y) + + val xAbsDot = abs(xDot) + val yAbsDot = abs(yDot) + val zAbsDot = abs(zDot) + + val pose = + if ((xAbsDot >= yAbsDot) && (xAbsDot >= zAbsDot)) { + ON_SIDE + } else if ((yAbsDot >= xAbsDot) && (yAbsDot >= zAbsDot)) { + if (yDot >= 0) { + TOP_FACING_UP + } else { + TOP_FACING_DOWN + } + } else { + // Tracker local POS_Z is behind + if (zDot >= 0) { + FRONT_FACING_DOWN + } else { + FRONT_FACING_UP + } + } + + return pose + } + } +} diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-skeleton.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-skeleton.kt index e1ed544823..441437ea76 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-skeleton.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-skeleton.kt @@ -233,183 +233,183 @@ class TrackerSkeleton(skeleton: Skeleton) { // } // } - private fun visitUpperBodyTrackers( - tracker: Tracker, - visitor: TrackerVisitor, - head: Tracker?, - upperBody: List, - leftUpperLeg: Tracker?, - rightUpperLeg: Tracker?, - ) { - val index = upperBody.indexOf(tracker) - if (index < 0) { - return - } + private fun visitUpperBodyTrackers( + tracker: Tracker, + visitor: TrackerVisitor, + head: Tracker?, + upperBody: List, + leftUpperLeg: Tracker?, + rightUpperLeg: Tracker?, + ) { + val index = upperBody.indexOf(tracker) + if (index < 0) { + return + } - if (index == 0) { - if (upperBody.size == 1) { - // Only upper body tracker - visitor.visitUpperBodyTracker( - tracker, - head, - leftUpperLeg, - rightUpperLeg, - ) - } else { - // First upper body tracker - visitor.visitUpperBodyTracker( - tracker, - head, - upperBody[1], - ) - } - } else if (index < upperBody.size - 1) { - // Middle upper body tracker - visitor.visitUpperBodyTracker( - tracker, - upperBody[index - 1], - upperBody[index + 1], - ) - } else { - // Last upper body tracker - visitor.visitUpperBodyTracker( - tracker, - upperBody[index - 1], - leftUpperLeg, - rightUpperLeg, - ) - } - } + if (index == 0) { + if (upperBody.size == 1) { + // Only upper body tracker + visitor.visitUpperBodyTracker( + tracker, + head, + leftUpperLeg, + rightUpperLeg, + ) + } else { + // First upper body tracker + visitor.visitUpperBodyTracker( + tracker, + head, + upperBody[1], + ) + } + } else if (index < upperBody.size - 1) { + // Middle upper body tracker + visitor.visitUpperBodyTracker( + tracker, + upperBody[index - 1], + upperBody[index + 1], + ) + } else { + // Last upper body tracker + visitor.visitUpperBodyTracker( + tracker, + upperBody[index - 1], + leftUpperLeg, + rightUpperLeg, + ) + } + } - private fun visitArmTrackers( - tracker: Tracker, - visitor: TrackerVisitor, - side: Side, - upperBody: Tracker?, - arm: List, - hand: Tracker?, - ) { - val index = arm.indexOf(tracker) - if (index < 0) { - return - } + private fun visitArmTrackers( + tracker: Tracker, + visitor: TrackerVisitor, + side: Side, + upperBody: Tracker?, + arm: List, + hand: Tracker?, + ) { + val index = arm.indexOf(tracker) + if (index < 0) { + return + } - if (index == 0) { - if (arm.size == 1) { - // Only arm tracker - visitor.visitArmTracker( - side, - tracker, - upperBody, - hand, - ) - } else { - // First arm tracker - visitor.visitArmTracker( - side, - tracker, - upperBody, - arm[1], - ) - } - } else if (index < arm.size - 1) { - // Middle arm tracker - visitor.visitArmTracker( - side, - tracker, - arm[index - 1], - arm[index + 1], - ) - } else { - // Last arm tracker - visitor.visitArmTracker( - side, - tracker, - arm[index - 1], - hand, - ) - } - } + if (index == 0) { + if (arm.size == 1) { + // Only arm tracker + visitor.visitArmTracker( + side, + tracker, + upperBody, + hand, + ) + } else { + // First arm tracker + visitor.visitArmTracker( + side, + tracker, + upperBody, + arm[1], + ) + } + } else if (index < arm.size - 1) { + // Middle arm tracker + visitor.visitArmTracker( + side, + tracker, + arm[index - 1], + arm[index + 1], + ) + } else { + // Last arm tracker + visitor.visitArmTracker( + side, + tracker, + arm[index - 1], + hand, + ) + } + } - interface TrackerVisitor { + interface TrackerVisitor { - /** - * Visits the head tracker. - */ - fun visitHeadTracker( - tracker: Tracker, - belowUpperBody: Tracker?, - ) + /** + * Visits the head tracker. + */ + fun visitHeadTracker( + tracker: Tracker, + belowUpperBody: Tracker?, + ) - /** - * Visits an upper body tracker (except for the bottom-most tracker). - */ - fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowUpperBody: Tracker?, - ) + /** + * Visits an upper body tracker (except for the bottom-most tracker). + */ + fun visitUpperBodyTracker( + tracker: Tracker, + aboveHeadOrUpperBody: Tracker?, + belowUpperBody: Tracker?, + ) - /** - * Visits the bottom-most upper body tracker. - */ - fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowLeftUpperLeg: Tracker?, - belowRightUpperLeg: Tracker?, - ) + /** + * Visits the bottom-most upper body tracker. + */ + fun visitUpperBodyTracker( + tracker: Tracker, + aboveHeadOrUpperBody: Tracker?, + belowLeftUpperLeg: Tracker?, + belowRightUpperLeg: Tracker?, + ) - /** - * Visits an arm tracker. - */ - fun visitArmTracker( - side: Side, - tracker: Tracker, - aboveUpperBodyOrArm: Tracker?, - belowHandOrArm: Tracker?, - ) + /** + * Visits an arm tracker. + */ + fun visitArmTracker( + side: Side, + tracker: Tracker, + aboveUpperBodyOrArm: Tracker?, + belowHandOrArm: Tracker?, + ) - /** - * Visits a hand tracker. - */ - fun visitHandTracker( - side: Side, - tracker: Tracker, - aboveArm: Tracker?, - oppositeHand: Tracker?, - ) + /** + * Visits a hand tracker. + */ + fun visitHandTracker( + side: Side, + tracker: Tracker, + aboveArm: Tracker?, + oppositeHand: Tracker?, + ) - /** - * Visits an upper leg tracker. - */ - fun visitUpperLegTracker( - side: Side, - tracker: Tracker, - aboveUpperBody: Tracker?, - belowLowerLeg: Tracker?, - oppositeUpperLeg: Tracker?, - ) + /** + * Visits an upper leg tracker. + */ + fun visitUpperLegTracker( + side: Side, + tracker: Tracker, + aboveUpperBody: Tracker?, + belowLowerLeg: Tracker?, + oppositeUpperLeg: Tracker?, + ) - /** - * Visits a lower leg tracker. - */ - fun visitLowerLegTracker( - side: Side, - tracker: Tracker, - aboveUpperLeg: Tracker?, - belowFoot: Tracker?, - oppositeLowerLeg: Tracker?, - ) + /** + * Visits a lower leg tracker. + */ + fun visitLowerLegTracker( + side: Side, + tracker: Tracker, + aboveUpperLeg: Tracker?, + belowFoot: Tracker?, + oppositeLowerLeg: Tracker?, + ) - /** - * Visits a foot tracker. - */ - fun visitFootTracker( - side: Side, - tracker: Tracker, - aboveLowerLeg: Tracker?, - oppositeFoot: Tracker?, - ) - } -} \ No newline at end of file + /** + * Visits a foot tracker. + */ + fun visitFootTracker( + side: Side, + tracker: Tracker, + aboveLowerLeg: Tracker?, + oppositeFoot: Tracker?, + ) + } +} diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-yaw.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-yaw.kt index ec41e901d5..24e3320b5e 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-yaw.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-yaw.kt @@ -1,8 +1,8 @@ package dev.slimevr.stayaligned.todo import dev.slimevr.math.angle.Angle -import dev.slimevr.util.Side import dev.slimevr.tracker.Tracker +import dev.slimevr.util.Side import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Vector3 @@ -17,39 +17,39 @@ import io.github.axisangles.ktmath.Vector3 * left is positive yaw, right is negative yaw. */ object TrackerYaw { - /** - * Whether we can get the yaw of a tracker. - */ - fun hasTrackerYaw(tracker: Tracker) = Angle.absBetween( - tracker.context.state.value.rotation.sandwichUnitX(), // TODO tracker.getAdjustedRotationForceStayAligned() - Vector3.POS_Y, - ) > MIN_ON_SIDE_ANGLE + /** + * Whether we can get the yaw of a tracker. + */ + fun hasTrackerYaw(tracker: Tracker) = Angle.absBetween( + tracker.context.state.value.rotation.sandwichUnitX(), // TODO tracker.getAdjustedRotationForceStayAligned() + Vector3.POS_Y, + ) > MIN_ON_SIDE_ANGLE - /** - * Gets the yaw of the tracker, for trackers that are not on its side. - * - * WARNING: DO NOT USE for a tracker that is on its side. Euler YZX angles have a - * singularity for a tracker that is on its side, and can yield arbitrary yaws. - * For example, the Euler YZX angles (Y=0°, Z=90°, X=30°) and (Y=30°, Z=90°, X=0°) - * are equivalent but yield completely different yaws. - * - * WARNING: It is possible to use another EulerOrder which does not have a - * singularity for this rotation to get "some" yaw, but this yaw will be very - * different from the from YZX. DO NOT ATTEMPT! - */ - fun trackerYaw(tracker: Tracker) = Angle.ofRad( - tracker.context.state.value.rotation // TODO tracker.getAdjustedRotationForceStayAligned() - .toEulerAngles(EulerOrder.YZX) - .y, - ) + /** + * Gets the yaw of the tracker, for trackers that are not on its side. + * + * WARNING: DO NOT USE for a tracker that is on its side. Euler YZX angles have a + * singularity for a tracker that is on its side, and can yield arbitrary yaws. + * For example, the Euler YZX angles (Y=0°, Z=90°, X=30°) and (Y=30°, Z=90°, X=0°) + * are equivalent but yield completely different yaws. + * + * WARNING: It is possible to use another EulerOrder which does not have a + * singularity for this rotation to get "some" yaw, but this yaw will be very + * different from the from YZX. DO NOT ATTEMPT! + */ + fun trackerYaw(tracker: Tracker) = Angle.ofRad( + tracker.context.state.value.rotation // TODO tracker.getAdjustedRotationForceStayAligned() + .toEulerAngles(EulerOrder.YZX) + .y, + ) - /** - * Applies an extra yaw in the specified direction. - */ - fun extraYaw(direction: Side, angle: Angle) = when (direction) { - Side.LEFT -> angle - Side.RIGHT -> -angle - } + /** + * Applies an extra yaw in the specified direction. + */ + fun extraYaw(direction: Side, angle: Angle) = when (direction) { + Side.LEFT -> angle + Side.RIGHT -> -angle + } - private val MIN_ON_SIDE_ANGLE = Angle.ofDeg(30.0f) -} \ No newline at end of file + private val MIN_ON_SIDE_ANGLE = Angle.ofDeg(30.0f) +} diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt index 37d98506e2..2517d7199f 100644 --- a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt @@ -1,8 +1,8 @@ package dev.slimevr.tapdetection import dev.slimevr.config.TapDetectionConfig -import dev.slimevr.util.timeSource import dev.slimevr.tracker.Tracker +import dev.slimevr.util.timeSource import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.combine diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index f128a80156..d7df911fcd 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -2,15 +2,15 @@ package dev.slimevr.tracker import com.jme3.math.FastMath import dev.slimevr.config.Settings -import dev.slimevr.util.inFloatingSeconds import dev.slimevr.logging.AppLogger +import dev.slimevr.math.angle.Angle import dev.slimevr.resets.ResetBodyParts import dev.slimevr.skeleton.SkeletonActions import dev.slimevr.stayaligned.StayAlignedDefaults.IMU_TO_YAW_CORRECTION import dev.slimevr.stayaligned.StayAlignedDefaults.YAW_CORRECTION_DEFAULT import dev.slimevr.stayaligned.StayAlignedManager import dev.slimevr.stayaligned.todo.AdjustTrackerYaw -import dev.slimevr.math.angle.Angle +import dev.slimevr.util.inFloatingSeconds import dev.slimevr.util.timeSource import io.github.axisangles.ktmath.EulerAngles import io.github.axisangles.ktmath.EulerOrder @@ -190,8 +190,8 @@ class TrackerBasicBehaviour : TrackerBehaviour { state.copy(sessionCalibration = state.sessionCalibration?.copy(headingAlignment = Quaternion.IDENTITY)) } - else -> state - } + else -> state + } override fun observe(receiver: Tracker) { // Refresh the tracker's rotation whenever calibration gets updated @@ -351,8 +351,8 @@ class TrackerToSkeletonBehaviour : TrackerBehaviour { receiver.appContext.skeleton.context.dispatchAll( listOfNotNull( SkeletonActions.SetBoneRotation(bodyPart, trackerState.rotation), - if (trackerState.position != null) SkeletonActions.SetBonePosition(bodyPart, trackerState.position) else null - ) + if (trackerState.position != null) SkeletonActions.SetBonePosition(bodyPart, trackerState.position) else null, + ), ) lastBodyPartSent = trackerState.bodyPart } @@ -438,7 +438,7 @@ class TrackerRestDetectionBehaviour : TrackerBehaviour { when (receiver.context.state.value.restState) { RestState.MOVING, RestState.RECENTLY_AT_REST, - -> + -> if (Angle.absBetween(lastRotation, rotation) > MAX_ROTATION) { lastRotation = rotation lastUpdateTime = now @@ -467,7 +467,6 @@ class TrackerRestDetectionBehaviour : TrackerBehaviour { override fun reduce(state: TrackerState, action: TrackerActions): TrackerState = when (action) { is TrackerActions.SetRestState -> state.copy(restState = action.restState) - else -> state } @@ -485,17 +484,16 @@ class TrackerStayAlignedBehaviour( private var lastRotationTime = timeSource.markNow() @OptIn(ExperimentalCoroutinesApi::class) - override fun observe(receiver: Tracker) { + override fun observe(receiver: Tracker) { receiver.context.state .distinctUntilChanged { old, new -> old.sessionCalibration == new.sessionCalibration } .onEach { -// lockedRotation = null -// yawCorrection = Angle.ZERO -// yawErrors = YawErrors() -// receiver.context.dispatch(TrackerActions.) +// lockedRotation = null +// yawCorrection = Angle.ZERO +// yawErrors = YawErrors() +// receiver.context.dispatch(TrackerActions.) }.launchIn(receiver.context.scope) - val stayAlignedConfigFlow = settings.context.state.map { it.data.stayAlignedConfig }.distinctUntilChanged() val imuTypeFlow = receiver.context.state.map { it.imuType }.distinctUntilChanged() val magStatusFlow = receiver.context.state.map { it.magStatus }.distinctUntilChanged() @@ -525,13 +523,13 @@ class TrackerStayAlignedBehaviour( stayAlignedConfig, ) -// if (receiver.context.state.value.restState == RestDetector.State.AT_REST) { -// if (lockedRotation == null) { -// lockedRotation = receiver.context.state.value.rotation // TODO tracker.getAdjustedRotationForceStayAligned() -// } -// } else { -// lockedRotation = null -// } +// if (receiver.context.state.value.restState == RestDetector.State.AT_REST) { +// if (lockedRotation == null) { +// lockedRotation = receiver.context.state.value.rotation // TODO tracker.getAdjustedRotationForceStayAligned() +// } +// } else { +// lockedRotation = null +// } } } .launchIn(receiver.context.scope) diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 35e6d5d189..4efbfd6f14 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -38,7 +38,7 @@ data class YawResetSmoothing( /** * Aggregates the yaw errors from multiple forces. */ -data class YawErrors ( +data class YawErrors( val lockedError: AngleErrors, val centerError: AngleErrors, val neighborError: AngleErrors, @@ -152,9 +152,9 @@ class Tracker( YawErrors( AngleErrors(), AngleErrors(), - AngleErrors() - ) - ) + AngleErrors(), + ), + ), ) val trackerState = if (savedConfig != null) { restoreFromConfig(baseState, savedConfig, settings.context.state.value.data.resetsConfig.saveMountingReset) diff --git a/server/core/src/main/java/dev/slimevr/util/side.kt b/server/core/src/main/java/dev/slimevr/util/side.kt index c463a67312..79d030308d 100644 --- a/server/core/src/main/java/dev/slimevr/util/side.kt +++ b/server/core/src/main/java/dev/slimevr/util/side.kt @@ -4,12 +4,12 @@ package dev.slimevr.util * Used to represent the side of the skeleton a bone is on. */ enum class Side { - LEFT, - RIGHT, + LEFT, + RIGHT, } val Side.opposite: Side - get() = when (this) { - Side.LEFT -> Side.RIGHT - Side.RIGHT -> Side.LEFT - } + get() = when (this) { + Side.LEFT -> Side.RIGHT + Side.RIGHT -> Side.LEFT + } diff --git a/server/core/src/main/java/dev/slimevr/util/time.kt b/server/core/src/main/java/dev/slimevr/util/time.kt index 07ed1c709b..76fd3e6c41 100644 --- a/server/core/src/main/java/dev/slimevr/util/time.kt +++ b/server/core/src/main/java/dev/slimevr/util/time.kt @@ -18,4 +18,4 @@ const val nanosecondsInSecond = 1_000_000_000L * Microseconds are used under the hood due to Float precision being limited. */ val Duration.inFloatingSeconds: Float - get() = this.inWholeMicroseconds / microsecondsInSecond.toFloat() + get() = this.inWholeMicroseconds / microsecondsInSecond.toFloat() diff --git a/server/core/src/test/java/dev/slimevr/tracker/YawResetSmoothingTest.kt b/server/core/src/test/java/dev/slimevr/tracker/YawResetSmoothingTest.kt index ad20118f25..1446b5ead2 100644 --- a/server/core/src/test/java/dev/slimevr/tracker/YawResetSmoothingTest.kt +++ b/server/core/src/test/java/dev/slimevr/tracker/YawResetSmoothingTest.kt @@ -37,7 +37,8 @@ class YawResetSmoothingTest { TrackerStatus.OK, rawRotation = raw, additionalBehaviours = listOf(TrackerYawResetSmoothingBehaviour()), - sessionCalibration = if (withCalibration) SessionCalibration(headingCorrection = prevHeading) else null) + sessionCalibration = if (withCalibration) SessionCalibration(headingCorrection = prevHeading) else null, + ) } private fun heading(tracker: Tracker): Quaternion = assertNotNull(tracker.context.state.value.sessionCalibration).headingCorrection diff --git a/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt b/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt index d7c8ebed92..b8b321deea 100644 --- a/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt +++ b/server/desktop/src/main/java/dev/slimevr/desktop/trackingchecklist/behaviours.kt @@ -11,10 +11,10 @@ import dev.slimevr.Platform import dev.slimevr.VRServer import dev.slimevr.desktop.getSteamVRDriversList import dev.slimevr.logging.AppLogger -import dev.slimevr.util.timeSource import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.trackingchecklist.TrackingChecklistActions import dev.slimevr.trackingchecklist.TrackingChecklistBehaviourType +import dev.slimevr.util.timeSource import io.ktor.client.HttpClient import io.ktor.client.engine.cio.CIO import kotlinx.coroutines.Dispatchers From 40289631057639dcd849ae9719db91fe2c0bfc13 Mon Sep 17 00:00:00 2001 From: Erimel Date: Thu, 30 Jul 2026 21:05:56 -0400 Subject: [PATCH 338/350] Fix unit tests --- server/core/src/test/java/dev/slimevr/TestServer.kt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index 79a3aa477e..b3750e8952 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -5,6 +5,7 @@ import dev.slimevr.config.AppConfig import dev.slimevr.config.ConfigStorage import dev.slimevr.config.DefaultUserBehaviour import dev.slimevr.config.Settings +import dev.slimevr.config.SettingsActions import dev.slimevr.config.SettingsConfigState import dev.slimevr.config.SettingsState import dev.slimevr.config.TextFileHandle @@ -18,6 +19,8 @@ import dev.slimevr.heightcalibration.HeightCalibrationActions import dev.slimevr.heightcalibration.HeightCalibrationManager import dev.slimevr.heightcalibration.HeightCalibrationState import dev.slimevr.keybind.KeybindManager +import dev.slimevr.math.angle.Angle +import dev.slimevr.math.angle.AngleErrors import dev.slimevr.networkprofile.NetworkProfileManager import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager import dev.slimevr.provisioning.ProvisioningManager @@ -35,11 +38,14 @@ import dev.slimevr.skeleton.Skeleton import dev.slimevr.skeleton.buildBones import dev.slimevr.stayaligned.StayAlignedManager import dev.slimevr.tapdetection.TapDetectionManager +import dev.slimevr.tracker.RestState import dev.slimevr.tracker.SessionCalibration +import dev.slimevr.tracker.StayAlignedData import dev.slimevr.tracker.Tracker import dev.slimevr.tracker.TrackerBasicBehaviour import dev.slimevr.tracker.TrackerBehaviour import dev.slimevr.tracker.TrackerState +import dev.slimevr.tracker.YawErrors import dev.slimevr.trackingchecklist.TrackingChecklist import dev.slimevr.udp.UdpServer import dev.slimevr.vmc.VMCManager @@ -102,7 +108,7 @@ fun buildTestSkeleton(scope: CoroutineScope): Skeleton { behaviours = listOf(ProportionsBehaviour(buildTestUserConfig(scope))), name = "TestSkeleton", ) - val skeleton = Skeleton(context, MutableStateFlow(ComputedSkeleton(bones = buildBones(context.state.value), frameTime = Duration.ZERO))) + val skeleton = Skeleton(context, MutableStateFlow(buildBones(context.state.value))) skeleton.startObserving() return skeleton } @@ -161,6 +167,9 @@ fun buildTestTracker( completedRestCalibration = completedRestCalibration, magStatus = MagnetometerStatus.NOT_SUPPORTED, sessionCalibration = sessionCalibration, + restState = RestState.MOVING, + yawResetSmoothing = null, + stayAlignedData = StayAlignedData(null, Angle.ZERO, YawErrors(AngleErrors(), AngleErrors(), AngleErrors())), ) val context = Context.create( initialState = state, @@ -173,7 +182,7 @@ fun buildTestTracker( fun buildTestSettings(scope: CoroutineScope): Settings { val initialState = SettingsState(data = SettingsConfigState(), name = "test") - val context = Context.create( + val context = Context.create( initialState = initialState, scope = scope, behaviours = emptyList(), From 179277d66e8a34ef161fe989fbf39c5e6b59bfe1 Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 31 Jul 2026 13:26:47 -0400 Subject: [PATCH 339/350] Accept sleeping for tracking checklist --- .../java/dev/slimevr/skeleton/processors/bone-smoothing.kt | 2 +- .../src/main/java/dev/slimevr/trackingchecklist/behaviours.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt index b995ecd4ce..4b496a89b0 100644 --- a/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt +++ b/server/core/src/main/java/dev/slimevr/skeleton/processors/bone-smoothing.kt @@ -52,6 +52,6 @@ class BoneSmoothingProcessor(val settings: Settings) : SkeletonProcessor { companion object { private const val SMOOTHING_MULTIPLIER = 100f private const val SMOOTH_MIN = 0.6f - private const val SMOOTH_MAX = 0.93f + private const val SMOOTH_MAX = 0.95f } } diff --git a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt index ac0d38cb7c..f3f6478c26 100644 --- a/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/trackingchecklist/behaviours.kt @@ -90,7 +90,7 @@ class TrackerRestCheckBehaviour(private val server: VRServer) : TrackingChecklis private fun computeStep(trackers: List): TrackingChecklistStep { val uncalibratedTrackers = trackers.filter { tracker -> (tracker.origin == DeviceOrigin.UDP || tracker.origin == DeviceOrigin.HID) && - tracker.status == TrackerStatus.OK && + (tracker.status == TrackerStatus.OK || tracker.status == TrackerStatus.SLEEPING) && (tracker.completedRestCalibration != null && !tracker.completedRestCalibration) } return TrackingChecklistStep( @@ -201,7 +201,7 @@ private fun isImuAssigned(tracker: TrackerState): Boolean = (tracker.origin == D private fun isConnectedAssignedImu(tracker: TrackerState): Boolean = (tracker.origin == DeviceOrigin.UDP || tracker.origin == DeviceOrigin.HID) && tracker.position == null && tracker.imuType !== null && - tracker.status == TrackerStatus.OK && + (tracker.status == TrackerStatus.OK || tracker.status == TrackerStatus.SLEEPING) && tracker.bodyPart != null class FullResetCheckBehaviour( From 0b1cb6ae7e4fee5e69290cb21936cb12b680ccb2 Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 31 Jul 2026 15:45:41 -0400 Subject: [PATCH 340/350] Nice TrackerRestDetectionBehaviour :3 --- .../dev/slimevr/tapdetection/behaviours.kt | 126 ++++++------ .../java/dev/slimevr/tracker/behaviours.kt | 186 ++++++++++-------- .../main/java/dev/slimevr/tracker/module.kt | 14 +- .../src/test/java/dev/slimevr/TestServer.kt | 6 +- .../slimevr/tapdetection/TapDetectionTest.kt | 15 +- 5 files changed, 193 insertions(+), 154 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt index 2517d7199f..a64197399a 100644 --- a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt @@ -1,7 +1,8 @@ package dev.slimevr.tapdetection import dev.slimevr.config.TapDetectionConfig -import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.Motion +import dev.slimevr.tracker.TrackerState import dev.slimevr.util.timeSource import io.github.axisangles.ktmath.Vector3 import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -24,7 +25,6 @@ import kotlin.time.TimeMark class TapDetectionBasicBehaviour : TapDetectionBehaviour { data class TrackerTapDetectionState( var trackerId: Int, - var numberTrackersOverThreshold: Int = 1, var resetType: ResetType? = null, var tapsNeeded: Int = 2, var actionDelay: Float = 0f, @@ -33,6 +33,18 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { var waitForLowAccel: Boolean = false, ) + // This vvv + the assigned tap body parts are used. + private val falsePositiveBodyParts = setOf( + BodyPart.UPPER_CHEST, + BodyPart.CHEST, + BodyPart.WAIST, + BodyPart.HIP, + BodyPart.LEFT_UPPER_LEG, + BodyPart.RIGHT_UPPER_LEG, + BodyPart.LEFT_LOWER_LEG, + BodyPart.RIGHT_LOWER_LEG, + ) + @OptIn(ExperimentalCoroutinesApi::class) override fun observe(receiver: TapDetectionManager) { val tapConfigFlow = receiver.settings.context.state @@ -54,7 +66,12 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { states.map { it.bodyPart to it.status } } .distinctUntilChanged() - .map { trackers.filter { tracker -> tracker.context.state.value.status == TrackerStatus.OK } } + .map { + trackers.filter { tracker -> + val status = tracker.context.state.value.status + status == TrackerStatus.OK + } + } } // Outer flow is refreshed whenever TapDetection config, setupMode, or a tracker's bodyPart or status changes @@ -65,9 +82,6 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { ::Triple, ) .flatMapLatest { (tapDetectionConfig, setupMode, trackers) -> - // To keep track of which trackers are over threshold - val trackersOverThreshold = mutableSetOf() - // Computed once per outer-flow refresh for all trackers val trackersBodyParts = trackers.map { it.context.state.value.bodyPart }.toSet() val yawResetBodyPart = listOf(tapDetectionConfig.yawResetBodyPart, BodyPart.UPPER_CHEST, BodyPart.CHEST, BodyPart.HIP, BodyPart.WAIST) @@ -77,11 +91,16 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { val mountingResetBodyPart = listOf(tapDetectionConfig.mountingResetBodyPart, BodyPart.RIGHT_UPPER_LEG, BodyPart.RIGHT_LOWER_LEG) .firstOrNull { it in trackersBodyParts } ?: BodyPart.RIGHT_UPPER_LEG + // To keep track of which trackers are moving + val bodyPartsToCheck = falsePositiveBodyParts + yawResetBodyPart + fullResetBodyPart + mountingResetBodyPart + val movingTrackers = mutableSetOf() + val numberTrackersOverThreshold = tapDetectionConfig.numberTrackersOverThreshold + trackers.map { tracker -> val trackerTapDetectionState = createTrackerTapDetectionState( tapDetectionConfig, setupMode, - tracker, + tracker.context.state.value, yawResetBodyPart, fullResetBodyPart, mountingResetBodyPart, @@ -89,35 +108,43 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { // Inner flow emits whenever a tracker's rawAcceleration is updated tracker.context.state - .filter { trackerTapDetectionState.resetType != null || setupMode } - .map { it.rawAcceleration } + .filter { it.bodyPart in bodyPartsToCheck || setupMode } + .map { it.rawAcceleration to it.motion } .distinctUntilChanged() - .onEach { - val tapTriggered = runTapDetection( - timeSource.markNow(), - trackersOverThreshold, - trackerTapDetectionState, - it, - ) - - if (tapTriggered) { - receiver.context.scope.launch { - // If it's in setup mode, tap to assign - if (setupMode) { - receiver.server.sendSolarxrRpc( - TapDetectionSetupNotification( - trackerTapDetectionState.trackerId.toUShort(), - ), - ) - } + .onEach { (rawAcceleration, motionState) -> + if (motionState == Motion.MOVING) { + movingTrackers.add(trackerTapDetectionState.trackerId) + } else { + movingTrackers.remove(trackerTapDetectionState.trackerId) + } + + if (trackerTapDetectionState.resetType != null || setupMode) { + val tapTriggered = runTapDetection( + timeSource.markNow(), + movingTrackers.count() > numberTrackersOverThreshold, + trackerTapDetectionState, + rawAcceleration, + ) + + if (tapTriggered) { + receiver.context.scope.launch { + // If it's in setup mode, tap to assign + if (setupMode) { + receiver.server.sendSolarxrRpc( + TapDetectionSetupNotification( + trackerTapDetectionState.trackerId.toUShort(), + ), + ) + } - // If it has a reset to execute - trackerTapDetectionState.resetType?.let { reset -> - receiver.resetsManager.scheduleReset( - "TapDetection", - reset, - trackerTapDetectionState.actionDelay, - ) + // If it has a reset to execute + trackerTapDetectionState.resetType?.let { reset -> + receiver.resetsManager.scheduleReset( + "TapDetection", + reset, + trackerTapDetectionState.actionDelay, + ) + } } } } @@ -131,18 +158,17 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { private fun createTrackerTapDetectionState( tapDetectionConfig: TapDetectionConfig, setupMode: Boolean, - tracker: Tracker, + trackerState: TrackerState, yawResetBodyPart: BodyPart, fullResetBodyPart: BodyPart, mountingResetBodyPart: BodyPart, ): TrackerTapDetectionState { // This holds a tracker's config and state for tap detection - val trackerTapDetectionState = TrackerTapDetectionState(tracker.context.state.value.id) - trackerTapDetectionState.numberTrackersOverThreshold = tapDetectionConfig.numberTrackersOverThreshold + val trackerTapDetectionState = TrackerTapDetectionState(trackerState.id) // setupMode uses defaults if (!setupMode) { - when (tracker.context.state.value.bodyPart) { + when (trackerState.bodyPart) { yawResetBodyPart if tapDetectionConfig.yawResetEnabled -> { trackerTapDetectionState.resetType = ResetType.YAW trackerTapDetectionState.tapsNeeded = tapDetectionConfig.yawResetTaps @@ -171,7 +197,7 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { // Logic loop for tap detection fun runTapDetection( now: TimeMark, - trackersOverThreshold: MutableSet, + bodyMoving: Boolean, trackerTapDetectionState: TrackerTapDetectionState, trackerAcceleration: Vector3, ): Boolean { @@ -187,25 +213,14 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { val min = trackerTapDetectionState.accelList.minOf { it.first } val accelDelta = max - min - // Is this tracker over threshold for false positive prevention? - val isOverThreshold = trackerAcceleration.lenSq() > ALLOWED_BODY_ACCEL_SQUARED - if (isOverThreshold) { - trackersOverThreshold.add(trackerTapDetectionState.trackerId) - } else { - trackersOverThreshold.remove(trackerTapDetectionState.trackerId) - } - // Check for a single tap - if (accelDelta > NEEDED_ACCEL_DELTA && !trackerTapDetectionState.waitForLowAccel) { - val othersOverThreshold = trackersOverThreshold.size - if (isOverThreshold) 1 else 0 - if (othersOverThreshold < trackerTapDetectionState.numberTrackersOverThreshold) { - trackerTapDetectionState.tapTimestamps.add(now) - trackerTapDetectionState.waitForLowAccel = true - } + if (!bodyMoving && accelDelta > NEEDED_ACCEL_DELTA && !trackerTapDetectionState.waitForLowAccel) { + trackerTapDetectionState.tapTimestamps.add(now) + trackerTapDetectionState.waitForLowAccel = true } // Achieved low accel? - if (max < ALLOWED_BODY_ACCEL) trackerTapDetectionState.waitForLowAccel = false + if (max < LOW_ACCEL_THRESHOLD) trackerTapDetectionState.waitForLowAccel = false if (trackerTapDetectionState.tapTimestamps.isNotEmpty()) { // Remove old stored taps (if they are too old) @@ -234,8 +249,7 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { companion object { const val NEEDED_ACCEL_DELTA = 6.0f - const val ALLOWED_BODY_ACCEL = 2.5f - const val ALLOWED_BODY_ACCEL_SQUARED = ALLOWED_BODY_ACCEL * ALLOWED_BODY_ACCEL + const val LOW_ACCEL_THRESHOLD = 2.5f val ACCEL_WINDOW = 0.06.seconds val TAP_WINDOW_PER_TAP = 0.3.seconds } diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index d7df911fcd..b797d36fcf 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -37,6 +37,7 @@ import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class TrackerBasicBehaviour : TrackerBehaviour { @@ -313,7 +314,16 @@ class TrackerTPSBehaviour : TrackerBehaviour { delay(1000) val elapsed = mark.elapsedNow() val tps = count.exchange(0) * 1000L / elapsed.inWholeMilliseconds - receiver.context.dispatch(TrackerActions.Update { copy(tps = tps.toUShort()) }) + + // Tracker is at rest if it hasn't been updated in the last second + val updateRestStateAction = if (tps == 0L && receiver.context.state.value.motion != Motion.RESTING) TrackerActions.SetRestState(Motion.RESTING) else null + receiver.context.dispatchAll( + listOfNotNull( + TrackerActions.Update { copy(tps = tps.toUShort()) }, + updateRestStateAction, + ), + ) + mark = timeSource.markNow() } catch (e: Exception) { AppLogger.coroutines.error(e, "Error in TrackerTPSBehaviour") @@ -323,6 +333,91 @@ class TrackerTPSBehaviour : TrackerBehaviour { } } +/** + * Detects whether a tracker is at rest or moving. + * + * A tracker is at rest when it stays within a certain rotational range and acceleration for a given + * amount of time. If it rotates past that range or accelerates too fast, it is no longer at rest. + */ +class TrackerMotionDetectionBehaviour : TrackerBehaviour { + + override fun observe(receiver: Tracker) { + var lastStateChangeTime = timeSource.markNow() + var lastRotationTime = timeSource.markNow() + var lastRotation: RawRotation = Quaternion.IDENTITY + + // For update loop + receiver.context.state + .map { it.rawRotation to it.rawAcceleration } + .distinctUntilChanged() + .onEach { (rotation, acceleration) -> + val now = timeSource.markNow() + val motionState = receiver.context.state.value.motion + val isRotating = isRotating(lastRotation, rotation) + val isAccelerating = isAccelerating(acceleration) + val isMoving = isRotating || isAccelerating + + when (motionState) { + // Detect if tracker is at rest + Motion.MOVING, + Motion.STARTED_MOVING, + -> + if (!isMoving) { + if (now > lastRotationTime + ENTER_REST_TIME) { + // Been not moving for long enough; set as at rest. + receiver.context.dispatch(TrackerActions.SetRestState(Motion.RESTING)) + lastStateChangeTime = now + + // Update the rotation to continue detecting if the tracker is at rest + lastRotation = rotation + lastRotationTime = now + } + } else if (motionState == Motion.STARTED_MOVING && (now > lastStateChangeTime + ENTER_MOVING_ROTATION_TIME || isAccelerating)) { + // Been moving for long enough or accel high; set as moving. + receiver.context.dispatch(TrackerActions.SetRestState(Motion.MOVING)) + lastStateChangeTime = now + } + + // Detect if tracker is moving + Motion.RESTING -> + if (isMoving) { + // Started moving; set as recently at rest. + receiver.context.dispatch(TrackerActions.SetRestState(Motion.STARTED_MOVING)) + lastStateChangeTime = now + } + } + + // Update rotation if the tracker is moving + if (isMoving) { + lastRotation = rotation + lastRotationTime = now + } + } + .launchIn(receiver.context.scope) + } + + private fun isRotating(lastRotation: RawRotation, rotation: RawRotation) = Angle.absBetween(lastRotation, rotation) > MAX_ROTATION + + private fun isAccelerating(acceleration: RawAcceleration) = acceleration.lenSq() > MAX_SQUARED_ACCEL + + override fun reduce(state: TrackerState, action: TrackerActions): TrackerState = when (action) { + is TrackerActions.SetRestState -> { + println(action.restState) + state.copy(motion = action.restState) + } + + else -> state + } + + // TODO : these values may need fine-tuning + companion object { + const val MAX_SQUARED_ACCEL = 16f + val MAX_ROTATION = Angle.ofDeg(2.5f) + val ENTER_REST_TIME = 600.milliseconds + val ENTER_MOVING_ROTATION_TIME = 3.seconds + } +} + class TrackerToSkeletonBehaviour : TrackerBehaviour { var lastBodyPartSent: BodyPart? = null @@ -362,6 +457,10 @@ class TrackerToSkeletonBehaviour : TrackerBehaviour { } } +/** + * Sets the orientation the tracker should match after a Full Reset. + * Used for T-Pose down. + */ class TrackerRestOrientationBehaviour( private val settings: Settings, ) : TrackerBehaviour { @@ -392,91 +491,6 @@ class TrackerRestOrientationBehaviour( } } -/** - * Detects whether a tracker is at rest. - * - * A tracker is at rest when it stays within a certain rotational range for a given - * amount of time. If it rotates past that range, it is no longer at rest. - * - * TODO: Consider accel and use for tap detection as well - */ -class TrackerRestDetectionBehaviour : TrackerBehaviour { - - override fun observe(receiver: Tracker) { - var startTime = timeSource.markNow() - var lastUpdateTime = timeSource.markNow() - var lastRotation = Quaternion.IDENTITY - - // To reset rest detection - receiver.context.state - .distinctUntilChanged { old, new -> old.sessionCalibration == new.sessionCalibration } - .onEach { - lastRotation = Quaternion.IDENTITY - val now = timeSource.markNow() - startTime = now - lastUpdateTime = now - receiver.context.dispatch(TrackerActions.SetRestState(RestState.MOVING)) // TODO : should this be AT_REST? - }.launchIn(receiver.context.scope) - - // For update loop - receiver.context.state - .map { it.rotation to it.acceleration } - .distinctUntilChanged() - .onEach { (rotation, acceleration) -> - val now = timeSource.markNow() - - if ( - receiver.context.state.value.restState == RestState.RECENTLY_AT_REST && - now > startTime.plus(ENTER_MOVING_TIME) - ) { - receiver.context.dispatch(TrackerActions.SetRestState(RestState.MOVING)) - startTime = now - lastRotation = rotation - lastUpdateTime = now - } - - when (receiver.context.state.value.restState) { - RestState.MOVING, - RestState.RECENTLY_AT_REST, - -> - if (Angle.absBetween(lastRotation, rotation) > MAX_ROTATION) { - lastRotation = rotation - lastUpdateTime = now - } else { - // When we detect the tracker is at rest, use the current rotation as the - // new start rotation for continuing to detect the tracker is at rest - if (now > lastUpdateTime.plus(ENTER_REST_TIME)) { - receiver.context.dispatch(TrackerActions.SetRestState(RestState.AT_REST)) - startTime = now - lastRotation = rotation - lastUpdateTime = now - } - } - - RestState.AT_REST -> - if (Angle.absBetween(lastRotation, rotation) > MAX_ROTATION) { - receiver.context.dispatch(TrackerActions.SetRestState(RestState.RECENTLY_AT_REST)) - startTime = now - lastRotation = rotation - lastUpdateTime = now - } - } - } - .launchIn(receiver.context.scope) - } - - override fun reduce(state: TrackerState, action: TrackerActions): TrackerState = when (action) { - is TrackerActions.SetRestState -> state.copy(restState = action.restState) - else -> state - } - - companion object { - val MAX_ROTATION = Angle.ofDeg(2.0f) - val ENTER_REST_TIME = 1.seconds - val ENTER_MOVING_TIME = 3.seconds - } -} - class TrackerStayAlignedBehaviour( private val settings: Settings, private val stayAlignedManager: StayAlignedManager, diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 4efbfd6f14..21d991a94e 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -23,10 +23,10 @@ data class TrackerSensorIds(val trackerId: Int, val sensorId: Int) /** * Indicates if the tracker is moving, at rest or recently at rest. */ -enum class RestState { +enum class Motion { MOVING, - AT_REST, - RECENTLY_AT_REST, + RESTING, + STARTED_MOVING, } data class YawResetSmoothing( @@ -76,7 +76,7 @@ data class TrackerState( val completedRestCalibration: Boolean?, val magStatus: MagnetometerStatus, val sessionCalibration: SessionCalibration?, - val restState: RestState, + val motion: Motion, val yawResetSmoothing: YawResetSmoothing?, val stayAlignedData: StayAlignedData, ) @@ -93,7 +93,7 @@ sealed interface TrackerActions { data class TickYawResetSmoothing(val heading: HeadingCorrection, val done: Boolean) : TrackerActions data class MountingReset(val referenceRotation: Quaternion, val yawOffset: Float) : TrackerActions data object ClearMountingReset : TrackerActions - data class SetRestState(val restState: RestState) : TrackerActions + data class SetRestState(val restState: Motion) : TrackerActions } typealias TrackerContext = Context @@ -144,7 +144,7 @@ class Tracker( completedRestCalibration = false, magStatus = MagnetometerStatus.NOT_SUPPORTED, sessionCalibration = null, - restState = RestState.MOVING, // TODO : should this be AT_REST? + motion = Motion.RESTING, yawResetSmoothing = null, stayAlignedData = StayAlignedData( null, @@ -168,9 +168,9 @@ class Tracker( TrackerDefaultMountingOrientationBehaviour(), TrackerConfigBehaviour(settings, hardwareId), TrackerTPSBehaviour(), + TrackerMotionDetectionBehaviour(), TrackerToSkeletonBehaviour(), TrackerRestOrientationBehaviour(settings), - TrackerRestDetectionBehaviour(), TrackerStayAlignedBehaviour(settings, appContext.stayAlignedManager), ) val context = Context.create( diff --git a/server/core/src/test/java/dev/slimevr/TestServer.kt b/server/core/src/test/java/dev/slimevr/TestServer.kt index b3750e8952..0a245be00b 100644 --- a/server/core/src/test/java/dev/slimevr/TestServer.kt +++ b/server/core/src/test/java/dev/slimevr/TestServer.kt @@ -31,14 +31,13 @@ import dev.slimevr.resets.ResetsState import dev.slimevr.serial.FlashingHandler import dev.slimevr.serial.SerialPortHandle import dev.slimevr.serial.SerialServer -import dev.slimevr.skeleton.ComputedSkeleton import dev.slimevr.skeleton.DEFAULT_SKELETON_STATE import dev.slimevr.skeleton.ProportionsBehaviour import dev.slimevr.skeleton.Skeleton import dev.slimevr.skeleton.buildBones import dev.slimevr.stayaligned.StayAlignedManager import dev.slimevr.tapdetection.TapDetectionManager -import dev.slimevr.tracker.RestState +import dev.slimevr.tracker.Motion import dev.slimevr.tracker.SessionCalibration import dev.slimevr.tracker.StayAlignedData import dev.slimevr.tracker.Tracker @@ -60,7 +59,6 @@ import solarxr_protocol.datatypes.MagnetometerStatus import solarxr_protocol.datatypes.TrackerStatus import solarxr_protocol.datatypes.hardware_info.ImuType import solarxr_protocol.rpc.UserHeightCalibrationStatus -import kotlin.time.Duration fun buildTestSerialServer(scope: CoroutineScope) = SerialServer.create( openPort = { loc, _, _ -> SerialPortHandle(loc, "Fake $loc", {}, {}) }, @@ -167,7 +165,7 @@ fun buildTestTracker( completedRestCalibration = completedRestCalibration, magStatus = MagnetometerStatus.NOT_SUPPORTED, sessionCalibration = sessionCalibration, - restState = RestState.MOVING, + motion = Motion.RESTING, yawResetSmoothing = null, stayAlignedData = StayAlignedData(null, Angle.ZERO, YawErrors(AngleErrors(), AngleErrors(), AngleErrors())), ) diff --git a/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt b/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt index 539ee0f924..55f2510aa1 100644 --- a/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt +++ b/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt @@ -34,6 +34,7 @@ class TapDetectionTest { private fun runTapSequence( tapsNeeded: Int, events: List, + bodyMoving: Boolean = false, ) { val state = TapDetectionBasicBehaviour.TrackerTapDetectionState( trackerId = 1, @@ -49,7 +50,7 @@ class TapDetectionTest { event.expectedTap, behaviour.runTapDetection( now = timeSource.markNow(), - trackersOverThreshold = trackersOverThreshold, + bodyMoving = bodyMoving, trackerTapDetectionState = state, trackerAcceleration = event.accel, ), @@ -116,4 +117,16 @@ class TapDetectionTest { AccelEvent(tapDelay, highAccel, expectedTap = false), ), ) + + @Test + fun `Tap not detected while body is moving`() = runTapSequence( + tapsNeeded = 2, + events = listOf( + AccelEvent(Duration.ZERO, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + AccelEvent(tapDelay, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + ), + true, + ) } From 036ffa0b55cf646ed1d339f52771160dd23e961a Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 31 Jul 2026 18:03:32 -0400 Subject: [PATCH 341/350] More StayAligned wip --- .../src/main/java/dev/slimevr/behaviours.kt | 7 +- .../java/dev/slimevr/stayaligned/module.kt | 2 +- .../stayaligned/todo/adjust-tracker-yaw.kt | 116 ++--- .../stayaligned/todo/center-error-visitor.kt | 61 +-- .../slimevr/stayaligned/todo/center-yaw.kt | 112 ++--- .../stayaligned/todo/locked-error-visitor.kt | 61 +-- .../todo/neighbor-error-visitor.kt | 72 +-- .../slimevr/stayaligned/todo/player-pose.kt | 52 +-- .../slimevr/stayaligned/todo/relaxed-pose.kt | 50 +-- .../stayaligned/todo/tracker-groups.kt | 379 ++++++++++++++++ .../slimevr/stayaligned/todo/tracker-pose.kt | 8 +- .../stayaligned/todo/tracker-skeleton.kt | 415 ------------------ .../slimevr/stayaligned/todo/tracker-yaw.kt | 9 +- .../java/dev/slimevr/tracker/behaviours.kt | 55 ++- 14 files changed, 688 insertions(+), 711 deletions(-) create mode 100644 server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-groups.kt delete mode 100644 server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-skeleton.kt diff --git a/server/core/src/main/java/dev/slimevr/behaviours.kt b/server/core/src/main/java/dev/slimevr/behaviours.kt index 75408163b6..5bca8321fe 100644 --- a/server/core/src/main/java/dev/slimevr/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/behaviours.kt @@ -1,7 +1,8 @@ package dev.slimevr -import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach class BaseBehaviour : VRServerBehaviour { @@ -15,8 +16,8 @@ class BaseBehaviour : VRServerBehaviour { } override fun observe(receiver: VRServer) { - receiver.context.state.distinctUntilChangedBy { it.trackers.size }.onEach { - println("tracker list size changed") + receiver.context.state.map { it.trackers.size }.distinctUntilChanged().onEach { + println("Tracker list size changed to $it") }.launchIn(receiver.context.scope) } } diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/module.kt b/server/core/src/main/java/dev/slimevr/stayaligned/module.kt index 1040f22c96..301f98e1ba 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/module.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/module.kt @@ -36,7 +36,7 @@ class StayAlignedManager(val context: StayAlignedContext, val server: VRServer, * Sets and enables a relaxed pose from the user's current pose. */ fun detectRelaxedPose(pose: StayAlignedRelaxedPose) { - // TODO get user pose + // TODO get user pose fromTrackers updatePoseInConfig(pose, StayAlignedRelaxedPoseConfig(true)) } diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/adjust-tracker-yaw.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/adjust-tracker-yaw.kt index 3a66ee55f8..727de7bcad 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/adjust-tracker-yaw.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/adjust-tracker-yaw.kt @@ -4,7 +4,9 @@ import dev.slimevr.config.StayAlignedConfig import dev.slimevr.math.angle.Angle import dev.slimevr.skeleton.ComputedSkeleton import dev.slimevr.stayaligned.StayAlignedDefaults +import dev.slimevr.tracker.Motion import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerState import dev.slimevr.tracker.YawErrors object AdjustTrackerYaw { @@ -55,26 +57,24 @@ object AdjustTrackerYaw { * alignment. */ fun adjust( - tracker: Tracker, - trackers: ComputedSkeleton, + trackerState: TrackerState, + computedSkeleton: ComputedSkeleton, yawCorrection: Angle, config: StayAlignedConfig, ) { -// // Clear errors, in case we don't adjust the tracker -// var state = tracker.context.state.value.stayAlignedData -// state.yawErrors = YawErrors() -// -// val restDetector = state.restDetector -// when (restDetector.state) { -// RestDetector.State.MOVING -> -// adjustMovingTracker(tracker, trackers, yawCorrection, config) + // Clear errors, in case we don't adjust the tracker +// trackerState.stayAlignedData.yawErrors = YawErrors() + +// when (trackerState.motion) { +// Motion.MOVING -> +// adjustMovingTracker(trackerState, computedSkeleton, yawCorrection, config) // -// RestDetector.State.AT_REST -> -// adjustLockedTracker(tracker, trackers, yawCorrection) +// Motion.RESTING -> +// adjustLockedTracker(trackerState, computedSkeleton, yawCorrection) // -// RestDetector.State.RECENTLY_AT_REST -> { -// // Do not adjust trackers that were recently at rest, to support play -// // styles that are primarily at rest +// Motion.STARTED_MOVING -> { +// // Do not adjust trackers that were recently at rest +// // to support play styles that are primarily at rest // } // } } @@ -83,15 +83,15 @@ object AdjustTrackerYaw { * Adjusts a locked tracker. */ private fun adjustLockedTracker( - tracker: Tracker, - trackers: TrackerSkeleton, + trackerState: TrackerState, + computedSkeleton: ComputedSkeleton, yawCorrection: Angle, ) { -// val lockedRotation = tracker.stayAligned.lockedRotation ?: return -// -// adjustByError(tracker, yawCorrection) { + val lockedRotation = trackerState.stayAlignedData.lockedRotation ?: return + +// adjustByError(trackerState, yawCorrection) { // YawErrors().also { -// trackers.visit(tracker, LockedErrorVisitor(lockedRotation, it.lockedError)) +// computedSkeleton.visit(trackerState, LockedErrorVisitor(lockedRotation, it.lockedError)) // } // } } @@ -100,20 +100,20 @@ object AdjustTrackerYaw { * Adjusts a tracker that is moving. */ private fun adjustMovingTracker( - tracker: Tracker, - trackers: TrackerSkeleton, + trackerState: TrackerState, + trackerGroup: TrackerGroup, yawCorrection: Angle, config: StayAlignedConfig, ) { -// val centerYaw = CenterYaw.ofSkeleton(trackers) ?: return -// -// val pose = PlayerPose.ofTrackers(trackers) + val centerYaw = CenterYaw.ofTrackerGroup(trackerGroup) ?: return + +// val pose = PlayerPose.of(trackers) // val relaxedPose = RelaxedPose.forPose(pose, config) ?: return // -// adjustByError(tracker, yawCorrection) { +// adjustByError(trackerState, yawCorrection) { // YawErrors().also { -// trackers.visit(tracker, CenterErrorVisitor(centerYaw, relaxedPose, it.centerError)) -// trackers.visit(tracker, NeighborErrorVisitor(relaxedPose, it.neighborError)) +// trackerGroup.visit(trackerState, CenterErrorVisitor(centerYaw, relaxedPose, it.centerError)) +// trackerGroup.visit(trackerState, NeighborErrorVisitor(relaxedPose, it.neighborError)) // } // } } @@ -122,37 +122,37 @@ object AdjustTrackerYaw { * Adjusts the yaw by applying gradient descent. */ private fun adjustByError( - tracker: Tracker, + trackerState: TrackerState, yawCorrection: Angle, - errorFn: (tracker: Tracker) -> YawErrors, + errorFn: (tracker: TrackerState) -> YawErrors, ) { -// val state = tracker.stayAligned -// -// val curYaw = state.yawCorrection -// val curError = errorFn(tracker) -// -// val posYaw = curYaw + yawCorrection -// state.yawCorrection = posYaw -// val posError = errorFn(tracker) -// -// val negYaw = curYaw - yawCorrection -// state.yawCorrection = negYaw -// val negError = errorFn(tracker) -// -// val posYawDelta = gradient(posError, curError) -// val negYawDelta = gradient(negError, curError) -// -// // Pick the yaw correction that minimizes the error -// if ((posYawDelta < Angle.ZERO) && (posYawDelta < negYawDelta)) { -// state.yawCorrection = posYaw -// state.yawErrors = posError -// } else if (negYawDelta < Angle.ZERO) { -// state.yawCorrection = negYaw -// state.yawErrors = negError -// } else { -// state.yawCorrection = curYaw -// state.yawErrors = curError -// } + val stayAlignedState = trackerState.stayAlignedData + + val curYaw = stayAlignedState.yawCorrection + val curError = errorFn(trackerState) + + val posYaw = curYaw + yawCorrection +// stayAlignedState.yawCorrection = posYaw + val posError = errorFn(trackerState) + + val negYaw = curYaw - yawCorrection +// stayAlignedState.yawCorrection = negYaw + val negError = errorFn(trackerState) + + val posYawDelta = gradient(posError, curError) + val negYawDelta = gradient(negError, curError) + + // Pick the yaw correction that minimizes the error + if ((posYawDelta < Angle.ZERO) && (posYawDelta < negYawDelta)) { +// stayAlignedState.yawCorrection = posYaw +// stayAlignedState.yawErrors = posError + } else if (negYawDelta < Angle.ZERO) { +// stayAlignedState.yawCorrection = negYaw +// stayAlignedState.yawErrors = negError + } else { +// stayAlignedState.yawCorrection = curYaw +// stayAlignedState.yawErrors = curError + } } /** diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-error-visitor.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-error-visitor.kt index ba12e3a721..438e548b7c 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-error-visitor.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-error-visitor.kt @@ -5,6 +5,7 @@ import dev.slimevr.math.angle.AngleErrors import dev.slimevr.stayaligned.todo.TrackerYaw.extraYaw import dev.slimevr.stayaligned.todo.TrackerYaw.trackerYaw import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerState import dev.slimevr.util.Side /** @@ -15,75 +16,75 @@ class CenterErrorVisitor( val centerYaw: Angle, val relaxedPose: RelaxedPose, val errors: AngleErrors, -) : TrackerSkeleton.TrackerVisitor { +) : TrackerGroup.TrackerVisitor { override fun visitHeadTracker( - tracker: Tracker, - belowUpperBody: Tracker?, + trackerState: TrackerState, + belowUpperBody: TrackerState?, ) { - errors.add(centerYaw - trackerYaw(tracker)) + errors.add(centerYaw - trackerYaw(trackerState)) } override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowUpperBody: Tracker?, + trackerState: TrackerState, + aboveHeadOrUpperBody: TrackerState?, + belowUpperBody: TrackerState?, ) { - errors.add(centerYaw - trackerYaw(tracker)) + errors.add(centerYaw - trackerYaw(trackerState)) } override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowLeftUpperLeg: Tracker?, - belowRightUpperLeg: Tracker?, + trackerState: TrackerState, + aboveHeadOrUpperBody: TrackerState?, + belowLeftUpperLeg: TrackerState?, + belowRightUpperLeg: TrackerState?, ) { - errors.add(centerYaw - trackerYaw(tracker)) + errors.add(centerYaw - trackerYaw(trackerState)) } override fun visitArmTracker( side: Side, - tracker: Tracker, - aboveUpperBodyOrArm: Tracker?, - belowHandOrArm: Tracker?, + tracker: TrackerState, + aboveUpperBodyOrArm: TrackerState?, + belowHandOrArm: TrackerState?, ) { // No error because arms can go anywhere } override fun visitHandTracker( side: Side, - tracker: Tracker, - aboveArm: Tracker?, - oppositeHand: Tracker?, + tracker: TrackerState, + aboveArm: TrackerState?, + oppositeHand: TrackerState?, ) { // No error because hands can go anywhere } override fun visitUpperLegTracker( side: Side, - tracker: Tracker, - aboveUpperBody: Tracker?, - belowLowerLeg: Tracker?, - oppositeUpperLeg: Tracker?, + tracker: TrackerState, + aboveUpperBody: TrackerState?, + belowLowerLeg: TrackerState?, + oppositeUpperLeg: TrackerState?, ) { errors.add(centerYaw + extraYaw(side, relaxedPose.upperLeg) - trackerYaw(tracker)) } override fun visitLowerLegTracker( side: Side, - tracker: Tracker, - aboveUpperLeg: Tracker?, - belowFoot: Tracker?, - oppositeLowerLeg: Tracker?, + tracker: TrackerState, + aboveUpperLeg: TrackerState?, + belowFoot: TrackerState?, + oppositeLowerLeg: TrackerState?, ) { errors.add(centerYaw + extraYaw(side, relaxedPose.lowerLeg) - trackerYaw(tracker)) } override fun visitFootTracker( side: Side, - tracker: Tracker, - aboveLowerLeg: Tracker?, - oppositeFoot: Tracker?, + tracker: TrackerState, + aboveLowerLeg: TrackerState?, + oppositeFoot: TrackerState?, ) { errors.add(centerYaw + extraYaw(side, relaxedPose.foot) - trackerYaw(tracker)) } diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-yaw.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-yaw.kt index f1f2aaec28..45b21d8b42 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-yaw.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/center-yaw.kt @@ -1,57 +1,63 @@ package dev.slimevr.stayaligned.todo +import dev.slimevr.math.angle.Angle +import dev.slimevr.math.angle.AngleAverage +import dev.slimevr.stayaligned.StayAlignedDefaults +import dev.slimevr.stayaligned.todo.TrackerYaw.hasTrackerYaw +import dev.slimevr.stayaligned.todo.TrackerYaw.trackerYaw + object CenterYaw { -// fun ofSkeleton( -// trackers: TrackerSkeleton, -// ): Angle? { -// -// val head = trackers.head -// val upperBody = trackers.upperBody -// val leftUpperLeg = trackers.leftUpperLeg -// val rightUpperLeg = trackers.rightUpperLeg -// val leftLowerLeg = trackers.leftLowerLeg -// val rightLowerLeg = trackers.rightLowerLeg -// -// if ( -// // Head optional, because some mocap scenarios don't use one -// upperBody.isEmpty() || -// leftUpperLeg == null || -// rightUpperLeg == null || -// leftLowerLeg == null || -// rightLowerLeg == null -// ) { -// return null -// } -// -// // Need a minimum set of trackers, and the trackers need to be oriented in a -// // way where we can actually calculate its yaw. -// val hasCenterYaw = -// upperBody.all(::hasTrackerYaw) && -// hasTrackerYaw(leftUpperLeg) && -// hasTrackerYaw(rightUpperLeg) && -// hasTrackerYaw(leftLowerLeg) && -// hasTrackerYaw(rightLowerLeg) -// if (!hasCenterYaw) { -// return null -// } -// -// // Calculate average yaw of the body -// val averageYaw = AngleAverage() -// -// if (head != null && hasTrackerYaw(head)) { -// averageYaw.add(trackerYaw(head), StayAlignedDefaults.CENTER_ERROR_HEAD_WEIGHT) -// } -// -// upperBody.forEach { -// averageYaw.add(trackerYaw(it), StayAlignedDefaults.CENTER_ERROR_UPPER_BODY_WEIGHT) -// } -// -// averageYaw.add(trackerYaw(leftUpperLeg), StayAlignedDefaults.CENTER_ERROR_UPPER_LEG_WEIGHT) -// averageYaw.add(trackerYaw(rightUpperLeg), StayAlignedDefaults.CENTER_ERROR_UPPER_LEG_WEIGHT) -// -// averageYaw.add(trackerYaw(leftLowerLeg), StayAlignedDefaults.CENTER_ERROR_LOWER_LEG_WEIGHT) -// averageYaw.add(trackerYaw(rightLowerLeg), StayAlignedDefaults.CENTER_ERROR_LOWER_LEG_WEIGHT) -// -// return averageYaw.toAngle() -// } + fun ofTrackerGroup( + trackerGroup: TrackerGroup, + ): Angle? { + + val head = trackerGroup.head + val upperBody = trackerGroup.upperBody + val leftUpperLeg = trackerGroup.leftUpperLeg + val rightUpperLeg = trackerGroup.rightUpperLeg + val leftLowerLeg = trackerGroup.leftLowerLeg + val rightLowerLeg = trackerGroup.rightLowerLeg + + if ( + // Head optional, because some mocap scenarios don't use one + upperBody.isEmpty() || + leftUpperLeg == null || + rightUpperLeg == null || + leftLowerLeg == null || + rightLowerLeg == null + ) { + return null + } + + // Need a minimum set of trackers, and the trackers need to be oriented in a + // way where we can actually calculate its yaw. + val hasCenterYaw = + upperBody.all(::hasTrackerYaw) && + hasTrackerYaw(leftUpperLeg) && + hasTrackerYaw(rightUpperLeg) && + hasTrackerYaw(leftLowerLeg) && + hasTrackerYaw(rightLowerLeg) + if (!hasCenterYaw) { + return null + } + + // Calculate average yaw of the body + val averageYaw = AngleAverage() + + if (head != null && hasTrackerYaw(head)) { + averageYaw.add(trackerYaw(head), StayAlignedDefaults.CENTER_ERROR_HEAD_WEIGHT) + } + + upperBody.forEach { + averageYaw.add(trackerYaw(it), StayAlignedDefaults.CENTER_ERROR_UPPER_BODY_WEIGHT) + } + + averageYaw.add(trackerYaw(leftUpperLeg), StayAlignedDefaults.CENTER_ERROR_UPPER_LEG_WEIGHT) + averageYaw.add(trackerYaw(rightUpperLeg), StayAlignedDefaults.CENTER_ERROR_UPPER_LEG_WEIGHT) + + averageYaw.add(trackerYaw(leftLowerLeg), StayAlignedDefaults.CENTER_ERROR_LOWER_LEG_WEIGHT) + averageYaw.add(trackerYaw(rightLowerLeg), StayAlignedDefaults.CENTER_ERROR_LOWER_LEG_WEIGHT) + + return averageYaw.toAngle() + } } diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/locked-error-visitor.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/locked-error-visitor.kt index 899609364a..02721587ce 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/locked-error-visitor.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/locked-error-visitor.kt @@ -3,6 +3,7 @@ package dev.slimevr.stayaligned.todo import dev.slimevr.math.angle.Angle import dev.slimevr.math.angle.AngleErrors import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerState import dev.slimevr.util.Side import io.github.axisangles.ktmath.Quaternion import io.github.axisangles.ktmath.Vector3 @@ -15,75 +16,75 @@ import kotlin.math.atan2 class LockedErrorVisitor( val lockedRotation: Quaternion, val errors: AngleErrors, -) : TrackerSkeleton.TrackerVisitor { +) : TrackerGroup.TrackerVisitor { override fun visitHeadTracker( - tracker: Tracker, - belowUpperBody: Tracker?, + trackerState: TrackerState, + belowUpperBody: TrackerState?, ) { - errors.add(error(tracker)) + errors.add(error(trackerState)) } override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowUpperBody: Tracker?, + trackerState: TrackerState, + aboveHeadOrUpperBody: TrackerState?, + belowUpperBody: TrackerState?, ) { - errors.add(error(tracker)) + errors.add(error(trackerState)) } override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowLeftUpperLeg: Tracker?, - belowRightUpperLeg: Tracker?, + trackerState: TrackerState, + aboveHeadOrUpperBody: TrackerState?, + belowLeftUpperLeg: TrackerState?, + belowRightUpperLeg: TrackerState?, ) { - errors.add(error(tracker)) + errors.add(error(trackerState)) } override fun visitArmTracker( side: Side, - tracker: Tracker, - aboveUpperBodyOrArm: Tracker?, - belowHandOrArm: Tracker?, + tracker: TrackerState, + aboveUpperBodyOrArm: TrackerState?, + belowHandOrArm: TrackerState?, ) { errors.add(error(tracker)) } override fun visitHandTracker( side: Side, - tracker: Tracker, - aboveArm: Tracker?, - oppositeHand: Tracker?, + tracker: TrackerState, + aboveArm: TrackerState?, + oppositeHand: TrackerState?, ) { errors.add(error(tracker)) } override fun visitUpperLegTracker( side: Side, - tracker: Tracker, - aboveUpperBody: Tracker?, - belowLowerLeg: Tracker?, - oppositeUpperLeg: Tracker?, + tracker: TrackerState, + aboveUpperBody: TrackerState?, + belowLowerLeg: TrackerState?, + oppositeUpperLeg: TrackerState?, ) { errors.add(error(tracker)) } override fun visitLowerLegTracker( side: Side, - tracker: Tracker, - aboveUpperLeg: Tracker?, - belowFoot: Tracker?, - oppositeLowerLeg: Tracker?, + tracker: TrackerState, + aboveUpperLeg: TrackerState?, + belowFoot: TrackerState?, + oppositeLowerLeg: TrackerState?, ) { errors.add(error(tracker)) } override fun visitFootTracker( side: Side, - tracker: Tracker, - aboveLowerLeg: Tracker?, - oppositeFoot: Tracker?, + tracker: TrackerState, + aboveLowerLeg: TrackerState?, + oppositeFoot: TrackerState?, ) { errors.add(error(tracker)) } diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/neighbor-error-visitor.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/neighbor-error-visitor.kt index bae5d742a1..e58a0dd321 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/neighbor-error-visitor.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/neighbor-error-visitor.kt @@ -3,7 +3,7 @@ package dev.slimevr.stayaligned.todo import dev.slimevr.math.angle.AngleErrors import dev.slimevr.stayaligned.todo.TrackerYaw.extraYaw import dev.slimevr.stayaligned.todo.TrackerYaw.trackerYaw -import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerState import dev.slimevr.util.Side import solarxr_protocol.datatypes.BodyPart @@ -13,47 +13,47 @@ import solarxr_protocol.datatypes.BodyPart class NeighborErrorVisitor( val relaxedPose: RelaxedPose, val errors: AngleErrors, -) : TrackerSkeleton.TrackerVisitor { +) : TrackerGroup.TrackerVisitor { override fun visitHeadTracker( - tracker: Tracker, - belowUpperBody: Tracker?, + trackerState: TrackerState, + belowUpperBody: TrackerState?, ) { if (belowUpperBody != null) { - errors.add(trackerYaw(belowUpperBody) - trackerYaw(tracker)) + errors.add(trackerYaw(belowUpperBody) - trackerYaw(trackerState)) } } override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowUpperBody: Tracker?, + trackerState: TrackerState, + aboveHeadOrUpperBody: TrackerState?, + belowUpperBody: TrackerState?, ) { if ( aboveHeadOrUpperBody != null && // Head often drags the upper body trackers off to the side, so ignore it - aboveHeadOrUpperBody.context.state.value.bodyPart != BodyPart.HEAD + aboveHeadOrUpperBody.bodyPart != BodyPart.HEAD ) { - errors.add(trackerYaw(aboveHeadOrUpperBody) - trackerYaw(tracker)) + errors.add(trackerYaw(aboveHeadOrUpperBody) - trackerYaw(trackerState)) } if (belowUpperBody != null) { - errors.add(trackerYaw(belowUpperBody) - trackerYaw(tracker)) + errors.add(trackerYaw(belowUpperBody) - trackerYaw(trackerState)) } } override fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowLeftUpperLeg: Tracker?, - belowRightUpperLeg: Tracker?, + trackerState: TrackerState, + aboveHeadOrUpperBody: TrackerState?, + belowLeftUpperLeg: TrackerState?, + belowRightUpperLeg: TrackerState?, ) { if ( aboveHeadOrUpperBody != null && // Head often drags the upper body trackers off to the side, so ignore it - aboveHeadOrUpperBody.context.state.value.bodyPart != BodyPart.HEAD + aboveHeadOrUpperBody.bodyPart != BodyPart.HEAD ) { - errors.add(trackerYaw(aboveHeadOrUpperBody) - trackerYaw(tracker)) + errors.add(trackerYaw(aboveHeadOrUpperBody) - trackerYaw(trackerState)) } // Only consider upper leg trackers if both are available, so that the upper @@ -65,40 +65,40 @@ class NeighborErrorVisitor( errors.add( trackerYaw(belowLeftUpperLeg) - extraYaw(Side.LEFT, relaxedPose.upperLeg) - - trackerYaw(tracker), + trackerYaw(trackerState), ) errors.add( trackerYaw(belowRightUpperLeg) - extraYaw(Side.RIGHT, relaxedPose.upperLeg) - - trackerYaw(tracker), + trackerYaw(trackerState), ) } } override fun visitArmTracker( side: Side, - tracker: Tracker, - aboveUpperBodyOrArm: Tracker?, - belowHandOrArm: Tracker?, + tracker: TrackerState, + aboveUpperBodyOrArm: TrackerState?, + belowHandOrArm: TrackerState?, ) { // No error because arms can go anywhere } override fun visitHandTracker( side: Side, - tracker: Tracker, - aboveArm: Tracker?, - oppositeHand: Tracker?, + tracker: TrackerState, + aboveArm: TrackerState?, + oppositeHand: TrackerState?, ) { // No error because hands can go anywhere } override fun visitUpperLegTracker( side: Side, - tracker: Tracker, - aboveUpperBody: Tracker?, - belowLowerLeg: Tracker?, - oppositeUpperLeg: Tracker?, + tracker: TrackerState, + aboveUpperBody: TrackerState?, + belowLowerLeg: TrackerState?, + oppositeUpperLeg: TrackerState?, ) { if (aboveUpperBody != null) { errors.add( @@ -120,10 +120,10 @@ class NeighborErrorVisitor( override fun visitLowerLegTracker( side: Side, - tracker: Tracker, - aboveUpperLeg: Tracker?, - belowFoot: Tracker?, - oppositeLowerLeg: Tracker?, + tracker: TrackerState, + aboveUpperLeg: TrackerState?, + belowFoot: TrackerState?, + oppositeLowerLeg: TrackerState?, ) { if (aboveUpperLeg != null) { errors.add( @@ -146,9 +146,9 @@ class NeighborErrorVisitor( override fun visitFootTracker( side: Side, - tracker: Tracker, - aboveLowerLeg: Tracker?, - oppositeFoot: Tracker?, + tracker: TrackerState, + aboveLowerLeg: TrackerState?, + oppositeFoot: TrackerState?, ) { if (aboveLowerLeg != null) { errors.add( diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/player-pose.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/player-pose.kt index 0882fbc8dd..8c0cc51ac5 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/player-pose.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/player-pose.kt @@ -14,32 +14,32 @@ enum class PlayerPose { companion object { -// fun ofTrackers(trackers: TrackerSkeleton): PlayerPose { -// val poses = -// TrackerPoses( -// trackers.upperBody.map(TrackerPose.Companion::ofTracker), -// TrackerPose.ofTracker(trackers.leftUpperLeg), -// TrackerPose.ofTracker(trackers.rightUpperLeg), -// TrackerPose.ofTracker(trackers.leftLowerLeg), -// TrackerPose.ofTracker(trackers.rightLowerLeg), -// ) -// -// return ( -// if (isStanding(poses)) { -// STANDING -// } else if (isSittingInChair(poses)) { -// SITTING_IN_CHAIR -// } else if (isSittingOnGround(poses)) { -// SITTING_ON_GROUND -// } else if (isLyingOnBack(poses)) { -// LYING_ON_BACK -// } else if (isKneeling(poses)) { -// KNEELING -// } else { -// UNKNOWN -// } -// ) -// } + fun ofTrackers(trackers: TrackerGroup): PlayerPose { + val poses = + TrackerPoses( + trackers.upperBody.map(TrackerPose.Companion::ofTracker), + TrackerPose.ofTracker(trackers.leftUpperLeg), + TrackerPose.ofTracker(trackers.rightUpperLeg), + TrackerPose.ofTracker(trackers.leftLowerLeg), + TrackerPose.ofTracker(trackers.rightLowerLeg), + ) + + return ( + if (isStanding(poses)) { + STANDING + } else if (isSittingInChair(poses)) { + SITTING_IN_CHAIR + } else if (isSittingOnGround(poses)) { + SITTING_ON_GROUND + } else if (isLyingOnBack(poses)) { + LYING_ON_BACK + } else if (isKneeling(poses)) { + KNEELING + } else { + UNKNOWN + } + ) + } private class TrackerPoses( val upperBody: List, diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/relaxed-pose.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/relaxed-pose.kt index 6140d26845..2d013adabb 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/relaxed-pose.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/relaxed-pose.kt @@ -2,7 +2,12 @@ package dev.slimevr.stayaligned.todo import dev.slimevr.config.StayAlignedConfig import dev.slimevr.math.angle.Angle +import dev.slimevr.skeleton.BoneState +import dev.slimevr.skeleton.ComputedSkeleton import dev.slimevr.stayaligned.StayAlignedDefaults +import dev.slimevr.stayaligned.todo.TrackerYaw.trackerYaw +import dev.slimevr.tracker.TrackerState +import solarxr_protocol.datatypes.BodyPart class RelaxedPose( val upperLeg: Angle, @@ -71,36 +76,19 @@ class RelaxedPose( null } -// /** -// * Gets the relaxed angles from the trackers. -// */ -// fun fromTrackers(humanSkeleton: HumanSkeleton): RelaxedPose { -// val halfAngleBetween = { left: Tracker, right: Tracker -> -// (trackerYaw(left) - trackerYaw(right)) * 0.5f -// } -// -// var upperLegAngle = Angle.ZERO -// humanSkeleton.leftUpperLegTracker?.let { left -> -// humanSkeleton.rightUpperLegTracker?.let { right -> -// upperLegAngle = halfAngleBetween(left, right) -// } -// } -// -// var lowerLegAngle = Angle.ZERO -// humanSkeleton.leftLowerLegTracker?.let { left -> -// humanSkeleton.rightLowerLegTracker?.let { right -> -// lowerLegAngle = halfAngleBetween(left, right) -// } -// } -// -// var footAngle = Angle.ZERO -// humanSkeleton.leftFootTracker?.let { left -> -// humanSkeleton.rightFootTracker?.let { right -> -// footAngle = halfAngleBetween(left, right) -// } -// } -// -// return RelaxedPose(upperLegAngle, lowerLegAngle, footAngle) -// } + /** + * Gets the relaxed angles from the trackers. + */ + fun fromTrackers(trackerStates: List): RelaxedPose { + val halfAngleBetween = { left: TrackerState, right: TrackerState -> + (trackerYaw(left) - trackerYaw(right)) * 0.5f + } + + val upperLegAngle = halfAngleBetween(trackerStates.first { it.bodyPart == BodyPart.LEFT_UPPER_LEG }, trackerStates.first { it.bodyPart == BodyPart.RIGHT_UPPER_LEG }) + val lowerLegAngle = halfAngleBetween(trackerStates.first { it.bodyPart == BodyPart.LEFT_LOWER_LEG }, trackerStates.first { it.bodyPart == BodyPart.RIGHT_LOWER_LEG }) + val footAngle = halfAngleBetween(trackerStates.first { it.bodyPart == BodyPart.LEFT_FOOT }, trackerStates.first { it.bodyPart == BodyPart.RIGHT_FOOT }) + + return RelaxedPose(upperLegAngle, lowerLegAngle, footAngle) + } } } diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-groups.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-groups.kt new file mode 100644 index 0000000000..ac006a12e9 --- /dev/null +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-groups.kt @@ -0,0 +1,379 @@ +package dev.slimevr.stayaligned.todo + +import dev.slimevr.tracker.TrackerState +import dev.slimevr.util.Side +import solarxr_protocol.datatypes.BodyPart + +/** + * Represents a skeleton of trackers. + * + * The skeleton consists of: + * - An upper body group + * - A head tracker, connected to the top of the upper body group + * - Two arm groups, connected to the top of the upper body group + * - Two hands connected to the bottom of the corresponding arm group + * - Two upper legs, connected to the bottom of the upper body group + * - Two lower legs, connected to the bottom of each corresponding upper leg + * - Two feet, connected to the bottom of each corresponding lower leg + */ +class TrackerGroup(trackerStates: List) { + + private fun List.get(bodyPart: BodyPart): TrackerState? { + return this.firstOrNull { it.bodyPart == bodyPart } + } + + val upperBody = listOfNotNull( + trackerStates.get(BodyPart.UPPER_CHEST), + trackerStates.get(BodyPart.CHEST), + trackerStates.get(BodyPart.WAIST), + trackerStates.get(BodyPart.HIP), + ) + + val leftArm = listOfNotNull( + trackerStates.get(BodyPart.LEFT_SHOULDER), + trackerStates.get(BodyPart.LEFT_UPPER_ARM), + trackerStates.get(BodyPart.LEFT_LOWER_ARM), + ) + + val rightArm = listOfNotNull( + trackerStates.get(BodyPart.RIGHT_SHOULDER), + trackerStates.get(BodyPart.RIGHT_UPPER_ARM), + trackerStates.get(BodyPart.RIGHT_LOWER_ARM), + ) + + // Individual trackers + val head = trackerStates.get(BodyPart.HEAD) + val leftHand = trackerStates.get(BodyPart.LEFT_HAND) + val rightHand = trackerStates.get(BodyPart.RIGHT_HAND) + val leftUpperLeg = trackerStates.get(BodyPart.LEFT_UPPER_LEG) + val leftLowerLeg = trackerStates.get(BodyPart.LEFT_LOWER_LEG) + val leftFoot = trackerStates.get(BodyPart.LEFT_FOOT) + val rightUpperLeg = trackerStates.get(BodyPart.RIGHT_UPPER_LEG) + val rightLowerLeg = trackerStates.get(BodyPart.RIGHT_LOWER_LEG) + val rightFoot = trackerStates.get(BodyPart.RIGHT_FOOT) + + /** + * Visits a tracker within the skeleton. + */ + fun visit( + trackerState: TrackerState, + visitor: TrackerVisitor, + ) { + when (trackerState.bodyPart) { + BodyPart.HEAD -> + visitor.visitHeadTracker(trackerState, upperBody.firstOrNull()) + + // Upper body + BodyPart.NECK, + BodyPart.UPPER_CHEST, + BodyPart.CHEST, + BodyPart.WAIST, + BodyPart.HIP, + -> + visitUpperBodyTrackers( + trackerState, + visitor, + head, + upperBody, + leftUpperLeg, + rightUpperLeg, + ) + + // Left arm + BodyPart.LEFT_SHOULDER, + BodyPart.LEFT_UPPER_ARM, + BodyPart.LEFT_LOWER_ARM, + -> + visitArmTrackers( + trackerState, + visitor, + Side.LEFT, + upperBody.firstOrNull(), + leftArm, + leftHand, + ) + + // Right arm + BodyPart.RIGHT_SHOULDER, + BodyPart.RIGHT_UPPER_ARM, + BodyPart.RIGHT_LOWER_ARM, + -> + visitArmTrackers( + trackerState, + visitor, + Side.RIGHT, + upperBody.firstOrNull(), + rightArm, + rightHand, + ) + + BodyPart.LEFT_HAND -> + if (trackerState == leftHand) { + visitor.visitHandTracker( + Side.LEFT, + trackerState, + leftArm.lastOrNull(), + rightHand, + ) + } + + BodyPart.RIGHT_HAND -> + if (trackerState == rightHand) { + visitor.visitHandTracker( + Side.RIGHT, + trackerState, + rightArm.lastOrNull(), + leftHand, + ) + } + + BodyPart.LEFT_UPPER_LEG -> + if (trackerState == leftUpperLeg) { + visitor.visitUpperLegTracker( + Side.LEFT, + trackerState, + upperBody.lastOrNull(), + leftLowerLeg, + rightUpperLeg, + ) + } + + BodyPart.RIGHT_UPPER_LEG -> + if (trackerState == rightUpperLeg) { + visitor.visitUpperLegTracker( + Side.RIGHT, + trackerState, + upperBody.lastOrNull(), + rightLowerLeg, + leftUpperLeg, + ) + } + + BodyPart.LEFT_LOWER_LEG -> + if (trackerState == leftLowerLeg) { + visitor.visitLowerLegTracker( + Side.LEFT, + trackerState, + leftUpperLeg, + leftFoot, + rightLowerLeg, + ) + } + + BodyPart.RIGHT_LOWER_LEG -> + if (trackerState == rightLowerLeg) { + visitor.visitLowerLegTracker( + Side.RIGHT, + trackerState, + rightUpperLeg, + rightFoot, + leftLowerLeg, + ) + } + + BodyPart.LEFT_FOOT -> + if (trackerState == leftFoot) { + visitor.visitFootTracker( + Side.LEFT, + trackerState, + leftLowerLeg, + rightFoot, + ) + } + + BodyPart.RIGHT_FOOT -> + if (trackerState == rightFoot) { + visitor.visitFootTracker( + Side.RIGHT, + trackerState, + rightLowerLeg, + leftFoot, + ) + } + + else -> { + // No tracker to visit + } + } + } + + private fun visitUpperBodyTrackers( + tracker: TrackerState, + visitor: TrackerVisitor, + head: TrackerState?, + upperBody: List, + leftUpperLeg: TrackerState?, + rightUpperLeg: TrackerState?, + ) { + val index = upperBody.map { it?.bodyPart }.indexOf(tracker.bodyPart) + if (index < 0) { + return + } + + if (index == 0) { + if (upperBody.size == 1) { + // Only upper body tracker + visitor.visitUpperBodyTracker( + tracker, + head, + leftUpperLeg, + rightUpperLeg, + ) + } else { + // First upper body tracker + visitor.visitUpperBodyTracker( + tracker, + head, + upperBody[1], + ) + } + } else if (index < upperBody.size - 1) { + // Middle upper body tracker + visitor.visitUpperBodyTracker( + tracker, + upperBody[index - 1], + upperBody[index + 1], + ) + } else { + // Last upper body tracker + visitor.visitUpperBodyTracker( + tracker, + upperBody[index - 1], + leftUpperLeg, + rightUpperLeg, + ) + } + } + + private fun visitArmTrackers( + tracker: TrackerState, + visitor: TrackerVisitor, + side: Side, + upperBody: TrackerState?, + arm: List, + hand: TrackerState?, + ) { + val index = arm.map { it?.bodyPart }.indexOf(tracker.bodyPart) + if (index < 0) { + return + } + + if (index == 0) { + if (arm.size == 1) { + // Only arm tracker + visitor.visitArmTracker( + side, + tracker, + upperBody, + hand, + ) + } else { + // First arm tracker + visitor.visitArmTracker( + side, + tracker, + upperBody, + arm[1], + ) + } + } else if (index < arm.size - 1) { + // Middle arm tracker + visitor.visitArmTracker( + side, + tracker, + arm[index - 1], + arm[index + 1], + ) + } else { + // Last arm tracker + visitor.visitArmTracker( + side, + tracker, + arm[index - 1], + hand, + ) + } + } + + interface TrackerVisitor { + + /** + * Visits the head tracker. + */ + fun visitHeadTracker( + trackerState: TrackerState, + belowUpperBody: TrackerState?, + ) + + /** + * Visits an upper body tracker (except for the bottom-most tracker). + */ + fun visitUpperBodyTracker( + trackerState: TrackerState, + aboveHeadOrUpperBody: TrackerState?, + belowUpperBody: TrackerState?, + ) + + /** + * Visits the bottom-most upper body tracker. + */ + fun visitUpperBodyTracker( + trackerState: TrackerState, + aboveHeadOrUpperBody: TrackerState?, + belowLeftUpperLeg: TrackerState?, + belowRightUpperLeg: TrackerState?, + ) + + /** + * Visits an arm tracker. + */ + fun visitArmTracker( + side: Side, + tracker: TrackerState, + aboveUpperBodyOrArm: TrackerState?, + belowHandOrArm: TrackerState?, + ) + + /** + * Visits a hand tracker. + */ + fun visitHandTracker( + side: Side, + tracker: TrackerState, + aboveArm: TrackerState?, + oppositeHand: TrackerState?, + ) + + /** + * Visits an upper leg tracker. + */ + fun visitUpperLegTracker( + side: Side, + tracker: TrackerState, + aboveUpperBody: TrackerState?, + belowLowerLeg: TrackerState?, + oppositeUpperLeg: TrackerState?, + ) + + /** + * Visits a lower leg tracker. + */ + fun visitLowerLegTracker( + side: Side, + tracker: TrackerState, + aboveUpperLeg: TrackerState?, + belowFoot: TrackerState?, + oppositeLowerLeg: TrackerState?, + ) + + /** + * Visits a foot tracker. + */ + fun visitFootTracker( + side: Side, + tracker: TrackerState, + aboveLowerLeg: TrackerState?, + oppositeFoot: TrackerState?, + ) + } +} diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-pose.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-pose.kt index a6c8452871..b2b66dfff6 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-pose.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-pose.kt @@ -1,6 +1,6 @@ package dev.slimevr.stayaligned.todo -import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerState import io.github.axisangles.ktmath.Vector3 import kotlin.math.abs @@ -18,12 +18,12 @@ enum class TrackerPose { companion object { - fun ofTracker(tracker: Tracker?): TrackerPose { - if (tracker == null) { + fun ofTracker(trackerState: TrackerState?): TrackerPose { + if (trackerState == null) { return NONE } - val rotation = tracker.context.state.value.rotation // TODO tracker.getAdjustedRotationForceStayAligned() + val rotation = trackerState.rotation // TODO tracker.getAdjustedRotationForceStayAligned() val x = rotation.sandwichUnitX() val y = rotation.sandwichUnitY() diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-skeleton.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-skeleton.kt deleted file mode 100644 index 441437ea76..0000000000 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-skeleton.kt +++ /dev/null @@ -1,415 +0,0 @@ -package dev.slimevr.stayaligned.todo - -import dev.slimevr.skeleton.Skeleton -import dev.slimevr.tracker.Tracker -import dev.slimevr.util.Side - -/** - * Represents a skeleton of trackers. - * - * The skeleton consists of: - * - An upper body group - * - A head tracker, connected to the top of the upper body group - * - Two arm groups, connected to the top of the upper body group - * - Two hands connected to the bottom of the corresponding arm group - * - Two upper legs, connected to the bottom of the upper body group - * - Two lower legs, connected to the bottom of each corresponding upper leg - * - Two feet, connected to the bottom of each corresponding lower leg - */ -class TrackerSkeleton(skeleton: Skeleton) { - -// val allTrackers = with(skeleton) { -// listOfNotNull( -// headTracker, -// // Upper body -// neckTracker, -// upperChestTracker, -// chestTracker, -// waistTracker, -// hipTracker, -// // Left arm -// leftShoulderTracker, -// leftUpperArmTracker, -// leftLowerArmTracker, -// leftHandTracker, -// // Right arm -// rightShoulderTracker, -// rightUpperArmTracker, -// rightLowerArmTracker, -// rightHandTracker, -// // Left leg -// leftUpperLegTracker, -// leftLowerLegTracker, -// leftFootTracker, -// // Right leg -// rightUpperLegTracker, -// rightLowerLegTracker, -// rightFootTracker, -// ) -// } -// -// // Tracker groups -// val upperBody = with(skeleton) { -// listOfNotNull( -// neckTracker, -// upperChestTracker, -// chestTracker, -// waistTracker, -// hipTracker, -// ) -// } -// -// val leftArm = with(skeleton) { -// listOfNotNull( -// leftShoulderTracker, -// leftUpperArmTracker, -// leftLowerArmTracker, -// ) -// } -// -// val rightArm = with(skeleton) { -// listOfNotNull( -// rightShoulderTracker, -// rightUpperArmTracker, -// rightLowerArmTracker, -// ) -// } -// -// // Individual trackers -// val head = skeleton.headTracker -// val leftHand = skeleton.leftHandTracker -// val rightHand = skeleton.rightHandTracker -// val leftUpperLeg = skeleton.leftUpperLegTracker -// val leftLowerLeg = skeleton.leftLowerLegTracker -// val leftFoot = skeleton.leftFootTracker -// val rightUpperLeg = skeleton.rightUpperLegTracker -// val rightLowerLeg = skeleton.rightLowerLegTracker -// val rightFoot = skeleton.rightFootTracker -// -// /** -// * Visits a tracker within the skeleton. -// */ -// fun visit( -// tracker: Tracker, -// visitor: TrackerVisitor, -// ) { -// when (tracker.context.state.value.bodyPart) { -// BodyPart.HEAD -> -// if (tracker == head) { -// visitor.visitHeadTracker(tracker, upperBody.firstOrNull()) -// } -// -// // Upper body -// BodyPart.NECK, -// BodyPart.UPPER_CHEST, -// BodyPart.CHEST, -// BodyPart.WAIST, -// BodyPart.HIP, -// -> -// visitUpperBodyTrackers( -// tracker, -// visitor, -// head, -// upperBody, -// leftUpperLeg, -// rightUpperLeg, -// ) -// -// // Left arm -// BodyPart.LEFT_SHOULDER, -// BodyPart.LEFT_UPPER_ARM, -// BodyPart.LEFT_LOWER_ARM, -// -> -// visitArmTrackers( -// tracker, -// visitor, -// Side.LEFT, -// upperBody.firstOrNull(), -// leftArm, -// leftHand, -// ) -// -// // Right arm -// BodyPart.RIGHT_SHOULDER, -// BodyPart.RIGHT_UPPER_ARM, -// BodyPart.RIGHT_LOWER_ARM, -// -> -// visitArmTrackers( -// tracker, -// visitor, -// Side.RIGHT, -// upperBody.firstOrNull(), -// rightArm, -// rightHand, -// ) -// -// BodyPart.LEFT_HAND -> -// if (tracker == leftHand) { -// visitor.visitHandTracker( -// Side.LEFT, -// tracker, -// leftArm.lastOrNull(), -// rightHand, -// ) -// } -// -// BodyPart.RIGHT_HAND -> -// if (tracker == rightHand) { -// visitor.visitHandTracker( -// Side.RIGHT, -// tracker, -// rightArm.lastOrNull(), -// leftHand, -// ) -// } -// -// BodyPart.LEFT_UPPER_LEG -> -// if (tracker == leftUpperLeg) { -// visitor.visitUpperLegTracker( -// Side.LEFT, -// tracker, -// upperBody.lastOrNull(), -// leftLowerLeg, -// rightUpperLeg, -// ) -// } -// -// BodyPart.RIGHT_UPPER_LEG -> -// if (tracker == rightUpperLeg) { -// visitor.visitUpperLegTracker( -// Side.RIGHT, -// tracker, -// upperBody.lastOrNull(), -// rightLowerLeg, -// leftUpperLeg, -// ) -// } -// -// BodyPart.LEFT_LOWER_LEG -> -// if (tracker == leftLowerLeg) { -// visitor.visitLowerLegTracker( -// Side.LEFT, -// tracker, -// leftUpperLeg, -// leftFoot, -// rightLowerLeg, -// ) -// } -// -// BodyPart.RIGHT_LOWER_LEG -> -// if (tracker == rightLowerLeg) { -// visitor.visitLowerLegTracker( -// Side.RIGHT, -// tracker, -// rightUpperLeg, -// rightFoot, -// leftLowerLeg, -// ) -// } -// -// BodyPart.LEFT_FOOT -> -// if (tracker == leftFoot) { -// visitor.visitFootTracker( -// Side.LEFT, -// tracker, -// leftLowerLeg, -// rightFoot, -// ) -// } -// -// BodyPart.RIGHT_FOOT -> -// if (tracker == rightFoot) { -// visitor.visitFootTracker( -// Side.RIGHT, -// tracker, -// rightLowerLeg, -// leftFoot, -// ) -// } -// -// else -> { -// // No tracker to visit -// } -// } -// } - - private fun visitUpperBodyTrackers( - tracker: Tracker, - visitor: TrackerVisitor, - head: Tracker?, - upperBody: List, - leftUpperLeg: Tracker?, - rightUpperLeg: Tracker?, - ) { - val index = upperBody.indexOf(tracker) - if (index < 0) { - return - } - - if (index == 0) { - if (upperBody.size == 1) { - // Only upper body tracker - visitor.visitUpperBodyTracker( - tracker, - head, - leftUpperLeg, - rightUpperLeg, - ) - } else { - // First upper body tracker - visitor.visitUpperBodyTracker( - tracker, - head, - upperBody[1], - ) - } - } else if (index < upperBody.size - 1) { - // Middle upper body tracker - visitor.visitUpperBodyTracker( - tracker, - upperBody[index - 1], - upperBody[index + 1], - ) - } else { - // Last upper body tracker - visitor.visitUpperBodyTracker( - tracker, - upperBody[index - 1], - leftUpperLeg, - rightUpperLeg, - ) - } - } - - private fun visitArmTrackers( - tracker: Tracker, - visitor: TrackerVisitor, - side: Side, - upperBody: Tracker?, - arm: List, - hand: Tracker?, - ) { - val index = arm.indexOf(tracker) - if (index < 0) { - return - } - - if (index == 0) { - if (arm.size == 1) { - // Only arm tracker - visitor.visitArmTracker( - side, - tracker, - upperBody, - hand, - ) - } else { - // First arm tracker - visitor.visitArmTracker( - side, - tracker, - upperBody, - arm[1], - ) - } - } else if (index < arm.size - 1) { - // Middle arm tracker - visitor.visitArmTracker( - side, - tracker, - arm[index - 1], - arm[index + 1], - ) - } else { - // Last arm tracker - visitor.visitArmTracker( - side, - tracker, - arm[index - 1], - hand, - ) - } - } - - interface TrackerVisitor { - - /** - * Visits the head tracker. - */ - fun visitHeadTracker( - tracker: Tracker, - belowUpperBody: Tracker?, - ) - - /** - * Visits an upper body tracker (except for the bottom-most tracker). - */ - fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowUpperBody: Tracker?, - ) - - /** - * Visits the bottom-most upper body tracker. - */ - fun visitUpperBodyTracker( - tracker: Tracker, - aboveHeadOrUpperBody: Tracker?, - belowLeftUpperLeg: Tracker?, - belowRightUpperLeg: Tracker?, - ) - - /** - * Visits an arm tracker. - */ - fun visitArmTracker( - side: Side, - tracker: Tracker, - aboveUpperBodyOrArm: Tracker?, - belowHandOrArm: Tracker?, - ) - - /** - * Visits a hand tracker. - */ - fun visitHandTracker( - side: Side, - tracker: Tracker, - aboveArm: Tracker?, - oppositeHand: Tracker?, - ) - - /** - * Visits an upper leg tracker. - */ - fun visitUpperLegTracker( - side: Side, - tracker: Tracker, - aboveUpperBody: Tracker?, - belowLowerLeg: Tracker?, - oppositeUpperLeg: Tracker?, - ) - - /** - * Visits a lower leg tracker. - */ - fun visitLowerLegTracker( - side: Side, - tracker: Tracker, - aboveUpperLeg: Tracker?, - belowFoot: Tracker?, - oppositeLowerLeg: Tracker?, - ) - - /** - * Visits a foot tracker. - */ - fun visitFootTracker( - side: Side, - tracker: Tracker, - aboveLowerLeg: Tracker?, - oppositeFoot: Tracker?, - ) - } -} diff --git a/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-yaw.kt b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-yaw.kt index 24e3320b5e..ccf5ce3dba 100644 --- a/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-yaw.kt +++ b/server/core/src/main/java/dev/slimevr/stayaligned/todo/tracker-yaw.kt @@ -2,6 +2,7 @@ package dev.slimevr.stayaligned.todo import dev.slimevr.math.angle.Angle import dev.slimevr.tracker.Tracker +import dev.slimevr.tracker.TrackerState import dev.slimevr.util.Side import io.github.axisangles.ktmath.EulerOrder import io.github.axisangles.ktmath.Vector3 @@ -20,8 +21,8 @@ object TrackerYaw { /** * Whether we can get the yaw of a tracker. */ - fun hasTrackerYaw(tracker: Tracker) = Angle.absBetween( - tracker.context.state.value.rotation.sandwichUnitX(), // TODO tracker.getAdjustedRotationForceStayAligned() + fun hasTrackerYaw(tracker: TrackerState) = Angle.absBetween( + tracker.rotation.sandwichUnitX(), // TODO tracker.getAdjustedRotationForceStayAligned() Vector3.POS_Y, ) > MIN_ON_SIDE_ANGLE @@ -37,8 +38,8 @@ object TrackerYaw { * singularity for this rotation to get "some" yaw, but this yaw will be very * different from the from YZX. DO NOT ATTEMPT! */ - fun trackerYaw(tracker: Tracker) = Angle.ofRad( - tracker.context.state.value.rotation // TODO tracker.getAdjustedRotationForceStayAligned() + fun trackerYaw(trackerState: TrackerState) = Angle.ofRad( + trackerState.rotation // TODO tracker.getAdjustedRotationForceStayAligned() .toEulerAngles(EulerOrder.YZX) .y, ) diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index b797d36fcf..0347e55b1f 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -4,6 +4,7 @@ import com.jme3.math.FastMath import dev.slimevr.config.Settings import dev.slimevr.logging.AppLogger import dev.slimevr.math.angle.Angle +import dev.slimevr.math.angle.AngleErrors import dev.slimevr.resets.ResetBodyParts import dev.slimevr.skeleton.SkeletonActions import dev.slimevr.stayaligned.StayAlignedDefaults.IMU_TO_YAW_CORRECTION @@ -401,11 +402,7 @@ class TrackerMotionDetectionBehaviour : TrackerBehaviour { private fun isAccelerating(acceleration: RawAcceleration) = acceleration.lenSq() > MAX_SQUARED_ACCEL override fun reduce(state: TrackerState, action: TrackerActions): TrackerState = when (action) { - is TrackerActions.SetRestState -> { - println(action.restState) - state.copy(motion = action.restState) - } - + is TrackerActions.SetRestState -> state.copy(motion = action.restState) else -> state } @@ -502,10 +499,14 @@ class TrackerStayAlignedBehaviour( receiver.context.state .distinctUntilChanged { old, new -> old.sessionCalibration == new.sessionCalibration } .onEach { -// lockedRotation = null -// yawCorrection = Angle.ZERO -// yawErrors = YawErrors() -// receiver.context.dispatch(TrackerActions.) + // TODO use state + lockedRotation = null + yawCorrection = Angle.ZERO + yawErrors = YawErrors( + lockedError = AngleErrors(), + centerError = AngleErrors(), + neighborError = AngleErrors() + ) }.launchIn(receiver.context.scope) val stayAlignedConfigFlow = settings.context.state.map { it.data.stayAlignedConfig }.distinctUntilChanged() @@ -519,33 +520,47 @@ class TrackerStayAlignedBehaviour( receiver.context.state .distinctUntilChangedBy { it.rawRotation } - .onEach { - val yawCorrectionPerSec = IMU_TO_YAW_CORRECTION.getOrDefault(receiver.context.state.value.imuType, YAW_CORRECTION_DEFAULT) + .onEach { state -> + val yawCorrectionPerSec = IMU_TO_YAW_CORRECTION.getOrDefault(state.imuType, YAW_CORRECTION_DEFAULT) if (yawCorrectionPerSec == Angle.ZERO) return@onEach val lastFrameTimeSeconds = lastRotationTime.elapsedNow().inFloatingSeconds lastRotationTime = timeSource.markNow() val normalizedYawCorrection = yawCorrectionPerSec * lastFrameTimeSeconds + + // TODO val hideYawCorrection = stayAlignedManager.context.state.value.hideYawCorrection // TODO AdjustTrackerYaw.adjust( - receiver, + state, skeleton.computed.value, normalizedYawCorrection, stayAlignedConfig, ) -// if (receiver.context.state.value.restState == RestDetector.State.AT_REST) { -// if (lockedRotation == null) { -// lockedRotation = receiver.context.state.value.rotation // TODO tracker.getAdjustedRotationForceStayAligned() -// } -// } else { -// lockedRotation = null -// } - } + // TODO + if (lockedRotation == null && state.motion == Motion.RESTING) { + lockedRotation = state.rotation // TODO tracker.getAdjustedRotationForceStayAligned() + } else if (lockedRotation != null) { + lockedRotation = null + } + } } .launchIn(receiver.context.scope) } + + // TODO use state for these + var yawCorrection = Angle.ZERO + var yawErrors = YawErrors( + lockedError = AngleErrors(), + centerError = AngleErrors(), + neighborError = AngleErrors() + ) + var lockedRotation: Quaternion? = Quaternion.IDENTITY + + override fun reduce(state: TrackerState, action: TrackerActions): TrackerState = when (action) { + else -> state + } } From 1721943d20c1454ab299fdc87bbf87ef2b679302 Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 31 Jul 2026 19:10:25 -0400 Subject: [PATCH 342/350] MotionDetector only for rotation, Tap checks for own motion --- .../dev/slimevr/tapdetection/behaviours.kt | 35 +++++++------- .../java/dev/slimevr/tracker/behaviours.kt | 46 ++++++++----------- .../main/java/dev/slimevr/tracker/module.kt | 10 ++-- .../slimevr/tapdetection/TapDetectionTest.kt | 23 ++++++++-- 4 files changed, 62 insertions(+), 52 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt index a64197399a..6d357b7f09 100644 --- a/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tapdetection/behaviours.kt @@ -66,12 +66,7 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { states.map { it.bodyPart to it.status } } .distinctUntilChanged() - .map { - trackers.filter { tracker -> - val status = tracker.context.state.value.status - status == TrackerStatus.OK - } - } + .map { trackers.filter { it.context.state.value.status == TrackerStatus.OK } } } // Outer flow is refreshed whenever TapDetection config, setupMode, or a tracker's bodyPart or status changes @@ -92,9 +87,9 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { .firstOrNull { it in trackersBodyParts } ?: BodyPart.RIGHT_UPPER_LEG // To keep track of which trackers are moving - val bodyPartsToCheck = falsePositiveBodyParts + yawResetBodyPart + fullResetBodyPart + mountingResetBodyPart - val movingTrackers = mutableSetOf() val numberTrackersOverThreshold = tapDetectionConfig.numberTrackersOverThreshold + val bodyPartsToCheck = falsePositiveBodyParts + yawResetBodyPart + fullResetBodyPart + mountingResetBodyPart + val trackersOverThreshold = mutableSetOf() trackers.map { tracker -> val trackerTapDetectionState = createTrackerTapDetectionState( @@ -112,18 +107,24 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { .map { it.rawAcceleration to it.motion } .distinctUntilChanged() .onEach { (rawAcceleration, motionState) -> - if (motionState == Motion.MOVING) { - movingTrackers.add(trackerTapDetectionState.trackerId) + // Is this tracker over threshold for false positive prevention? + val isOverThreshold = if (rawAcceleration.lenSq() > ALLOWED_BODY_ACCEL_SQUARED) { + trackersOverThreshold.add(trackerTapDetectionState.trackerId) + true } else { - movingTrackers.remove(trackerTapDetectionState.trackerId) + trackersOverThreshold.remove(trackerTapDetectionState.trackerId) + false } + // Only trackers that actually have to detect taps if (trackerTapDetectionState.resetType != null || setupMode) { + val othersOverThreshold = trackersOverThreshold.count() - if (isOverThreshold) 1 else 0 val tapTriggered = runTapDetection( timeSource.markNow(), - movingTrackers.count() > numberTrackersOverThreshold, + othersOverThreshold >= numberTrackersOverThreshold, trackerTapDetectionState, rawAcceleration, + motionState, ) if (tapTriggered) { @@ -197,9 +198,10 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { // Logic loop for tap detection fun runTapDetection( now: TimeMark, - bodyMoving: Boolean, + bodyAccelerating: Boolean, trackerTapDetectionState: TrackerTapDetectionState, trackerAcceleration: Vector3, + trackerMotion: Motion, ): Boolean { // Remove old stored accelerations (if they are too old) while (trackerTapDetectionState.accelList.isNotEmpty() && (trackerTapDetectionState.accelList.first().second + ACCEL_WINDOW).hasPassedNow()) { @@ -214,13 +216,13 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { val accelDelta = max - min // Check for a single tap - if (!bodyMoving && accelDelta > NEEDED_ACCEL_DELTA && !trackerTapDetectionState.waitForLowAccel) { + if (!bodyAccelerating && trackerMotion != Motion.ROTATING && accelDelta > NEEDED_ACCEL_DELTA && !trackerTapDetectionState.waitForLowAccel) { trackerTapDetectionState.tapTimestamps.add(now) trackerTapDetectionState.waitForLowAccel = true } // Achieved low accel? - if (max < LOW_ACCEL_THRESHOLD) trackerTapDetectionState.waitForLowAccel = false + if (max < ALLOWED_BODY_ACCEL) trackerTapDetectionState.waitForLowAccel = false if (trackerTapDetectionState.tapTimestamps.isNotEmpty()) { // Remove old stored taps (if they are too old) @@ -249,7 +251,8 @@ class TapDetectionBasicBehaviour : TapDetectionBehaviour { companion object { const val NEEDED_ACCEL_DELTA = 6.0f - const val LOW_ACCEL_THRESHOLD = 2.5f + const val ALLOWED_BODY_ACCEL = 2.5f + const val ALLOWED_BODY_ACCEL_SQUARED = ALLOWED_BODY_ACCEL * ALLOWED_BODY_ACCEL val ACCEL_WINDOW = 0.06.seconds val TAP_WINDOW_PER_TAP = 0.3.seconds } diff --git a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt index 0347e55b1f..45f3a2a940 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/behaviours.kt @@ -38,7 +38,6 @@ import kotlin.concurrent.atomics.AtomicInt import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.incrementAndFetch import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class TrackerBasicBehaviour : TrackerBehaviour { @@ -317,11 +316,11 @@ class TrackerTPSBehaviour : TrackerBehaviour { val tps = count.exchange(0) * 1000L / elapsed.inWholeMilliseconds // Tracker is at rest if it hasn't been updated in the last second - val updateRestStateAction = if (tps == 0L && receiver.context.state.value.motion != Motion.RESTING) TrackerActions.SetRestState(Motion.RESTING) else null + val updateMotionAction = if (tps == 0L && receiver.context.state.value.motion != Motion.RESTING) TrackerActions.SetMotion(Motion.RESTING) else null receiver.context.dispatchAll( listOfNotNull( TrackerActions.Update { copy(tps = tps.toUShort()) }, - updateRestStateAction, + updateMotionAction, ), ) @@ -336,9 +335,6 @@ class TrackerTPSBehaviour : TrackerBehaviour { /** * Detects whether a tracker is at rest or moving. - * - * A tracker is at rest when it stays within a certain rotational range and acceleration for a given - * amount of time. If it rotates past that range or accelerates too fast, it is no longer at rest. */ class TrackerMotionDetectionBehaviour : TrackerBehaviour { @@ -349,47 +345,45 @@ class TrackerMotionDetectionBehaviour : TrackerBehaviour { // For update loop receiver.context.state - .map { it.rawRotation to it.rawAcceleration } + .map { it.rawRotation } .distinctUntilChanged() - .onEach { (rotation, acceleration) -> + .onEach { rotation -> val now = timeSource.markNow() val motionState = receiver.context.state.value.motion val isRotating = isRotating(lastRotation, rotation) - val isAccelerating = isAccelerating(acceleration) - val isMoving = isRotating || isAccelerating when (motionState) { // Detect if tracker is at rest - Motion.MOVING, - Motion.STARTED_MOVING, + Motion.ROTATING, + Motion.STARTED_ROTATING, -> - if (!isMoving) { + if (!isRotating) { if (now > lastRotationTime + ENTER_REST_TIME) { // Been not moving for long enough; set as at rest. - receiver.context.dispatch(TrackerActions.SetRestState(Motion.RESTING)) + receiver.context.dispatch(TrackerActions.SetMotion(Motion.RESTING)) lastStateChangeTime = now // Update the rotation to continue detecting if the tracker is at rest lastRotation = rotation lastRotationTime = now } - } else if (motionState == Motion.STARTED_MOVING && (now > lastStateChangeTime + ENTER_MOVING_ROTATION_TIME || isAccelerating)) { - // Been moving for long enough or accel high; set as moving. - receiver.context.dispatch(TrackerActions.SetRestState(Motion.MOVING)) + } else if (motionState == Motion.STARTED_ROTATING && (now > lastStateChangeTime + ENTER_MOVING_TIME)) { + // Been moving for long enough; set as moving. + receiver.context.dispatch(TrackerActions.SetMotion(Motion.ROTATING)) lastStateChangeTime = now } // Detect if tracker is moving Motion.RESTING -> - if (isMoving) { + if (isRotating) { // Started moving; set as recently at rest. - receiver.context.dispatch(TrackerActions.SetRestState(Motion.STARTED_MOVING)) + receiver.context.dispatch(TrackerActions.SetMotion(Motion.STARTED_ROTATING)) lastStateChangeTime = now } } // Update rotation if the tracker is moving - if (isMoving) { + if (isRotating) { lastRotation = rotation lastRotationTime = now } @@ -397,21 +391,21 @@ class TrackerMotionDetectionBehaviour : TrackerBehaviour { .launchIn(receiver.context.scope) } + /** + * Tracker is moving if rotating or accelerating past certain thresholds. + */ private fun isRotating(lastRotation: RawRotation, rotation: RawRotation) = Angle.absBetween(lastRotation, rotation) > MAX_ROTATION - private fun isAccelerating(acceleration: RawAcceleration) = acceleration.lenSq() > MAX_SQUARED_ACCEL - override fun reduce(state: TrackerState, action: TrackerActions): TrackerState = when (action) { - is TrackerActions.SetRestState -> state.copy(motion = action.restState) + is TrackerActions.SetMotion -> state.copy(motion = action.motion) else -> state } // TODO : these values may need fine-tuning companion object { - const val MAX_SQUARED_ACCEL = 16f val MAX_ROTATION = Angle.ofDeg(2.5f) - val ENTER_REST_TIME = 600.milliseconds - val ENTER_MOVING_ROTATION_TIME = 3.seconds + val ENTER_REST_TIME = 0.8.seconds + val ENTER_MOVING_TIME = 3.seconds } } diff --git a/server/core/src/main/java/dev/slimevr/tracker/module.kt b/server/core/src/main/java/dev/slimevr/tracker/module.kt index 21d991a94e..85132e31e5 100644 --- a/server/core/src/main/java/dev/slimevr/tracker/module.kt +++ b/server/core/src/main/java/dev/slimevr/tracker/module.kt @@ -20,13 +20,11 @@ import kotlin.time.Duration data class TrackerSensorIds(val trackerId: Int, val sensorId: Int) -/** - * Indicates if the tracker is moving, at rest or recently at rest. - */ +// TODO maybe have more states, like ROTATING_FAST for TapDetection? enum class Motion { - MOVING, + ROTATING, RESTING, - STARTED_MOVING, + STARTED_ROTATING, } data class YawResetSmoothing( @@ -93,7 +91,7 @@ sealed interface TrackerActions { data class TickYawResetSmoothing(val heading: HeadingCorrection, val done: Boolean) : TrackerActions data class MountingReset(val referenceRotation: Quaternion, val yawOffset: Float) : TrackerActions data object ClearMountingReset : TrackerActions - data class SetRestState(val restState: Motion) : TrackerActions + data class SetMotion(val motion: Motion) : TrackerActions } typealias TrackerContext = Context diff --git a/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt b/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt index 55f2510aa1..0e2d738475 100644 --- a/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt +++ b/server/core/src/test/java/dev/slimevr/tapdetection/TapDetectionTest.kt @@ -1,5 +1,6 @@ package dev.slimevr.tapdetection +import dev.slimevr.tracker.Motion import io.github.axisangles.ktmath.Vector3 import kotlin.test.Test import kotlin.test.assertEquals @@ -34,7 +35,8 @@ class TapDetectionTest { private fun runTapSequence( tapsNeeded: Int, events: List, - bodyMoving: Boolean = false, + bodyAccelerating: Boolean = false, + motion: Motion = Motion.RESTING, ) { val state = TapDetectionBasicBehaviour.TrackerTapDetectionState( trackerId = 1, @@ -50,9 +52,10 @@ class TapDetectionTest { event.expectedTap, behaviour.runTapDetection( now = timeSource.markNow(), - bodyMoving = bodyMoving, + bodyAccelerating = bodyAccelerating, trackerTapDetectionState = state, trackerAcceleration = event.accel, + trackerMotion = motion, ), ) } @@ -119,7 +122,7 @@ class TapDetectionTest { ) @Test - fun `Tap not detected while body is moving`() = runTapSequence( + fun `Tap not detected while rest of body is accelerating`() = runTapSequence( tapsNeeded = 2, events = listOf( AccelEvent(Duration.ZERO, lowAccel, expectedTap = false), @@ -127,6 +130,18 @@ class TapDetectionTest { AccelEvent(tapDelay, lowAccel, expectedTap = false), AccelEvent(accelDelay, highAccel, expectedTap = false), ), - true, + bodyAccelerating = true, + ) + + @Test + fun `Tap not detected while tracker is rotating`() = runTapSequence( + tapsNeeded = 2, + events = listOf( + AccelEvent(Duration.ZERO, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + AccelEvent(tapDelay, lowAccel, expectedTap = false), + AccelEvent(accelDelay, highAccel, expectedTap = false), + ), + motion = Motion.ROTATING, ) } From dd70977b6f875cc79556e97f4df50dc3db4278ef Mon Sep 17 00:00:00 2001 From: Erimel Date: Fri, 31 Jul 2026 23:48:06 -0400 Subject: [PATCH 343/350] Fix automatic tracker toggle not cancelling on manual --- .../main/java/dev/slimevr/outputtrackertoggle/behaviours.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt index 6b8bf79ac7..c76a2550e1 100644 --- a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt @@ -5,6 +5,7 @@ import dev.slimevr.device.DeviceOrigin import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn @@ -28,7 +29,6 @@ class OutputTrackerToggleBasicBehaviour : OutputTrackerToggleBehaviour { BodyPart.RIGHT_FOOT to setOf(BodyPart.RIGHT_FOOT, BodyPart.RIGHT_LOWER_LEG, BodyPart.RIGHT_UPPER_LEG), ) - // TODO filter out DeviceOrigin.STEAMVR fun determineAutomaticOutputTrackers( config: OutputTrackersConfig, fineBodyParts: Set, @@ -54,8 +54,9 @@ class OutputTrackerToggleBasicBehaviour : OutputTrackerToggleBehaviour { receiver.context.dispatch(OutputTrackerToggleActions.SetOutputTrackers(config.trackers)) } } - .filter { it.automaticTrackerToggle } // Automatic .flatMapLatest { config -> + if (!config.automaticTrackerToggle) return@flatMapLatest emptyFlow() + receiver.server.context.state .map { it.trackers.values } .flatMapLatest { trackers -> From 91374826345f490e04044fa40ba4c9dec8c1fa4d Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 31 Jul 2026 21:00:22 -0500 Subject: [PATCH 344/350] Fix TPS display at 0 TPS --- gui/app/src/components/tracker/TrackersTable.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/gui/app/src/components/tracker/TrackersTable.tsx b/gui/app/src/components/tracker/TrackersTable.tsx index fa33db39b0..482760904a 100644 --- a/gui/app/src/components/tracker/TrackersTable.tsx +++ b/gui/app/src/components/tracker/TrackersTable.tsx @@ -250,8 +250,10 @@ function Row({ )} - {tracker.tps && ( - {tracker.tps} + {tracker.tps !== null && ( + + {tracker.tps.toString()} + )} From f6b72b1ecf57844740bbdc5394f3a7e4f052eed4 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Fri, 31 Jul 2026 23:03:54 -0500 Subject: [PATCH 345/350] Always send output trackers config to SolarXR clients on change --- .../slimevr/outputtrackertoggle/behaviours.kt | 11 -------- .../dev/slimevr/solarxr/output-trackers.kt | 28 +++++++++++++------ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt index c76a2550e1..904bd75659 100644 --- a/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt +++ b/server/core/src/main/java/dev/slimevr/outputtrackertoggle/behaviours.kt @@ -6,14 +6,12 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.datatypes.TrackerStatus -import solarxr_protocol.rpc.OutputTrackersSettingsResponse import kotlin.collections.filter import kotlin.collections.map @@ -80,15 +78,6 @@ class OutputTrackerToggleBasicBehaviour : OutputTrackerToggleBehaviour { // Update state receiver.context.dispatch(OutputTrackerToggleActions.SetOutputTrackers(outputTrackers)) - - // Update SolarXR - receiver.server.sendSolarxrRpc( - OutputTrackersSettingsResponse( - automaticTrackerToggle = true, - trackers = outputTrackers, - sendDerivedVelocity = config.sendDerivedVelocity, - ), - ) } .launchIn(receiver.context.scope) } diff --git a/server/core/src/main/java/dev/slimevr/solarxr/output-trackers.kt b/server/core/src/main/java/dev/slimevr/solarxr/output-trackers.kt index 59c27c6a56..a5c5ce9fa7 100644 --- a/server/core/src/main/java/dev/slimevr/solarxr/output-trackers.kt +++ b/server/core/src/main/java/dev/slimevr/solarxr/output-trackers.kt @@ -4,7 +4,12 @@ import dev.slimevr.config.OutputTrackersConfig import dev.slimevr.config.Settings import dev.slimevr.config.SettingsActions import dev.slimevr.outputtrackertoggle.OutputTrackerToggleManager +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import solarxr_protocol.datatypes.BodyPart import solarxr_protocol.rpc.ChangeOutputTrackersSettingsRequest import solarxr_protocol.rpc.OutputTrackersSettingsRequest import solarxr_protocol.rpc.OutputTrackersSettingsResponse @@ -13,19 +18,26 @@ class OutputTrackersBehaviour( private val settings: Settings, private val outputTrackerToggleManager: OutputTrackerToggleManager, ) : SolarXRBridgeBehaviour { + companion object { + private fun getSettingsResponse(config: OutputTrackersConfig, autoTrackers: List) = OutputTrackersSettingsResponse( + automaticTrackerToggle = config.automaticTrackerToggle, + trackers = if (config.automaticTrackerToggle) autoTrackers else config.trackers, + sendDerivedVelocity = config.sendDerivedVelocity, + ) + } + override fun observe(receiver: SolarXRBridge) { // Send config receiver.rpcDispatcher.on { - val config = settings.context.state.value.data.outputTrackersConfig - receiver.sendRpc( - OutputTrackersSettingsResponse( - automaticTrackerToggle = config.automaticTrackerToggle, - trackers = outputTrackerToggleManager.context.state.value.trackers, - sendDerivedVelocity = config.sendDerivedVelocity, - ), - ) + receiver.sendRpc(getSettingsResponse(settings.context.state.value.data.outputTrackersConfig, outputTrackerToggleManager.context.state.value.trackers)) }.launchIn(receiver.context.scope) + combine(settings.context.state.map { it.data.outputTrackersConfig }, outputTrackerToggleManager.context.state.map { it.trackers }, ::Pair) + .map { (config, trackers) -> getSettingsResponse(config, trackers) } + .distinctUntilChanged() + .onEach(receiver::sendRpc) + .launchIn(receiver.context.scope) + // Receive config receiver.rpcDispatcher.on { req -> settings.context.dispatch( From 6a193e235d34a901400fdbcb5c5bc6c0d200a72d Mon Sep 17 00:00:00 2001 From: Sapphire Date: Sat, 1 Aug 2026 00:07:27 -0500 Subject: [PATCH 346/350] Revert re-addition of FHS by 9efc2b2b3c69a809b3466a81f251cc04b336e13b Everything seems to work fine without it, including Windows cross-build --- flake.nix | 142 ++++++++++++++++++------------------------------------ 1 file changed, 47 insertions(+), 95 deletions(-) diff --git a/flake.nix b/flake.nix index 3e01ee2b33..70ae41f774 100644 --- a/flake.nix +++ b/flake.nix @@ -124,67 +124,6 @@ "36" ]; }; - - # Toolchain shared by the dev shell and the packaging FHS env. - commonPackages = [ - # for running the jar - pkgs.jdk25 - - # for build - pkgs.electron - pkgs.rpm - pkgs.fpm - pkgs.p7zip - pkgs.wineWow64Packages.stable - pkgs.zlib - pkgs.squashfsTools - pkgs.desktop-file-utils - pkgs.fakeroot - pkgs.libarchive - pkgs.icu - pkgs.nodejs_22 - pkgs.pnpm - pkgs.pkg-config - pkgs.python3 - pkgs.gcc - pkgs.gnumake - pkgs.binutils - pkgs.git - pkgs.node-gyp-build - androidComposition.androidsdk - ]; - - buildEnv = rec { - JAVA_HOME = "${pkgs.jdk25}/lib/openjdk"; - USE_SYSTEM_FPM = "true"; - ELECTRON_BUILDER_7ZIP_PATH = "${pkgs.p7zip}/bin/7za"; - APPIMAGE_TOOLS_PATH = "${appImageTools}"; - # for electron-vite, so `pnpm gui` works - ELECTRON_EXEC_PATH = "${pkgs.electron}/bin/electron"; - ANDROID_HOME = "${androidComposition.androidsdk}/libexec/android-sdk"; - GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/${buildToolsVersion}/aapt2"; - }; - - # electron-builder produces generic dynamically-linked binaries that - # NixOS can't run (no /lib64 loader). Building and running the packaged - # app inside an FHS sandbox provides that standard loader, so the final - # electron build works. `nix run .#package` runs `pnpm package:build`. - packageBuildScript = pkgs.writeShellScript "slimevr-package-build" '' - set -eu - cd "$(${pkgs.git}/bin/git rev-parse --show-toplevel)" - ${lib.concatStringsSep "\n" ( - lib.mapAttrsToList (name: value: "export ${name}=${lib.escapeShellArg value}") buildEnv - )} - export LD_LIBRARY_PATH="${lib.makeLibraryPath (runtimeLibs ++ [ pkgs.hidapi ])}:''${LD_LIBRARY_PATH:-}" - pnpm install - pnpm --filter @slimevr/gui-electron run package:build - ''; - - packageBuildFhs = pkgs.buildFHSEnv { - name = "slimevr-package-build"; - targetPkgs = _: commonPackages ++ runtimeLibs; - runScript = "${packageBuildScript}"; - }; in { # For Android SDK @@ -194,41 +133,54 @@ config.android_sdk.accept_license = true; }; - devShells.default = pkgs.mkShell ( - buildEnv - // { - packages = commonPackages; - buildInputs = runtimeLibs; - - shellHook = '' - export LD_LIBRARY_PATH="${ - lib.makeLibraryPath [ - pkgs.systemd - pkgs.hidapi - ] - }:$LD_LIBRARY_PATH" - ''; - } - ); - - # Build the desktop package inside an FHS sandbox: - # nix run .#package - apps.package = { - type = "app"; - program = "${packageBuildFhs}/bin/slimevr-package-build"; - }; + devShells.default = pkgs.mkShell rec { + packages = [ + # for running the jar + pkgs.jdk25 + + # for build + pkgs.electron + pkgs.rpm + pkgs.fpm + pkgs.p7zip + # For Windows GUI cross-build + pkgs.wineWow64Packages.stable + pkgs.zlib + pkgs.squashfsTools + pkgs.desktop-file-utils + pkgs.fakeroot + pkgs.libarchive + pkgs.icu + pkgs.nodejs_22 + pkgs.pnpm + pkgs.pkg-config + pkgs.python3 + pkgs.gcc + pkgs.gnumake + pkgs.binutils + pkgs.git + pkgs.node-gyp-build + androidComposition.androidsdk + ]; + buildInputs = runtimeLibs; + + shellHook = '' + export LD_LIBRARY_PATH="${ + lib.makeLibraryPath [ + pkgs.systemd + pkgs.hidapi + ] + }:$LD_LIBRARY_PATH" + ''; - # Interactive FHS shell for manual packaging / running the built app: - # nix run .#package-shell - apps.package-shell = { - type = "app"; - program = "${ - pkgs.buildFHSEnv { - name = "slimevr-fhs"; - targetPkgs = _: commonPackages ++ runtimeLibs; - runScript = "bash"; - } - }/bin/slimevr-fhs"; + JAVA_HOME = "${pkgs.jdk25}/lib/openjdk"; + USE_SYSTEM_FPM = "true"; + ELECTRON_BUILDER_7ZIP_PATH = "${pkgs.p7zip}/bin/7za"; + APPIMAGE_TOOLS_PATH = "${appImageTools}"; + # for electron-vite, so `pnpm gui` works + ELECTRON_EXEC_PATH = "${pkgs.electron}/bin/electron"; + ANDROID_HOME = "${androidComposition.androidsdk}/libexec/android-sdk"; + GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${ANDROID_HOME}/build-tools/${buildToolsVersion}/aapt2"; }; }; }; From 6d8c5ea9aeed6f35d0aab54fcd21f1d04c0c8ae9 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Sat, 1 Aug 2026 00:32:54 -0500 Subject: [PATCH 347/350] Switch to static AppImage runtime --- flake.nix | 57 ++++++++----------------------- gui/electron/electron-builder.yml | 3 ++ 2 files changed, 18 insertions(+), 42 deletions(-) diff --git a/flake.nix b/flake.nix index 70ae41f774..837189dd8d 100644 --- a/flake.nix +++ b/flake.nix @@ -64,54 +64,27 @@ pkgs.libxcrypt-legacy ]; - toolDirectoryName = - if pkgs.stdenv.hostPlatform.isDarwin then - "darwin" - else if pkgs.stdenv.hostPlatform.isLinux then - { - "armv7l-linux" = "linux-arm32"; - "aarch64-linux" = "linux-arm64"; - "i686-linux" = "linux-ia32"; - "x86_64-linux" = "linux-x64"; - } - ."${pkgs.stdenv.hostPlatform.system}" - else - throw "Unsupported platform"; - - # we use fuse2 + # we use static runtime # https://github.com/electron-userland/electron-builder/blob/a6117b3011a105204af8cc2eca02a56976d1ef29/packages/app-builder-lib/src/toolsets/linux.ts#L122 - runtime = pkgs.stdenvNoCC.mkDerivation { - name = "electron-builder-appimage-runtime"; - src = pkgs.fetchurl { - url = "https://github.com/electron-userland/electron-builder-binaries/releases/download/appimage-12.0.1/appimage-12.0.1.7z"; - hash = "sha256-0S/3648dHsRlLKUjen+9yjOswMdYBFY2/spi3G7LjsQ="; - }; - # https://github.com/NixOS/nixpkgs/blob/7890ba0a99c064446fe2178ef2f8e3abdf6ec42a/pkgs/by-name/lo/losslesscut-bin/build-from-windows.nix#L20 - nativeBuildInputs = [ pkgs.p7zip ]; - unpackPhase = '' - runHook preUnpack - 7z x "$src" - runHook postUnpack - ''; - installPhase = '' - mkdir $out - cp -r -t $out/ \ - lib/ \ - runtime-* - ''; - }; - appImageTools = pkgs.stdenvNoCC.mkDerivation { - name = "electron-builder-appimage-tools"; - dontUnpack = true; + name = "electron-builder-appimage-runtime"; + src = + let + # Keep in sync with "appimage" toolset version in gui/electron/electron-builder.yml + appimageToolsVersion = "1.0.3"; + in + pkgs.fetchzip { + url = "https://github.com/electron-userland/electron-builder-binaries/releases/download/appimage@${appimageToolsVersion}/appimage-tools-runtime-20251108.tar.gz"; + hash = "sha256-Iqhvyp6BNpH+tXvVO7MLgYbsJ6va4lXO7El3wtyWBII="; + stripRoot = false; + }; installPhase = '' - mkdir -p "$out/${toolDirectoryName}" - ln -s -t $out/${toolDirectoryName} \ + mkdir $out + cp -r -t $out/ lib/ runtimes/ + ln -s -t $out \ "${pkgs.desktop-file-utils}/bin/desktop-file-validate" \ "${pkgs.squashfsTools}/bin/mksquashfs" - ln -s ${runtime}/runtime-* $out/ - ln -s ${runtime}/lib $out/lib ''; }; diff --git a/gui/electron/electron-builder.yml b/gui/electron/electron-builder.yml index 7f60c8757e..04912685fb 100644 --- a/gui/electron/electron-builder.yml +++ b/gui/electron/electron-builder.yml @@ -19,6 +19,9 @@ asarUnpack: electronLanguages: - en-US +toolsets: + appimage: "1.0.3" + files: - out/**/* - resources/**/* From 68429e1fb8caef8db2b7a9ce28eebf5653a5cd2e Mon Sep 17 00:00:00 2001 From: Sapphire Date: Sat, 1 Aug 2026 00:35:37 -0500 Subject: [PATCH 348/350] Update stale Java version checks and messages in GUI --- gui/electron/electron-builder.yml | 2 +- gui/electron/main/index.ts | 2 +- gui/electron/main/paths.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gui/electron/electron-builder.yml b/gui/electron/electron-builder.yml index 04912685fb..eb1277118c 100644 --- a/gui/electron/electron-builder.yml +++ b/gui/electron/electron-builder.yml @@ -53,7 +53,7 @@ linux: icon: "./resources/icons" deb: - depends: [openjdk-17-jre-headless, udev] + depends: [openjdk-25-jre-headless, udev] afterInstall: "./resources/scripts/postinstall.sh" afterRemove: "./resources/scripts/postremove.sh" diff --git a/gui/electron/main/index.ts b/gui/electron/main/index.ts index acff710b7b..ac371f1d02 100644 --- a/gui/electron/main/index.ts +++ b/gui/electron/main/index.ts @@ -413,7 +413,7 @@ const spawnServer = async () => { if (!javaBin) { dialog.showErrorBox( 'SlimeVR', - 'Unable to find a compatible Java version, please download Java 17 or higher' + 'Unable to find a compatible Java version, please download Java 25 or higher' ); app.quit(); return; diff --git a/gui/electron/main/paths.ts b/gui/electron/main/paths.ts index 065f5cada1..d7fc432f92 100644 --- a/gui/electron/main/paths.ts +++ b/gui/electron/main/paths.ts @@ -104,7 +104,7 @@ export const findSystemJRE = async (sharedDir: string) => { resolve(version); }); }); - if (version && version >= 17) return path; + if (version && version >= 25) return path; } return null; }; From e2cb06fe2b4a219d1a8a1bbcd7e667a099d45bd6 Mon Sep 17 00:00:00 2001 From: Sapphire Date: Sat, 1 Aug 2026 01:18:03 -0500 Subject: [PATCH 349/350] Use temurin JDK distribution in CI --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 53c90563cc..e11be9b0de 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -96,7 +96,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: '25' - distribution: 'adopt' + distribution: 'temurin' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 - name: Server Spotless Check @@ -119,7 +119,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: '25' - distribution: 'adopt' + distribution: 'temurin' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 - name: Build ShadowJar @@ -266,7 +266,7 @@ jobs: uses: actions/setup-java@v5 with: java-version: '25' - distribution: 'adopt' + distribution: 'temurin' - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 - name: Download GUI Dist From 4b1460aa18718ccd50e773409968f99a3e055f4c Mon Sep 17 00:00:00 2001 From: Sapphire Date: Sat, 1 Aug 2026 01:33:04 -0500 Subject: [PATCH 350/350] Explicitly set desktopName for Electron electron-builder keeps complaining about this --- gui/electron/electron-builder.yml | 1 + gui/electron/package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/gui/electron/electron-builder.yml b/gui/electron/electron-builder.yml index eb1277118c..43555721c4 100644 --- a/gui/electron/electron-builder.yml +++ b/gui/electron/electron-builder.yml @@ -37,6 +37,7 @@ files: linux: category: Game artifactName: "${productName}-${arch}.${ext}" + syncDesktopName: true target: - target: AppImage - target: deb diff --git a/gui/electron/package.json b/gui/electron/package.json index 5f9efce4dc..140cfae8f6 100644 --- a/gui/electron/package.json +++ b/gui/electron/package.json @@ -6,6 +6,7 @@ "private": true, "type": "module", "main": "./out/main/index.js", + "desktopName": "dev.slimevr.SlimeVR", "scripts": { "dev": "node scripts/dev.mjs", "build": "electron-vite build --config electron.vite.config.ts && pnpm --filter @slimevr/gui-app exec vite build --outDir ../electron/out/renderer --emptyOutDir",